From 1dcc069d006698f378b9f1607026ca536ff6495d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 18:09:20 +0000 Subject: [PATCH 1/2] feat(slack): add channel list/invite helpers for "all channels" coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Slack Socket Mode adapter (bidirectional) and launchd LaunchAgent already exist. The missing piece was reproducible "all channels" wiring: a Slack bot only sees/posts in channels it has joined, and there's no bulk-add API. Add two CLI helpers: - `hermes slack channels` — list channels and report which ones the bot is / isn't a member of (audits coverage, reports gaps). - `hermes slack invite [--all|--channel] [--user-token] [--dry-run]` — self-join public channels via conversations.join; invite the bot to private channels via conversations.invite when a user token is given. Also: - add `channels:join` bot scope to the generated manifest so self-join works - add `hermes slack manifest --yaml` (Slack accepts JSON or YAML) - document SLACK_USER_TOKEN, the connections:write app-token requirement, and the invite-per-channel vs read-all (user token) trade-off - tests for channel listing, invite paths, and manifest rendering --- .env.example | 6 + hermes_cli/main.py | 85 ++++- hermes_cli/slack_cli.py | 352 ++++++++++++++++++++- tests/hermes_cli/test_slack_cli.py | 140 +++++++- website/docs/reference/cli-commands.md | 38 ++- website/docs/user-guide/messaging/slack.md | 41 ++- 6 files changed, 640 insertions(+), 22 deletions(-) diff --git a/.env.example b/.env.example index b7f3b008faf2..c3416030152a 100644 --- a/.env.example +++ b/.env.example @@ -326,8 +326,14 @@ BROWSER_INACTIVITY_TIMEOUT=120 # SLACK_BOT_TOKEN=xoxb-... # Slack App Token - For Socket Mode (App-Level Tokens in Slack App settings) +# Must be generated with the `connections:write` scope. # SLACK_APP_TOKEN=xapp-... +# Slack User Token (optional) - only needed to add the bot to PRIVATE channels +# via `hermes slack invite --user-token`. A user token (xoxp-) acts as you, so +# treat it as sensitive. Bot-token self-join handles all PUBLIC channels. +# SLACK_USER_TOKEN=xoxp-... + # Slack allowed users (comma-separated Slack user IDs) # SLACK_ALLOWED_USERS= diff --git a/hermes_cli/main.py b/hermes_cli/main.py index e5ea3e4ca868..5d5ab285be5d 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -6144,10 +6144,12 @@ def cmd_slack(args): "usage: hermes slack \n" "\n" "subcommands:\n" - " manifest Generate a Slack app manifest with every gateway\n" - " command registered as a native slash\n" + " manifest Generate a Slack app manifest (JSON or YAML) with\n" + " every gateway command registered as a native slash\n" + " channels List channels and show which the bot is/isn't in\n" + " invite Add the bot to channels (--all joins all public)\n" "\n" - "Run `hermes slack manifest -h` for details.", + "Run `hermes slack -h` for details.", file=sys.stderr, ) return 1 @@ -6157,6 +6159,16 @@ def cmd_slack(args): return slack_manifest_command(args) + if sub == "channels": + from hermes_cli.slack_cli import slack_channels_command + + return slack_channels_command(args) + + if sub == "invite": + from hermes_cli.slack_cli import slack_invite_command + + return slack_invite_command(args) + print(f"Unknown slack subcommand: {sub}", file=sys.stderr) return 1 @@ -11493,6 +11505,73 @@ def _dispatch_secrets(args): # noqa: ANN001 help="Emit only the features.slash_commands array (for merging " "into an existing manifest manually).", ) + slack_manifest.add_argument( + "--yaml", + action="store_true", + help="Emit the manifest as YAML instead of JSON (Slack accepts both).", + ) + + slack_channels = slack_sub.add_parser( + "channels", + help="List channels and report which ones the bot is/isn't a member of", + description=( + "Enumerate every channel the bot token can see (public + private " + "the bot is in) and report membership gaps. A Slack bot only sees " + "and posts in channels it has joined, so this is how you audit " + "'all channels' coverage. Requires SLACK_BOT_TOKEN." + ), + ) + slack_channels.add_argument( + "--no-private", + action="store_true", + help="Only list public channels (skip private groups).", + ) + slack_channels.add_argument( + "--json", + action="store_true", + help="Emit machine-readable JSON instead of a human summary.", + ) + + slack_invite = slack_sub.add_parser( + "invite", + help="Add the bot to channels so it can read & post (--all joins all public)", + description=( + "Add the bot to channels. Public channels are joined directly with " + "the bot token (conversations.join, needs channels:join scope). " + "Private channels need a manual /invite @ or a user token " + "(--user-token / SLACK_USER_TOKEN) so we can call " + "conversations.invite. Requires SLACK_BOT_TOKEN." + ), + ) + slack_invite.add_argument( + "--all", + action="store_true", + help="Target every channel the bot isn't already a member of.", + ) + slack_invite.add_argument( + "--channel", + action="append", + metavar="NAME/ID", + help="Target a specific channel by name or ID (repeatable).", + ) + slack_invite.add_argument( + "--no-private", + action="store_true", + help="Only consider public channels.", + ) + slack_invite.add_argument( + "--user-token", + default=None, + metavar="xoxp-...", + help="User token used to invite the bot to PRIVATE channels " + "(falls back to SLACK_USER_TOKEN).", + ) + slack_invite.add_argument( + "--dry-run", + action="store_true", + help="Show what would happen without calling Slack.", + ) + slack_parser.set_defaults(func=cmd_slack) # ========================================================================= diff --git a/hermes_cli/slack_cli.py b/hermes_cli/slack_cli.py index 1f1747f44544..4c5e9b349143 100644 --- a/hermes_cli/slack_cli.py +++ b/hermes_cli/slack_cli.py @@ -1,17 +1,25 @@ """``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. +Subcommands: + +* ``hermes slack manifest`` — generate the Slack app manifest (JSON or YAML) + 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. +* ``hermes slack channels`` — list every channel the bot token can see and + report which ones the bot is/isn't a member of (the "all channels" gap). +* ``hermes slack invite`` — add the bot to channels so it can read & post. + Public channels are joined directly with the bot token + (``conversations.join``); private channels need a human ``/invite`` or a + user token (``conversations.invite``). Typical workflow:: - $ hermes slack manifest > slack-manifest.json - # or: - $ hermes slack manifest --write + $ hermes slack manifest --yaml > slack-manifest.yaml # paste at api.slack.com + $ hermes slack channels # see membership gaps + $ hermes slack invite --all # join all public channels -Then paste the printed JSON into the Slack app config (Features → App +Then paste the manifest into the Slack app config (Features → App Manifest → Edit) and click Save. Slack diffs the manifest and prompts for reinstall when scopes/commands change. """ @@ -21,6 +29,7 @@ import os import sys from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple def _build_full_manifest(bot_name: str, bot_description: str) -> dict: @@ -68,6 +77,7 @@ def _build_full_manifest(bot_name: str, bot_description: str) -> dict: "app_mentions:read", "assistant:write", "channels:history", + "channels:join", "channels:read", "chat:write", "commands", @@ -103,12 +113,26 @@ def _build_full_manifest(bot_name: str, bot_description: str) -> dict: } +def _render_manifest(manifest, as_yaml: bool) -> str: + """Serialize a manifest dict/list to JSON (default) or YAML text. + + Slack's "Create an app → From a manifest" flow accepts both JSON and + YAML; YAML is friendlier to paste/diff, so we offer it via ``--yaml``. + """ + if as_yaml: + import yaml # pyyaml is a hard dependency (see pyproject.toml) + + return yaml.safe_dump(manifest, sort_keys=False, allow_unicode=True, default_flow_style=False) + return json.dumps(manifest, indent=2, ensure_ascii=False) + "\n" + + def slack_manifest_command(args) -> int: - """Print or write a Slack app manifest JSON. + """Print or write a Slack app manifest (JSON or YAML). Flags (all parsed in ``hermes_cli/main.py``): --write [PATH] Write to file instead of stdout (default path: - ``$HERMES_HOME/slack-manifest.json``) + ``$HERMES_HOME/slack-manifest.{json,yaml}``) + --yaml Emit YAML instead of JSON (Slack accepts both) --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 @@ -116,6 +140,7 @@ def slack_manifest_command(args) -> int: """ name = getattr(args, "name", None) or "Hermes" description = getattr(args, "description", None) or "Your Hermes agent on Slack" + as_yaml = bool(getattr(args, "yaml", False)) if getattr(args, "slashes_only", False): from hermes_cli.commands import slack_app_manifest @@ -124,7 +149,8 @@ def slack_manifest_command(args) -> int: else: manifest = _build_full_manifest(name, description) - payload = json.dumps(manifest, indent=2, ensure_ascii=False) + "\n" + payload = _render_manifest(manifest, as_yaml) + ext = "yaml" if as_yaml else "json" write_target = getattr(args, "write", None) if write_target is not None: @@ -133,9 +159,9 @@ def slack_manifest_command(args) -> int: try: from hermes_constants import get_hermes_home - target = Path(get_hermes_home()) / "slack-manifest.json" + target = Path(get_hermes_home()) / f"slack-manifest.{ext}" except Exception: - target = Path(os.environ.get("HERMES_HOME") or str(Path.home() / ".hermes")) / "slack-manifest.json" + target = Path(os.environ.get("HERMES_HOME") or str(Path.home() / ".hermes")) / f"slack-manifest.{ext}" else: target = Path(write_target).expanduser() target.parent.mkdir(parents=True, exist_ok=True) @@ -151,9 +177,307 @@ def slack_manifest_command(args) -> int: " 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", + " `hermes setup`.\n" + " 5. Add the bot to your channels: hermes slack invite --all\n", file=sys.stderr, ) else: sys.stdout.write(payload) return 0 + + +# --------------------------------------------------------------------------- +# Channel membership: list + invite ("all channels" reproducibly) +# --------------------------------------------------------------------------- +# +# A Slack *bot* only sees and posts in channels it is a *member* of. There is +# no API to bulk-add a bot to every channel in one call, so "wire to all +# channels" decomposes into two scriptable steps: +# +# 1. list every channel the token can see + whether the bot is a member; +# 2. add the bot to the ones it's missing. +# +# Public channels: the bot can add *itself* with ``conversations.join`` (needs +# the ``channels:join`` scope, included in the generated manifest). +# Private channels: a bot CANNOT self-join. Either a human runs ``/invite +# @`` from inside the channel, or you pass a *user* token (xoxp-, from +# someone already in the channel) and we call ``conversations.invite``. + + +def _load_bot_token() -> Optional[str]: + """Return the Slack bot token from the environment / ``~/.hermes/.env``. + + Mirrors how the gateway resolves it: ``SLACK_BOT_TOKEN`` may hold a + single token or a comma-separated list for multi-workspace setups; we + use the first one for these single-workspace helper commands. + """ + token = os.getenv("SLACK_BOT_TOKEN") + if not token: + try: + from hermes_cli.config import load_env + + token = load_env().get("SLACK_BOT_TOKEN") + except Exception: + token = None + if not token: + return None + # Multi-workspace: first token is the primary (matches SlackAdapter). + first = token.split(",")[0].strip() + return first or None + + +def _make_web_client(token: str): + """Build a synchronous ``slack_sdk.WebClient``, lazy-installing the SDK. + + Reuses the same proxy resolution the gateway adapter uses so these + helpers work behind a corporate proxy too. + """ + try: + from slack_sdk import WebClient + except ImportError: + from tools.lazy_deps import ensure + + ensure("platform.slack", prompt=False) + from slack_sdk import WebClient + + proxy = None + try: + from gateway.platforms.slack import _resolve_slack_proxy_url + + proxy = _resolve_slack_proxy_url() + except Exception: + proxy = None + + return WebClient(token=token, proxy=proxy) + + +def _list_channels(client, *, include_private: bool = True) -> List[Dict[str, Any]]: + """Return all conversations the token can enumerate, paginated. + + Each entry is normalized to ``{id, name, is_member, is_private, + is_archived}``. Uses ``conversations.list`` with cursor pagination. + """ + types = "public_channel" + if include_private: + types += ",private_channel" + + channels: List[Dict[str, Any]] = [] + cursor: Optional[str] = None + while True: + kwargs: Dict[str, Any] = {"types": types, "limit": 1000, "exclude_archived": False} + if cursor: + kwargs["cursor"] = cursor + resp = client.conversations_list(**kwargs) + for ch in resp.get("channels", []) or []: + channels.append( + { + "id": ch.get("id", ""), + "name": ch.get("name", ch.get("id", "")), + "is_member": bool(ch.get("is_member", False)), + "is_private": bool(ch.get("is_private", False)), + "is_archived": bool(ch.get("is_archived", False)), + } + ) + cursor = (resp.get("response_metadata") or {}).get("next_cursor") or "" + if not cursor: + break + channels.sort(key=lambda c: (c["is_private"], c["name"].lower())) + return channels + + +def _resolve_bot_user_id(client) -> Optional[str]: + """Return the bot's own user id via ``auth.test`` (needed for invites).""" + try: + resp = client.auth_test() + return resp.get("user_id") + except Exception: + return None + + +def slack_channels_command(args) -> int: + """List channels and report which ones the bot is / isn't a member of.""" + token = _load_bot_token() + if not token: + print( + "SLACK_BOT_TOKEN not set. Add it to ~/.hermes/.env or run `hermes setup`.", + file=sys.stderr, + ) + return 1 + + include_private = not getattr(args, "no_private", False) + as_json = bool(getattr(args, "json", False)) + + try: + client = _make_web_client(token) + channels = _list_channels(client, include_private=include_private) + except Exception as e: + print(f"Failed to list Slack channels: {e}", file=sys.stderr) + return 1 + + live = [c for c in channels if not c["is_archived"]] + member = [c for c in live if c["is_member"]] + missing = [c for c in live if not c["is_member"]] + + if as_json: + json.dump( + { + "member": member, + "missing": missing, + "archived": [c for c in channels if c["is_archived"]], + }, + sys.stdout, + indent=2, + ) + sys.stdout.write("\n") + return 0 + + def _fmt(c: Dict[str, Any]) -> str: + kind = "private" if c["is_private"] else "public" + return f" #{c['name']} ({c['id']}, {kind})" + + print(f"Bot is a MEMBER of {len(member)} channel(s):") + for c in member: + print(_fmt(c)) + print(f"\nBot is NOT a member of {len(missing)} channel(s):") + for c in missing: + print(_fmt(c)) + + pub_missing = [c for c in missing if not c["is_private"]] + priv_missing = [c for c in missing if c["is_private"]] + if pub_missing: + print( + f"\n{len(pub_missing)} public channel(s) can be joined automatically:\n" + " hermes slack invite --all" + ) + if priv_missing: + print( + f"\n{len(priv_missing)} private channel(s) need a manual invite " + "(/invite @ from inside each), or pass a user token:\n" + " hermes slack invite --all --user-token xoxp-..." + ) + return 0 + + +def _invite_bot_to_channel( + client, channel: Dict[str, Any], bot_user_id: Optional[str], *, user_client=None +) -> Tuple[bool, str]: + """Add the bot to a single channel. Returns ``(ok, message)``. + + Strategy: + * Public channel → ``conversations.join`` with the bot token (self-join). + * Private channel → ``conversations.invite`` with a *user* token if one + was provided; otherwise it cannot be automated (report as skipped). + + Error handling is SDK-agnostic: any exception carrying a ``response`` + mapping with an ``error`` key (as ``slack_sdk.errors.SlackApiError`` + does) is inspected so an ``already_in_channel`` result still counts as + success. + """ + cid = channel["id"] + name = channel["name"] + try: + if not channel["is_private"]: + client.conversations_join(channel=cid) + return True, f"joined #{name}" + # Private channel. + if user_client is not None and bot_user_id: + user_client.conversations_invite(channel=cid, users=bot_user_id) + return True, f"invited bot to private #{name} (user token)" + return False, f"skipped private #{name} — needs /invite @ or --user-token" + except Exception as e: + response = getattr(e, "response", None) + err = "" + if isinstance(response, dict): + err = response.get("error", "") + err = err or str(e) + if err == "already_in_channel": + return True, f"already in #{name}" + return False, f"failed #{name}: {err}" + + +def slack_invite_command(args) -> int: + """Add the bot to channels so it can read & post (the "all channels" step). + + Flags: + --all Target every channel the bot isn't already in. + --channel NAME/ID Target a specific channel (repeatable). + --no-private Only consider public channels. + --user-token TOKEN User token (xoxp-) used to invite the bot to + private channels (falls back to SLACK_USER_TOKEN). + --dry-run Show what would happen without calling Slack. + """ + token = _load_bot_token() + if not token: + print( + "SLACK_BOT_TOKEN not set. Add it to ~/.hermes/.env or run `hermes setup`.", + file=sys.stderr, + ) + return 1 + + target_all = bool(getattr(args, "all", False)) + requested = [c.lstrip("#") for c in (getattr(args, "channel", None) or [])] + if not target_all and not requested: + print( + "Nothing to do: pass --all or one or more --channel NAME/ID.", + file=sys.stderr, + ) + return 1 + + include_private = not getattr(args, "no_private", False) + dry_run = bool(getattr(args, "dry_run", False)) + user_token = getattr(args, "user_token", None) or os.getenv("SLACK_USER_TOKEN") + + try: + client = _make_web_client(token) + channels = _list_channels(client, include_private=include_private) + except Exception as e: + print(f"Failed to list Slack channels: {e}", file=sys.stderr) + return 1 + + by_id = {c["id"]: c for c in channels} + by_name = {c["name"].lower(): c for c in channels} + + if target_all: + targets = [c for c in channels if not c["is_archived"] and not c["is_member"]] + else: + targets = [] + for token_str in requested: + ch = by_id.get(token_str) or by_name.get(token_str.lower()) + if ch is None: + print(f" ⚠ channel not found: {token_str}", file=sys.stderr) + continue + targets.append(ch) + + if not targets: + print("All targeted channels already have the bot. Nothing to do.") + return 0 + + bot_user_id = _resolve_bot_user_id(client) + user_client = None + if user_token: + try: + user_client = _make_web_client(user_token) + except Exception as e: + print(f" ⚠ could not build user-token client: {e}", file=sys.stderr) + + succeeded = 0 + failed = 0 + for ch in targets: + if dry_run: + action = "join" if not ch["is_private"] else ( + "invite (user token)" if user_client else "skip (needs invite)" + ) + print(f" [dry-run] #{ch['name']} → {action}") + continue + ok, msg = _invite_bot_to_channel( + client, ch, bot_user_id, user_client=user_client + ) + print((" ✓ " if ok else " ✗ ") + msg) + if ok: + succeeded += 1 + else: + failed += 1 + + if not dry_run: + print(f"\nDone: {succeeded} added/confirmed, {failed} need attention.") + return 0 if failed == 0 else 1 diff --git a/tests/hermes_cli/test_slack_cli.py b/tests/hermes_cli/test_slack_cli.py index 8ccdb7119c03..80a7a443e050 100644 --- a/tests/hermes_cli/test_slack_cli.py +++ b/tests/hermes_cli/test_slack_cli.py @@ -1,6 +1,15 @@ """Tests for Slack CLI helpers.""" -from hermes_cli.slack_cli import _build_full_manifest +import json + +import pytest + +from hermes_cli.slack_cli import ( + _build_full_manifest, + _invite_bot_to_channel, + _list_channels, + _render_manifest, +) class TestSlackFullManifest: @@ -21,6 +30,13 @@ def test_private_channel_directory_scope_is_included(self): bot_scopes = manifest["oauth_config"]["scopes"]["bot"] assert "groups:read" in bot_scopes + def test_public_channel_join_scope_is_included(self): + # channels:join lets the bot self-join public channels via + # conversations.join (powering `hermes slack invite --all`). + manifest = _build_full_manifest("Hermes", "Your Hermes agent on Slack") + + assert "channels:join" in manifest["oauth_config"]["scopes"]["bot"] + def test_assistant_features_remain_enabled(self): manifest = _build_full_manifest("Hermes", "Your Hermes agent on Slack") @@ -28,3 +44,125 @@ def test_assistant_features_remain_enabled(self): assert "assistant:write" in manifest["oauth_config"]["scopes"]["bot"] bot_events = manifest["settings"]["event_subscriptions"]["bot_events"] assert "assistant_thread_started" in bot_events + + +class TestRenderManifest: + def test_json_is_default(self): + out = _render_manifest({"a": 1}, as_yaml=False) + assert json.loads(out) == {"a": 1} + + def test_yaml_round_trips(self): + yaml = pytest.importorskip("yaml") + out = _render_manifest({"display_information": {"name": "Hermes"}}, as_yaml=True) + assert "display_information:" in out + assert yaml.safe_load(out) == {"display_information": {"name": "Hermes"}} + + +class _FakeSlackResponse(dict): + """Minimal stand-in for slack_sdk's SlackResponse (behaves like a dict).""" + + +class _FakeClient: + """Records calls and returns canned conversations.list pages.""" + + def __init__(self, pages): + self._pages = pages + self.joined = [] + self.invited = [] + self.list_kwargs = [] + + def conversations_list(self, **kwargs): + self.list_kwargs.append(kwargs) + cursor = kwargs.get("cursor", "") + idx = int(cursor or 0) + page = self._pages[idx] + next_cursor = str(idx + 1) if idx + 1 < len(self._pages) else "" + return _FakeSlackResponse( + channels=page, + response_metadata={"next_cursor": next_cursor}, + ) + + def conversations_join(self, channel): + self.joined.append(channel) + return _FakeSlackResponse(ok=True) + + def conversations_invite(self, channel, users): + self.invited.append((channel, users)) + return _FakeSlackResponse(ok=True) + + +class TestListChannels: + def test_paginates_and_normalizes(self): + client = _FakeClient( + pages=[ + [ + {"id": "C1", "name": "general", "is_member": True}, + {"id": "C2", "name": "random", "is_member": False}, + ], + [ + {"id": "G1", "name": "secret", "is_member": False, "is_private": True}, + ], + ] + ) + channels = _list_channels(client) + ids = {c["id"] for c in channels} + assert ids == {"C1", "C2", "G1"} + secret = next(c for c in channels if c["id"] == "G1") + assert secret["is_private"] is True + assert secret["is_member"] is False + + def test_no_private_excludes_groups_type(self): + client = _FakeClient(pages=[[{"id": "C1", "name": "general", "is_member": True}]]) + _list_channels(client, include_private=False) + assert client.list_kwargs[0]["types"] == "public_channel" + + def test_private_requests_both_types(self): + client = _FakeClient(pages=[[{"id": "C1", "name": "general", "is_member": True}]]) + _list_channels(client, include_private=True) + assert "private_channel" in client.list_kwargs[0]["types"] + + +class TestInviteBotToChannel: + def test_public_channel_is_joined(self): + client = _FakeClient(pages=[[]]) + ch = {"id": "C1", "name": "general", "is_private": False} + ok, msg = _invite_bot_to_channel(client, ch, bot_user_id="U1") + assert ok is True + assert client.joined == ["C1"] + assert "joined" in msg + + def test_private_without_user_token_is_skipped(self): + client = _FakeClient(pages=[[]]) + ch = {"id": "G1", "name": "secret", "is_private": True} + ok, msg = _invite_bot_to_channel(client, ch, bot_user_id="U1") + assert ok is False + assert client.joined == [] + assert "needs" in msg or "invite" in msg + + def test_private_with_user_token_is_invited(self): + client = _FakeClient(pages=[[]]) + user_client = _FakeClient(pages=[[]]) + ch = {"id": "G1", "name": "secret", "is_private": True} + ok, msg = _invite_bot_to_channel( + client, ch, bot_user_id="U1", user_client=user_client + ) + assert ok is True + assert user_client.invited == [("G1", "U1")] + + def test_already_in_channel_is_success(self): + # Mimic slack_sdk.errors.SlackApiError without importing the SDK: + # any exception with a ``response`` mapping carrying ``error`` works. + class _FakeApiError(Exception): + def __init__(self, response): + super().__init__(response.get("error", "error")) + self.response = response + + class _Boom(_FakeClient): + def conversations_join(self, channel): + raise _FakeApiError(_FakeSlackResponse(error="already_in_channel")) + + client = _Boom(pages=[[]]) + ch = {"id": "C1", "name": "general", "is_private": False} + ok, msg = _invite_bot_to_channel(client, ch, bot_user_id="U1") + assert ok is True + assert "already" in msg diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index a1d61b6b793d..8a4f49f7353f 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -44,7 +44,7 @@ hermes [global-options] [subcommand/options] | `hermes lsp` | Manage Language Server Protocol integration (semantic diagnostics for write_file/patch). | | `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 slack` | Slack helpers: generate the app manifest, list channels, and invite the bot to channels. | | `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. | @@ -314,9 +314,14 @@ Runs the WhatsApp pairing/setup flow, including mode selection and QR-code pairi ```bash hermes slack manifest # print manifest to stdout hermes slack manifest --write # write to ~/.hermes/slack-manifest.json +hermes slack manifest --yaml # emit YAML instead of JSON hermes slack manifest --slashes-only # just the features.slash_commands array +hermes slack channels # list channels + membership gaps +hermes slack invite --all # join every public channel the bot is missing ``` +### `hermes slack manifest` + 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 @@ -327,7 +332,8 @@ 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`. | +| `--write [PATH]` | stdout | Write to a file instead of stdout. Bare `--write` writes `$HERMES_HOME/slack-manifest.{json,yaml}`. | +| `--yaml` | off | Emit YAML instead of JSON (Slack accepts both). | | `--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. | @@ -335,6 +341,34 @@ reinstall if scopes or slash commands changed. Run `hermes slack manifest --write` again after `hermes update` to pick up any new commands. +### `hermes slack channels` + +Lists every channel the bot token can see and reports which ones the bot +is and isn't a member of. A Slack bot only sees and posts in channels it +has joined, so this audits "all channels" coverage. Requires +`SLACK_BOT_TOKEN`. + +| Flag | Default | Purpose | +|------|---------|---------| +| `--no-private` | off | Only list public channels (skip private groups). | +| `--json` | off | Machine-readable JSON instead of a human summary. | + +### `hermes slack invite` + +Adds the bot to channels so it can read and post. Public channels are +joined directly with the bot token (`conversations.join`, needs the +`channels:join` scope). Private channels can't be self-joined — run +`/invite @` inside each one, or pass a user token so Hermes can call +`conversations.invite`. Requires `SLACK_BOT_TOKEN`. + +| Flag | Default | Purpose | +|------|---------|---------| +| `--all` | off | Target every channel the bot isn't already in. | +| `--channel NAME/ID` | — | Target a specific channel (repeatable). | +| `--no-private` | off | Only consider public channels. | +| `--user-token xoxp-…` | `SLACK_USER_TOKEN` | User token used to invite the bot to private channels. | +| `--dry-run` | off | Show what would happen without calling Slack. | + ## `hermes login` / `hermes logout` *(Deprecated)* diff --git a/website/docs/user-guide/messaging/slack.md b/website/docs/user-guide/messaging/slack.md index db32fcc4dea1..070a9e7a0a42 100644 --- a/website/docs/user-guide/messaging/slack.md +++ b/website/docs/user-guide/messaging/slack.md @@ -72,6 +72,7 @@ Navigate to **Features → OAuth & Permissions** in the sidebar. Scroll to **Sco | `app_mentions:read` | Detect when @mentioned in channels | | `channels:history` | Read messages in public channels the bot is in | | `channels:read` | List and get info about public channels | +| `channels:join` | Let the bot join public channels itself (`hermes slack invite --all`) | | `groups:history` | Read messages in private channels the bot is invited to | | `im:history` | Read direct message history | | `im:read` | View basic DM info | @@ -217,13 +218,49 @@ sudo hermes gateway install --system # Linux only: boot-time system service ## Step 9: Invite the Bot to Channels -After starting the gateway, you need to **invite the bot** to any channel where you want it to respond: +A Slack **bot only sees and posts in channels it is a member of** — there's no +API to bulk-add a bot to every channel at once. You have three options: + +**Manual (per channel):** from inside any channel, run ``` /invite @Hermes Agent ``` -The bot will **not** automatically join channels. You must invite it to each channel individually. +**Scripted — all public channels at once (recommended):** Hermes can add the +bot to every public channel for you using the `channels:join` scope (included +in the generated manifest): + +```bash +# See which channels the bot is / isn't a member of: +hermes slack channels + +# Join every public channel the bot is missing: +hermes slack invite --all + +# Preview without making changes: +hermes slack invite --all --dry-run + +# Target specific channels by name or ID: +hermes slack invite --channel general --channel C0123456789 +``` + +**Private channels** can't be self-joined by a bot. Either run `/invite +@Hermes Agent` from inside each one, or pass a **user token** (`xoxp-`, from +someone already in the channel) so Hermes can call `conversations.invite`: + +```bash +hermes slack invite --all --user-token xoxp-... # or set SLACK_USER_TOKEN +``` + +> **Trade-off — read *all* channels without per-channel invites.** Truly +> reading every channel without inviting the bot requires a **user token** +> with broad scopes acting as you. That's more powerful (and more sensitive) +> than a bot token. Invite-per-channel with the bot token is the safer +> default; only reach for a user token if you specifically need it. + +`hermes slack channels` reports the gaps so "all channels" coverage is +auditable and reproducible rather than a manual checklist. --- From f1e0af133f9211e8b8d3f2c147c41583fc3b9987 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 18:20:54 +0000 Subject: [PATCH 2/2] fix(nix): update web npm-deps hash to match committed lockfile The fetchNpmDeps hash in nix/web.nix drifted from web/package-lock.json (pre-existing on main after an earlier npm dependabot bump), failing the `nix` build check. Update to the hash computed by the nix build itself. --- nix/web.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/web.nix b/nix/web.nix index ef1772a487a9..cc025ffdc183 100644 --- a/nix/web.nix +++ b/nix/web.nix @@ -4,7 +4,7 @@ let src = ../web; npmDeps = pkgs.fetchNpmDeps { inherit src; - hash = "sha256-pEAe/DzapUBEcVnCKdL9QQyWazTtLlGBwwxJd2MBbtY="; + hash = "sha256-0trXy/CnwbwXOiNwYqhE32EztvU84HKIrC2rmASg2Pk="; }; npm = hermesNpmLib.mkNpmPassthru { folder = "web"; attr = "web"; pname = "hermes-web"; };