fix(slack): honor documented mention_patterns wake words

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.
This commit is contained in:
devorun 2026-06-22 15:02:00 +03:00 committed by Teknium
parent 2617946397
commit 4966268764
2 changed files with 116 additions and 3 deletions

View file

@ -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)

View file

@ -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