Skip to content
Open
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
62 changes: 61 additions & 1 deletion plugins/platforms/discord/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -2131,6 +2131,9 @@ async def _dispatch_recovered_message(self, message: Any) -> bool:
and not (channel_keys & free_channels)
and not in_bot_thread
and not self._self_is_explicitly_mentioned(message)
and not self._discord_message_matches_mention_patterns(
str(getattr(message, "content", "") or "")
)
):
return False
admitted, role_authorized = self._discord_message_admission(
Expand Down Expand Up @@ -5642,6 +5645,54 @@ def _discord_require_mention(self) -> bool:
return bool(configured)
return os.getenv("DISCORD_REQUIRE_MENTION", "true").lower() not in {"false", "0", "no", "off"}

def _discord_mention_patterns(self) -> List["re.Pattern"]:
"""Compile optional regex wake-word patterns for mention gating."""
cached = getattr(self, "_compiled_mention_patterns", None)
if cached is not None:
return cached

patterns = self.config.extra.get("mention_patterns") if self.config.extra else None
if patterns is None:
raw = os.getenv("DISCORD_MENTION_PATTERNS", "").strip()
if raw:
try:
patterns = json.loads(raw)
except Exception:
patterns = [
part.strip()
for part in raw.replace("\n", ",").split(",")
if part.strip()
]

if isinstance(patterns, str):
patterns = [patterns]

compiled: List["re.Pattern"] = []
if isinstance(patterns, list):
for pattern in patterns:
if not isinstance(pattern, str) or not pattern.strip():
continue
try:
compiled.append(re.compile(pattern, re.IGNORECASE))
except re.error as exc:
logger.warning("[Discord] Invalid mention pattern %r: %s", pattern, exc)
elif patterns is not None:
logger.warning(
"[Discord] mention_patterns must be a list or string; got %s",
type(patterns).__name__,
)

if compiled:
logger.info("[Discord] Loaded %d mention pattern(s)", len(compiled))
self._compiled_mention_patterns = compiled
return compiled

def _discord_message_matches_mention_patterns(self, text: str) -> bool:
"""Return True when text matches a configured wake-word pattern."""
if not text:
return False
return any(pattern.search(text) for pattern in self._discord_mention_patterns())

def _discord_allow_any_attachment(self) -> bool:
"""Return whether Discord attachments bypass the SUPPORTED_DOCUMENT_TYPES allowlist.

Expand Down Expand Up @@ -7068,6 +7119,7 @@ async def _handle_message(
#
# Config (all settable via discord.* in config.yaml or DISCORD_* env vars):
# discord.require_mention: Require @mention in server channels (default: true)
# discord.mention_patterns: Regex wake words accepted instead of an @mention
# discord.free_response_channels: Channel IDs where bot responds without mention
# discord.ignored_channels: Channel IDs where bot NEVER responds (even when mentioned)
# discord.allowed_channels: If set, bot ONLY responds in these channels (whitelist)
Expand Down Expand Up @@ -7105,6 +7157,10 @@ async def _handle_message(
normalized_content = normalized_content.replace(f"<@{self._client.user.id}>", "").strip()
normalized_content = normalized_content.replace(f"<@!{self._client.user.id}>", "").strip()
message.content = normalized_content
mention_pattern_match = (
not mention_prefix
and self._discord_message_matches_mention_patterns(normalized_content)
)
if not isinstance(message.channel, discord.DMChannel):
channel_ids = {str(message.channel.id)}
if parent_channel_id:
Expand Down Expand Up @@ -7152,7 +7208,11 @@ async def _handle_message(
)

if require_mention and not is_free_channel and not in_bot_thread:
if not self._self_is_explicitly_mentioned(message) and not mention_prefix:
if (
not self._self_is_explicitly_mentioned(message)
and not mention_prefix
and not mention_pattern_match
):
return False
# Auto-thread: when enabled, automatically create a thread for every
# @mention in a text channel so each conversation is isolated (like Slack).
Expand Down
55 changes: 55 additions & 0 deletions tests/gateway/test_discord_free_response.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ def adapter(monkeypatch):
"DISCORD_HISTORY_BACKFILL",
"DISCORD_HISTORY_BACKFILL_LIMIT",
"DISCORD_ALLOW_BOTS",
"DISCORD_MENTION_PATTERNS",
):
monkeypatch.delenv(_var, raising=False)

Expand Down Expand Up @@ -268,6 +269,60 @@ async def test_discord_can_still_require_mentions_when_enabled(adapter, monkeypa
adapter.handle_message.assert_not_awaited()


@pytest.mark.asyncio
async def test_discord_mention_pattern_triggers_without_literal_mention(adapter, monkeypatch):
"""A configured wake-word pattern should satisfy mention gating."""
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true")
monkeypatch.setenv("DISCORD_AUTO_THREAD", "false")
monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False)
adapter.config.extra["mention_patterns"] = [r"^하온아(?:\s|[::,,!!??、])"]

message = make_message(
channel=FakeTextChannel(channel_id=123),
content="하온아 상태 확인",
)

await adapter._handle_message(message)

adapter.handle_message.assert_awaited_once()
event = adapter.handle_message.await_args.args[0]
assert event.text == "하온아 상태 확인"


@pytest.mark.asyncio
async def test_discord_recovered_mention_pattern_triggers_without_literal_mention(
adapter, monkeypatch
):
"""Recovered wake-word messages should pass the pre-dispatch mention gate."""
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true")
monkeypatch.setenv("DISCORD_AUTO_THREAD", "false")
monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False)
adapter.config.extra["mention_patterns"] = [r"^하온아(?:\s|[::,,!!??、])"]
adapter._is_allowed_user = MagicMock(return_value=True)

channel = FakeTextChannel(channel_id=123)
channel.guild.id = 321
message = make_message(channel=channel, content="하온아 상태 확인")
message.guild = channel.guild

dispatched = await adapter._dispatch_recovered_message(message)

assert dispatched is True
adapter.handle_message.assert_awaited_once()


def test_discord_mention_patterns_env_json_and_invalid_regex(adapter, monkeypatch):
monkeypatch.setenv(
"DISCORD_MENTION_PATTERNS",
'["(unclosed", "^hey hermes"]',
)

patterns = adapter._discord_mention_patterns()

assert [pattern.pattern for pattern in patterns] == ["^hey hermes"]
assert adapter._discord_message_matches_mention_patterns("HEY HERMES status") is True


@pytest.mark.asyncio
async def test_discord_free_response_channel_overrides_mention_requirement(adapter, monkeypatch):
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true")
Expand Down
1 change: 1 addition & 0 deletions website/docs/reference/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,7 @@ For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETI
| `DISCORD_HOME_CHANNEL_NAME` | Display name for the Discord home channel |
| `DISCORD_COMMAND_SYNC_POLICY` | Discord slash-command startup sync policy: `safe` (diff and reconcile), `bulk` (legacy `tree.sync()`), or `off` |
| `DISCORD_REQUIRE_MENTION` | Require an @mention before responding in server channels |
| `DISCORD_MENTION_PATTERNS` | JSON array, newline-separated list, or comma-separated list of regex wake-word patterns accepted when Discord mention gating is enabled. Equivalent to `discord.mention_patterns`. |
| `DISCORD_FREE_RESPONSE_CHANNELS` | Comma-separated channel IDs where mention is not required |
| `DISCORD_AUTO_THREAD` | Auto-thread long replies when supported |
| `DISCORD_ALLOW_ANY_ATTACHMENT` | When `true`, accept attachments of any file type (not just the built-in PDF/text/zip/office allowlist). Unknown types are cached and surfaced to the agent as a local path so it can inspect them via `terminal` / `read_file` / `ffprobe`. Default `false`. |
Expand Down
20 changes: 20 additions & 0 deletions website/docs/user-guide/messaging/discord.md
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,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
mention_patterns: [] # Optional regex wake words accepted as mentions
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
Expand Down Expand Up @@ -364,6 +365,25 @@ 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.mention_patterns`

**Type:** string or list of strings — **Default:** `[]`

Optional case-insensitive regular expressions that count as a bot mention when
`require_mention` is enabled. Use an anchored pattern when the wake word must
appear at the start of the message:

```yaml
discord:
require_mention: true
mention_patterns:
- '^hermes(?:\\s|[::,,!!??])'
```

This keeps unrelated channel conversation gated while allowing messages such
as `hermes status` without a literal Discord `@mention`. Invalid patterns are
ignored with a warning instead of stopping the gateway.

#### `discord.thread_require_mention`

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