Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions gateway/platforms/slack.py
Original file line number Diff line number Diff line change
Expand Up @@ -1799,6 +1799,26 @@ async def _handle_slack_message(self, event: dict) -> None:
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.
Expand Down
88 changes: 88 additions & 0 deletions tests/gateway/test_slack.py
Original file line number Diff line number Diff line change
Expand Up @@ -691,10 +691,98 @@ async def test_send_video_api_error_falls_back(self, adapter, tmp_path):
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."""
Expand Down
16 changes: 16 additions & 0 deletions website/docs/user-guide/messaging/slack.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading