diff --git a/plugins/platforms/slack/adapter.py b/plugins/platforms/slack/adapter.py index 97a39f4708..2addcd8a66 100644 --- a/plugins/platforms/slack/adapter.py +++ b/plugins/platforms/slack/adapter.py @@ -4301,8 +4301,8 @@ class SlackAdapter(BasePlatformAdapter): return None if await self._drop_bot_sender(event): return None - # Edits were normalized above so an @mention added by edit can wake the bot once, - # which also means their subtype is gone by the time this check runs. + # Edits were normalized above so an @mention added by edit can wake the bot once; + # the normalized event retains the edited message's own subtype. # Housekeeping subtypes (joins/leaves, topic/name/purpose changes, convert_to_private/ # public, pins, deletions, file comments...) are not a person speaking, so they must # not start a turn in free-response channels (#110778). Allowlist rather than denylist @@ -4311,8 +4311,13 @@ class SlackAdapter(BasePlatformAdapter): # ``file_shared`` fallback synthesizes exactly this subtype. ``thread_broadcast`` # passes: a human sharing a threaded reply into the channel carries user/text. # ``me_message`` passes: ``/me`` is a person speaking. + # ``bot_message`` already passed allow_bots above; document_mention is an + # explicit app mention from a Slack canvas, not a lifecycle notification. subtype = event.get("subtype") - if subtype not in (None, "", "file_share", "thread_broadcast", "me_message"): + if subtype not in ( + None, "", "file_share", "thread_broadcast", "me_message", + "bot_message", "document_mention", + ): logger.debug( "[Slack] Dropping non-conversational message subtype=%s in channel %s", subtype, channel_id) diff --git a/tests/gateway/test_slack_conversational_senders.py b/tests/gateway/test_slack_conversational_senders.py new file mode 100644 index 0000000000..71431e4efb --- /dev/null +++ b/tests/gateway/test_slack_conversational_senders.py @@ -0,0 +1,92 @@ +"""Preserve conversational senders through the subtype filter in PR #110780.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from gateway.config import PlatformConfig +from plugins.platforms.slack.adapter import SlackAdapter + + +@pytest.fixture +def adapter(): + instance = SlackAdapter(PlatformConfig( + enabled=True, + token="xoxb-test", + extra={"free_response_channels": ["C_TEST"], "allow_bots": "all"}, + )) + instance._bot_user_id = "U_BOT" + instance._running = True + instance._app = SimpleNamespace(client=SimpleNamespace( + users_info=AsyncMock(return_value={ + "ok": True, "user": {"is_bot": False, "real_name": "Test User"}, + }), + conversations_info=AsyncMock(return_value={ + "ok": True, "channel": {"name": "test"}, + }), + conversations_replies=AsyncMock(return_value={"ok": True, "messages": []}), + )) + instance.handle_message = AsyncMock() + return instance + + +def _event(subtype, text, *, edited): + message = { + "type": "app_mention" if subtype == "document_mention" else "message", + "subtype": subtype, "user": "U_TEST", "text": text, + "channel": "C_TEST", "channel_type": "channel", "team": "T_TEST", + "ts": "100.000001", + } + if subtype == "bot_message": + message["bot_id"] = "B_OTHER" + else: + message["document_mention"] = { + "file_id": "F_CANVAS", "section_id": "section-1", + "mentioning_user_ids": ["U_TEST"], + } + if not edited: + return message + return { + "type": "message", "subtype": "message_changed", + "channel": "C_TEST", "channel_type": "channel", "team": "T_TEST", + "ts": "101.000001", "event_ts": "101.000001", "message": message, + } + + +@pytest.mark.asyncio +@pytest.mark.parametrize("edited", [False, True], ids=["original", "edited"]) +@pytest.mark.parametrize(("policy", "mentioned", "accepted"), [ + ("none", False, False), + ("none", True, False), + ("mentions", False, False), + ("mentions", True, True), + ("all", False, True), +]) +async def test_bot_messages_retain_the_configured_policy(adapter, policy, mentioned, accepted, edited): + adapter.config.extra["allow_bots"] = policy + text = "<@U_BOT> A message" if mentioned else "A message" + + await adapter._handle_slack_message(_event("bot_message", text, edited=edited)) + + assert adapter.handle_message.await_count == int(accepted) + if accepted: + delivered = adapter.handle_message.await_args.args[0] + assert delivered.text == "A message" + assert delivered.source.is_bot is True + + +@pytest.mark.asyncio +@pytest.mark.parametrize("edited", [False, True], ids=["original", "edited"]) +async def test_canvas_mentions_reach_the_agent(adapter, edited): + adapter.config.extra = {"allow_bots": "none"} + + await adapter._handle_slack_message( + _event("document_mention", "<@U_BOT> Summarize this canvas", edited=edited) + ) + + adapter.handle_message.assert_awaited_once() + delivered = adapter.handle_message.await_args.args[0] + assert delivered.text == "Summarize this canvas" + assert delivered.source.user_id == "U_TEST" + assert delivered.raw_message["document_mention"]["file_id"] == "F_CANVAS"