diff --git a/plugins/platforms/slack/adapter.py b/plugins/platforms/slack/adapter.py index 05aeaf56f46a..b1ddda0a78d5 100644 --- a/plugins/platforms/slack/adapter.py +++ b/plugins/platforms/slack/adapter.py @@ -2620,6 +2620,25 @@ async def _handle_slack_message(self, event: dict) -> None: return original_text = event.get("text", "") + raw_original_text = original_text + + # Slack app_mention/message events include the literal leading bot + # mention in text (e.g. "<@U_BOT> !status"). Strip that addressing + # prefix before command detection so mentioned commands behave like + # the same command typed directly in an addressed thread/channel. + team_id_for_mention = event.get("team") or event.get("team_id") or "" + bot_uid_for_mention = self._team_bot_user_ids.get( + team_id_for_mention, + self._bot_user_id, + ) + if bot_uid_for_mention: + original_text = re.sub( + rf"^\s*<@{re.escape(bot_uid_for_mention)}>\s*", + "", + original_text, + count=1, + ) + mention_stripped_original_text = original_text # Slack blocks native slash commands inside threads ("/queue is not # supported in threads. Sorry!"). As a workaround, recognise a @@ -2659,7 +2678,12 @@ async def _handle_slack_message(self, event: dict) -> None: # Only append if the blocks contain text not already present # in the plain text field (avoids duplication). stripped_blocks = blocks_text.strip() - if stripped_blocks and stripped_blocks not in text.strip(): + if ( + stripped_blocks + and stripped_blocks not in text.strip() + and stripped_blocks != raw_original_text.strip() + and stripped_blocks != mention_stripped_original_text.strip() + ): logger.debug( "Slack: extracted additional text from blocks " "(likely quoted/forwarded content): %s", @@ -2824,7 +2848,7 @@ async def _handle_slack_message(self, event: dict) -> None: # 3. The message is in a thread where the bot was previously @mentioned, OR # 4. There's an existing session for this thread (survives restarts) bot_uid = self._team_bot_user_ids.get(team_id, self._bot_user_id) - routing_text = original_text or "" + routing_text = raw_original_text or "" is_mentioned = bool( (bot_uid and f"<@{bot_uid}>" in routing_text) or self._slack_message_matches_mention_patterns(routing_text) diff --git a/tests/gateway/test_slack.py b/tests/gateway/test_slack.py index de3edf2db272..d41b8b77d7bb 100644 --- a/tests/gateway/test_slack.py +++ b/tests/gateway/test_slack.py @@ -1129,7 +1129,14 @@ class TestBangPrefixCommands: command before downstream processing. """ - def _make_event(self, text, thread_ts=None, channel_type="im", channel="D123"): + def _make_event( + self, + text, + thread_ts=None, + channel_type="im", + channel="D123", + blocks=None, + ): evt = { "text": text, "user": "U_USER", @@ -1139,6 +1146,8 @@ def _make_event(self, text, thread_ts=None, channel_type="im", channel="D123"): } if thread_ts: evt["thread_ts"] = thread_ts + if blocks is not None: + evt["blocks"] = blocks return evt @pytest.mark.asyncio @@ -1173,6 +1182,15 @@ async def test_bang_works_inside_thread(self, adapter): # same thread. assert msg_event.source.thread_id == "1111111111.000001" + @pytest.mark.asyncio + async def test_leading_bot_mention_before_bang_command_is_stripped(self, adapter): + """Slack includes the addressed bot mention in app_mention text.""" + await adapter._handle_slack_message(self._make_event("<@U_BOT> !status")) + + msg_event = adapter.handle_message.call_args[0][0] + assert msg_event.text == "/status" + assert msg_event.message_type == MessageType.COMMAND + @pytest.mark.asyncio async def test_bang_unknown_token_passes_through_unchanged(self, adapter): """``!nice work`` is just a casual message — must NOT be rewritten.""" @@ -1191,6 +1209,35 @@ async def test_bang_with_bot_suffix_resolves(self, adapter): assert msg_event.text.startswith("/stop@hermes") assert msg_event.message_type == MessageType.COMMAND + @pytest.mark.asyncio + async def test_bang_command_does_not_reappend_matching_rich_text(self, adapter): + """Slack's rich_text block mirrors plain text for normal messages. + + ``!commands`` is rewritten to ``/commands`` before rich block extraction. + The dedupe check must compare against Slack's original plain text too; + otherwise the matching rich_text block is appended as a bogus argument + and downstream handlers see ``/commands\n!commands``. + """ + blocks = [ + { + "type": "rich_text", + "elements": [ + { + "type": "rich_text_section", + "elements": [{"type": "text", "text": "!commands"}], + } + ], + } + ] + + await adapter._handle_slack_message( + self._make_event("!commands", blocks=blocks) + ) + + msg_event = adapter.handle_message.call_args[0][0] + assert msg_event.text == "/commands" + assert msg_event.message_type == MessageType.COMMAND + @pytest.mark.asyncio async def test_plain_slash_still_works(self, adapter): """Sanity check — ``/queue`` (top-level channel/DM) still dispatches."""