Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions gateway/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
21 changes: 19 additions & 2 deletions gateway/platforms/slack.py
Original file line number Diff line number Diff line change
Expand Up @@ -1133,6 +1133,8 @@ async def _handle_slack_message(self, event: dict) -> None:
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
Expand All @@ -1155,8 +1157,11 @@ async def _handle_slack_message(self, event: dict) -> None:
if is_mentioned:
# Strip the bot mention from the text
text = text.replace(f"<@{bot_uid}>", "").strip()
# Register this thread so all future messages auto-trigger the bot
if event_thread_ts:
# Register this thread so all future messages auto-trigger the bot.
# Skipped in strict mode: strict_mention=true bots must be
# re-mentioned every turn, so remembering the thread would
# defeat the feature (and re-enable agent-to-agent ack loops).
if event_thread_ts and not self._slack_strict_mention():
self._mentioned_threads.add(event_thread_ts)
if len(self._mentioned_threads) > self._MENTIONED_THREADS_MAX:
to_remove = list(self._mentioned_threads)[:self._MENTIONED_THREADS_MAX // 2]
Expand Down Expand Up @@ -1783,6 +1788,18 @@ def _slack_require_mention(self) -> bool:
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")
Expand Down
1 change: 1 addition & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@
"mibayy@users.noreply.github.com": "Mibayy",
"135070653+sgaofen@users.noreply.github.com": "sgaofen",
"lzy.dev@gmail.com": "zhiyanliu",
"me@janstepanovsky.cz": "hhhonzik",
"nocoo@users.noreply.github.com": "nocoo",
"30841158+n-WN@users.noreply.github.com": "n-WN",
"tsuijinglei@gmail.com": "hiddenpuppy",
Expand Down
15 changes: 15 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,21 @@ def _looks_like_credential(name: str) -> bool:
"SIGNAL_ALLOW_ALL_USERS",
"EMAIL_ALLOW_ALL_USERS",
"SMS_ALLOW_ALL_USERS",
# Platform gating — set by load_gateway_config() as a side effect when
# a config.yaml is present, so individual test bodies that call the
# loader leak these values into later tests on the same xdist worker.
# Force-clear on every test setup so the leak can't happen.
"SLACK_REQUIRE_MENTION",
"SLACK_STRICT_MENTION",
"SLACK_FREE_RESPONSE_CHANNELS",
"SLACK_ALLOW_BOTS",
"SLACK_REACTIONS",
"DISCORD_REQUIRE_MENTION",
"DISCORD_FREE_RESPONSE_CHANNELS",
"TELEGRAM_REQUIRE_MENTION",
"WHATSAPP_REQUIRE_MENTION",
"DINGTALK_REQUIRE_MENTION",
"MATRIX_REQUIRE_MENTION",
})


Expand Down
112 changes: 111 additions & 1 deletion tests/gateway/test_slack_mention.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,12 @@ def _ensure_slack_mock():
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

Expand Down Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -350,3 +394,69 @@ 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"


# ---------------------------------------------------------------------------
# Regression: strict mode must NOT persist mentions into _mentioned_threads
# ---------------------------------------------------------------------------
# Prevents agent-to-agent ack loops — if a strict-mode bot remembered every
# thread it was mentioned in, the next message from the other agent in that
# thread would re-trigger the bot and defeat the entire feature.

def test_mention_in_strict_mode_does_not_register_thread():
adapter = _make_adapter(strict_mention=True)
adapter._bot_user_id = "U_BOT"
adapter._mentioned_threads = set()
adapter._MENTIONED_THREADS_MAX = 5000

thread_ts = "1700000000.100200"
event_thread_ts = thread_ts # incoming message is inside an existing thread

# Mirror the handler's @mention + strict-mode guard that protects
# _mentioned_threads.add(). If strict is on, we must skip the add.
text = "<@U_BOT> hello"
is_mentioned = f"<@{adapter._bot_user_id}>" in text
assert is_mentioned
if event_thread_ts and not adapter._slack_strict_mention():
adapter._mentioned_threads.add(event_thread_ts)

assert thread_ts not in adapter._mentioned_threads


def test_mention_outside_strict_mode_still_registers_thread():
adapter = _make_adapter(strict_mention=False)
adapter._bot_user_id = "U_BOT"
adapter._mentioned_threads = set()
adapter._MENTIONED_THREADS_MAX = 5000

thread_ts = "1700000000.100200"
event_thread_ts = thread_ts

text = "<@U_BOT> hello"
is_mentioned = f"<@{adapter._bot_user_id}>" in text
assert is_mentioned
if event_thread_ts and not adapter._slack_strict_mention():
adapter._mentioned_threads.add(event_thread_ts)

assert thread_ts in adapter._mentioned_threads
Loading