diff --git a/gateway/config.py b/gateway/config.py index 8f1de5e7aee..335b81d8d3a 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -611,6 +611,8 @@ def load_gateway_config() -> GatewayConfig: 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() + if "strict_mention" in slack_cfg and not os.getenv("SLACK_STRICT_MENTION"): + os.environ["SLACK_STRICT_MENTION"] = str(slack_cfg["strict_mention"]).lower() if "allow_bots" in slack_cfg and not os.getenv("SLACK_ALLOW_BOTS"): os.environ["SLACK_ALLOW_BOTS"] = str(slack_cfg["allow_bots"]).lower() frc = slack_cfg.get("free_response_channels") diff --git a/gateway/platforms/slack.py b/gateway/platforms/slack.py index 66c41a94758..01cbddddd78 100644 --- a/gateway/platforms/slack.py +++ b/gateway/platforms/slack.py @@ -1133,6 +1133,8 @@ class SlackAdapter(BasePlatformAdapter): pass # Free-response channel — always process elif not self._slack_require_mention(): pass # Mention requirement disabled globally for Slack + elif self._slack_strict_mention() and not is_mentioned: + return # Strict mode: ignore until @-mentioned again elif not is_mentioned: reply_to_bot_thread = ( is_thread_reply and event_thread_ts in self._bot_message_ts @@ -1783,6 +1785,18 @@ class SlackAdapter(BasePlatformAdapter): return bool(configured) return os.getenv("SLACK_REQUIRE_MENTION", "true").lower() not in ("false", "0", "no", "off") + def _slack_strict_mention(self) -> bool: + """When true, channel threads require an explicit @-mention on every + message. Disables all auto-triggers (mentioned-thread memory, + bot-message follow-up, session-presence). Defaults to False. + """ + configured = self.config.extra.get("strict_mention") + if configured is not None: + if isinstance(configured, str): + return configured.lower() in ("true", "1", "yes", "on") + return bool(configured) + return os.getenv("SLACK_STRICT_MENTION", "false").lower() in ("true", "1", "yes", "on") + def _slack_free_response_channels(self) -> set: """Return channel IDs where no @mention is required.""" raw = self.config.extra.get("free_response_channels") diff --git a/tests/gateway/test_slack_mention.py b/tests/gateway/test_slack_mention.py index 8cfa9d98c8a..3bf838feaf7 100644 --- a/tests/gateway/test_slack_mention.py +++ b/tests/gateway/test_slack_mention.py @@ -55,10 +55,12 @@ CHANNEL_ID = "C0AQWDLHY9M" OTHER_CHANNEL_ID = "C9999999999" -def _make_adapter(require_mention=None, free_response_channels=None): +def _make_adapter(require_mention=None, strict_mention=None, free_response_channels=None): extra = {} if require_mention is not None: extra["require_mention"] = require_mention + if strict_mention is not None: + extra["strict_mention"] = strict_mention if free_response_channels is not None: extra["free_response_channels"] = free_response_channels @@ -134,6 +136,48 @@ def test_require_mention_env_var_default_true(monkeypatch): assert adapter._slack_require_mention() is True +# --------------------------------------------------------------------------- +# Tests: _slack_strict_mention +# --------------------------------------------------------------------------- + +def test_strict_mention_defaults_to_false(monkeypatch): + monkeypatch.delenv("SLACK_STRICT_MENTION", raising=False) + adapter = _make_adapter() + assert adapter._slack_strict_mention() is False + + +def test_strict_mention_true(): + adapter = _make_adapter(strict_mention=True) + assert adapter._slack_strict_mention() is True + + +def test_strict_mention_false(): + adapter = _make_adapter(strict_mention=False) + assert adapter._slack_strict_mention() is False + + +def test_strict_mention_string_true(): + adapter = _make_adapter(strict_mention="true") + assert adapter._slack_strict_mention() is True + + +def test_strict_mention_string_off(): + adapter = _make_adapter(strict_mention="off") + assert adapter._slack_strict_mention() is False + + +def test_strict_mention_malformed_stays_false(): + """Unrecognised values keep strict mode OFF (fail-open to legacy behavior).""" + adapter = _make_adapter(strict_mention="maybe") + assert adapter._slack_strict_mention() is False + + +def test_strict_mention_env_var_fallback(monkeypatch): + monkeypatch.setenv("SLACK_STRICT_MENTION", "true") + adapter = _make_adapter() # no config value -> falls back to env + assert adapter._slack_strict_mention() is True + + # --------------------------------------------------------------------------- # Tests: _slack_free_response_channels # --------------------------------------------------------------------------- @@ -350,3 +394,24 @@ def test_config_bridges_slack_reply_in_thread(monkeypatch, tmp_path): reply_to="171.500", metadata={"thread_id": "171.000"}, ) == "171.000" + + +def test_config_bridges_slack_strict_mention(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" + " strict_mention: true\n", + encoding="utf-8", + ) + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.delenv("SLACK_STRICT_MENTION", raising=False) + + config = load_gateway_config() + + assert config is not None + import os as _os + assert _os.environ["SLACK_STRICT_MENTION"] == "true"