From f5dc4e905d89b14392b68dc7660b9a0d3b07587b Mon Sep 17 00:00:00 2001 From: OwenYWT Date: Tue, 14 Apr 2026 19:37:46 +0800 Subject: [PATCH] fix(discord): skip auto-threading reply messages --- gateway/platforms/discord.py | 28 ++++++++++++-- tests/gateway/test_discord_free_response.py | 43 ++++++++++++++++++++- 2 files changed, 66 insertions(+), 5 deletions(-) diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index 3c222587e..a4f33516b 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -2285,6 +2285,26 @@ class DiscordAdapter(BasePlatformAdapter): from gateway.platforms.base import resolve_channel_prompt return resolve_channel_prompt(self.config.extra, channel_id, parent_id) + def _discord_require_mention(self) -> bool: + """Return whether Discord channel messages require a bot mention.""" + configured = self.config.extra.get("require_mention") + if configured is not None: + if isinstance(configured, str): + return configured.lower() not in ("false", "0", "no", "off") + return bool(configured) + return os.getenv("DISCORD_REQUIRE_MENTION", "true").lower() not in ("false", "0", "no", "off") + + def _discord_free_response_channels(self) -> set: + """Return Discord channel IDs where no bot mention is required.""" + raw = self.config.extra.get("free_response_channels") + if raw is None: + raw = os.getenv("DISCORD_FREE_RESPONSE_CHANNELS", "") + if isinstance(raw, list): + return {str(part).strip() for part in raw if str(part).strip()} + if isinstance(raw, str) and raw.strip(): + return {part.strip() for part in raw.split(",") if part.strip()} + return set() + def _thread_parent_channel(self, channel: Any) -> Any: """Return the parent text channel when invoked from a thread.""" return getattr(channel, "parent", None) or channel @@ -2745,12 +2765,11 @@ class DiscordAdapter(BasePlatformAdapter): logger.debug("[%s] Ignoring message in ignored channel: %s", self.name, channel_ids) return - free_channels_raw = os.getenv("DISCORD_FREE_RESPONSE_CHANNELS", "") - free_channels = {ch.strip() for ch in free_channels_raw.split(",") if ch.strip()} + free_channels = self._discord_free_response_channels() if parent_channel_id: channel_ids.add(parent_channel_id) - require_mention = os.getenv("DISCORD_REQUIRE_MENTION", "true").lower() not in ("false", "0", "no") + require_mention = self._discord_require_mention() # Voice-linked text channels act as free-response while voice is active. # Only the exact bound channel gets the exemption, not sibling threads. voice_linked_ids = {str(ch_id) for ch_id in self._voice_text_channels.values()} @@ -2780,7 +2799,8 @@ class DiscordAdapter(BasePlatformAdapter): no_thread_channels = {ch.strip() for ch in no_thread_channels_raw.split(",") if ch.strip()} skip_thread = bool(channel_ids & no_thread_channels) or is_free_channel auto_thread = os.getenv("DISCORD_AUTO_THREAD", "true").lower() in ("true", "1", "yes") - if auto_thread and not skip_thread and not is_voice_linked_channel: + is_reply_message = getattr(message, "type", None) == discord.MessageType.reply + if auto_thread and not skip_thread and not is_voice_linked_channel and not is_reply_message: thread = await self._auto_create_thread(message) if thread: is_thread = True diff --git a/tests/gateway/test_discord_free_response.py b/tests/gateway/test_discord_free_response.py index ee4f14e65..f1ee99606 100644 --- a/tests/gateway/test_discord_free_response.py +++ b/tests/gateway/test_discord_free_response.py @@ -96,7 +96,7 @@ def adapter(monkeypatch): return adapter -def make_message(*, channel, content: str, mentions=None): +def make_message(*, channel, content: str, mentions=None, msg_type=None): author = SimpleNamespace(id=42, display_name="Jezza", name="Jezza") return SimpleNamespace( id=123, @@ -107,6 +107,7 @@ def make_message(*, channel, content: str, mentions=None): created_at=datetime.now(timezone.utc), channel=channel, author=author, + type=msg_type if msg_type is not None else discord_platform.discord.MessageType.default, ) @@ -204,6 +205,21 @@ async def test_discord_free_response_channel_overrides_mention_requirement(adapt assert event.text == "allowed without mention" +@pytest.mark.asyncio +async def test_discord_free_response_channel_can_come_from_config_extra(adapter, monkeypatch): + monkeypatch.delenv("DISCORD_REQUIRE_MENTION", raising=False) + monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False) + adapter.config.extra["free_response_channels"] = ["789", "999"] + + message = make_message(channel=FakeTextChannel(channel_id=789), content="allowed from config") + + await adapter._handle_message(message) + + adapter.handle_message.assert_awaited_once() + event = adapter.handle_message.await_args.args[0] + assert event.text == "allowed from config" + + @pytest.mark.asyncio async def test_discord_forum_parent_in_free_response_list_allows_forum_thread(adapter, monkeypatch): monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true") @@ -276,6 +292,31 @@ async def test_discord_auto_thread_enabled_by_default(adapter, monkeypatch): assert event.source.thread_id == "999" +@pytest.mark.asyncio +async def test_discord_reply_message_skips_auto_thread(adapter, monkeypatch): + """Quote-replies should stay in-channel instead of trying to create a thread.""" + monkeypatch.delenv("DISCORD_AUTO_THREAD", raising=False) + monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true") + monkeypatch.setenv("DISCORD_FREE_RESPONSE_CHANNELS", "123") + + adapter._auto_create_thread = AsyncMock() + + message = make_message( + channel=FakeTextChannel(channel_id=123), + content="reply without mention", + msg_type=discord_platform.discord.MessageType.reply, + ) + + 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.text == "reply without mention" + assert event.source.chat_id == "123" + assert event.source.chat_type == "group" + + @pytest.mark.asyncio async def test_discord_auto_thread_can_be_disabled(adapter, monkeypatch): """Setting auto_thread to false skips thread creation."""