From 49662687646d424595126c8254334bcf0284656f Mon Sep 17 00:00:00 2001 From: devorun <130918800+devorun@users.noreply.github.com> Date: Mon, 22 Jun 2026 15:02:00 +0300 Subject: [PATCH] fix(slack): honor documented `mention_patterns` wake words MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Slack docs document `slack.mention_patterns` as custom wake words that trigger the bot alongside `@mention`, and the config layer bridges the key into the Slack adapter's `config.extra` — but the adapter never read it. With `require_mention` on, a channel message containing a configured wake word (and no literal `<@BOTUID>`) was silently ignored. Every other adapter that documents `mention_patterns` (Telegram, DingTalk, Mattermost, WhatsApp, BlueBubbles, Photon) implements it; Slack was the odd one out. Add `_slack_mention_patterns()` (compiled, cached; reads `slack.mention_patterns` as a list/string or `SLACK_MENTION_PATTERNS` as a JSON/CSV/newline list, invalid regexes warned and skipped) and `_slack_message_matches_mention_patterns()`, mirroring the existing adapters. Channel mention detection now also triggers on a wake-word match, so the documented field works as described. Adds tests for pattern compilation (list/string/env/invalid-regex) and for the channel-trigger gating with a wake word under require_mention. --- plugins/platforms/slack/adapter.py | 61 ++++++++++++++++++++++++++++- tests/gateway/test_slack_mention.py | 58 ++++++++++++++++++++++++++- 2 files changed, 116 insertions(+), 3 deletions(-) diff --git a/plugins/platforms/slack/adapter.py b/plugins/platforms/slack/adapter.py index 1ea5af4c4..8b7e66841 100644 --- a/plugins/platforms/slack/adapter.py +++ b/plugins/platforms/slack/adapter.py @@ -2485,7 +2485,10 @@ class SlackAdapter(BasePlatformAdapter): # 4. There's an existing session for this thread (survives restarts) bot_uid = self._team_bot_user_ids.get(team_id, self._bot_user_id) routing_text = original_text or "" - is_mentioned = bot_uid and f"<@{bot_uid}>" in routing_text + is_mentioned = bool( + (bot_uid and f"<@{bot_uid}>" in routing_text) + or self._slack_message_matches_mention_patterns(routing_text) + ) event_thread_ts = event.get("thread_ts") is_thread_reply = bool(event_thread_ts and event_thread_ts != ts) @@ -3812,6 +3815,62 @@ class SlackAdapter(BasePlatformAdapter): return {part.strip() for part in raw.split(",") if part.strip()} return set() + def _slack_mention_patterns(self) -> List["re.Pattern"]: + """Compile optional regex wake-word patterns for channel triggers. + + Parity with the other adapters (Telegram, DingTalk, Mattermost, + WhatsApp, BlueBubbles, Photon): when ``require_mention`` is on, a + channel message matching one of these patterns triggers the bot even + without a literal ``<@BOTUID>`` mention. Reads ``slack.mention_patterns`` + (a list or single string) or ``SLACK_MENTION_PATTERNS`` (a JSON list, or + newline/comma-separated values). Compiled patterns are cached on the + instance. Previously this documented field was silently dropped. + """ + 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("SLACK_MENTION_PATTERNS", "").strip() + if raw: + try: + import json as _json + patterns = _json.loads(raw) + except Exception: + patterns = [p.strip() for p in raw.splitlines() if p.strip()] or [ + p.strip() for p in raw.split(",") if p.strip() + ] + + if isinstance(patterns, str): + patterns = [patterns] + + compiled: List["re.Pattern"] = [] + if isinstance(patterns, list): + for pat in patterns: + if not isinstance(pat, str) or not pat.strip(): + continue + try: + compiled.append(re.compile(pat, re.IGNORECASE)) + except re.error as exc: + logger.warning("[Slack] Invalid mention pattern %r: %s", pat, exc) + elif patterns is not None: + logger.warning( + "[Slack] mention_patterns must be a list or string; got %s", + type(patterns).__name__, + ) + + if compiled: + logger.info("[Slack] Loaded %d mention pattern(s)", len(compiled)) + self._compiled_mention_patterns = compiled + return compiled + + def _slack_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._slack_mention_patterns()) + # ────────────────────────────────────────────────────────────────────────── # Plugin migration glue (#41112 / #3823) diff --git a/tests/gateway/test_slack_mention.py b/tests/gateway/test_slack_mention.py index 78efb4782..32b38ad73 100644 --- a/tests/gateway/test_slack_mention.py +++ b/tests/gateway/test_slack_mention.py @@ -55,7 +55,8 @@ CHANNEL_ID = "C0AQWDLHY9M" OTHER_CHANNEL_ID = "C9999999999" -def _make_adapter(require_mention=None, strict_mention=None, free_response_channels=None, allowed_channels=None): +def _make_adapter(require_mention=None, strict_mention=None, free_response_channels=None, + allowed_channels=None, mention_patterns=None): extra = {} if require_mention is not None: extra["require_mention"] = require_mention @@ -65,6 +66,8 @@ def _make_adapter(require_mention=None, strict_mention=None, free_response_chann extra["free_response_channels"] = free_response_channels if allowed_channels is not None: extra["allowed_channels"] = allowed_channels + if mention_patterns is not None: + extra["mention_patterns"] = mention_patterns adapter = object.__new__(SlackAdapter) adapter.platform = Platform.SLACK @@ -249,7 +252,10 @@ def _would_process(adapter, *, is_dm=False, channel_id=CHANNEL_ID, bot_uid = adapter._team_bot_user_ids.get("T1", adapter._bot_user_id) if mentioned: text = f"<@{bot_uid}> {text}" - is_mentioned = bot_uid and f"<@{bot_uid}>" in text + is_mentioned = bool( + (bot_uid and f"<@{bot_uid}>" in text) + or adapter._slack_message_matches_mention_patterns(text) + ) if not is_dm and bot_uid: # allowed_channels check (whitelist — must pass before other gating) @@ -687,3 +693,51 @@ def test_config_bridges_slack_allowed_channels_env_takes_precedence(monkeypatch, import os as _os # env var must not be overwritten by config.yaml assert _os.environ["SLACK_ALLOWED_CHANNELS"] == OTHER_CHANNEL_ID + + +# --------------------------------------------------------------------------- +# Tests: mention_patterns (wake words) — parity with other adapters (#50732) +# --------------------------------------------------------------------------- + +def test_mention_patterns_default_no_match(monkeypatch): + monkeypatch.delenv("SLACK_MENTION_PATTERNS", raising=False) + adapter = _make_adapter() + assert adapter._slack_mention_patterns() == [] + assert adapter._slack_message_matches_mention_patterns("hello there") is False + + +def test_mention_patterns_list_matches(): + adapter = _make_adapter(mention_patterns=["hey hermes", "hermes,"]) + assert adapter._slack_message_matches_mention_patterns("hey hermes, you there?") is True + assert adapter._slack_message_matches_mention_patterns("just chatting") is False + + +def test_mention_patterns_case_insensitive(): + adapter = _make_adapter(mention_patterns=["hey hermes"]) + assert adapter._slack_message_matches_mention_patterns("HEY HERMES!") is True + + +def test_mention_patterns_single_string(): + adapter = _make_adapter(mention_patterns="^hermes") + assert adapter._slack_message_matches_mention_patterns("hermes do this") is True + assert adapter._slack_message_matches_mention_patterns("ok hermes") is False + + +def test_mention_patterns_invalid_regex_skipped_without_crash(): + # An invalid pattern is dropped; valid siblings still work. + adapter = _make_adapter(mention_patterns=["(unclosed", "hey hermes"]) + assert adapter._slack_message_matches_mention_patterns("hey hermes") is True + + +def test_mention_patterns_env_var_fallback(monkeypatch): + monkeypatch.setenv("SLACK_MENTION_PATTERNS", '["hey hermes", "hermes,"]') + adapter = _make_adapter() # no config value -> falls back to env + assert adapter._slack_message_matches_mention_patterns("hey hermes") is True + + +def test_mention_patterns_trigger_in_channel_without_literal_mention(): + """A wake word triggers the bot in a channel even with require_mention on.""" + adapter = _make_adapter(require_mention=True, mention_patterns=["hey hermes"]) + assert _would_process(adapter, text="hey hermes what's the status") is True + # Unrelated channel chatter is still ignored. + assert _would_process(adapter, text="lunch anyone?") is False