diff --git a/gateway/config.py b/gateway/config.py index a7c742839c0..39a583e2e79 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -908,6 +908,8 @@ def load_gateway_config() -> GatewayConfig: if isinstance(discord_cfg, dict): if "require_mention" in discord_cfg and not os.getenv("DISCORD_REQUIRE_MENTION"): 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() frc = discord_cfg.get("free_response_channels") if frc is not None and not os.getenv("DISCORD_FREE_RESPONSE_CHANNELS"): if isinstance(frc, list): diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index e770d5558da..b1b5012776b 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -3577,6 +3577,25 @@ class DiscordAdapter(BasePlatformAdapter): return {part.strip() for part in s.split(",") if part.strip()} return set() + def _discord_thread_require_mention(self) -> bool: + """Return whether thread participation requires @mention to follow up. + + When ``False`` (default), once the bot has participated in a thread it + keeps responding to every message in that thread without needing to be + mentioned again — useful for one-on-one conversations. + + When ``True``, the @mention requirement is enforced inside threads as + well. Set this when multiple bots share a thread and you want each + one to only fire on explicit @mention, avoiding bot-to-bot loops or + unwanted cross-replies. + """ + configured = self.config.extra.get("thread_require_mention") + if configured is not None: + if isinstance(configured, str): + return configured.lower() not in ("false", "0", "no", "off") + return bool(configured) + return os.getenv("DISCORD_THREAD_REQUIRE_MENTION", "false").lower() in ("true", "1", "yes", "on") + def _thread_parent_channel(self, channel: Any) -> Any: """Return the parent text channel when invoked from a thread.""" return getattr(channel, "parent", None) or channel @@ -4209,8 +4228,15 @@ class DiscordAdapter(BasePlatformAdapter): ) # Skip the mention check if the message is in a thread where - # the bot has previously participated (auto-created or replied in). - in_bot_thread = is_thread and thread_id in self._threads + # the bot has previously participated (auto-created or replied in) + # — UNLESS thread_require_mention is enabled, in which case threads + # are gated the same as channels. Useful when multiple bots share + # a thread. + in_bot_thread = ( + is_thread + and thread_id in self._threads + and not self._discord_thread_require_mention() + ) 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: diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 5d4ecb5b619..fd9784d7847 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1244,6 +1244,7 @@ DEFAULT_CONFIG = { "free_response_channels": "", # Comma-separated channel IDs where bot responds without mention "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) "reactions": True, # Add 👀/✅/❌ reactions to messages during processing "channel_prompts": {}, # Per-channel ephemeral system prompts (forum parents apply to child threads) # Opt-in DM role-based auth (#12136). By default, DISCORD_ALLOWED_ROLES diff --git a/tests/gateway/test_config.py b/tests/gateway/test_config.py index c59b27d8001..aae3c9e5880 100644 --- a/tests/gateway/test_config.py +++ b/tests/gateway/test_config.py @@ -302,6 +302,43 @@ class TestLoadGatewayConfig: assert config.thread_sessions_per_user is False + def test_bridges_discord_thread_require_mention_from_config_yaml(self, tmp_path, monkeypatch): + """discord.thread_require_mention in config.yaml 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" + " thread_require_mention: true\n", + encoding="utf-8", + ) + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.delenv("DISCORD_THREAD_REQUIRE_MENTION", raising=False) + + load_gateway_config() + + assert os.environ.get("DISCORD_THREAD_REQUIRE_MENTION") == "true" + + def test_thread_require_mention_yaml_does_not_overwrite_env(self, tmp_path, monkeypatch): + """Explicit env var should win over config.yaml (env > yaml precedence).""" + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + config_path = hermes_home / "config.yaml" + config_path.write_text( + "discord:\n" + " thread_require_mention: false\n", + encoding="utf-8", + ) + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.setenv("DISCORD_THREAD_REQUIRE_MENTION", "true") # user override + + load_gateway_config() + + # Env value preserved, not clobbered by yaml. + assert os.environ.get("DISCORD_THREAD_REQUIRE_MENTION") == "true" + def test_bridges_quoted_false_platform_enabled_from_config_yaml(self, tmp_path, monkeypatch): hermes_home = tmp_path / ".hermes" hermes_home.mkdir() diff --git a/tests/gateway/test_discord_free_response.py b/tests/gateway/test_discord_free_response.py index 7fa388dc4ae..57198b9e73a 100644 --- a/tests/gateway/test_discord_free_response.py +++ b/tests/gateway/test_discord_free_response.py @@ -88,6 +88,20 @@ def adapter(monkeypatch): monkeypatch.setattr(discord_platform.discord, "Thread", FakeThread, raising=False) monkeypatch.setattr(discord_platform.discord, "ForumChannel", FakeForumChannel, raising=False) + # Clear DISCORD_* env vars the test file exercises so tests don't leak + # process-env state from the contributor's shell into per-test behaviour. + # Individual tests still monkeypatch.setenv() for their own scenarios. + for _var in ( + "DISCORD_REQUIRE_MENTION", + "DISCORD_THREAD_REQUIRE_MENTION", + "DISCORD_FREE_RESPONSE_CHANNELS", + "DISCORD_AUTO_THREAD", + "DISCORD_NO_THREAD_CHANNELS", + "DISCORD_ALLOWED_CHANNELS", + "DISCORD_IGNORED_CHANNELS", + ): + monkeypatch.delenv(_var, raising=False) + config = PlatformConfig(enabled=True, token="fake-token") adapter = DiscordAdapter(config) adapter._client = SimpleNamespace(user=SimpleNamespace(id=999)) @@ -494,3 +508,73 @@ async def test_discord_voice_linked_parent_thread_still_requires_mention(adapter await adapter._handle_message(message) adapter.handle_message.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_discord_thread_default_keeps_responding_after_participation(adapter, monkeypatch): + """Default behavior: once the bot is in a thread, it auto-responds without @mention.""" + monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true") + monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False) + monkeypatch.delenv("DISCORD_THREAD_REQUIRE_MENTION", raising=False) + + thread = FakeThread(channel_id=456, name="follow-up") + adapter._threads.mark("456") # bot has previously participated + + message = make_message(channel=thread, content="follow-up without mention") + await adapter._handle_message(message) + + adapter.handle_message.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_discord_thread_require_mention_gates_followups(adapter, monkeypatch): + """When thread_require_mention=true, even bot-participated threads need @mention.""" + monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true") + monkeypatch.setenv("DISCORD_THREAD_REQUIRE_MENTION", "true") + monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False) + + thread = FakeThread(channel_id=456, name="multi-bot thread") + adapter._threads.mark("456") # bot has previously participated + + message = make_message(channel=thread, content="ambient chatter — not for me") + await adapter._handle_message(message) + + adapter.handle_message.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_discord_thread_require_mention_still_responds_when_mentioned(adapter, monkeypatch): + """thread_require_mention=true still lets explicit @mentions through in threads.""" + monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true") + monkeypatch.setenv("DISCORD_THREAD_REQUIRE_MENTION", "true") + monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False) + + thread = FakeThread(channel_id=456, name="multi-bot thread") + adapter._threads.mark("456") + bot_user = adapter._client.user + + message = make_message( + channel=thread, + content=f"<@{bot_user.id}> hey, this one's for you", + mentions=[bot_user], + ) + await adapter._handle_message(message) + + adapter.handle_message.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_discord_thread_require_mention_via_config_extra(adapter, monkeypatch): + """thread_require_mention can also be set via config.extra (yaml).""" + monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true") + monkeypatch.delenv("DISCORD_THREAD_REQUIRE_MENTION", raising=False) + monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False) + adapter.config.extra["thread_require_mention"] = True + + thread = FakeThread(channel_id=456, name="multi-bot thread") + adapter._threads.mark("456") + + message = make_message(channel=thread, content="ambient — should be ignored") + await adapter._handle_message(message) + + adapter.handle_message.assert_not_awaited() diff --git a/website/docs/user-guide/messaging/discord.md b/website/docs/user-guide/messaging/discord.md index 375d682f92d..a4530148cbf 100644 --- a/website/docs/user-guide/messaging/discord.md +++ b/website/docs/user-guide/messaging/discord.md @@ -277,6 +277,7 @@ Discord behavior is controlled through two files: **`~/.hermes/.env`** for crede | `DISCORD_HOME_CHANNEL_NAME` | No | `"Home"` | Display name for the home channel in logs and status output. | | `DISCORD_COMMAND_SYNC_POLICY` | No | `"safe"` | Controls native slash-command startup sync. `"safe"` diffs existing global commands and only updates what changed, recreating commands when Discord metadata changes cannot be applied via patch. `"bulk"` preserves the old `tree.sync()` behavior. `"off"` skips startup sync entirely. | | `DISCORD_REQUIRE_MENTION` | No | `true` | When `true`, the bot only responds in server channels when `@mentioned`. Set to `false` to respond to all messages in every channel. | +| `DISCORD_THREAD_REQUIRE_MENTION` | No | `false` | When `true`, the in-thread mention shortcut is disabled — threads are gated the same as channels, requiring `@mention` even after the bot has already participated. Use this when multiple bots share a thread and you want each to fire only on explicit `@mention`. | | `DISCORD_FREE_RESPONSE_CHANNELS` | No | — | Comma-separated channel IDs where the bot responds without requiring an `@mention`, even when `DISCORD_REQUIRE_MENTION` is `true`. | | `DISCORD_IGNORE_NO_MENTION` | No | `true` | When `true`, the bot stays silent if a message `@mentions` other users but does **not** mention the bot. Prevents the bot from jumping into conversations directed at other people. Only applies in server channels, not DMs. | | `DISCORD_AUTO_THREAD` | No | `true` | When `true`, automatically creates a new thread for every `@mention` in a text channel, so each conversation is isolated (similar to Slack behavior). Messages already inside threads or DMs are unaffected. | @@ -302,6 +303,7 @@ The `discord` section in `~/.hermes/config.yaml` mirrors the env vars above. Con # Discord-specific settings discord: require_mention: true # Require @mention in server channels + thread_require_mention: false # If true, require @mention in threads too (multi-bot threads) free_response_channels: "" # Comma-separated channel IDs (or YAML list) auto_thread: true # Auto-create threads on @mention reactions: true # Add emoji reactions during processing @@ -324,6 +326,20 @@ group_sessions_per_user: true # Isolate sessions per user in shared channels When enabled, the bot only responds in server channels when directly `@mentioned`. DMs always get a response regardless of this setting. +#### `discord.thread_require_mention` + +**Type:** boolean — **Default:** `false` + +By default, once the bot has participated in a thread (auto-created on `@mention` or replied in once), it keeps responding to every subsequent message in that thread without needing to be `@mentioned` again. That's the right default for one-on-one conversations. + +In **multi-bot threads** where users address one bot per turn, this default becomes a footgun — every other bot in the thread also fires on every message, burning credits and spamming the channel. Set `thread_require_mention: true` to disable the in-thread shortcut and gate threads the same way channels are gated. Explicit `@mentions` still work as before. + +```yaml +discord: + require_mention: true + thread_require_mention: true # multi-bot setup +``` + #### `discord.free_response_channels` **Type:** string or list — **Default:** `""` @@ -350,7 +366,7 @@ Free-response channels also **skip auto-threading** — the bot replies inline r **Type:** boolean — **Default:** `true` -When enabled, every `@mention` in a regular text channel automatically creates a new thread for the conversation. This keeps the main channel clean and gives each conversation its own isolated session history. Once a thread is created, subsequent messages in that thread don't require `@mention` — the bot knows it's already participating. +When enabled, every `@mention` in a regular text channel automatically creates a new thread for the conversation. This keeps the main channel clean and gives each conversation its own isolated session history. Once a thread is created, subsequent messages in that thread don't require `@mention` — the bot knows it's already participating. Set [`thread_require_mention`](#discordthread_require_mention) to `true` to disable this in-thread shortcut for multi-bot setups. Messages sent in existing threads or DMs are unaffected by this setting. Channels listed in `discord.free_response_channels` or `discord.no_thread_channels` also bypass auto-threading and get inline replies instead.