diff --git a/gateway/config.py b/gateway/config.py index 96ee83170..47b779eb8 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -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): diff --git a/gateway/platforms/slack.py b/gateway/platforms/slack.py index 49890170b..6a5471d59 100644 --- a/gateway/platforms/slack.py +++ b/gateway/platforms/slack.py @@ -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() diff --git a/tests/gateway/test_slack_mention.py b/tests/gateway/test_slack_mention.py new file mode 100644 index 000000000..22e17443f --- /dev/null +++ b/tests/gateway/test_slack_mention.py @@ -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"