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:
parent
60b1f6ce3f
commit
e9bceb5ae0
4 changed files with 171 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 YAML→env 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):
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue