mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
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.
312 lines
11 KiB
Python
312 lines
11 KiB
Python
"""
|
|
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"
|