From 23e8fdd1678b7dbb03020eacf400ad86b8df0007 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 15 Mar 2026 07:59:55 -0700 Subject: [PATCH] feat(discord): auto-thread on @mention + skip mention in bot threads Two changes to align Discord behavior with Slack: 1. Auto-thread on @mention (default: true) - When someone @mentions the bot in a server channel, a thread is automatically created from their message and the response goes there. - Each thread gets its own isolated session (like Slack). - Configurable via discord.auto_thread in config.yaml (default: true) or DISCORD_AUTO_THREAD env var (env takes precedence). - DMs and existing threads are unaffected. 2. Skip @mention in bot-participated threads - Once the bot has responded in a thread (auto-created or manually entered), subsequent messages in that thread no longer require @mention. Users can just type normally. - Tracked via in-memory set (_bot_participated_threads). After a gateway restart, users need to @mention once to re-establish. - Threads the bot hasn't participated in still require @mention. Config change: discord: auto_thread: true # new, added to DEFAULT_CONFIG Tests: 7 new tests covering auto-thread default, disable, bot thread participation tracking, and mention skip logic. All 903 gateway tests pass. --- gateway/platforms/discord.py | 36 ++++--- hermes_cli/config.py | 1 + tests/gateway/test_discord_free_response.py | 106 +++++++++++++++++++ tests/gateway/test_discord_slash_commands.py | 30 +++++- 4 files changed, 159 insertions(+), 14 deletions(-) diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index d932d39a1..ec14dd2d6 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -433,6 +433,9 @@ class DiscordAdapter(BasePlatformAdapter): self._voice_listen_tasks: Dict[int, asyncio.Task] = {} # guild_id -> listen loop self._voice_input_callback: 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: """Connect to Discord and start receiving events.""" @@ -613,7 +616,7 @@ class DiscordAdapter(BasePlatformAdapter): """Send a message to a Discord channel.""" if not self._client: return SendResult(success=False, error="Not connected") - + try: # Get the channel channel = self._client.get_channel(int(chat_id)) @@ -1798,14 +1801,13 @@ class DiscordAdapter(BasePlatformAdapter): async def _handle_message(self, message: DiscordMessage) -> None: """Handle incoming Discord messages.""" # 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: - # DISCORD_FREE_RESPONSE_CHANNELS: Comma-separated channel IDs where the - # bot responds to every message without needing a mention. - # DISCORD_REQUIRE_MENTION: Set to "false" to disable mention requirement - # globally (all channels become free-response). Default: "true". - # Can also be set via discord.require_mention in config.yaml. + # Config (all settable via discord.* in config.yaml): + # discord.require_mention: Require @mention in server channels (default: true) + # discord.free_response_channels: Channel IDs where bot responds without mention + # discord.auto_thread: Auto-create thread on @mention in channels (default: true) thread_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") 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: return @@ -1833,17 +1839,18 @@ class DiscordAdapter(BasePlatformAdapter): message.content = message.content.replace(f"<@!{self._client.user.id}>", "").strip() # 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. auto_threaded_channel = None 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: thread = await self._auto_create_thread(message) if thread: is_thread = True thread_id = str(thread.id) auto_threaded_channel = thread + self._bot_participated_threads.add(thread_id) # Determine message type msg_type = MessageType.TEXT @@ -1943,7 +1950,12 @@ class DiscordAdapter(BasePlatformAdapter): reply_to_message_id=str(message.reference.message_id) if message.reference else None, 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) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index f8db06202..d385cd8ba 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -280,6 +280,7 @@ DEFAULT_CONFIG = { "discord": { "require_mention": True, # Require @mention to respond in server channels "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) diff --git a/tests/gateway/test_discord_free_response.py b/tests/gateway/test_discord_free_response.py index 3d41104c8..bf8d4a292 100644 --- a/tests/gateway/test_discord_free_response.py +++ b/tests/gateway/test_discord_free_response.py @@ -252,3 +252,109 @@ async def test_discord_dms_ignore_mention_requirement(adapter, monkeypatch): event = adapter.handle_message.await_args.args[0] assert event.text == "dm without mention" 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 diff --git a/tests/gateway/test_discord_slash_commands.py b/tests/gateway/test_discord_slash_commands.py index 3c441258c..eea4dc2cb 100644 --- a/tests/gateway/test_discord_slash_commands.py +++ b/tests/gateway/test_discord_slash_commands.py @@ -363,11 +363,37 @@ async def test_auto_thread_creates_thread_and_redirects(adapter, monkeypatch): @pytest.mark.asyncio -async def test_auto_thread_disabled_by_default(adapter, monkeypatch): - """Without DISCORD_AUTO_THREAD, messages stay in the channel.""" +async def test_auto_thread_enabled_by_default_slash_commands(adapter, monkeypatch): + """Without DISCORD_AUTO_THREAD env var, auto-threading is enabled (default: true).""" monkeypatch.delenv("DISCORD_AUTO_THREAD", raising=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() captured_events = []