diff --git a/plugins/platforms/slack/adapter.py b/plugins/platforms/slack/adapter.py index caa1beec4529..33c13ea804d7 100644 --- a/plugins/platforms/slack/adapter.py +++ b/plugins/platforms/slack/adapter.py @@ -181,6 +181,23 @@ def _slack_mention_detection_text(event: dict) -> str: return (flat.strip() + "\n" + " ".join(extra)).strip() +def _rewrite_known_bang_command(text: str) -> str: + """Rewrite a known leading ``!cmd`` to the gateway ``/cmd`` form.""" + if not text.startswith("!"): + return text + + try: + from hermes_cli.commands import is_gateway_known_command + + first_token = text[1:].split(maxsplit=1)[0] + cmd_name = first_token.split("@", 1)[0].lower() + if cmd_name and "/" not in cmd_name and is_gateway_known_command(cmd_name): + return "/" + text[1:] + except Exception: # pragma: no cover - defensive + pass + return text + + def _extract_text_from_slack_blocks(blocks: list) -> str: """Extract readable text from Slack Block Kit blocks, including quoted/forwarded content. @@ -3788,24 +3805,9 @@ class SlackAdapter(BasePlatformAdapter): # gateway dispatcher) handles it like a normal slash command. Only # rewrite when the first token resolves to a known gateway command # so casual messages like "!nice work" pass through unchanged. - command_probe_text = original_text.lstrip() - if command_probe_text.startswith("!"): - try: - from hermes_cli.commands import is_gateway_known_command - - first_token = command_probe_text[1:].split(maxsplit=1)[0] - # Strip "@suffix" the same way get_command() does, so - # forms like ``!stop@hermes`` still resolve. - cmd_name = first_token.split("@", 1)[0].lower() - if ( - cmd_name - and "/" not in cmd_name - and is_gateway_known_command(cmd_name) - ): - original_text = "/" + command_probe_text[1:] - command_probe_text = original_text - except Exception: # pragma: no cover - defensive - pass + command_probe_text = _rewrite_known_bang_command(original_text.lstrip()) + if command_probe_text != original_text.lstrip(): + original_text = command_probe_text is_command_text = command_probe_text.startswith("/") text = original_text @@ -4026,6 +4028,18 @@ class SlackAdapter(BasePlatformAdapter): if is_mentioned: # Strip the bot mention from the text text = text.replace(f"<@{bot_uid}>", "").strip() + # Re-run command normalization against the canonical Slack text, + # not the block-augmented agent text. Otherwise quoted/forwarded + # rich-text payload can become accidental command arguments. + mention_stripped = original_text.replace(f"<@{bot_uid}>", "").strip() + command_text = _rewrite_known_bang_command(mention_stripped) + if command_text != mention_stripped: + original_text = command_text + text = command_text + # Refresh command classification: the bang was hidden behind + # the leading mention when the first probe ran. + command_probe_text = command_text + is_command_text = True # Register this thread so all future messages auto-trigger the bot. # Skipped in strict mode: strict_mention=true bots must be # re-mentioned every turn, so remembering the thread would diff --git a/tests/gateway/test_slack.py b/tests/gateway/test_slack.py index f4fdae41e6bd..00d2fdd45b93 100644 --- a/tests/gateway/test_slack.py +++ b/tests/gateway/test_slack.py @@ -1559,6 +1559,107 @@ class TestBangPrefixCommands: assert msg_event.text.startswith("/queue") assert msg_event.message_type == MessageType.COMMAND + @pytest.mark.asyncio + async def test_mention_prefixed_bang_is_rewritten(self, adapter): + evt = self._make_event( + "<@U_BOT> !new", + thread_ts="1111111111.000001", + channel_type="channel", + channel="C123", + ) + await adapter._handle_slack_message(evt) + + msg_event = adapter.handle_message.call_args[0][0] + assert msg_event.text == "/new" + assert msg_event.message_type == MessageType.COMMAND + + @pytest.mark.asyncio + async def test_mention_prefixed_bang_no_space(self, adapter): + evt = self._make_event( + "<@U_BOT>!new", + thread_ts="1111111111.000001", + channel_type="channel", + channel="C123", + ) + await adapter._handle_slack_message(evt) + + msg_event = adapter.handle_message.call_args[0][0] + assert msg_event.text == "/new" + assert msg_event.message_type == MessageType.COMMAND + + @pytest.mark.asyncio + async def test_mention_prefixed_unknown_bang_passes_through(self, adapter): + evt = self._make_event( + "<@U_BOT> !nice work", + channel_type="channel", + channel="C123", + ) + await adapter._handle_slack_message(evt) + + msg_event = adapter.handle_message.call_args[0][0] + assert msg_event.text == "!nice work" + assert msg_event.message_type != MessageType.COMMAND + + @pytest.mark.asyncio + async def test_thread_command_skips_context_prefix(self, adapter): + adapter._has_active_session_for_thread = MagicMock(return_value=False) + adapter._fetch_thread_context = AsyncMock( + side_effect=AssertionError( + "_fetch_thread_context must not be called for slash commands" + ) + ) + evt = self._make_event( + "<@U_BOT> !new", + thread_ts="1111111111.000001", + channel_type="channel", + channel="C123", + ) + await adapter._handle_slack_message(evt) + + msg_event = adapter.handle_message.call_args[0][0] + assert msg_event.text == "/new" + assert msg_event.message_type == MessageType.COMMAND + + @pytest.mark.asyncio + async def test_mention_command_drops_rich_text_command_arguments(self, adapter): + evt = self._make_event( + "<@U_BOT> !model", + thread_ts="1111111111.000001", + channel_type="channel", + channel="C123", + ) + evt["blocks"] = [ + { + "type": "rich_text", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + {"type": "user", "user_id": "U_BOT"}, + {"type": "text", "text": " !model"}, + ], + }, + { + "type": "rich_text_quote", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + {"type": "text", "text": "quoted context"} + ], + } + ], + }, + ], + } + ] + await adapter._handle_slack_message(evt) + + msg_event = adapter.handle_message.call_args[0][0] + assert msg_event.text == "/model" + assert "quoted context" not in msg_event.text + assert msg_event.message_type == MessageType.COMMAND + # --------------------------------------------------------------------------- # TestIncomingDocumentHandling