mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-24 05:41:40 +00:00
feat(slack): support !cmd as alternate prefix for slash commands in threads (#25355)
Some checks are pending
Deploy Site / deploy-vercel (push) Waiting to run
Deploy Site / deploy-docs (push) Waiting to run
Docker Build and Publish / build-amd64 (push) Waiting to run
Docker Build and Publish / build-arm64 (push) Waiting to run
Docker Build and Publish / merge (push) Blocked by required conditions
Docker Build and Publish / move-main (push) Blocked by required conditions
Docker Build and Publish / move-latest (push) Blocked by required conditions
Lint (ruff + ty) / ruff + ty diff (push) Waiting to run
Lint (ruff + ty) / ruff enforcement (blocking) (push) Waiting to run
Lint (ruff + ty) / Windows footguns (blocking) (push) Waiting to run
Nix / nix (macos-latest) (push) Waiting to run
Nix / nix (ubuntu-latest) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / e2e (push) Waiting to run
Some checks are pending
Deploy Site / deploy-vercel (push) Waiting to run
Deploy Site / deploy-docs (push) Waiting to run
Docker Build and Publish / build-amd64 (push) Waiting to run
Docker Build and Publish / build-arm64 (push) Waiting to run
Docker Build and Publish / merge (push) Blocked by required conditions
Docker Build and Publish / move-main (push) Blocked by required conditions
Docker Build and Publish / move-latest (push) Blocked by required conditions
Lint (ruff + ty) / ruff + ty diff (push) Waiting to run
Lint (ruff + ty) / ruff enforcement (blocking) (push) Waiting to run
Lint (ruff + ty) / Windows footguns (blocking) (push) Waiting to run
Nix / nix (macos-latest) (push) Waiting to run
Nix / nix (ubuntu-latest) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / e2e (push) Waiting to run
Slack platform-blocks native slash commands inside thread replies ("/queue
is not supported in threads. Sorry!") and there is no app-side setting to
re-enable them. As a workaround, rewrite a leading '!' to '/' for any known
gateway command before downstream processing — so '!queue', '!stop',
'!model gpt-5.4' etc. work inside Slack threads (and anywhere else).
Only the first token is checked against is_gateway_known_command(), so
casual messages like '!nice work' pass through to the agent unchanged.
Downstream pipeline (MessageType.COMMAND tagging, gateway dispatcher,
thread reply routing) is unchanged.
Adds 6 tests covering rewrite, args preservation, thread routing,
casual-message passthrough, '@bot' suffix, and plain '/' still-works.
This commit is contained in:
parent
3f13d78088
commit
6122a79aab
3 changed files with 124 additions and 0 deletions
|
|
@ -1799,6 +1799,26 @@ class SlackAdapter(BasePlatformAdapter):
|
||||||
return
|
return
|
||||||
|
|
||||||
original_text = event.get("text", "")
|
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
|
text = original_text
|
||||||
|
|
||||||
# Extract quoted/forwarded content from Slack blocks.
|
# Extract quoted/forwarded content from Slack blocks.
|
||||||
|
|
|
||||||
|
|
@ -691,10 +691,98 @@ class TestSendVideo:
|
||||||
adapter._app.client.chat_postMessage.assert_called_once()
|
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
|
# TestIncomingDocumentHandling
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class TestIncomingDocumentHandling:
|
class TestIncomingDocumentHandling:
|
||||||
def _make_event(self, files=None, text="hello", channel_type="im", blocks=None, attachments=None):
|
def _make_event(self, files=None, text="hello", channel_type="im", blocks=None, attachments=None):
|
||||||
"""Build a mock Slack message event with file attachments."""
|
"""Build a mock Slack message event with file attachments."""
|
||||||
|
|
|
||||||
|
|
@ -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
|
run the tests`. Free-form questions also work: `/hermes what's the
|
||||||
weather?` is treated as a regular message.
|
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
|
### Advanced: emit only the slash-commands array
|
||||||
|
|
||||||
If you maintain your Slack manifest by hand and just want the slash
|
If you maintain your Slack manifest by hand and just want the slash
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue