264 lines
9.0 KiB
Python
264 lines
9.0 KiB
Python
"""Tests for the Slack ``ignore_other_user_mentions`` option.
|
|
|
|
When enabled, the bot stays silent on a channel/thread message that opens by
|
|
@-mentioning someone other than itself (a message *addressed to* another user),
|
|
unless the bot is also mentioned. This is Slack parity for the Discord option
|
|
of the same name (PR #33501), adapted to Slack's thread model: the trigger is a
|
|
*leading* mention, so a message that merely references another user mid-sentence
|
|
(e.g. "loop in @rasha") still reaches the bot.
|
|
|
|
Helper-level tests exercise the real config/parse methods directly; the
|
|
integration tests drive the real ``SlackAdapter._handle_slack_message`` so the
|
|
gate is verified end-to-end rather than against a re-implementation.
|
|
"""
|
|
|
|
import sys
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from gateway.config import Platform, PlatformConfig
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Mock slack-bolt if not installed (same pattern as test_slack_mention.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 plugins.platforms.slack.adapter as _slack_mod # noqa: E402
|
|
|
|
_slack_mod.SLACK_AVAILABLE = True
|
|
|
|
from plugins.platforms.slack.adapter import SlackAdapter # noqa: E402
|
|
|
|
|
|
BOT_USER_ID = "U_BOT_123"
|
|
OTHER_USER_ID = "U_OTHER_456"
|
|
CHANNEL_ID = "C0AQWDLHY9M"
|
|
|
|
|
|
def _make_adapter(**extra):
|
|
adapter = object.__new__(SlackAdapter)
|
|
adapter.platform = Platform.SLACK
|
|
adapter.config = PlatformConfig(enabled=True, extra=dict(extra))
|
|
adapter._bot_user_id = BOT_USER_ID
|
|
adapter._team_bot_user_ids = {}
|
|
return adapter
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _slack_ignore_other_user_mentions()
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_ignore_other_user_mentions_defaults_off(monkeypatch):
|
|
monkeypatch.delenv("SLACK_IGNORE_OTHER_USER_MENTIONS", raising=False)
|
|
adapter = _make_adapter()
|
|
assert adapter._slack_ignore_other_user_mentions() is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _slack_message_addressed_to_other_user()
|
|
# ---------------------------------------------------------------------------
|
|
|
|
SELF_UIDS = {BOT_USER_ID}
|
|
|
|
|
|
def _addressed(text):
|
|
return _make_adapter()._slack_message_addressed_to_other_user(text, SELF_UIDS)
|
|
|
|
|
|
def test_addressed_empty_and_blank():
|
|
assert _addressed("") is False
|
|
assert _addressed(" ") is False
|
|
|
|
|
|
def test_addressed_channel_and_broadcast_tokens_are_not_users():
|
|
# <!here>, <!channel>, and channel refs address the room, not a person.
|
|
assert _addressed("<!here> standup in 5") is False
|
|
assert _addressed("<#C0000000000|general> see here") is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _slack_message_mentions_self()
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _mentions_self(text):
|
|
return _make_adapter()._slack_message_mentions_self(text, SELF_UIDS)
|
|
|
|
|
|
def test_mentions_self_plain_form():
|
|
assert _mentions_self(f"hello <@{BOT_USER_ID}>") is True
|
|
|
|
|
|
def test_mentions_self_id_prefix_is_not_a_match():
|
|
# <@U_BOT_123X> is a different user whose ID merely starts with ours.
|
|
assert _mentions_self(f"hello <@{BOT_USER_ID}X>") is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Integration: real _handle_slack_message
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.fixture
|
|
def adapter():
|
|
config = PlatformConfig(enabled=True, token="xoxb-fake-token")
|
|
a = SlackAdapter(config)
|
|
a._app = MagicMock()
|
|
a._app.client = AsyncMock()
|
|
a._bot_user_id = BOT_USER_ID
|
|
a._running = True
|
|
a.handle_message = AsyncMock()
|
|
return a
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _redirect_cache(tmp_path, monkeypatch):
|
|
monkeypatch.setattr(
|
|
"gateway.platforms.base.DOCUMENT_CACHE_DIR", tmp_path / "doc_cache"
|
|
)
|
|
# Keep gating driven by config.extra, not ambient env.
|
|
monkeypatch.delenv("SLACK_IGNORE_OTHER_USER_MENTIONS", raising=False)
|
|
monkeypatch.delenv("SLACK_FREE_RESPONSE_CHANNELS", raising=False)
|
|
monkeypatch.delenv("SLACK_REQUIRE_MENTION", raising=False)
|
|
|
|
|
|
def _event(text, ts, thread_ts=None):
|
|
event = {
|
|
"channel": CHANNEL_ID,
|
|
"channel_type": "channel",
|
|
"user": "U_HUMAN",
|
|
"text": text,
|
|
"ts": ts,
|
|
}
|
|
if thread_ts is not None:
|
|
event["thread_ts"] = thread_ts
|
|
return event
|
|
|
|
|
|
async def _run(adapter, event):
|
|
with patch.object(
|
|
adapter, "_resolve_user_name", new=AsyncMock(return_value="human")
|
|
), patch.object(
|
|
adapter, "_fetch_thread_context", new=AsyncMock(return_value=None)
|
|
), patch.object(
|
|
adapter, "_fetch_thread_parent_text", new=AsyncMock(return_value="")
|
|
), patch.object(
|
|
adapter, "_has_active_session_for_thread", return_value=False
|
|
):
|
|
await adapter._handle_slack_message(event)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_free_response_replies_when_bot_also_mentioned(adapter):
|
|
adapter.config.extra["free_response_channels"] = CHANNEL_ID
|
|
adapter.config.extra["ignore_other_user_mentions"] = True
|
|
|
|
await _run(
|
|
adapter,
|
|
_event(f"<@{OTHER_USER_ID}> and <@{BOT_USER_ID}> please compare", ts="1700000000.000003"),
|
|
)
|
|
|
|
adapter.handle_message.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_free_response_replies_when_bot_mentioned_in_pipe_form(adapter):
|
|
"""A pipe-form bot mention (``<@U123|name>``) counts as "also mentioned"
|
|
even though the exact-markup ``is_mentioned`` check misses it."""
|
|
adapter.config.extra["free_response_channels"] = CHANNEL_ID
|
|
adapter.config.extra["ignore_other_user_mentions"] = True
|
|
|
|
await _run(
|
|
adapter,
|
|
_event(
|
|
f"<@{OTHER_USER_ID}|rasha> and <@{BOT_USER_ID}|hermes> please compare",
|
|
ts="1700000000.000004",
|
|
),
|
|
)
|
|
|
|
adapter.handle_message.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_mentioned_thread_ignores_followup_addressed_to_other_user(adapter):
|
|
"""Ben's case: once the bot has been mentioned in a thread it auto-follows,
|
|
but a follow-up addressed to another human should not wake it."""
|
|
thread_ts = "1700000000.000010"
|
|
adapter._mentioned_threads.add(thread_ts)
|
|
adapter.config.extra["ignore_other_user_mentions"] = True
|
|
|
|
await _run(
|
|
adapter,
|
|
_event(f"<@{OTHER_USER_ID}> check this out", ts="1700000000.000011", thread_ts=thread_ts),
|
|
)
|
|
|
|
adapter.handle_message.assert_not_awaited()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Config bridge: config.yaml slack.ignore_other_user_mentions → extra + env
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_config_bridges_slack_ignore_other_user_mentions(monkeypatch, tmp_path):
|
|
"""config.yaml ``slack.ignore_other_user_mentions`` reaches the runtime env
|
|
var the adapter reads (same wiring as ``strict_mention`` — via the plugin's
|
|
apply_yaml_config_fn bridge, not the generic shared-key allowlist)."""
|
|
from gateway.config import load_gateway_config
|
|
|
|
hermes_home = tmp_path / ".hermes"
|
|
hermes_home.mkdir()
|
|
(hermes_home / "config.yaml").write_text(
|
|
"slack:\n ignore_other_user_mentions: true\n",
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
|
monkeypatch.delenv("SLACK_IGNORE_OTHER_USER_MENTIONS", raising=False)
|
|
|
|
load_gateway_config()
|
|
|
|
import os as _os
|
|
assert _os.environ["SLACK_IGNORE_OTHER_USER_MENTIONS"] == "true"
|
|
|
|
|
|
def test_ignore_other_user_mentions_env_wins_over_yaml(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 ignore_other_user_mentions: true\n",
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
|
monkeypatch.setenv("SLACK_IGNORE_OTHER_USER_MENTIONS", "false")
|
|
|
|
load_gateway_config()
|
|
|
|
import os as _os
|
|
assert _os.environ["SLACK_IGNORE_OTHER_USER_MENTIONS"] == "false"
|