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 <teknium1@gmail.com>
This commit is contained in:
codexGW 2026-06-30 16:02:13 -07:00 committed by Teknium
parent 97e0bbef53
commit 608e8a6062
3 changed files with 133 additions and 11 deletions

View file

@ -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

View file

@ -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")

View file

@ -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")