From 2bfefd6e6286b2ff47940e01d419eb8c4c067d83 Mon Sep 17 00:00:00 2001 From: rich-jojo Date: Sat, 18 Jul 2026 18:06:47 +0900 Subject: [PATCH] feat(discord): support regex wake-word patterns --- plugins/platforms/discord/adapter.py | 62 ++++++++++++++++++- tests/gateway/test_discord_free_response.py | 55 ++++++++++++++++ .../docs/reference/environment-variables.md | 1 + website/docs/user-guide/messaging/discord.md | 20 ++++++ 4 files changed, 137 insertions(+), 1 deletion(-) diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index 215ae0d46d18b..532ee6383721d 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -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( @@ -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. @@ -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) @@ -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: @@ -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). diff --git a/tests/gateway/test_discord_free_response.py b/tests/gateway/test_discord_free_response.py index 6b0c4c32753dd..3361ca664a058 100644 --- a/tests/gateway/test_discord_free_response.py +++ b/tests/gateway/test_discord_free_response.py @@ -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) @@ -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") diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index bb9f22882e8be..8693d980c5717 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -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`. | diff --git a/website/docs/user-guide/messaging/discord.md b/website/docs/user-guide/messaging/discord.md index 3414e14414e69..8bf1a0aed10f5 100644 --- a/website/docs/user-guide/messaging/discord.md +++ b/website/docs/user-guide/messaging/discord.md @@ -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 @@ -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`