diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 9bbd5a9f5367..96a728abd7c1 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -1826,14 +1826,15 @@ class MessageEvent: def is_command(self) -> bool: """Check if this is a command message (e.g., /new, /reset).""" - return self.text.startswith("/") + return (self.text or "").lstrip().startswith("/") def get_command(self) -> Optional[str]: """Extract command name if this is a command message.""" if not self.is_command(): return None # Split on space and get first word, strip the / - parts = self.text.split(maxsplit=1) + command_text = (self.text or "").lstrip() + parts = command_text.split(maxsplit=1) raw = parts[0][1:].lower() if parts else None if raw and "@" in raw: raw = raw.split("@", 1)[0] @@ -1846,7 +1847,8 @@ class MessageEvent: """Get the arguments after a command.""" if not self.is_command(): return self.text - parts = self.text.split(maxsplit=1) + command_text = (self.text or "").lstrip() + parts = command_text.split(maxsplit=1) args = parts[1] if len(parts) > 1 else "" # iOS auto-corrects -- to — (em dash) and - to – (en dash) args = args.replace("\u2014\u2014", "--").replace("\u2014", "--").replace("\u2013", "-") diff --git a/plugins/platforms/slack/adapter.py b/plugins/platforms/slack/adapter.py index bf76bd85021a..0f87a7092e5d 100644 --- a/plugins/platforms/slack/adapter.py +++ b/plugins/platforms/slack/adapter.py @@ -3788,11 +3788,12 @@ 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. - if original_text.startswith("!"): + command_probe_text = original_text.lstrip() + if command_probe_text.startswith("!"): try: from hermes_cli.commands import is_gateway_known_command - first_token = original_text[1:].split(maxsplit=1)[0] + 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() @@ -3801,10 +3802,12 @@ class SlackAdapter(BasePlatformAdapter): and "/" not in cmd_name and is_gateway_known_command(cmd_name) ): - original_text = "/" + original_text[1:] + original_text = "/" + command_probe_text[1:] + command_probe_text = original_text except Exception: # pragma: no cover - defensive pass + is_command_text = command_probe_text.startswith("/") text = original_text # Extract quoted/forwarded content from Slack blocks. @@ -4163,9 +4166,15 @@ class SlackAdapter(BasePlatformAdapter): # Determine message type msg_type = MessageType.TEXT - if (original_text or "").startswith("/"): + if is_command_text: msg_type = MessageType.COMMAND + # Commands typed as Slack text messages often intentionally carry a + # leading space (`` /stop``) so Slack itself does not intercept the + # slash. Once classified as a command, pass only the command text into + # the gateway dispatcher; do not prepend fetched thread context or + # block/attachment rendering before the leading slash. + # Handle file attachments media_urls = [] media_types = [] @@ -4514,7 +4523,7 @@ class SlackAdapter(BasePlatformAdapter): ) msg_event = MessageEvent( - text=text, + text=(command_probe_text if is_command_text else text), message_type=msg_type, source=source, raw_message=event, diff --git a/tests/e2e/test_platform_commands.py b/tests/e2e/test_platform_commands.py index 4924eed6a9e2..ffd9db601fcf 100644 --- a/tests/e2e/test_platform_commands.py +++ b/tests/e2e/test_platform_commands.py @@ -56,6 +56,16 @@ class TestSlashCommands: response_lower = response_text.lower() assert "no" in response_lower or "stop" in response_lower or "not running" in response_lower + @pytest.mark.asyncio + async def test_leading_space_stop_is_still_a_command(self, adapter, platform): + """Slack users type `` /stop`` to avoid native Slack slash interception.""" + send = await send_and_capture(adapter, " /stop", platform) + + send.assert_called_once() + response_text = send.call_args[1].get("content") or send.call_args[0][1] + response_lower = response_text.lower() + assert "no" in response_lower or "stop" in response_lower or "not running" in response_lower + @pytest.mark.asyncio async def test_commands_shows_listing(self, adapter, platform): send = await send_and_capture(adapter, "/commands", platform)