diff --git a/gateway/platforms/slack.py b/gateway/platforms/slack.py index 191689a5ae..61cc7020a2 100644 --- a/gateway/platforms/slack.py +++ b/gateway/platforms/slack.py @@ -207,8 +207,31 @@ class SlackAdapter(BasePlatformAdapter): async def handle_assistant_thread_context_changed(event, say): await self._handle_assistant_thread_lifecycle_event(event) - # Register slash command handler - @self._app.command("/hermes") + # Register slash command handler(s) + # + # Every gateway command from COMMAND_REGISTRY is a native Slack + # slash, matching Discord and Telegram's model (e.g. /btw, /stop, + # /model work directly without /hermes prefix). A single regex + # matcher dispatches all of them to one handler so we don't need + # N identical @app.command() decorators. + # + # The slash commands must ALSO be declared in the Slack app + # manifest (see `hermes slack manifest`). In Socket Mode, Slack + # routes the command event through the socket regardless of the + # manifest's request URL, but it will not deliver an event for + # a slash command the manifest doesn't declare. + from hermes_cli.commands import slack_native_slashes + import re as _re + + _slash_names = [name for name, _d, _h in slack_native_slashes()] + if _slash_names: + _slash_pattern = _re.compile( + r"^/(?:" + "|".join(_re.escape(n) for n in _slash_names) + r")$" + ) + else: # pragma: no cover - registry always non-empty + _slash_pattern = _re.compile(r"^/hermes$") + + @self._app.command(_slash_pattern) async def handle_hermes_command(ack, command): await ack() await self._handle_slash_command(command) @@ -1561,7 +1584,20 @@ class SlackAdapter(BasePlatformAdapter): return "" async def _handle_slash_command(self, command: dict) -> None: - """Handle /hermes slash command.""" + """Handle Slack slash commands. + + Every gateway command in COMMAND_REGISTRY is registered as a native + Slack slash (``/btw``, ``/stop``, ``/model``, etc.), matching the + Discord and Telegram model. The slash name itself is the command; + any text after it is the argument list. + + The legacy ``/hermes [args]`` form is preserved for + backward compatibility with older workspace manifests and for users + who want a single entry point for free-form questions (``/hermes + what's the weather`` — non-slash text is treated as a regular + message). + """ + slash_name = (command.get("command") or "").lstrip("/").strip() text = command.get("text", "").strip() user_id = command.get("user_id", "") channel_id = command.get("channel_id", "") @@ -1571,20 +1607,25 @@ class SlackAdapter(BasePlatformAdapter): if team_id and channel_id: self._channel_team[channel_id] = team_id - # Map subcommands to gateway commands — derived from central registry. - # Also keep "compact" as a Slack-specific alias for /compress. - from hermes_cli.commands import slack_subcommand_map - subcommand_map = slack_subcommand_map() - subcommand_map["compact"] = "/compress" - first_word = text.split()[0] if text else "" - if first_word in subcommand_map: - # Preserve arguments after the subcommand - rest = text[len(first_word):].strip() - text = f"{subcommand_map[first_word]} {rest}".strip() if rest else subcommand_map[first_word] - elif text: - pass # Treat as a regular question + if slash_name in ("hermes", ""): + # Legacy /hermes [args] routing + free-form questions. + # Empty slash_name falls into this branch for backward compat + # with any caller that didn't populate command["command"]. + from hermes_cli.commands import slack_subcommand_map + subcommand_map = slack_subcommand_map() + subcommand_map["compact"] = "/compress" + first_word = text.split()[0] if text else "" + if first_word in subcommand_map: + rest = text[len(first_word):].strip() + text = f"{subcommand_map[first_word]} {rest}".strip() if rest else subcommand_map[first_word] + elif text: + pass # Treat as a regular question + else: + text = "/help" else: - text = "/help" + # Native slash — / [args]. Route directly through the + # gateway command dispatcher by prepending the slash. + text = f"/{slash_name} {text}".strip() source = self.build_source( chat_id=channel_id, diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 614d783d95..d0eb74d872 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -806,6 +806,114 @@ def discord_skill_commands_by_category( return trimmed_categories, uncategorized, hidden +# --------------------------------------------------------------------------- +# Slack native slash commands +# --------------------------------------------------------------------------- + +# Slack slash command name constraints: lowercase a-z, 0-9, hyphens, +# underscores. Max 32 chars. Slack app manifest accepts up to 50 slash +# commands per app. +_SLACK_MAX_SLASH_COMMANDS = 50 +_SLACK_NAME_LIMIT = 32 +_SLACK_INVALID_CHARS = re.compile(r"[^a-z0-9_\-]") + + +def _sanitize_slack_name(raw: str) -> str: + """Convert a command name to a valid Slack slash command name. + + Slack allows lowercase a-z, digits, hyphens, and underscores. Max 32 + chars. Uppercase is lowercased; invalid chars are stripped. + """ + name = raw.lower() + name = _SLACK_INVALID_CHARS.sub("", name) + name = name.strip("-_") + return name[:_SLACK_NAME_LIMIT] + + +def slack_native_slashes() -> list[tuple[str, str, str]]: + """Return (slash_name, description, usage_hint) triples for Slack. + + Every gateway-available command in ``COMMAND_REGISTRY`` is surfaced as + a standalone Slack slash command (e.g. ``/btw``, ``/stop``, ``/model``), + matching Discord's and Telegram's model where every command is a + first-class slash and not a ``/hermes `` subcommand. + + Both canonical names and aliases are included so users can type any + documented form (e.g. ``/background``, ``/bg``, and ``/btw`` all work). + Plugin-registered slash commands are included too. + + Results are clamped to Slack's 50-command limit with duplicate-name + avoidance. ``/hermes`` is always reserved as the first entry so the + legacy ``/hermes `` form keeps working for anything that + gets dropped by the clamp or for free-form questions. + """ + overrides = _resolve_config_gates() + entries: list[tuple[str, str, str]] = [] + seen: set[str] = set() + + # Reserve /hermes as the catch-all top-level command. + entries.append(("hermes", "Talk to Hermes or run a subcommand", "[subcommand] [args]")) + seen.add("hermes") + + def _add(name: str, desc: str, hint: str) -> None: + slack_name = _sanitize_slack_name(name) + if not slack_name or slack_name in seen: + return + if len(entries) >= _SLACK_MAX_SLASH_COMMANDS: + return + # Slack description cap is 2000 chars; keep it short. + entries.append((slack_name, desc[:140], hint[:100])) + seen.add(slack_name) + + # First pass: canonical names (so they win slots if we hit the cap). + for cmd in COMMAND_REGISTRY: + if not _is_gateway_available(cmd, overrides): + continue + _add(cmd.name, cmd.description, cmd.args_hint or "") + + # Second pass: aliases. + for cmd in COMMAND_REGISTRY: + if not _is_gateway_available(cmd, overrides): + continue + for alias in cmd.aliases: + # Skip aliases that only differ from canonical by case/punctuation + # normalization (already covered by _add dedup). + _add(alias, f"Alias for /{cmd.name} — {cmd.description}", cmd.args_hint or "") + + # Third pass: plugin commands. + for name, description, args_hint in _iter_plugin_command_entries(): + _add(name, description, args_hint or "") + + return entries + + +def slack_app_manifest(request_url: str = "https://hermes-agent.local/slack/commands") -> dict[str, Any]: + """Generate a Slack app manifest with all gateway commands as slashes. + + ``request_url`` is required by Slack's manifest schema for every slash + command, but in Socket Mode (which we use) Slack ignores it and routes + the command event through the WebSocket. A placeholder URL is fine. + + The returned dict is the ``features.slash_commands`` portion only — + callers compose it into a full manifest (or merge into an existing + one). Keeping it narrow avoids coupling us to the rest of the manifest + schema (display_information, oauth_config, settings, etc.) which users + set up once in the Slack UI and rarely change. + """ + slashes = [] + for name, desc, usage in slack_native_slashes(): + entry = { + "command": f"/{name}", + "description": desc or f"Run /{name}", + "should_escape": False, + "url": request_url, + } + if usage: + entry["usage_hint"] = usage + slashes.append(entry) + return {"features": {"slash_commands": slashes}} + + def slack_subcommand_map() -> dict[str, str]: """Return subcommand -> /command mapping for Slack /hermes handler. diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 9c4b40de27..e10af44cd9 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -4780,6 +4780,37 @@ def cmd_webhook(args): webhook_command(args) +def cmd_slack(args): + """Slack integration helpers. + + Dispatches ``hermes slack ``. Currently supports: + manifest — print or write a Slack app manifest with every gateway + command registered as a first-class slash. + """ + sub = getattr(args, "slack_command", None) + if sub in (None, ""): + # No subcommand — print usage hint. + print( + "usage: hermes slack \n" + "\n" + "subcommands:\n" + " manifest Generate a Slack app manifest with every gateway\n" + " command registered as a native slash\n" + "\n" + "Run `hermes slack manifest -h` for details.", + file=sys.stderr, + ) + return 1 + + if sub == "manifest": + from hermes_cli.slack_cli import slack_manifest_command + + return slack_manifest_command(args) + + print(f"Unknown slack subcommand: {sub}", file=sys.stderr) + return 1 + + def cmd_hooks(args): """Shell-hook inspection and management.""" from hermes_cli.hooks import hooks_command @@ -7798,6 +7829,54 @@ For more help on a command: ) whatsapp_parser.set_defaults(func=cmd_whatsapp) + # ========================================================================= + # slack command + # ========================================================================= + slack_parser = subparsers.add_parser( + "slack", + help="Slack integration helpers (manifest generation, etc.)", + description="Slack integration helpers for Hermes.", + ) + slack_sub = slack_parser.add_subparsers(dest="slack_command") + slack_manifest = slack_sub.add_parser( + "manifest", + help="Print or write a Slack app manifest with every gateway command " + "registered as a native slash (/btw, /stop, /model, ...)", + description=( + "Generate a Slack app manifest that registers every gateway " + "command in COMMAND_REGISTRY as a first-class Slack slash " + "command (matching Discord and Telegram parity). Paste the " + "output into Slack app config → Features → App Manifest → " + "Edit, then Save. Reinstall the app if Slack prompts for it." + ), + ) + slack_manifest.add_argument( + "--write", + nargs="?", + const=True, + default=None, + metavar="PATH", + help="Write manifest to a file instead of stdout. With no PATH " + "writes to $HERMES_HOME/slack-manifest.json.", + ) + slack_manifest.add_argument( + "--name", + default=None, + help='Bot display name (default: "Hermes")', + ) + slack_manifest.add_argument( + "--description", + default=None, + help="Bot description shown in Slack's app directory.", + ) + slack_manifest.add_argument( + "--slashes-only", + action="store_true", + help="Emit only the features.slash_commands array (for merging " + "into an existing manifest manually).", + ) + slack_parser.set_defaults(func=cmd_slack) + # ========================================================================= # login command # ========================================================================= diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 0fa1f8abb2..2c4d28e027 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -1856,27 +1856,32 @@ def _setup_slack(): if existing: print_info("Slack: already configured") if not prompt_yes_no("Reconfigure Slack?", False): + # Even without reconfiguring, offer to refresh the manifest so + # new commands (e.g. /btw, /stop, ...) get registered in Slack. + if prompt_yes_no( + "Regenerate the Slack app manifest with the latest command " + "list? (recommended after `hermes update`)", + True, + ): + _write_slack_manifest_and_instruct() return print_info("Steps to create a Slack app:") - print_info(" 1. Go to https://api.slack.com/apps → Create New App (from scratch)") + print_info(" 1. Go to https://api.slack.com/apps → Create New App") + print_info(" Pick 'From an app manifest' — we'll generate one for you below.") print_info(" 2. Enable Socket Mode: Settings → Socket Mode → Enable") print_info(" • Create an App-Level Token with 'connections:write' scope") - print_info(" 3. Add Bot Token Scopes: Features → OAuth & Permissions") - print_info(" Required scopes: chat:write, app_mentions:read,") - print_info(" channels:history, channels:read, im:history,") - print_info(" im:read, im:write, users:read, files:read, files:write") - print_info(" Optional for private channels: groups:history") - print_info(" 4. Subscribe to Events: Features → Event Subscriptions → Enable") - print_info(" Required events: message.im, message.channels, app_mention") - print_info(" Optional for private channels: message.groups") - print_warning(" ⚠ Without message.channels the bot will ONLY work in DMs,") - print_warning(" not public channels.") - print_info(" 5. Install to Workspace: Settings → Install App") - print_info(" 6. Reinstall the app after any scope or event changes") - print_info(" 7. After installing, invite the bot to channels: /invite @YourBot") + print_info(" 3. Install to Workspace: Settings → Install App") + print_info(" 4. After installing, invite the bot to channels: /invite @YourBot") print() print_info(" Full guide: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/slack/") + print() + + # Generate and write manifest up-front so the user can paste it into + # the "Create from manifest" flow instead of clicking through scopes / + # events / slash commands one at a time. + _write_slack_manifest_and_instruct() + print() bot_token = prompt("Slack Bot Token (xoxb-...)", password=True) if not bot_token: @@ -1902,6 +1907,49 @@ def _setup_slack(): print_info(" Set SLACK_ALLOW_ALL_USERS=true or GATEWAY_ALLOW_ALL_USERS=true only if you intentionally want open workspace access.") +def _write_slack_manifest_and_instruct(): + """Generate the Slack manifest, write it under HERMES_HOME, and print + paste-into-Slack instructions. + + Exposed as its own helper so both the initial setup flow and the + "reconfigure? → no" branch can refresh the manifest without the user + re-entering tokens. Failures are non-fatal — if the manifest write + fails for any reason, we print a warning and skip rather than abort + the whole Slack setup. + """ + try: + from hermes_cli.slack_cli import _build_full_manifest + from hermes_constants import get_hermes_home + + manifest = _build_full_manifest( + bot_name="Hermes", + bot_description="Your Hermes agent on Slack", + ) + target = Path(get_hermes_home()) / "slack-manifest.json" + target.parent.mkdir(parents=True, exist_ok=True) + import json as _json + target.write_text( + _json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + print_success(f"Slack app manifest written to: {target}") + print_info( + " Paste it into https://api.slack.com/apps → your app → Features " + "→ App Manifest → Edit, then Save. Slack will prompt to " + "reinstall if scopes or slash commands changed." + ) + print_info( + " Re-run `hermes slack manifest --write` anytime to refresh after " + "Hermes adds new commands." + ) + except Exception as exc: # pragma: no cover - best-effort UX helper + print_warning(f"Couldn't write Slack manifest: {exc}") + print_info( + " You can generate it manually later with: " + "hermes slack manifest --write" + ) + + def _setup_matrix(): """Configure Matrix credentials.""" print_header("Matrix") diff --git a/hermes_cli/slack_cli.py b/hermes_cli/slack_cli.py new file mode 100644 index 0000000000..d76f8a6e06 --- /dev/null +++ b/hermes_cli/slack_cli.py @@ -0,0 +1,152 @@ +"""``hermes slack ...`` CLI subcommands. + +Today only ``hermes slack manifest`` is implemented — it generates the +Slack app manifest JSON for registering every gateway command as a native +Slack slash (``/btw``, ``/stop``, ``/model``, …) so users get the same +first-class slash UX Discord and Telegram already have. + +Typical workflow:: + + $ hermes slack manifest > slack-manifest.json + # or: + $ hermes slack manifest --write + +Then paste the printed JSON into the Slack app config (Features → App +Manifest → Edit) and click Save. Slack diffs the manifest and prompts +for reinstall when scopes/commands change. +""" +from __future__ import annotations + +import json +import sys +from pathlib import Path + + +def _build_full_manifest(bot_name: str, bot_description: str) -> dict: + """Build a full Slack manifest merging display info + our slash list. + + The slash-command list is always generated from ``COMMAND_REGISTRY`` so + it stays in sync with the rest of Hermes. Other manifest sections + (display info, OAuth scopes, socket mode) are set to sensible defaults + for a Hermes deployment — users can tweak them in the Slack UI after + pasting. + """ + from hermes_cli.commands import slack_app_manifest + + partial = slack_app_manifest() + slashes = partial["features"]["slash_commands"] + + return { + "_metadata": { + "major_version": 1, + "minor_version": 1, + }, + "display_information": { + "name": bot_name[:35], + "description": (bot_description or "Your Hermes agent on Slack")[:140], + "background_color": "#1a1a2e", + }, + "features": { + "bot_user": { + "display_name": bot_name[:80], + "always_online": True, + }, + "slash_commands": slashes, + "assistant_view": { + "assistant_description": "Chat with Hermes in threads and DMs.", + }, + }, + "oauth_config": { + "scopes": { + "bot": [ + "app_mentions:read", + "assistant:write", + "channels:history", + "channels:read", + "chat:write", + "commands", + "files:read", + "files:write", + "groups:history", + "im:history", + "im:read", + "im:write", + "users:read", + ], + }, + }, + "settings": { + "event_subscriptions": { + "bot_events": [ + "app_mention", + "assistant_thread_context_changed", + "assistant_thread_started", + "message.channels", + "message.groups", + "message.im", + ], + }, + "interactivity": { + "is_enabled": True, + }, + "org_deploy_enabled": False, + "socket_mode_enabled": True, + "token_rotation_enabled": False, + }, + } + + +def slack_manifest_command(args) -> int: + """Print or write a Slack app manifest JSON. + + Flags (all parsed in ``hermes_cli/main.py``): + --write [PATH] Write to file instead of stdout (default path: + ``$HERMES_HOME/slack-manifest.json``) + --name NAME Override the bot display name (default: "Hermes") + --description DESC Override the bot description + --slashes-only Emit only the ``features.slash_commands`` array (for + merging into an existing manifest manually) + """ + name = getattr(args, "name", None) or "Hermes" + description = getattr(args, "description", None) or "Your Hermes agent on Slack" + + if getattr(args, "slashes_only", False): + from hermes_cli.commands import slack_app_manifest + + manifest = slack_app_manifest()["features"]["slash_commands"] + else: + manifest = _build_full_manifest(name, description) + + payload = json.dumps(manifest, indent=2, ensure_ascii=False) + "\n" + + write_target = getattr(args, "write", None) + if write_target is not None: + if isinstance(write_target, bool) and write_target: + # --write with no value → default location + try: + from hermes_constants import get_hermes_home + + target = Path(get_hermes_home()) / "slack-manifest.json" + except Exception: + target = Path.home() / ".hermes" / "slack-manifest.json" + else: + target = Path(write_target).expanduser() + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(payload, encoding="utf-8") + print(f"Slack manifest written to: {target}", file=sys.stderr) + print( + "\nNext steps:\n" + " 1. Open https://api.slack.com/apps and pick your Hermes app\n" + " (or create a new one: Create New App → From an app manifest).\n" + f" 2. Features → App Manifest → paste the contents of\n" + f" {target}\n" + " 3. Save; Slack will prompt to reinstall the app if scopes or\n" + " slash commands changed.\n" + " 4. Make sure Socket Mode is enabled and you have a bot token\n" + " (xoxb-...) and app token (xapp-...) configured via\n" + " `hermes setup`.\n", + file=sys.stderr, + ) + else: + sys.stdout.write(payload) + return 0 diff --git a/tests/gateway/test_slack.py b/tests/gateway/test_slack.py index cdd27364b7..877d100d6f 100644 --- a/tests/gateway/test_slack.py +++ b/tests/gateway/test_slack.py @@ -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 " 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 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 diff --git a/tests/hermes_cli/test_commands.py b/tests/hermes_cli/test_commands.py index d77a076ebf..26bba9d58f 100644 --- a/tests/hermes_cli/test_commands.py +++ b/tests/hermes_cli/test_commands.py @@ -20,6 +20,8 @@ from hermes_cli.commands import ( discord_skill_commands, gateway_help_lines, resolve_command, + slack_app_manifest, + slack_native_slashes, slack_subcommand_map, telegram_bot_commands, telegram_menu_commands, @@ -256,6 +258,115 @@ class TestSlackSubcommandMap: assert cmd.name not in mapping +class TestSlackNativeSlashes: + """Slack native slash command generation — used to register every + COMMAND_REGISTRY entry as a first-class Slack slash, matching Discord + and Telegram.""" + + def test_returns_triples(self): + slashes = slack_native_slashes() + assert len(slashes) >= 10 + for entry in slashes: + assert isinstance(entry, tuple) and len(entry) == 3 + name, desc, hint = entry + assert isinstance(name, str) and name + assert isinstance(desc, str) + assert isinstance(hint, str) + + def test_hermes_catchall_is_first(self): + """``/hermes`` must be reserved as the first slot so the legacy + ``/hermes `` form keeps working after we add new + commands and hit the 50-slash cap.""" + slashes = slack_native_slashes() + assert slashes[0][0] == "hermes" + + def test_names_respect_slack_limits(self): + for name, _desc, _hint in slack_native_slashes(): + # Slack: lowercase a-z, 0-9, hyphens, underscores; max 32 chars + assert len(name) <= 32, f"slash {name!r} exceeds 32 chars" + assert name == name.lower() + for ch in name: + assert ch.isalnum() or ch in "-_", f"invalid char {ch!r} in {name!r}" + + def test_under_fifty_command_cap(self): + """Slack allows at most 50 slash commands per app.""" + assert len(slack_native_slashes()) <= 50 + + def test_unique_names(self): + names = [n for n, _d, _h in slack_native_slashes()] + assert len(names) == len(set(names)), "duplicate Slack slash names" + + def test_includes_canonical_commands(self): + names = {n for n, _d, _h in slack_native_slashes()} + # Sample of gateway-available canonical commands + for expected in ("new", "stop", "background", "model", "help", "status"): + assert expected in names, f"missing canonical /{expected}" + + def test_includes_aliases_as_first_class_slashes(self): + """Aliases (/btw, /bg, /reset, /q) must be registered as standalone + slashes — this is the whole point of native-slashes parity.""" + names = {n for n, _d, _h in slack_native_slashes()} + assert "btw" in names + assert "bg" in names + assert "reset" in names + assert "q" in names + + def test_telegram_parity(self): + """Every Telegram bot command must be registerable on Slack too. + + This catches the old behavior where Slack users couldn't invoke + commands like /btw natively. If a future command surfaces on + Telegram but not Slack (because of Slack's 50-slash cap), this + test fails loudly so we can curate the list rather than silently + dropping parity. + """ + slack_names = {n for n, _d, _h in slack_native_slashes()} + tg_names = {n for n, _d in telegram_bot_commands()} + # Some Telegram names have underscores where Slack uses hyphens + # (e.g. set_home vs sethome). Normalize both sides for comparison. + def _norm(s: str) -> str: + return s.replace("-", "_").replace("__", "_").strip("_") + + slack_norm = {_norm(n) for n in slack_names} + tg_norm = {_norm(n) for n in tg_names} + missing = tg_norm - slack_norm + assert not missing, ( + f"commands on Telegram but missing from Slack native slashes: {sorted(missing)}" + ) + + +class TestSlackAppManifest: + """Generated Slack app manifest (used by `hermes slack manifest`).""" + + def test_returns_dict(self): + m = slack_app_manifest() + assert isinstance(m, dict) + assert "features" in m + assert "slash_commands" in m["features"] + + def test_each_slash_has_required_fields(self): + m = slack_app_manifest() + for entry in m["features"]["slash_commands"]: + assert entry["command"].startswith("/") + assert "description" in entry + assert "url" in entry + # should_escape must be present (Slack defaults to True which + # HTML-escapes args — we want the raw text) + assert "should_escape" in entry + + def test_btw_is_in_manifest(self): + """Regression: /btw must be a native Slack slash, not just a + /hermes subcommand.""" + m = slack_app_manifest() + commands = [c["command"] for c in m["features"]["slash_commands"]] + assert "/btw" in commands + + def test_custom_request_url(self): + m = slack_app_manifest(request_url="https://example.com/slack") + for entry in m["features"]["slash_commands"]: + assert entry["url"] == "https://example.com/slack" + + # --------------------------------------------------------------------------- # Config-gated gateway commands # --------------------------------------------------------------------------- diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index 947994844b..9a804859eb 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -41,6 +41,7 @@ hermes [global-options] [subcommand/options] | `hermes gateway` | Run or manage the messaging gateway service. | | `hermes setup` | Interactive setup wizard for all or part of the configuration. | | `hermes whatsapp` | Configure and pair the WhatsApp bridge. | +| `hermes slack` | Slack helpers (currently: generate the app manifest with every command as a native slash). | | `hermes auth` | Manage credentials — add, list, remove, reset, set strategy. Handles OAuth flows for Codex/Nous/Anthropic. | | `hermes login` / `logout` | **Deprecated** — use `hermes auth` instead. | | `hermes status` | Show agent, auth, and platform status. | @@ -221,6 +222,33 @@ hermes whatsapp Runs the WhatsApp pairing/setup flow, including mode selection and QR-code pairing. +## `hermes slack` + +```bash +hermes slack manifest # print manifest to stdout +hermes slack manifest --write # write to ~/.hermes/slack-manifest.json +hermes slack manifest --slashes-only # just the features.slash_commands array +``` + +Generates a Slack app manifest that registers every gateway command in +`COMMAND_REGISTRY` (`/btw`, `/stop`, `/model`, …) as a first-class +Slack slash command — matching Discord and Telegram parity. Paste the +output into your Slack app config at +[https://api.slack.com/apps](https://api.slack.com/apps) → your app → +**Features → App Manifest → Edit**, then **Save**. Slack prompts for +reinstall if scopes or slash commands changed. + +| Flag | Default | Purpose | +|------|---------|---------| +| `--write [PATH]` | stdout | Write to a file instead of stdout. Bare `--write` writes `$HERMES_HOME/slack-manifest.json`. | +| `--name NAME` | `Hermes` | Bot display name in Slack. | +| `--description DESC` | default blurb | Bot description shown in the Slack app directory. | +| `--slashes-only` | off | Emit only `features.slash_commands` for merging into a manually-maintained manifest. | + +Run `hermes slack manifest --write` again after `hermes update` to pick +up any new commands. + + ## `hermes login` / `hermes logout` *(Deprecated)* :::caution diff --git a/website/docs/user-guide/messaging/slack.md b/website/docs/user-guide/messaging/slack.md index a7eff683da..2f598fcfe9 100644 --- a/website/docs/user-guide/messaging/slack.md +++ b/website/docs/user-guide/messaging/slack.md @@ -29,13 +29,36 @@ the steps below. ## Step 1: Create a Slack App +The fastest path is to paste a manifest Hermes generates for you. It +declares every built-in slash command (`/btw`, `/stop`, `/model`, …), +every required OAuth scope, every event subscription, and enables Socket +Mode — all at once. + +### Option A: From a Hermes-generated manifest (recommended) + +1. Generate the manifest: + ```bash + hermes slack manifest --write + ``` + This writes `~/.hermes/slack-manifest.json` and prints paste-in + instructions. +2. Go to [https://api.slack.com/apps](https://api.slack.com/apps) → + **Create New App** → **From an app manifest** +3. Pick your workspace, paste the JSON contents, review, click **Next** + → **Create** +4. Skip ahead to **Step 6: Install App to Workspace**. The manifest + handled scopes, events, and slash commands for you. + +### Option B: From scratch (manual) + 1. Go to [https://api.slack.com/apps](https://api.slack.com/apps) 2. Click **Create New App** 3. Choose **From scratch** 4. Enter an app name (e.g., "Hermes Agent") and select your workspace 5. Click **Create App** -You'll land on the app's **Basic Information** page. +You'll land on the app's **Basic Information** page. Continue with +Steps 2–6 below. --- @@ -203,6 +226,57 @@ The bot will **not** automatically join channels. You must invite it to each cha --- +## Slash Commands + +Every Hermes command (`/btw`, `/stop`, `/new`, `/model`, `/help`, ...) +is a native Slack slash command — exactly the way they work on Telegram +and Discord. Type `/` in Slack and the autocomplete picker lists every +Hermes command with its description. + +Under the hood: Hermes ships with a generated Slack app manifest (see +Step 1, Option A) that declares every command in +[`COMMAND_REGISTRY`](https://github.com/NousResearch/hermes-agent/blob/main/hermes_cli/commands.py) +as a slash command. In Socket Mode, Slack routes the command event +through the WebSocket regardless of the manifest's `url` field. + +### Refreshing slash commands after updates + +When Hermes adds new commands (e.g. after `hermes update`), regenerate +the manifest and update your Slack app: + +```bash +hermes slack manifest --write +``` + +Then in Slack: +1. Open [https://api.slack.com/apps](https://api.slack.com/apps) → + your Hermes app +2. **Features → App Manifest → Edit** +3. Paste the new contents of `~/.hermes/slack-manifest.json` +4. **Save**. Slack will prompt to reinstall the app if scopes or slash + commands changed. + +### Legacy `/hermes ` still works + +For backward compatibility with older manifests, you can still type +`/hermes btw run the tests` — Hermes routes it the same way as `/btw +run the tests`. Free-form questions also work: `/hermes what's the +weather?` is treated as a regular message. + +### Advanced: emit only the slash-commands array + +If you maintain your Slack manifest by hand and just want the slash +command list: + +```bash +hermes slack manifest --slashes-only > /tmp/slashes.json +``` + +Paste that array into the `features.slash_commands` key of your +existing manifest. + +--- + ## How the Bot Responds Understanding how Hermes behaves in different contexts: