feat(slack): add require_mention and free_response_channels config support

Port the mention gating pattern from Telegram, Discord, WhatsApp, and
Matrix adapters to the Slack platform adapter.

- Add _slack_require_mention() with explicit-false parsing and env var
  fallback (SLACK_REQUIRE_MENTION)
- Add _slack_free_response_channels() with env var fallback
  (SLACK_FREE_RESPONSE_CHANNELS)
- Replace hardcoded mention check with configurable gating logic
- Bridge slack config.yaml settings to env vars
- Bridge free_response_channels through the generic platform bridging loop
- Add 26 tests covering config parsing, env fallback, gating logic

Config usage:
  slack:
    require_mention: false
    free_response_channels:
      - "C0AQWDLHY9M"

Default behavior unchanged: channels require @mention (backward compatible).

Based on PR #5885 by dorukardahan, cherry-picked and adapted to current main.
This commit is contained in:
Doruk Ardahan
2026-04-09 13:31:36 -07:00
committed by Teknium
parent 997e219c14
commit 7d499c75db
3 changed files with 376 additions and 17 deletions

View File

@@ -532,6 +532,8 @@ def load_gateway_config() -> GatewayConfig:
bridged["reply_prefix"] = platform_cfg["reply_prefix"]
if "require_mention" in platform_cfg:
bridged["require_mention"] = platform_cfg["require_mention"]
if "free_response_channels" in platform_cfg:
bridged["free_response_channels"] = platform_cfg["free_response_channels"]
if "mention_patterns" in platform_cfg:
bridged["mention_patterns"] = platform_cfg["mention_patterns"]
if not bridged:
@@ -546,6 +548,17 @@ def load_gateway_config() -> GatewayConfig:
plat_data["extra"] = extra
extra.update(bridged)
# Slack settings → env vars (env vars take precedence)
slack_cfg = yaml_cfg.get("slack", {})
if isinstance(slack_cfg, dict):
if "require_mention" in slack_cfg and not os.getenv("SLACK_REQUIRE_MENTION"):
os.environ["SLACK_REQUIRE_MENTION"] = str(slack_cfg["require_mention"]).lower()
frc = slack_cfg.get("free_response_channels")
if frc is not None and not os.getenv("SLACK_FREE_RESPONSE_CHANNELS"):
if isinstance(frc, list):
frc = ",".join(str(v) for v in frc)
os.environ["SLACK_FREE_RESPONSE_CHANNELS"] = str(frc)
# Discord settings → env vars (env vars take precedence)
discord_cfg = yaml_cfg.get("discord", {})
if isinstance(discord_cfg, dict):

View File

@@ -961,6 +961,8 @@ class SlackAdapter(BasePlatformAdapter):
thread_ts = event.get("thread_ts") or ts # ts fallback for channels
# In channels, respond if:
# 0. Channel is in free_response_channels, OR require_mention is
# disabled — always process regardless of mention.
# 1. The bot is @mentioned in this message, OR
# 2. The message is a reply in a thread the bot started/participated in, OR
# 3. The message is in a thread where the bot was previously @mentioned, OR
@@ -970,24 +972,29 @@ class SlackAdapter(BasePlatformAdapter):
event_thread_ts = event.get("thread_ts")
is_thread_reply = bool(event_thread_ts and event_thread_ts != ts)
if not is_dm and bot_uid and not is_mentioned:
reply_to_bot_thread = (
is_thread_reply and event_thread_ts in self._bot_message_ts
)
in_mentioned_thread = (
event_thread_ts is not None
and event_thread_ts in self._mentioned_threads
)
has_session = (
is_thread_reply
and self._has_active_session_for_thread(
channel_id=channel_id,
thread_ts=event_thread_ts,
user_id=user_id,
if not is_dm and bot_uid:
if channel_id in self._slack_free_response_channels():
pass # Free-response channel — always process
elif not self._slack_require_mention():
pass # Mention requirement disabled globally for Slack
elif not is_mentioned:
reply_to_bot_thread = (
is_thread_reply and event_thread_ts in self._bot_message_ts
)
)
if not reply_to_bot_thread and not in_mentioned_thread and not has_session:
return
in_mentioned_thread = (
event_thread_ts is not None
and event_thread_ts in self._mentioned_threads
)
has_session = (
is_thread_reply
and self._has_active_session_for_thread(
channel_id=channel_id,
thread_ts=event_thread_ts,
user_id=user_id,
)
)
if not reply_to_bot_thread and not in_mentioned_thread and not has_session:
return
if is_mentioned:
# Strip the bot mention from the text
@@ -1527,3 +1534,30 @@ class SlackAdapter(BasePlatformAdapter):
continue
raise
raise last_exc
# ── Channel mention gating ─────────────────────────────────────────────
def _slack_require_mention(self) -> bool:
"""Return whether channel messages require an explicit bot mention.
Uses explicit-false parsing (like Discord/Matrix) rather than
truthy parsing, since the safe default is True (gating on).
Unrecognised or empty values keep gating enabled.
"""
configured = self.config.extra.get("require_mention")
if configured is not None:
if isinstance(configured, str):
return configured.lower() not in ("false", "0", "no", "off")
return bool(configured)
return os.getenv("SLACK_REQUIRE_MENTION", "true").lower() not in ("false", "0", "no", "off")
def _slack_free_response_channels(self) -> set:
"""Return channel IDs where no @mention is required."""
raw = self.config.extra.get("free_response_channels")
if raw is None:
raw = os.getenv("SLACK_FREE_RESPONSE_CHANNELS", "")
if isinstance(raw, list):
return {str(part).strip() for part in raw if str(part).strip()}
if isinstance(raw, str) and raw.strip():
return {part.strip() for part in raw.split(",") if part.strip()}
return set()

View File

@@ -0,0 +1,312 @@
"""
Tests for Slack mention gating (require_mention / free_response_channels).
Follows the same pattern as test_whatsapp_group_gating.py.
"""
import sys
from unittest.mock import MagicMock
from gateway.config import Platform, PlatformConfig
# ---------------------------------------------------------------------------
# Mock slack-bolt if not installed (same as test_slack.py)
# ---------------------------------------------------------------------------
def _ensure_slack_mock():
if "slack_bolt" in sys.modules and hasattr(sys.modules["slack_bolt"], "__file__"):
return
slack_bolt = MagicMock()
slack_bolt.async_app.AsyncApp = MagicMock
slack_bolt.adapter.socket_mode.async_handler.AsyncSocketModeHandler = MagicMock
slack_sdk = MagicMock()
slack_sdk.web.async_client.AsyncWebClient = MagicMock
for name, mod in [
("slack_bolt", slack_bolt),
("slack_bolt.async_app", slack_bolt.async_app),
("slack_bolt.adapter", slack_bolt.adapter),
("slack_bolt.adapter.socket_mode", slack_bolt.adapter.socket_mode),
("slack_bolt.adapter.socket_mode.async_handler", slack_bolt.adapter.socket_mode.async_handler),
("slack_sdk", slack_sdk),
("slack_sdk.web", slack_sdk.web),
("slack_sdk.web.async_client", slack_sdk.web.async_client),
]:
sys.modules.setdefault(name, mod)
_ensure_slack_mock()
import gateway.platforms.slack as _slack_mod
_slack_mod.SLACK_AVAILABLE = True
from gateway.platforms.slack import SlackAdapter # noqa: E402
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
BOT_USER_ID = "U_BOT_123"
CHANNEL_ID = "C0AQWDLHY9M"
OTHER_CHANNEL_ID = "C9999999999"
def _make_adapter(require_mention=None, free_response_channels=None):
extra = {}
if require_mention is not None:
extra["require_mention"] = require_mention
if free_response_channels is not None:
extra["free_response_channels"] = free_response_channels
adapter = object.__new__(SlackAdapter)
adapter.platform = Platform.SLACK
adapter.config = PlatformConfig(enabled=True, extra=extra)
adapter._bot_user_id = BOT_USER_ID
adapter._team_bot_user_ids = {}
return adapter
# ---------------------------------------------------------------------------
# Tests: _slack_require_mention
# ---------------------------------------------------------------------------
def test_require_mention_defaults_to_true(monkeypatch):
monkeypatch.delenv("SLACK_REQUIRE_MENTION", raising=False)
adapter = _make_adapter()
assert adapter._slack_require_mention() is True
def test_require_mention_false():
adapter = _make_adapter(require_mention=False)
assert adapter._slack_require_mention() is False
def test_require_mention_true():
adapter = _make_adapter(require_mention=True)
assert adapter._slack_require_mention() is True
def test_require_mention_string_true():
adapter = _make_adapter(require_mention="true")
assert adapter._slack_require_mention() is True
def test_require_mention_string_false():
adapter = _make_adapter(require_mention="false")
assert adapter._slack_require_mention() is False
def test_require_mention_string_no():
adapter = _make_adapter(require_mention="no")
assert adapter._slack_require_mention() is False
def test_require_mention_string_yes():
adapter = _make_adapter(require_mention="yes")
assert adapter._slack_require_mention() is True
def test_require_mention_empty_string_stays_true():
"""Empty/malformed strings keep gating ON (explicit-false parser)."""
adapter = _make_adapter(require_mention="")
assert adapter._slack_require_mention() is True
def test_require_mention_malformed_string_stays_true():
"""Unrecognised values keep gating ON (fail-closed)."""
adapter = _make_adapter(require_mention="maybe")
assert adapter._slack_require_mention() is True
def test_require_mention_env_var_fallback(monkeypatch):
monkeypatch.setenv("SLACK_REQUIRE_MENTION", "false")
adapter = _make_adapter() # no config value -> falls back to env
assert adapter._slack_require_mention() is False
def test_require_mention_env_var_default_true(monkeypatch):
monkeypatch.delenv("SLACK_REQUIRE_MENTION", raising=False)
adapter = _make_adapter()
assert adapter._slack_require_mention() is True
# ---------------------------------------------------------------------------
# Tests: _slack_free_response_channels
# ---------------------------------------------------------------------------
def test_free_response_channels_default_empty(monkeypatch):
monkeypatch.delenv("SLACK_FREE_RESPONSE_CHANNELS", raising=False)
adapter = _make_adapter()
assert adapter._slack_free_response_channels() == set()
def test_free_response_channels_list():
adapter = _make_adapter(free_response_channels=[CHANNEL_ID, OTHER_CHANNEL_ID])
result = adapter._slack_free_response_channels()
assert CHANNEL_ID in result
assert OTHER_CHANNEL_ID in result
def test_free_response_channels_csv_string():
adapter = _make_adapter(free_response_channels=f"{CHANNEL_ID}, {OTHER_CHANNEL_ID}")
result = adapter._slack_free_response_channels()
assert CHANNEL_ID in result
assert OTHER_CHANNEL_ID in result
def test_free_response_channels_empty_string():
adapter = _make_adapter(free_response_channels="")
assert adapter._slack_free_response_channels() == set()
def test_free_response_channels_env_var_fallback(monkeypatch):
monkeypatch.setenv("SLACK_FREE_RESPONSE_CHANNELS", f"{CHANNEL_ID},{OTHER_CHANNEL_ID}")
adapter = _make_adapter() # no config value → falls back to env
result = adapter._slack_free_response_channels()
assert CHANNEL_ID in result
assert OTHER_CHANNEL_ID in result
# ---------------------------------------------------------------------------
# Tests: mention gating integration (simulating _handle_slack_message logic)
# ---------------------------------------------------------------------------
def _would_process(adapter, *, is_dm=False, channel_id=CHANNEL_ID,
text="hello", mentioned=False, thread_reply=False,
active_session=False):
"""Simulate the mention gating logic from _handle_slack_message.
Returns True if the message would be processed, False if it would be
skipped (returned early).
"""
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
if not is_dm:
if channel_id in adapter._slack_free_response_channels():
return True
elif not adapter._slack_require_mention():
return True
elif not is_mentioned:
if thread_reply and active_session:
return True
else:
return False
return True
def test_default_require_mention_channel_without_mention_ignored():
adapter = _make_adapter() # default: require_mention=True
assert _would_process(adapter, text="hello everyone") is False
def test_require_mention_false_channel_without_mention_processed():
adapter = _make_adapter(require_mention=False)
assert _would_process(adapter, text="hello everyone") is True
def test_channel_in_free_response_processed_without_mention():
adapter = _make_adapter(
require_mention=True,
free_response_channels=[CHANNEL_ID],
)
assert _would_process(adapter, channel_id=CHANNEL_ID, text="hello") is True
def test_other_channel_not_in_free_response_still_gated():
adapter = _make_adapter(
require_mention=True,
free_response_channels=[CHANNEL_ID],
)
assert _would_process(adapter, channel_id=OTHER_CHANNEL_ID, text="hello") is False
def test_dm_always_processed_regardless_of_setting():
adapter = _make_adapter(require_mention=True)
assert _would_process(adapter, is_dm=True, text="hello") is True
def test_mentioned_message_always_processed():
adapter = _make_adapter(require_mention=True)
assert _would_process(adapter, mentioned=True, text="what's up") is True
def test_thread_reply_with_active_session_processed():
adapter = _make_adapter(require_mention=True)
assert _would_process(
adapter, text="followup",
thread_reply=True, active_session=True,
) is True
def test_thread_reply_without_active_session_ignored():
adapter = _make_adapter(require_mention=True)
assert _would_process(
adapter, text="followup",
thread_reply=True, active_session=False,
) is False
def test_bot_uid_none_processes_channel_message():
"""When bot_uid is None (before auth_test), channel messages pass through.
This preserves the old behavior: the gating block is skipped entirely
when bot_uid is falsy, so messages are not silently dropped during
startup or for new workspaces.
"""
adapter = _make_adapter(require_mention=True)
adapter._bot_user_id = None
adapter._team_bot_user_ids = {}
# With bot_uid=None, the `if not is_dm and bot_uid:` condition is False,
# so the gating block is skipped — message passes through.
bot_uid = adapter._team_bot_user_ids.get("T1", adapter._bot_user_id)
assert bot_uid is None
# Simulate: gating block not entered when bot_uid is falsy
is_dm = False
if not is_dm and bot_uid:
result = False # would enter gating
else:
result = True # gating skipped, message processed
assert result is True
# ---------------------------------------------------------------------------
# Tests: config bridging
# ---------------------------------------------------------------------------
def test_config_bridges_slack_free_response_channels(monkeypatch, tmp_path):
from gateway.config import load_gateway_config
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / "config.yaml").write_text(
"slack:\n"
" require_mention: false\n"
" free_response_channels:\n"
" - C0AQWDLHY9M\n"
" - C9999999999\n",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.delenv("SLACK_REQUIRE_MENTION", raising=False)
monkeypatch.delenv("SLACK_FREE_RESPONSE_CHANNELS", raising=False)
config = load_gateway_config()
assert config is not None
slack_extra = config.platforms[Platform.SLACK].extra
assert slack_extra.get("require_mention") is False
assert slack_extra.get("free_response_channels") == ["C0AQWDLHY9M", "C9999999999"]
# Verify env vars were set by config bridging
import os as _os
assert _os.environ["SLACK_REQUIRE_MENTION"] == "false"
assert _os.environ["SLACK_FREE_RESPONSE_CHANNELS"] == "C0AQWDLHY9M,C9999999999"