fix(discord): ignore reply-ping-only mentions for bot-authored messages

Two Hermes bots sharing a channel could volley replies at each other
indefinitely. Root cause: Discord reply-pings (allowed_mentions
replied_user=true) add the replied-to bot to message.mentions without a
literal <@bot> token in the body, so the existing bot-admission gate
treated a reply chip as an explicit @mention and re-triggered the peer.

Adds opt-in discord.bots_require_inline_mention (default false; env
DISCORD_BOTS_REQUIRE_INLINE_MENTION). When enabled, bot-authored
messages must carry a raw inline <@id>/<@!id> mention in the content;
reply-ping-only mentions no longer admit the message. Human messages and
all existing defaults are unchanged.

The new _self_is_raw_mentioned helper deliberately ignores the resolved
message.mentions list (which reply-ping populates) and checks only the
raw content token via the shared _raw_mentioned_user_ids primitive.
This commit is contained in:
snav 2026-07-01 15:31:25 -04:00
parent 60b1f6ce3f
commit e9bceb5ae0
4 changed files with 171 additions and 2 deletions

View file

@ -2297,6 +2297,7 @@ DEFAULT_CONFIG = {
"allowed_channels": "", # If set, bot ONLY responds in these channel IDs (whitelist)
"auto_thread": True, # Auto-create threads on @mention in channels (like Slack)
"thread_require_mention": False, # If True, require @mention in threads too (multi-bot threads)
"bots_require_inline_mention": False, # Multi-bot rooms: if True, another bot must type @thisbot in its message to trigger a reply; a Discord reply/quote alone won't. Prevents two bots auto-replying to each other forever. Does not affect humans.
"history_backfill": True, # If True, prepend recent channel scrollback when bot is triggered (recovers messages missed while require_mention gated them out)
"history_backfill_limit": 50, # Max number of recent messages to scan when assembling the backfill block
"reactions": True, # Add 👀/✅/❌ reactions to messages during processing

View file

@ -1066,6 +1066,11 @@ class DiscordAdapter(BasePlatformAdapter):
elif allow_bots == "mentions":
if not self._self_is_explicitly_mentioned(message):
return
if (
self._discord_bots_require_inline_mention()
and not self._self_is_raw_mentioned(message)
):
return
# "all" falls through; bot is permitted — skip the
# human-user allowlist below (bots aren't in it).
else:
@ -4670,6 +4675,44 @@ class DiscordAdapter(BasePlatformAdapter):
return True
return str(self._client.user.id) in self._raw_mentioned_user_ids(message)
def _self_is_raw_mentioned(self, message: Any) -> bool:
"""Return True only when this bot has an inline mention token.
Discord reply-pings can add the replied-to bot to ``message.mentions``
without a literal ``<@bot>`` token in ``message.content``. This helper
intentionally ignores the resolved mentions list so the bot admission
gate can distinguish an explicit cross-bot address from a reply chip.
"""
if not self._client or not self._client.user:
return False
return str(self._client.user.id) in self._raw_mentioned_user_ids(message)
def _discord_bots_require_inline_mention(self) -> bool:
"""Whether another bot must type an inline @mention to trigger us.
Off by default. When on, a bot-authored message only wakes this bot
if its content contains a literal ``<@thisbot>`` token. A Discord
reply/quote to one of our messages is NOT enough on its own, because
Discord's reply-ping silently adds us to ``message.mentions`` even
though the author never typed our handle which otherwise lets two
bots ping-pong replies at each other indefinitely. Humans are never
affected by this gate; it only applies to bot authors.
Config: ``discord.bots_require_inline_mention`` (or env
``DISCORD_BOTS_REQUIRE_INLINE_MENTION``).
"""
configured = self.config.extra.get("bots_require_inline_mention")
if configured is not None:
if isinstance(configured, str):
return configured.lower() in {"true", "1", "yes", "on"}
return bool(configured)
return os.getenv("DISCORD_BOTS_REQUIRE_INLINE_MENTION", "false").lower() in {
"true",
"1",
"yes",
"on",
}
def _discord_channel_keys(self, message: Any, parent_channel_id: Optional[str] = None) -> set[str]:
"""Return channel identifiers accepted by Discord channel config gates.
@ -7599,7 +7642,8 @@ def _apply_yaml_config(yaml_cfg: dict, discord_cfg: dict) -> dict | None:
``DISCORD_IGNORED_CHANNELS``, ``DISCORD_ALLOWED_CHANNELS``,
``DISCORD_NO_THREAD_CHANNELS``, ``DISCORD_HISTORY_BACKFILL``,
``DISCORD_HISTORY_BACKFILL_LIMIT``, ``DISCORD_ALLOW_MENTION_*``,
``DISCORD_REPLY_TO_MODE``, ``DISCORD_THREAD_REQUIRE_MENTION``).
``DISCORD_REPLY_TO_MODE``, ``DISCORD_THREAD_REQUIRE_MENTION``,
``DISCORD_BOTS_REQUIRE_INLINE_MENTION``).
Rather than rewrite ~50 call sites inside the adapter to read from
``PlatformConfig.extra`` instead, this hook keeps the existing
env-driven model and merely owns the YAMLenv translation here, next to
@ -7614,6 +7658,8 @@ def _apply_yaml_config(yaml_cfg: dict, discord_cfg: dict) -> dict | None:
os.environ["DISCORD_REQUIRE_MENTION"] = str(discord_cfg["require_mention"]).lower()
if "thread_require_mention" in discord_cfg and not os.getenv("DISCORD_THREAD_REQUIRE_MENTION"):
os.environ["DISCORD_THREAD_REQUIRE_MENTION"] = str(discord_cfg["thread_require_mention"]).lower()
if "bots_require_inline_mention" in discord_cfg and not os.getenv("DISCORD_BOTS_REQUIRE_INLINE_MENTION"):
os.environ["DISCORD_BOTS_REQUIRE_INLINE_MENTION"] = str(discord_cfg["bots_require_inline_mention"]).lower()
platforms_cfg = yaml_cfg.get("platforms")
platform_extra_cfg = {}
if isinstance(platforms_cfg, dict):

View file

@ -521,6 +521,42 @@ class TestLoadGatewayConfig:
# Env value preserved, not clobbered by yaml.
assert os.environ.get("DISCORD_THREAD_REQUIRE_MENTION") == "true"
def test_bridges_discord_bots_require_inline_mention_from_config_yaml(self, tmp_path, monkeypatch):
"""discord.bots_require_inline_mention should reach the runtime env var."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
config_path = hermes_home / "config.yaml"
config_path.write_text(
"discord:\n"
" bots_require_inline_mention: true\n",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.delenv("DISCORD_BOTS_REQUIRE_INLINE_MENTION", raising=False)
load_gateway_config()
assert os.environ.get("DISCORD_BOTS_REQUIRE_INLINE_MENTION") == "true"
def test_bots_require_inline_mention_yaml_does_not_overwrite_env(self, tmp_path, monkeypatch):
"""Explicit env var should win over config.yaml for inline bot mention gating."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
config_path = hermes_home / "config.yaml"
config_path.write_text(
"discord:\n"
" bots_require_inline_mention: false\n",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setenv("DISCORD_BOTS_REQUIRE_INLINE_MENTION", "true")
load_gateway_config()
assert os.environ.get("DISCORD_BOTS_REQUIRE_INLINE_MENTION") == "true"
def test_bridges_discord_allow_from_from_config_yaml(self, tmp_path, monkeypatch):
"""discord.allow_from should populate DISCORD_ALLOWED_USERS for auth."""
hermes_home = tmp_path / ".hermes"

View file

@ -54,7 +54,24 @@ class TestDiscordBotFilter(unittest.TestCase):
}
return str(client_user.id) in raw_ids
def _run_filter(self, message, allow_bots="none", client_user=None):
@staticmethod
def _self_is_raw_mentioned(message, client_user):
"""Mirror adapter._self_is_raw_mentioned: raw inline token only."""
if not client_user:
return False
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,
bots_require_inline_mention=False,
):
"""Simulate the on_message filter logic and return whether message was accepted."""
# Replicate the exact filter logic from discord.py on_message
if message.author == client_user:
@ -67,6 +84,11 @@ class TestDiscordBotFilter(unittest.TestCase):
elif allow == "mentions":
if not self._self_is_explicitly_mentioned(message, client_user):
return False
if (
bots_require_inline_mention
and not self._self_is_raw_mentioned(message, client_user)
):
return False
# "all" falls through
return True # message accepted
@ -118,6 +140,70 @@ class TestDiscordBotFilter(unittest.TestCase):
msg = _make_message(author=bot, content=f"<@!{our_user.id}> relay", mentions=[])
self.assertTrue(self._run_filter(msg, "mentions", our_user))
def test_inline_mention_requirement_off_preserves_reply_ping_behavior(self):
"""Default behavior: resolved reply-ping mentions still admit bot messages."""
our_user = _make_author(is_self=True)
bot = _make_author(bot=True)
msg = _make_message(author=bot, content="reply-ping only", mentions=[our_user])
self.assertTrue(
self._run_filter(
msg,
"all",
our_user,
bots_require_inline_mention=False,
)
)
def test_inline_mention_requirement_rejects_reply_ping_only(self):
"""Opt-in guard rejects bot messages where only Discord's reply-ping mentions us."""
our_user = _make_author(is_self=True)
bot = _make_author(bot=True)
msg = _make_message(author=bot, content="reply-ping only", mentions=[our_user])
self.assertFalse(
self._run_filter(
msg,
"all",
our_user,
bots_require_inline_mention=True,
)
)
def test_inline_mention_requirement_accepts_body_mention(self):
"""Opt-in guard still admits intentional inline cross-bot mentions."""
our_user = _make_author(is_self=True)
bot = _make_author(bot=True)
msg = _make_message(
author=bot,
content=f"<@{our_user.id}> intentional handoff",
mentions=[our_user],
)
self.assertTrue(
self._run_filter(
msg,
"all",
our_user,
bots_require_inline_mention=True,
)
)
def test_inline_mention_requirement_does_not_affect_humans(self):
"""The opt-in guard only applies to bot-authored messages."""
human = _make_author(bot=False)
our_user = _make_author(is_self=True)
msg = _make_message(author=human, content="human reply-ping", mentions=[our_user])
self.assertTrue(
self._run_filter(
msg,
"none",
our_user,
bots_require_inline_mention=True,
)
)
def test_default_is_none(self):
"""Default behavior (no env var) should be 'none'."""
default = os.getenv("DISCORD_ALLOW_BOTS", "none")