From cac288a0c332e8c096fbc7f31401f552d2628050 Mon Sep 17 00:00:00 2001 From: liuhao1024 Date: Mon, 14 Sep 2026 18:52:04 +0800 Subject: [PATCH] fix(slack): gate inbound turns on a conversational-subtype allowlist Housekeeping subtypes (channel_join/leave/topic/name/purpose, convert_to_private/public, pins, deletions) are not a person speaking, yet _prefilter_inbound only rejected message_changed/message_deleted, so each of them started a full agent turn in free-response channels. Replace the denylist with an allowlist: a message passes when subtype is absent, file_share, thread_broadcast or me_message; everything else is dropped. Fixes #110778. --- plugins/platforms/slack/adapter.py | 17 ++++++- tests/gateway/test_slack.py | 73 ++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 2 deletions(-) diff --git a/plugins/platforms/slack/adapter.py b/plugins/platforms/slack/adapter.py index 0a06ec5a1a..97a39f4708 100644 --- a/plugins/platforms/slack/adapter.py +++ b/plugins/platforms/slack/adapter.py @@ -4301,8 +4301,21 @@ 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. - if event.get("subtype") == "message_deleted": + # 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. + # 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 + # so subtypes Slack adds later are dropped instead of silently readmitted. + # ``file_share`` passes: a human attaching a file is a person speaking, and the + # ``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. + subtype = event.get("subtype") + if subtype not in (None, "", "file_share", "thread_broadcast", "me_message"): + logger.debug( + "[Slack] Dropping non-conversational message subtype=%s in channel %s", + subtype, channel_id) return None return event, dedup_team_id, channel_id diff --git a/tests/gateway/test_slack.py b/tests/gateway/test_slack.py index 76f9515e17..905b396d11 100644 --- a/tests/gateway/test_slack.py +++ b/tests/gateway/test_slack.py @@ -6049,3 +6049,76 @@ class TestAgentSessionsApiRouting: thread_ts="171234.0001", title="Summarize the incident", ) + + +# --------------------------------------------------------------------------- +# TestNonConversationalSubtypeAllowlist +# --------------------------------------------------------------------------- + + +class TestNonConversationalSubtypeAllowlist: + """#110778 — housekeeping subtypes must not start a turn in free-response + channels. The gate is an allowlist so subtypes Slack adds later are dropped + instead of silently readmitted.""" + + @staticmethod + def _event(subtype): + event = { + "type": "message", + "user": "U_HUMAN", + "text": "hello", + "ts": "12345.6789", + "channel": "C_FREE", + } + if subtype is not None: + event["subtype"] = subtype + return event + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "subtype", + [ + "channel_join", + "channel_leave", + "channel_topic", + "channel_purpose", + "channel_name", + "channel_convert_to_private", + "channel_convert_to_public", + "pinned_item", + "unpinned_item", + "message_deleted", + "file_comment", + ], + ) + async def test_housekeeping_subtypes_are_dropped(self, adapter, subtype): + assert await adapter._prefilter_inbound(self._event(subtype), None) is None + adapter.handle_message.assert_not_awaited() + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "subtype", [None, "file_share", "thread_broadcast", "me_message"] + ) + async def test_conversational_subtypes_pass(self, adapter, subtype): + accepted = await adapter._prefilter_inbound(self._event(subtype), None) + assert accepted is not None + assert accepted[0].get("channel") == "C_FREE" + + @pytest.mark.asyncio + async def test_edited_message_still_wakes_the_bot(self, adapter): + event = { + "type": "message", + "subtype": "message_changed", + "channel": "C_FREE", + "ts": "12340.0000", + "message": { + "type": "message", + "user": "U_HUMAN", + "text": "edited hello", + "ts": "12345.6789", + "edited": {"ts": "12346.0000"}, + }, + } + accepted = await adapter._prefilter_inbound(event, None) + assert accepted is not None + assert accepted[0].get("text") == "edited hello"