Skip to content
Closed
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
11 changes: 11 additions & 0 deletions gateway/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -592,6 +592,10 @@ def load_gateway_config() -> GatewayConfig:
bridged["require_mention"] = platform_cfg["require_mention"]
if "free_response_channels" in platform_cfg:
bridged["free_response_channels"] = platform_cfg["free_response_channels"]
if plat == Platform.DISCORD and "strict_mention" in platform_cfg:
bridged["strict_mention"] = platform_cfg[
"strict_mention"
]
if "mention_patterns" in platform_cfg:
bridged["mention_patterns"] = platform_cfg["mention_patterns"]
if "dm_policy" in platform_cfg:
Expand Down Expand Up @@ -653,6 +657,13 @@ def load_gateway_config() -> GatewayConfig:
os.environ["DISCORD_AUTO_THREAD"] = str(discord_cfg["auto_thread"]).lower()
if "reactions" in discord_cfg and not os.getenv("DISCORD_REACTIONS"):
os.environ["DISCORD_REACTIONS"] = str(discord_cfg["reactions"]).lower()
if (
"strict_mention" in discord_cfg
and not os.getenv("DISCORD_STRICT_MENTION")
):
os.environ["DISCORD_STRICT_MENTION"] = str(
discord_cfg["strict_mention"]
).lower()
# ignored_channels: channels where bot never responds (even when mentioned)
ic = discord_cfg.get("ignored_channels")
if ic is not None and not os.getenv("DISCORD_IGNORED_CHANNELS"):
Expand Down
39 changes: 34 additions & 5 deletions gateway/platforms/discord.py
Original file line number Diff line number Diff line change
Expand Up @@ -2687,14 +2687,38 @@ def _resolve_channel_prompt(self, channel_id: str, parent_id: str | None = None)
from gateway.platforms.base import resolve_channel_prompt
return resolve_channel_prompt(self.config.extra, channel_id, parent_id)

@staticmethod
def _truthy_config(value: Any) -> bool:
"""Parse a config/env value as a boolean, treating common false strings as false."""
if isinstance(value, str):
return value.lower() not in ("false", "0", "no", "off")
return bool(value)

def _discord_require_mention(self) -> bool:
"""Return whether Discord channel messages require a bot mention."""
configured = self.config.extra.get("require_mention")
if configured is not None:
return self._truthy_config(configured)
return os.getenv("DISCORD_REQUIRE_MENTION", "true").lower() not in ("false", "0", "no", "off")

def _discord_strict_mention(self) -> bool:
"""Return whether Discord channel/thread messages require explicit mentions.

When enabled, previously participated threads do not bypass mention
gating. Defaults to false to preserve the existing thread-continuation
behavior.
"""
configured = self.config.extra.get("strict_mention")
if configured is not None:
if isinstance(configured, str):
return configured.lower() not in ("false", "0", "no", "off")
return configured.lower() in ("true", "1", "yes", "on")
return bool(configured)
return os.getenv("DISCORD_REQUIRE_MENTION", "true").lower() not in ("false", "0", "no", "off")
return os.getenv("DISCORD_STRICT_MENTION", "false").lower() in (
"true",
"1",
"yes",
"on",
)

def _discord_free_response_channels(self) -> set:
"""Return Discord channel IDs where no bot mention is required.
Expand Down Expand Up @@ -3222,9 +3246,14 @@ async def _handle_message(self, message: DiscordMessage) -> None:
or is_voice_linked_channel
)

# 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
# Skip the mention check unless strict mode is enabled and the
# message is in a thread where the bot has previously participated
# (auto-created or replied in).
in_bot_thread = (
not self._discord_strict_mention()
and is_thread
and thread_id in self._threads
)

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 @@ -863,6 +863,7 @@ def _ensure_hermes_home_managed(home: Path):
# Discord platform settings (gateway mode)
"discord": {
"require_mention": True, # Require @mention to respond in server channels
"strict_mention": False, # Require @mention on every channel/thread message (disables known-thread auto-engagement)
"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)
Expand Down
1 change: 1 addition & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,7 @@ def _looks_like_credential(name: str) -> bool:
"SLACK_ALLOW_BOTS",
"SLACK_REACTIONS",
"DISCORD_REQUIRE_MENTION",
"DISCORD_STRICT_MENTION",
"DISCORD_FREE_RESPONSE_CHANNELS",
"TELEGRAM_REQUIRE_MENTION",
"WHATSAPP_REQUIRE_MENTION",
Expand Down
34 changes: 34 additions & 0 deletions tests/gateway/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,40 @@ def test_bridges_discord_channel_prompts_from_config_yaml(self, tmp_path, monkey
"456": "Therapist mode",
}

def test_bridges_discord_strict_mention_from_config_yaml(self, tmp_path, monkeypatch):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
config_path = hermes_home / "config.yaml"
config_path.write_text(
"discord:\n"
" strict_mention: true\n",
encoding="utf-8",
)

monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.delenv("DISCORD_STRICT_MENTION", raising=False)

load_gateway_config()

assert os.environ.get("DISCORD_STRICT_MENTION") == "true"

def test_discord_strict_mention_env_takes_precedence(self, tmp_path, monkeypatch):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
config_path = hermes_home / "config.yaml"
config_path.write_text(
"discord:\n"
" strict_mention: false\n",
encoding="utf-8",
)

monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setenv("DISCORD_STRICT_MENTION", "true")

load_gateway_config()

assert os.environ.get("DISCORD_STRICT_MENTION") == "true"

def test_bridges_telegram_channel_prompts_from_config_yaml(self, tmp_path, monkeypatch):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
Expand Down
60 changes: 60 additions & 0 deletions tests/gateway/test_discord_free_response.py
Original file line number Diff line number Diff line change
Expand Up @@ -335,12 +335,35 @@ async def test_discord_auto_thread_can_be_disabled(adapter, monkeypatch):
assert event.source.chat_type == "group"


@pytest.mark.asyncio
async def test_discord_strict_mention_defaults_to_false(adapter, monkeypatch):
monkeypatch.delenv("DISCORD_STRICT_MENTION", raising=False)

assert adapter._discord_strict_mention() is False


@pytest.mark.asyncio
async def test_discord_strict_mention_env_var_fallback(adapter, monkeypatch):
monkeypatch.setenv("DISCORD_STRICT_MENTION", "true")

assert adapter._discord_strict_mention() is True


@pytest.mark.asyncio
async def test_discord_strict_mention_malformed_stays_false(adapter, monkeypatch):
monkeypatch.delenv("DISCORD_STRICT_MENTION", raising=False)
adapter.config.extra["strict_mention"] = "maybe"

assert adapter._discord_strict_mention() is False


@pytest.mark.asyncio
async def test_discord_bot_thread_skips_mention_requirement(adapter, monkeypatch):
"""Messages in a thread the bot has participated in should not require @mention."""
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true")
monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False)
monkeypatch.setenv("DISCORD_AUTO_THREAD", "false")
monkeypatch.delenv("DISCORD_STRICT_MENTION", raising=False)

# Simulate bot having previously participated in thread 456
adapter._threads.mark("456")
Expand All @@ -356,6 +379,43 @@ async def test_discord_bot_thread_skips_mention_requirement(adapter, monkeypatch
assert event.source.chat_type == "thread"


@pytest.mark.asyncio
async def test_discord_bot_thread_requires_mention_in_strict_mode(adapter, monkeypatch):
"""Strict mode should enforce mentions even in known threads."""
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true")
monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False)
monkeypatch.setenv("DISCORD_AUTO_THREAD", "false")
monkeypatch.setenv("DISCORD_STRICT_MENTION", "true")

adapter._threads.mark("456")

thread = FakeThread(channel_id=456, name="existing thread")
message = make_message(channel=thread, content="follow-up without mention")

await adapter._handle_message(message)

adapter.handle_message.assert_not_awaited()


@pytest.mark.asyncio
async def test_discord_config_extra_can_enable_strict_mention(adapter, monkeypatch):
"""Config extra should be able to enable strict mention mode without env vars."""
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true")
monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False)
monkeypatch.delenv("DISCORD_STRICT_MENTION", raising=False)
monkeypatch.setenv("DISCORD_AUTO_THREAD", "false")
adapter.config.extra["strict_mention"] = True

adapter._threads.mark("456")

thread = FakeThread(channel_id=456, name="existing thread")
message = make_message(channel=thread, content="follow-up without mention")

await adapter._handle_message(message)

adapter.handle_message.assert_not_awaited()


@pytest.mark.asyncio
async def test_discord_unknown_thread_still_requires_mention(adapter, monkeypatch):
"""Messages in a thread the bot hasn't participated in should still require @mention."""
Expand Down
20 changes: 18 additions & 2 deletions website/docs/user-guide/messaging/discord.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Before setup, here's the part most people want to know: how Hermes behaves once
| **DMs** | Hermes responds to every message. No `@mention` needed. Each DM has its own session. |
| **Server channels** | By default, Hermes only responds when you `@mention` it. If you post in a channel without mentioning it, Hermes ignores the message. |
| **Free-response channels** | You can make specific channels mention-free with `DISCORD_FREE_RESPONSE_CHANNELS`, or disable mentions globally with `DISCORD_REQUIRE_MENTION=false`. Messages in these channels are answered inline — auto-threading is skipped so the channel stays a lightweight chat. |
| **Threads** | Hermes replies in the same thread. Mention rules still apply unless that thread or its parent channel is configured as free-response. Threads stay isolated from the parent channel for session history. |
| **Threads** | Hermes replies in the same thread. By default, once Hermes has participated in a thread, follow-up messages in that thread continue without repeating the `@mention`. Set `discord.strict_mention: true` / `DISCORD_STRICT_MENTION=true` to require an explicit mention on every thread message. Threads stay isolated from the parent channel for session history. |
| **Shared channels with multiple users** | By default, Hermes isolates session history per user inside the channel for safety and clarity. Two people talking in the same channel do not share one transcript unless you explicitly disable that. |
| **Messages mentioning other users** | When `DISCORD_IGNORE_NO_MENTION` is `true` (the default), Hermes stays silent if a message @mentions other users but does **not** mention the bot. This prevents the bot from jumping into conversations directed at other people. Set to `false` if you want the bot to respond to all messages regardless of who is mentioned. This only applies in server channels, not DMs. |

Expand Down 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_STRICT_MENTION` | No | `false` | When `true`, channel/thread messages require an explicit `@mention` every time. Disables the default known-thread continuation behavior where previously participated threads can continue without repeating the mention. Useful for multi-bot channels where mentions route work to a specific bot. |
| `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
strict_mention: false # If true, require @mention on every channel/thread message
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.strict_mention`

**Type:** boolean — **Default:** `false`

When disabled (the default), Discord preserves its thread-continuation behavior: once Hermes has participated in a thread, later messages in that known thread can continue without repeating the `@mention`.

When enabled, every server channel/thread message must explicitly mention the bot unless the channel is configured as free-response. This is useful in multi-bot or multi-agent Discord servers where explicit mentions are the routing mechanism and a bot should not keep responding just because it participated earlier in the thread.

```yaml
discord:
require_mention: true
strict_mention: true
```

#### `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 `discord.strict_mention: true` if you want thread follow-ups to require a fresh mention every time.

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