mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-30 16:01:49 +08:00
Compare commits
1 Commits
fix/plugin
...
hermes/her
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ec2ad3182 |
@@ -433,6 +433,9 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||||||
self._voice_listen_tasks: Dict[int, asyncio.Task] = {} # guild_id -> listen loop
|
self._voice_listen_tasks: Dict[int, asyncio.Task] = {} # guild_id -> listen loop
|
||||||
self._voice_input_callback: Optional[Callable] = None # set by run.py
|
self._voice_input_callback: Optional[Callable] = None # set by run.py
|
||||||
self._on_voice_disconnect: Optional[Callable] = None # set by run.py
|
self._on_voice_disconnect: Optional[Callable] = None # set by run.py
|
||||||
|
# Track threads where the bot has participated so follow-up messages
|
||||||
|
# in those threads don't require @mention.
|
||||||
|
self._bot_participated_threads: set = set()
|
||||||
|
|
||||||
async def connect(self) -> bool:
|
async def connect(self) -> bool:
|
||||||
"""Connect to Discord and start receiving events."""
|
"""Connect to Discord and start receiving events."""
|
||||||
@@ -1798,14 +1801,13 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||||||
async def _handle_message(self, message: DiscordMessage) -> None:
|
async def _handle_message(self, message: DiscordMessage) -> None:
|
||||||
"""Handle incoming Discord messages."""
|
"""Handle incoming Discord messages."""
|
||||||
# In server channels (not DMs), require the bot to be @mentioned
|
# In server channels (not DMs), require the bot to be @mentioned
|
||||||
# UNLESS the channel is in the free-response list.
|
# UNLESS the channel is in the free-response list or the message is
|
||||||
|
# in a thread where the bot has already participated.
|
||||||
#
|
#
|
||||||
# Config:
|
# Config (all settable via discord.* in config.yaml):
|
||||||
# DISCORD_FREE_RESPONSE_CHANNELS: Comma-separated channel IDs where the
|
# discord.require_mention: Require @mention in server channels (default: true)
|
||||||
# bot responds to every message without needing a mention.
|
# discord.free_response_channels: Channel IDs where bot responds without mention
|
||||||
# DISCORD_REQUIRE_MENTION: Set to "false" to disable mention requirement
|
# discord.auto_thread: Auto-create thread on @mention in channels (default: true)
|
||||||
# globally (all channels become free-response). Default: "true".
|
|
||||||
# Can also be set via discord.require_mention in config.yaml.
|
|
||||||
|
|
||||||
thread_id = None
|
thread_id = None
|
||||||
parent_channel_id = None
|
parent_channel_id = None
|
||||||
@@ -1824,7 +1826,11 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||||||
require_mention = os.getenv("DISCORD_REQUIRE_MENTION", "true").lower() not in ("false", "0", "no")
|
require_mention = os.getenv("DISCORD_REQUIRE_MENTION", "true").lower() not in ("false", "0", "no")
|
||||||
is_free_channel = bool(channel_ids & free_channels)
|
is_free_channel = bool(channel_ids & free_channels)
|
||||||
|
|
||||||
if require_mention and not is_free_channel:
|
# Skip the mention check if the message is in a thread where
|
||||||
|
# the bot has previously participated (auto-created or replied in).
|
||||||
|
in_bot_thread = is_thread and thread_id in self._bot_participated_threads
|
||||||
|
|
||||||
|
if require_mention and not is_free_channel and not in_bot_thread:
|
||||||
if self._client.user not in message.mentions:
|
if self._client.user not in message.mentions:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -1833,17 +1839,18 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||||||
message.content = message.content.replace(f"<@!{self._client.user.id}>", "").strip()
|
message.content = message.content.replace(f"<@!{self._client.user.id}>", "").strip()
|
||||||
|
|
||||||
# Auto-thread: when enabled, automatically create a thread for every
|
# Auto-thread: when enabled, automatically create a thread for every
|
||||||
# new message in a text channel so each conversation is isolated.
|
# @mention in a text channel so each conversation is isolated (like Slack).
|
||||||
# Messages already inside threads or DMs are unaffected.
|
# Messages already inside threads or DMs are unaffected.
|
||||||
auto_threaded_channel = None
|
auto_threaded_channel = None
|
||||||
if not is_thread and not isinstance(message.channel, discord.DMChannel):
|
if not is_thread and not isinstance(message.channel, discord.DMChannel):
|
||||||
auto_thread = os.getenv("DISCORD_AUTO_THREAD", "").lower() in ("true", "1", "yes")
|
auto_thread = os.getenv("DISCORD_AUTO_THREAD", "true").lower() in ("true", "1", "yes")
|
||||||
if auto_thread:
|
if auto_thread:
|
||||||
thread = await self._auto_create_thread(message)
|
thread = await self._auto_create_thread(message)
|
||||||
if thread:
|
if thread:
|
||||||
is_thread = True
|
is_thread = True
|
||||||
thread_id = str(thread.id)
|
thread_id = str(thread.id)
|
||||||
auto_threaded_channel = thread
|
auto_threaded_channel = thread
|
||||||
|
self._bot_participated_threads.add(thread_id)
|
||||||
|
|
||||||
# Determine message type
|
# Determine message type
|
||||||
msg_type = MessageType.TEXT
|
msg_type = MessageType.TEXT
|
||||||
@@ -1944,6 +1951,11 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||||||
timestamp=message.created_at,
|
timestamp=message.created_at,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Track thread participation so the bot won't require @mention for
|
||||||
|
# follow-up messages in threads it has already engaged in.
|
||||||
|
if thread_id:
|
||||||
|
self._bot_participated_threads.add(thread_id)
|
||||||
|
|
||||||
await self.handle_message(event)
|
await self.handle_message(event)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -280,6 +280,7 @@ DEFAULT_CONFIG = {
|
|||||||
"discord": {
|
"discord": {
|
||||||
"require_mention": True, # Require @mention to respond in server channels
|
"require_mention": True, # Require @mention to respond in server channels
|
||||||
"free_response_channels": "", # Comma-separated channel IDs where bot responds without mention
|
"free_response_channels": "", # Comma-separated channel IDs where bot responds without mention
|
||||||
|
"auto_thread": True, # Auto-create threads on @mention in channels (like Slack)
|
||||||
},
|
},
|
||||||
|
|
||||||
# Permanently allowed dangerous command patterns (added via "always" approval)
|
# Permanently allowed dangerous command patterns (added via "always" approval)
|
||||||
|
|||||||
@@ -252,3 +252,109 @@ async def test_discord_dms_ignore_mention_requirement(adapter, monkeypatch):
|
|||||||
event = adapter.handle_message.await_args.args[0]
|
event = adapter.handle_message.await_args.args[0]
|
||||||
assert event.text == "dm without mention"
|
assert event.text == "dm without mention"
|
||||||
assert event.source.chat_type == "dm"
|
assert event.source.chat_type == "dm"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_discord_auto_thread_enabled_by_default(adapter, monkeypatch):
|
||||||
|
"""Auto-threading should be enabled by default (DISCORD_AUTO_THREAD defaults to 'true')."""
|
||||||
|
monkeypatch.delenv("DISCORD_AUTO_THREAD", raising=False)
|
||||||
|
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false")
|
||||||
|
|
||||||
|
# Patch _auto_create_thread to return a fake thread
|
||||||
|
fake_thread = FakeThread(channel_id=999, name="auto-thread")
|
||||||
|
adapter._auto_create_thread = AsyncMock(return_value=fake_thread)
|
||||||
|
|
||||||
|
message = make_message(channel=FakeTextChannel(channel_id=123), content="hello")
|
||||||
|
|
||||||
|
await adapter._handle_message(message)
|
||||||
|
|
||||||
|
adapter._auto_create_thread.assert_awaited_once()
|
||||||
|
adapter.handle_message.assert_awaited_once()
|
||||||
|
event = adapter.handle_message.await_args.args[0]
|
||||||
|
assert event.source.chat_type == "thread"
|
||||||
|
assert event.source.thread_id == "999"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_discord_auto_thread_can_be_disabled(adapter, monkeypatch):
|
||||||
|
"""Setting auto_thread to false skips thread creation."""
|
||||||
|
monkeypatch.setenv("DISCORD_AUTO_THREAD", "false")
|
||||||
|
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false")
|
||||||
|
|
||||||
|
adapter._auto_create_thread = AsyncMock()
|
||||||
|
|
||||||
|
message = make_message(channel=FakeTextChannel(channel_id=123), content="hello")
|
||||||
|
|
||||||
|
await adapter._handle_message(message)
|
||||||
|
|
||||||
|
adapter._auto_create_thread.assert_not_awaited()
|
||||||
|
adapter.handle_message.assert_awaited_once()
|
||||||
|
event = adapter.handle_message.await_args.args[0]
|
||||||
|
assert event.source.chat_type == "group"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_discord_bot_thread_skips_mention_requirement(adapter, monkeypatch):
|
||||||
|
"""Messages in a thread the bot has participated in should not require @mention."""
|
||||||
|
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true")
|
||||||
|
monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False)
|
||||||
|
monkeypatch.setenv("DISCORD_AUTO_THREAD", "false")
|
||||||
|
|
||||||
|
# Simulate bot having previously participated in thread 456
|
||||||
|
adapter._bot_participated_threads.add("456")
|
||||||
|
|
||||||
|
thread = FakeThread(channel_id=456, name="existing thread")
|
||||||
|
message = make_message(channel=thread, content="follow-up without mention")
|
||||||
|
|
||||||
|
await adapter._handle_message(message)
|
||||||
|
|
||||||
|
adapter.handle_message.assert_awaited_once()
|
||||||
|
event = adapter.handle_message.await_args.args[0]
|
||||||
|
assert event.text == "follow-up without mention"
|
||||||
|
assert event.source.chat_type == "thread"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_discord_unknown_thread_still_requires_mention(adapter, monkeypatch):
|
||||||
|
"""Messages in a thread the bot hasn't participated in should still require @mention."""
|
||||||
|
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true")
|
||||||
|
monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False)
|
||||||
|
monkeypatch.setenv("DISCORD_AUTO_THREAD", "false")
|
||||||
|
|
||||||
|
# Bot has NOT participated in thread 789
|
||||||
|
thread = FakeThread(channel_id=789, name="some thread")
|
||||||
|
message = make_message(channel=thread, content="hello from unknown thread")
|
||||||
|
|
||||||
|
await adapter._handle_message(message)
|
||||||
|
|
||||||
|
adapter.handle_message.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_discord_auto_thread_tracks_participation(adapter, monkeypatch):
|
||||||
|
"""Auto-created threads should be tracked for future mention-free replies."""
|
||||||
|
monkeypatch.delenv("DISCORD_AUTO_THREAD", raising=False)
|
||||||
|
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false")
|
||||||
|
|
||||||
|
fake_thread = FakeThread(channel_id=555, name="auto-thread")
|
||||||
|
adapter._auto_create_thread = AsyncMock(return_value=fake_thread)
|
||||||
|
|
||||||
|
message = make_message(channel=FakeTextChannel(channel_id=123), content="start a thread")
|
||||||
|
|
||||||
|
await adapter._handle_message(message)
|
||||||
|
|
||||||
|
assert "555" in adapter._bot_participated_threads
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_discord_thread_participation_tracked_on_dispatch(adapter, monkeypatch):
|
||||||
|
"""When the bot processes a message in a thread, it tracks participation."""
|
||||||
|
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false")
|
||||||
|
monkeypatch.setenv("DISCORD_AUTO_THREAD", "false")
|
||||||
|
|
||||||
|
thread = FakeThread(channel_id=777, name="manually created thread")
|
||||||
|
message = make_message(channel=thread, content="hello in thread")
|
||||||
|
|
||||||
|
await adapter._handle_message(message)
|
||||||
|
|
||||||
|
assert "777" in adapter._bot_participated_threads
|
||||||
|
|||||||
@@ -363,11 +363,37 @@ async def test_auto_thread_creates_thread_and_redirects(adapter, monkeypatch):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_auto_thread_disabled_by_default(adapter, monkeypatch):
|
async def test_auto_thread_enabled_by_default_slash_commands(adapter, monkeypatch):
|
||||||
"""Without DISCORD_AUTO_THREAD, messages stay in the channel."""
|
"""Without DISCORD_AUTO_THREAD env var, auto-threading is enabled (default: true)."""
|
||||||
monkeypatch.delenv("DISCORD_AUTO_THREAD", raising=False)
|
monkeypatch.delenv("DISCORD_AUTO_THREAD", raising=False)
|
||||||
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false")
|
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false")
|
||||||
|
|
||||||
|
fake_thread = _FakeThreadChannel(channel_id=999, name="auto-thread")
|
||||||
|
adapter._auto_create_thread = AsyncMock(return_value=fake_thread)
|
||||||
|
|
||||||
|
captured_events = []
|
||||||
|
|
||||||
|
async def capture_handle(event):
|
||||||
|
captured_events.append(event)
|
||||||
|
|
||||||
|
adapter.handle_message = capture_handle
|
||||||
|
|
||||||
|
msg = _fake_message(_FakeTextChannel())
|
||||||
|
|
||||||
|
await adapter._handle_message(msg)
|
||||||
|
|
||||||
|
adapter._auto_create_thread.assert_awaited_once()
|
||||||
|
assert len(captured_events) == 1
|
||||||
|
assert captured_events[0].source.chat_id == "999" # redirected to thread
|
||||||
|
assert captured_events[0].source.chat_type == "thread"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_auto_thread_can_be_disabled(adapter, monkeypatch):
|
||||||
|
"""Setting DISCORD_AUTO_THREAD=false keeps messages in the channel."""
|
||||||
|
monkeypatch.setenv("DISCORD_AUTO_THREAD", "false")
|
||||||
|
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false")
|
||||||
|
|
||||||
adapter._auto_create_thread = AsyncMock()
|
adapter._auto_create_thread = AsyncMock()
|
||||||
|
|
||||||
captured_events = []
|
captured_events = []
|
||||||
|
|||||||
Reference in New Issue
Block a user