diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 17f506a5d..90366da9d 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -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 diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index afbc1e95f..bfe472450 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -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): diff --git a/tests/gateway/test_config.py b/tests/gateway/test_config.py index ad733c836..823d89f21 100644 --- a/tests/gateway/test_config.py +++ b/tests/gateway/test_config.py @@ -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" diff --git a/tests/gateway/test_discord_bot_filter.py b/tests/gateway/test_discord_bot_filter.py index 014be1022..fdc511a91 100644 --- a/tests/gateway/test_discord_bot_filter.py +++ b/tests/gateway/test_discord_bot_filter.py @@ -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")