mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-29 01:31:41 +00:00
feat(slack): register every gateway command as a native slash (Discord/Telegram parity) (#16164)
Every command in COMMAND_REGISTRY (/btw, /stop, /model, /help, /new, /bg, /reset, ...) is now a first-class Slack slash command instead of a /hermes <subcommand>. Users get the same autocomplete-driven slash picker experience Slack users expect and that Discord and Telegram already provide. Previously Slack registered ONE native slash (/hermes) and split on the first word, so typing /btw in Slack's composer got 'couldn't find an app for /btw' because the workspace manifest never declared it. Changes - hermes_cli/commands.py: slack_native_slashes() + slack_app_manifest() generate a Slack manifest from the registry (canonical names + aliases + plugin commands), clamped to Slack's 50-slash cap with /hermes reserved as the catch-all. - gateway/platforms/slack.py: single regex matcher dispatches every registered slash to _handle_slash_command, which dispatches on command['command']. Legacy /hermes <subcommand> keeps working for backward compat with older workspace manifests. - hermes_cli/slack_cli.py + hermes_cli/main.py: new 'hermes slack manifest' command prints/writes a full manifest (display info, OAuth scopes, event subs, socket mode, slash commands) ready to paste into 'Create from manifest' or Features → App Manifest. - hermes_cli/setup.py: _setup_slack() now writes the manifest up-front and points users at the 'From an app manifest' flow; also offers to refresh the manifest on reconfigure for picking up new commands. - Tests: 14 new tests covering native-slash dispatch (/btw, /stop, /model), legacy /hermes <sub> compat, manifest structure, and telegram<->slack parity (every Telegram command must also register as a Slack slash). Existing /hermes-registration test updated to assert the new regex matches /hermes, /btw, /stop, /model, /help. - Docs: slack.md gains a 'Slash Commands' section + Option A manifest flow in Step 1; cli-commands.md documents 'hermes slack manifest'. Users pick up the new slashes by running 'hermes slack manifest --write' and pasting into Features → App Manifest → Edit in their Slack app config, then Save (Slack prompts for reinstall if scopes changed).
This commit is contained in:
parent
9be83728a6
commit
087e74d4d7
9 changed files with 763 additions and 32 deletions
|
|
@ -147,7 +147,20 @@ class TestAppMentionHandler:
|
|||
assert "app_mention" in registered_events
|
||||
assert "assistant_thread_started" in registered_events
|
||||
assert "assistant_thread_context_changed" in registered_events
|
||||
assert "/hermes" in registered_commands
|
||||
# Slack slash commands are registered via a single regex matcher
|
||||
# covering every COMMAND_REGISTRY entry (e.g. /hermes, /btw, /stop,
|
||||
# /model, ...) so users get native-slash parity with Discord and
|
||||
# Telegram. Verify the regex matches the key expected slashes.
|
||||
assert len(registered_commands) == 1, (
|
||||
f"expected 1 combined slash matcher, got {registered_commands!r}"
|
||||
)
|
||||
slash_matcher = registered_commands[0]
|
||||
import re as _re
|
||||
assert isinstance(slash_matcher, _re.Pattern)
|
||||
for expected in ("/hermes", "/btw", "/stop", "/model", "/help"):
|
||||
assert slash_matcher.match(expected), (
|
||||
f"Slack slash regex does not match {expected}"
|
||||
)
|
||||
|
||||
|
||||
class TestSlackConnectCleanup:
|
||||
|
|
@ -1544,6 +1557,83 @@ class TestSlashCommands:
|
|||
msg = adapter.handle_message.call_args[0][0]
|
||||
assert msg.text == "/reasoning"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Native slash commands — /btw, /stop, /model, ... dispatched directly
|
||||
# instead of as /hermes subcommands. This is the Discord/Telegram parity
|
||||
# fix: the slash name itself becomes the command.
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_native_btw_slash(self, adapter):
|
||||
"""/btw with args must dispatch to /background, not /hermes btw."""
|
||||
command = {
|
||||
"command": "/btw",
|
||||
"text": "fix the failing test",
|
||||
"user_id": "U1",
|
||||
"channel_id": "C1",
|
||||
}
|
||||
await adapter._handle_slash_command(command)
|
||||
msg = adapter.handle_message.call_args[0][0]
|
||||
# The gateway command dispatcher resolves /btw -> background via
|
||||
# resolve_command() — our handler's job is just to deliver
|
||||
# "/btw <args>" to the gateway runner, which is what this asserts.
|
||||
assert msg.text == "/btw fix the failing test"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_native_stop_slash_no_args(self, adapter):
|
||||
command = {
|
||||
"command": "/stop",
|
||||
"text": "",
|
||||
"user_id": "U1",
|
||||
"channel_id": "C1",
|
||||
}
|
||||
await adapter._handle_slash_command(command)
|
||||
msg = adapter.handle_message.call_args[0][0]
|
||||
assert msg.text == "/stop"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_native_model_slash_with_args(self, adapter):
|
||||
command = {
|
||||
"command": "/model",
|
||||
"text": "anthropic/claude-sonnet-4",
|
||||
"user_id": "U1",
|
||||
"channel_id": "C1",
|
||||
}
|
||||
await adapter._handle_slash_command(command)
|
||||
msg = adapter.handle_message.call_args[0][0]
|
||||
assert msg.text == "/model anthropic/claude-sonnet-4"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_legacy_hermes_prefix_still_works(self, adapter):
|
||||
"""Backward compat: /hermes btw foo must still route to /btw foo.
|
||||
|
||||
Old workspace manifests only declared /hermes as the single slash.
|
||||
After users refresh their manifest they get /btw natively, but the
|
||||
legacy form must keep working during the transition.
|
||||
"""
|
||||
command = {
|
||||
"command": "/hermes",
|
||||
"text": "btw run the tests",
|
||||
"user_id": "U1",
|
||||
"channel_id": "C1",
|
||||
}
|
||||
await adapter._handle_slash_command(command)
|
||||
msg = adapter.handle_message.call_args[0][0]
|
||||
assert msg.text == "/btw run the tests"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_legacy_hermes_freeform_question(self, adapter):
|
||||
"""/hermes <free-form text> must stay as the raw text (non-command)."""
|
||||
command = {
|
||||
"command": "/hermes",
|
||||
"text": "what's the weather today?",
|
||||
"user_id": "U1",
|
||||
"channel_id": "C1",
|
||||
}
|
||||
await adapter._handle_slash_command(command)
|
||||
msg = adapter.handle_message.call_args[0][0]
|
||||
assert msg.text == "what's the weather today?"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestMessageSplitting
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue