diff --git a/gateway/platforms/slack.py b/gateway/platforms/slack.py index 432b01d80bf..53d7c57da40 100644 --- a/gateway/platforms/slack.py +++ b/gateway/platforms/slack.py @@ -1799,6 +1799,26 @@ class SlackAdapter(BasePlatformAdapter): return original_text = event.get("text", "") + + # Slack blocks native slash commands inside threads ("/queue is not + # supported in threads. Sorry!"). As a workaround, recognise a + # leading ``!`` as an alternate command prefix and rewrite it to + # ``/`` so the rest of the pipeline (MessageType.COMMAND tagging, + # 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("!"): + try: + from hermes_cli.commands import is_gateway_known_command + first_token = original_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 = "/" + original_text[1:] + except Exception: # pragma: no cover - defensive + pass + text = original_text # Extract quoted/forwarded content from Slack blocks. diff --git a/tests/gateway/test_slack.py b/tests/gateway/test_slack.py index 478370d8c41..bc09279eec4 100644 --- a/tests/gateway/test_slack.py +++ b/tests/gateway/test_slack.py @@ -691,10 +691,98 @@ class TestSendVideo: adapter._app.client.chat_postMessage.assert_called_once() +# --------------------------------------------------------------------------- +# TestBangPrefixCommands +# --------------------------------------------------------------------------- + + +class TestBangPrefixCommands: + """``!cmd`` is rewritten to ``/cmd`` so commands work inside Slack threads. + + Slack natively rejects slash commands invoked from a thread reply + ("/queue is not supported in threads. Sorry!"). Typing ``!queue`` as a + plain text reply hits the message event pipeline instead, and the + adapter rewrites the leading ``!`` to ``/`` for any known gateway + command before downstream processing. + """ + + def _make_event(self, text, thread_ts=None, channel_type="im", channel="D123"): + evt = { + "text": text, + "user": "U_USER", + "channel": channel, + "channel_type": channel_type, + "ts": "1234567890.000001", + } + if thread_ts: + evt["thread_ts"] = thread_ts + return evt + + @pytest.mark.asyncio + async def test_bang_known_command_is_rewritten_to_slash(self, adapter): + """``!queue`` → ``/queue`` and tagged as COMMAND.""" + await adapter._handle_slack_message(self._make_event("!queue")) + + adapter.handle_message.assert_called_once() + msg_event = adapter.handle_message.call_args[0][0] + assert msg_event.text.startswith("/queue") + assert msg_event.message_type == MessageType.COMMAND + + @pytest.mark.asyncio + async def test_bang_command_with_args_preserved(self, adapter): + """``!model gpt-5.4`` → ``/model gpt-5.4``.""" + await adapter._handle_slack_message(self._make_event("!model gpt-5.4")) + + msg_event = adapter.handle_message.call_args[0][0] + assert msg_event.text.startswith("/model gpt-5.4") + assert msg_event.message_type == MessageType.COMMAND + + @pytest.mark.asyncio + async def test_bang_works_inside_thread(self, adapter): + """The whole point: ``!stop`` inside a thread reply dispatches.""" + evt = self._make_event("!stop", thread_ts="1111111111.000001") + await adapter._handle_slack_message(evt) + + msg_event = adapter.handle_message.call_args[0][0] + assert msg_event.text.startswith("/stop") + assert msg_event.message_type == MessageType.COMMAND + # thread_id is preserved on the source so the reply lands in the + # same thread. + assert msg_event.source.thread_id == "1111111111.000001" + + @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.""" + await adapter._handle_slack_message(self._make_event("!nice work")) + + 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_bang_with_bot_suffix_resolves(self, adapter): + """``!stop@hermes`` matches the get_command() ``@suffix`` stripping.""" + await adapter._handle_slack_message(self._make_event("!stop@hermes")) + + msg_event = adapter.handle_message.call_args[0][0] + assert msg_event.text.startswith("/stop@hermes") + 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.""" + await adapter._handle_slack_message(self._make_event("/queue")) + + msg_event = adapter.handle_message.call_args[0][0] + assert msg_event.text.startswith("/queue") + assert msg_event.message_type == MessageType.COMMAND + + # --------------------------------------------------------------------------- # TestIncomingDocumentHandling # --------------------------------------------------------------------------- + class TestIncomingDocumentHandling: def _make_event(self, files=None, text="hello", channel_type="im", blocks=None, attachments=None): """Build a mock Slack message event with file attachments.""" diff --git a/website/docs/user-guide/messaging/slack.md b/website/docs/user-guide/messaging/slack.md index f5b29c9d132..b5a64fb84f4 100644 --- a/website/docs/user-guide/messaging/slack.md +++ b/website/docs/user-guide/messaging/slack.md @@ -264,6 +264,22 @@ For backward compatibility with older manifests, you can still type run the tests`. Free-form questions also work: `/hermes what's the weather?` is treated as a regular message. +### Using commands inside threads (the `!cmd` prefix) + +Slack itself blocks native slash commands inside thread replies — try +`/queue` in a thread and Slack responds with *"/queue is not supported +in threads. Sorry!"* There is no app-side setting that re-enables them; +Slack never delivers them to Hermes. + +As a workaround, Hermes recognises a leading `!` as an alternate +command prefix that works in threads (and anywhere else). Type +`!queue`, `!stop`, `!model gpt-5.4`, etc. as a regular thread reply — +Hermes treats it identically to the slash form and replies in the same +thread. + +Only the first token is checked against the known command list, so +casual messages like `!nice work` pass through to the agent unchanged. + ### Advanced: emit only the slash-commands array If you maintain your Slack manifest by hand and just want the slash