Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions gateway/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -852,6 +852,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):
Expand Down
30 changes: 28 additions & 2 deletions gateway/platforms/discord.py
Original file line number Diff line number Diff line change
Expand Up @@ -3577,6 +3577,25 @@ def _discord_free_response_channels(self) -> set:
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
Expand Down Expand Up @@ -4209,8 +4228,15 @@ async def _handle_message(self, message: DiscordMessage) -> None:
)

# 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:
Expand Down
1 change: 1 addition & 0 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1229,6 +1229,7 @@ def _ensure_hermes_home_managed(home: Path):
"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
Expand Down
1 change: 1 addition & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -605,6 +605,7 @@
"iacobs@m0n5t3r.info": "m0n5t3r",
"jiayuw794@gmail.com": "JiayuuWang",
"jonny@nousresearch.com": "jquesnelle",
"jake@nousresearch.com": "simpolism",
"juan.ovalle@mistral.ai": "jjovalle99",
"julien.talbot@ergonomia.re": "Julientalbot",
"kagura.chen28@gmail.com": "kagura-agent",
Expand Down
37 changes: 37 additions & 0 deletions tests/gateway/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,43 @@ def test_thread_sessions_per_user_defaults_to_false(self, tmp_path, monkeypatch)

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()
Expand Down
84 changes: 84 additions & 0 deletions tests/gateway/test_discord_free_response.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -463,3 +477,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()
18 changes: 17 additions & 1 deletion website/docs/user-guide/messaging/discord.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand All @@ -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
Expand All @@ -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:** `""`
Expand All @@ -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.

Expand Down
Loading