From 608e8a6062271661ac2f2f27375ef9ad7eb32b12 Mon Sep 17 00:00:00 2001 From: codexGW <9350182+codexGW@users.noreply.github.com> Date: Tue, 30 Jun 2026 16:02:13 -0700 Subject: [PATCH] fix(discord): accept raw direct bot mentions and ignore bare mention-only pings Some legitimate @bot pings were dropped because the mention gates relied on message.mentions alone, which does not always populate raw <@ID> / <@!ID> forms (mobile, edited, relayed messages). A bare @bot with no other text could also spawn a fake empty-text turn. - add _self_is_explicitly_mentioned() / _raw_mentioned_user_ids() helpers that treat the bot as mentioned via resolved mentions OR raw content forms - use them at the allow_bots=mentions gate, multi-agent bot filtering, the mention-strip/mention_prefix step, and the require_mention gate - drop bare mention-only pings (no text, no media, no injection, no backfill context) instead of injecting a placeholder empty turn Co-authored-by: Teknium --- plugins/platforms/discord/adapter.py | 62 +++++++++++++++++---- tests/gateway/test_discord_bot_filter.py | 23 +++++++- tests/gateway/test_discord_free_response.py | 59 ++++++++++++++++++++ 3 files changed, 133 insertions(+), 11 deletions(-) diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index dc450ebf1ac..d433bcb4e56 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -1064,7 +1064,7 @@ class DiscordAdapter(BasePlatformAdapter): if allow_bots == "none": return elif allow_bots == "mentions": - if not self._client.user or self._client.user not in message.mentions: + if not self._self_is_explicitly_mentioned(message): return # "all" falls through; bot is permitted — skip the # human-user allowlist below (bots aren't in it). @@ -1093,11 +1093,11 @@ class DiscordAdapter(BasePlatformAdapter): # This replaces the older DISCORD_IGNORE_NO_MENTION logic # with bot-aware filtering that works correctly when multiple # agents share a channel. - if not isinstance(message.channel, discord.DMChannel) and message.mentions: - _self_mentioned = ( - self._client.user is not None - and self._client.user in message.mentions - ) + _raw_self_mention = self._self_is_explicitly_mentioned(message) + if not isinstance(message.channel, discord.DMChannel) and ( + message.mentions or _raw_self_mention + ): + _self_mentioned = _raw_self_mention _other_bots_mentioned = any( m.bot and m != self._client.user for m in message.mentions @@ -4607,6 +4607,30 @@ class DiscordAdapter(BasePlatformAdapter): return {part.strip() for part in s.split(",") if part.strip()} return set() + def _raw_mentioned_user_ids(self, message: Any) -> set: + """Extract Discord user-mention IDs directly from raw message content. + + Covers both raw forms — ``<@ID>`` and the legacy ``<@!ID>`` nickname + form — which ``message.mentions`` does not always populate (mobile, + edited, or relayed messages can carry the mention in the content while + leaving the resolved ``mentions`` list empty). + """ + content = getattr(message, "content", "") or "" + return {match.group(1) for match in re.finditer(r"<@!?(\d+)>", content)} + + def _self_is_explicitly_mentioned(self, message: Any) -> bool: + """Return True when this bot is explicitly @mentioned in the message. + + Treats the bot as mentioned if it is either present in the resolved + ``message.mentions`` list OR referenced by its raw ``<@ID>`` / ``<@!ID>`` + form in the message content. + """ + if not self._client or not self._client.user: + return False + if self._client.user in getattr(message, "mentions", []): + return True + return str(self._client.user.id) in self._raw_mentioned_user_ids(message) + def _discord_channel_keys(self, message: Any, parent_channel_id: Optional[str] = None) -> set[str]: """Return channel identifiers accepted by Discord channel config gates. @@ -5634,10 +5658,11 @@ class DiscordAdapter(BasePlatformAdapter): if snapshot_text_parts and not raw_content: raw_content = "\n".join(snapshot_text_parts) normalized_content = raw_content - if self._client.user and self._client.user in message.mentions: + if self._self_is_explicitly_mentioned(message): mention_prefix = True - normalized_content = normalized_content.replace(f"<@{self._client.user.id}>", "").strip() - normalized_content = normalized_content.replace(f"<@!{self._client.user.id}>", "").strip() + if self._client.user: + normalized_content = normalized_content.replace(f"<@{self._client.user.id}>", "").strip() + normalized_content = normalized_content.replace(f"<@!{self._client.user.id}>", "").strip() message.content = normalized_content if not isinstance(message.channel, discord.DMChannel): channel_ids = {str(message.channel.id)} @@ -5686,7 +5711,7 @@ class DiscordAdapter(BasePlatformAdapter): ) if require_mention and not is_free_channel and not in_bot_thread: - if self._client.user not in message.mentions and not mention_prefix: + if not self._self_is_explicitly_mentioned(message) and not mention_prefix: return # Auto-thread: when enabled, automatically create a thread for every # @mention in a text channel so each conversation is isolated (like Slack). @@ -5992,6 +6017,23 @@ class DiscordAdapter(BasePlatformAdapter): # When channel_context is present, a bare mention means "catch me up" # — the context IS the message, so skip the placeholder. if (not event_text or not event_text.strip()) and not _channel_context: + # Bare mention-only ping (e.g. "@Bot" with nothing else, including + # raw <@!ID> forms) with no media, no injected text, and no backfill + # context: drop it instead of spawning a fake empty-text turn. + # mention_prefix was computed (and message.content stripped) above, + # so reuse it rather than re-reading the now-stripped content. + if ( + mention_prefix + and not media_urls + and not pending_text_injection + ): + logger.info( + "[%s] Ignoring mention-only message from %s in %s", + self.name, + getattr(message.author, "display_name", getattr(message.author, "name", "unknown")), + getattr(message.channel, "id", "unknown"), + ) + return event_text = "(The user sent a message with no text content)" _chan = message.channel diff --git a/tests/gateway/test_discord_bot_filter.py b/tests/gateway/test_discord_bot_filter.py index 90dc9f8de00..014be10222b 100644 --- a/tests/gateway/test_discord_bot_filter.py +++ b/tests/gateway/test_discord_bot_filter.py @@ -1,6 +1,7 @@ """Tests for Discord bot message filtering (DISCORD_ALLOW_BOTS).""" import os +import re import unittest from unittest.mock import MagicMock @@ -40,6 +41,19 @@ def _make_message(*, author=None, content="hello", mentions=None, is_dm=False): class TestDiscordBotFilter(unittest.TestCase): """Test the DISCORD_ALLOW_BOTS filtering logic.""" + @staticmethod + def _self_is_explicitly_mentioned(message, client_user): + """Mirror adapter._self_is_explicitly_mentioned: resolved or raw mention.""" + if not client_user: + return False + if client_user in message.mentions: + return True + raw_ids = { + m.group(1) + for m in re.finditer(r"<@!?(\d+)>", getattr(message, "content", "") or "") + } + return str(client_user.id) in raw_ids + def _run_filter(self, message, allow_bots="none", client_user=None): """Simulate the on_message filter logic and return whether message was accepted.""" # Replicate the exact filter logic from discord.py on_message @@ -51,7 +65,7 @@ class TestDiscordBotFilter(unittest.TestCase): if allow == "none": return False elif allow == "mentions": - if not client_user or client_user not in message.mentions: + if not self._self_is_explicitly_mentioned(message, client_user): return False # "all" falls through @@ -97,6 +111,13 @@ class TestDiscordBotFilter(unittest.TestCase): msg = _make_message(author=bot, mentions=[our_user]) self.assertTrue(self._run_filter(msg, "mentions", our_user)) + def test_allow_bots_mentions_accepts_with_raw_content_mention(self): + """Raw <@!ID> mention counts even when message.mentions is empty.""" + our_user = _make_author(is_self=True) + bot = _make_author(bot=True) + msg = _make_message(author=bot, content=f"<@!{our_user.id}> relay", mentions=[]) + self.assertTrue(self._run_filter(msg, "mentions", our_user)) + def test_default_is_none(self): """Default behavior (no env var) should be 'none'.""" default = os.getenv("DISCORD_ALLOW_BOTS", "none") diff --git a/tests/gateway/test_discord_free_response.py b/tests/gateway/test_discord_free_response.py index 1c71ac64109..589c8a7c5cb 100644 --- a/tests/gateway/test_discord_free_response.py +++ b/tests/gateway/test_discord_free_response.py @@ -349,6 +349,65 @@ async def test_discord_accepts_and_strips_bot_mentions_when_required(adapter, mo assert event.text == "hello with mention" +@pytest.mark.asyncio +async def test_discord_accepts_raw_bot_mentions_when_required(adapter, monkeypatch): + """Raw <@!ID> mention should trigger even when message.mentions is empty.""" + monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true") + monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False) + monkeypatch.setenv("DISCORD_AUTO_THREAD", "false") + + bot_user = adapter._client.user + message = make_message( + channel=FakeTextChannel(channel_id=322), + content=f"<@!{bot_user.id}> hello from raw mention", + mentions=[], + ) + + await adapter._handle_message(message) + + adapter.handle_message.assert_awaited_once() + event = adapter.handle_message.await_args.args[0] + assert event.text == "hello from raw mention" + + +@pytest.mark.asyncio +async def test_discord_ignores_bare_bot_mentions_without_text(adapter, monkeypatch): + """A bare raw @bot ping with no other text should be dropped, not a fake turn.""" + monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true") + monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False) + monkeypatch.setenv("DISCORD_AUTO_THREAD", "false") + + bot_user = adapter._client.user + message = make_message( + channel=FakeTextChannel(channel_id=323), + content=f"<@{bot_user.id}>", + mentions=[], + ) + + await adapter._handle_message(message) + + adapter.handle_message.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_discord_ignores_bare_bot_mentions_with_populated_mentions(adapter, monkeypatch): + """Bare @bot ping is dropped even when message.mentions resolves the bot.""" + monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true") + monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False) + monkeypatch.setenv("DISCORD_AUTO_THREAD", "false") + + bot_user = adapter._client.user + message = make_message( + channel=FakeTextChannel(channel_id=324), + content=f"<@{bot_user.id}>", + mentions=[bot_user], + ) + + await adapter._handle_message(message) + + adapter.handle_message.assert_not_awaited() + + @pytest.mark.asyncio async def test_discord_dms_ignore_mention_requirement(adapter, monkeypatch): monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true")