diff --git a/.claude/skills/add-slack/SKILL.md b/.claude/skills/add-slack/SKILL.md index 2835ee162..c3cc60fe5 100644 --- a/.claude/skills/add-slack/SKILL.md +++ b/.claude/skills/add-slack/SKILL.md @@ -72,26 +72,41 @@ pnpm run build ### Event Subscriptions 8. Go to **Event Subscriptions** and toggle **Enable Events** -9. Set the **Request URL** to `https://your-domain/webhook/slack` — Slack will send a verification challenge; it must pass before you can save +9. **Webhook mode:** set the **Request URL** to `https://your-domain/webhook/slack` — Slack will send a verification challenge; it must pass before you can save. For **Socket Mode** (below), skip the Request URL. 10. Under **Subscribe to bot events**, add: - `message.channels`, `message.groups`, `message.im`, `app_mention` 11. Click **Save Changes** 12. Slack will show a banner asking you to **reinstall the app** — click it to apply the new event subscriptions +### Socket Mode (optional — no public URL) + +Socket Mode delivers events over an outbound WebSocket the bot opens to Slack, so the host needs **no public HTTPS endpoint** — ideal for local dev or a host behind NAT/a firewall. Setting `SLACK_APP_TOKEN` is what flips the adapter into Socket Mode; without it the adapter stays in webhook mode. + +13. Go to **Basic Information** > **App-Level Tokens** > **Generate Token and Scopes**, add the `connections:write` scope, and copy the token (`xapp-...`) +14. Go to **Socket Mode** and toggle **Enable Socket Mode** on +15. Keep **Event Subscriptions** enabled with the bot events above — under Socket Mode no Request URL is required + ### Configure environment -Add to `.env`: +Add to `.env` — **webhook mode**: ```bash SLACK_BOT_TOKEN=xoxb-your-bot-token SLACK_SIGNING_SECRET=your-signing-secret ``` +…or **Socket Mode** (no public URL; signing secret optional): + +```bash +SLACK_BOT_TOKEN=xoxb-your-bot-token +SLACK_APP_TOKEN=xapp-your-app-level-token +``` + Sync to container: `mkdir -p data/env && cp .env data/env/env` -### Webhook server +### Webhook server (webhook mode only) -The Chat SDK bridge automatically starts a shared webhook server on port 3000 (configurable via `WEBHOOK_PORT` env var). The server handles `/webhook/slack` for Slack and other webhook-based adapters. This port must be publicly reachable from the internet for Slack to deliver events. +In **webhook mode** the Chat SDK bridge automatically starts a shared webhook server on port 3000 (configurable via `WEBHOOK_PORT` env var). The server handles `/webhook/slack` for Slack and other webhook-based adapters. This port must be publicly reachable from the internet for Slack to deliver events. **In Socket Mode this is not needed** — skip this section if you set `SLACK_APP_TOKEN`. If running locally, discuss options for exposing the server — e.g. ngrok (`ngrok http 3000`), Cloudflare Tunnel, or a reverse proxy on a VPS. The resulting public URL becomes the base for `https://your-domain/webhook/slack`. diff --git a/setup/add-slack.sh b/setup/add-slack.sh index d18b485e3..23a9220d2 100755 --- a/setup/add-slack.sh +++ b/setup/add-slack.sh @@ -1,10 +1,11 @@ #!/usr/bin/env bash # -# Install the Slack adapter, persist SLACK_BOT_TOKEN + SLACK_SIGNING_SECRET to +# Install the Slack adapter, persist SLACK_BOT_TOKEN plus the mode-specific +# secret (SLACK_APP_TOKEN for Socket Mode, SLACK_SIGNING_SECRET for webhook) to # .env + data/env/env, and restart the service. Non-interactive — the # operator-facing app creation walkthrough + credential paste live in # setup/channels/slack.ts. Credentials come in via env vars: -# SLACK_BOT_TOKEN, SLACK_SIGNING_SECRET. +# SLACK_BOT_TOKEN, and SLACK_APP_TOKEN and/or SLACK_SIGNING_SECRET. # # Emits exactly one status block on stdout (ADD_SLACK) at the end. All chatty # progress messages go to stderr so setup:auto's raw-log capture sees the full @@ -41,8 +42,10 @@ if [ -z "${SLACK_BOT_TOKEN:-}" ]; then emit_status failed "SLACK_BOT_TOKEN env var not set" exit 1 fi -if [ -z "${SLACK_SIGNING_SECRET:-}" ]; then - emit_status failed "SLACK_SIGNING_SECRET env var not set" +# Socket Mode authenticates with SLACK_APP_TOKEN; webhook mode with +# SLACK_SIGNING_SECRET. Require at least one. +if [ -z "${SLACK_APP_TOKEN:-}" ] && [ -z "${SLACK_SIGNING_SECRET:-}" ]; then + emit_status failed "Set SLACK_APP_TOKEN (Socket Mode) or SLACK_SIGNING_SECRET (webhook)" exit 1 fi @@ -98,7 +101,12 @@ upsert_env() { fi } upsert_env SLACK_BOT_TOKEN "$SLACK_BOT_TOKEN" -upsert_env SLACK_SIGNING_SECRET "$SLACK_SIGNING_SECRET" +if [ -n "${SLACK_APP_TOKEN:-}" ]; then + upsert_env SLACK_APP_TOKEN "$SLACK_APP_TOKEN" +fi +if [ -n "${SLACK_SIGNING_SECRET:-}" ]; then + upsert_env SLACK_SIGNING_SECRET "$SLACK_SIGNING_SECRET" +fi # Container reads from data/env/env (the host mounts it). mkdir -p data/env diff --git a/setup/channels/slack.ts b/setup/channels/slack.ts index f66c29afb..09c522375 100644 --- a/setup/channels/slack.ts +++ b/setup/channels/slack.ts @@ -4,21 +4,23 @@ * `runSlackChannel(displayName)` walks the operator from a bare Slack * workspace through a running bot, then stops before wiring an agent: * - * 1. Walk through creating a Slack app (api.slack.com/apps) — scopes, - * event subscriptions, and signing secret - * 2. Paste the bot token + signing secret (clack password prompts) - * 3. Validate via auth.test → resolves workspace + bot identity - * 4. Install the adapter (setup/add-slack.sh, non-interactive) - * 5. Print the post-install checklist: set the public webhook URL in - * Slack's Event Subscriptions, DM the bot to bootstrap the channel, - * then `/manage-channels` to wire an agent. + * 1. Ask the delivery mode: Socket Mode (outbound WebSocket, no public + * URL) or a public webhook + * 2. Walk through creating a Slack app (api.slack.com/apps) — scopes, + * events, and the mode-specific credential (app-level token for + * Socket Mode, signing secret for webhook) + * 3. Paste the bot token + that credential (clack password prompts) + * 4. Validate via auth.test → resolves workspace + bot identity + * 5. Install the adapter (setup/add-slack.sh, non-interactive) + * 6. Print the post-install checklist (Socket Mode: just DM the bot; + * webhook: set the public Request URL in Event Subscriptions), then + * `/manage-channels` to wire an agent. * - * Why no welcome DM here: unlike Discord/Telegram (gateway / long-poll), - * Slack needs a public Event Subscriptions URL for inbound events, and - * opening an unsolicited DM would need `im:write` scope we don't force - * the SKILL.md to require. Shipping a honest "here's what's left" note - * is better than a welcome DM the user won't receive until they - * configure the webhook anyway. + * Why no welcome DM here: opening an unsolicited DM would need `im:write` + * scope we don't force the SKILL.md to require — and in webhook mode inbound + * events don't flow until the public Event Subscriptions URL is configured. + * Shipping an honest "here's what's left" note is better than a welcome DM + * the user won't receive until they finish wiring Slack up. * * All output obeys the three-level contract. See docs/setup-flow.md. */ @@ -26,6 +28,7 @@ import * as p from '@clack/prompts'; import k from 'kleur'; import * as setupLog from '../logs.js'; +import { brightSelect } from '../lib/bright-select.js'; import { confirmThenOpen } from '../lib/browser.js'; import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js'; import { wrapForGutter } from '../lib/theme.js'; @@ -40,16 +43,28 @@ interface WorkspaceInfo { botUserId: string; } +// Socket Mode (SLACK_APP_TOKEN, xapp-…) needs no public URL; webhook mode +// (SLACK_SIGNING_SECRET) needs a public Request URL. The adapter picks the mode +// purely from SLACK_APP_TOKEN's presence — this choice just decides which +// credential to collect and which post-install guidance to show. +type SlackMode = 'socket' | 'webhook'; + // displayName is reserved for when we start wiring the first agent here. // Kept to match the `runChannel(displayName)` signature every other // channel driver uses, so auto.ts can dispatch without a branch. export async function runSlackChannel(_displayName: string): Promise { - await walkThroughAppCreation(); + const mode = await askSlackMode(); + await walkThroughAppCreation(mode); const token = await collectBotToken(); - const signingSecret = await collectSigningSecret(); + const appToken = mode === 'socket' ? await collectAppToken() : undefined; + const signingSecret = mode === 'webhook' ? await collectSigningSecret() : undefined; const info = await validateSlackToken(token); + const env: Record = { SLACK_BOT_TOKEN: token }; + if (appToken) env.SLACK_APP_TOKEN = appToken; + if (signingSecret) env.SLACK_SIGNING_SECRET = signingSecret; + const install = await runQuietChild( 'slack-install', 'bash', @@ -59,11 +74,9 @@ export async function runSlackChannel(_displayName: string): Promise { done: 'Slack adapter installed.', }, { - env: { - SLACK_BOT_TOKEN: token, - SLACK_SIGNING_SECRET: signingSecret, - }, + env, extraFields: { + MODE: mode, BOT_NAME: info.botName, TEAM_NAME: info.teamName, TEAM_ID: info.teamId, @@ -71,21 +84,52 @@ export async function runSlackChannel(_displayName: string): Promise { }, ); if (!install.ok) { - await fail( - 'slack-install', - "Couldn't connect Slack.", - 'See logs/setup-steps/ for details, then retry setup.', - ); + await fail('slack-install', "Couldn't connect Slack.", 'See logs/setup-steps/ for details, then retry setup.'); } - showPostInstallChecklist(info); + showPostInstallChecklist(info, mode); } -async function walkThroughAppCreation(): Promise { +async function askSlackMode(): Promise { + const choice = ensureAnswer( + await brightSelect({ + message: 'How should Slack deliver events to NanoClaw?', + initialValue: 'socket', + options: [ + { + value: 'socket', + label: 'Socket Mode', + hint: 'no public URL — recommended for local or behind NAT', + }, + { + value: 'webhook', + label: 'Public webhook', + hint: 'needs a public HTTPS Request URL', + }, + ], + }), + ); + setupLog.userInput('slack_mode', String(choice)); + return choice; +} + +async function walkThroughAppCreation(mode: SlackMode): Promise { + const credSteps = + mode === 'socket' + ? [ + ' 4. Basic Information → App-Level Tokens → "Generate Token and', + ' Scopes" → add the connections:write scope → copy it (xapp-…)', + ' 5. Socket Mode → toggle "Enable Socket Mode" on', + ' 6. Install to Workspace → copy the "Bot User OAuth Token" (xoxb-…)', + ] + : [ + ' 4. Basic Information → copy the "Signing Secret"', + ' 5. Install to Workspace → copy the "Bot User OAuth Token" (xoxb-…)', + ]; p.note( [ "You'll create a Slack app that the assistant talks through.", - "Free and stays inside the workspaces you pick.", + 'Free and stays inside the workspaces you pick.', '', ' 1. Create a new app "From scratch", name it, pick a workspace', ' 2. OAuth & Permissions → add Bot Token Scopes:', @@ -93,8 +137,7 @@ async function walkThroughAppCreation(): Promise { ' channels:read, groups:read, users:read, reactions:write', ' 3. App Home → enable "Messages Tab" and "Allow users to send', ' slash commands and messages from the messages tab"', - ' 4. Basic Information → copy the "Signing Secret"', - ' 5. Install to Workspace → copy the "Bot User OAuth Token" (xoxb-…)', + ...credSteps, '', k.dim(SLACK_APPS_URL), ].join('\n'), @@ -104,7 +147,7 @@ async function walkThroughAppCreation(): Promise { ensureAnswer( await p.confirm({ - message: 'Got your bot token and signing secret?', + message: mode === 'socket' ? 'Got your bot token and app-level token?' : 'Got your bot token and signing secret?', initialValue: true, }), ); @@ -124,10 +167,7 @@ async function collectBotToken(): Promise { }), ); const token = (answer as string).trim(); - setupLog.userInput( - 'slack_bot_token', - `${token.slice(0, 10)}…${token.slice(-4)}`, - ); + setupLog.userInput('slack_bot_token', `${token.slice(0, 10)}…${token.slice(-4)}`); return token; } @@ -148,13 +188,28 @@ async function collectSigningSecret(): Promise { }), ); const secret = (answer as string).trim(); - setupLog.userInput( - 'slack_signing_secret', - `${secret.slice(0, 4)}…${secret.slice(-4)}`, - ); + setupLog.userInput('slack_signing_secret', `${secret.slice(0, 4)}…${secret.slice(-4)}`); return secret; } +async function collectAppToken(): Promise { + const answer = ensureAnswer( + await p.password({ + message: 'Paste your Slack app-level token (Socket Mode)', + validate: (v) => { + const t = (v ?? '').trim(); + if (!t) return 'App-level token is required for Socket Mode'; + if (!t.startsWith('xapp-')) return 'App-level tokens start with xapp-'; + if (t.length < 24) return "That's shorter than a real Slack app-level token"; + return undefined; + }, + }), + ); + const token = (answer as string).trim(); + setupLog.userInput('slack_app_token', `${token.slice(0, 10)}…${token.slice(-4)}`); + return token; +} + async function validateSlackToken(token: string): Promise { const s = p.spinner(); const start = Date.now(); @@ -177,9 +232,7 @@ async function validateSlackToken(token: string): Promise { }; const elapsedS = Math.round((Date.now() - start) / 1000); if (data.ok && data.team && data.user) { - s.stop( - `Connected to ${data.team} as @${data.user}. ${k.dim(`(${elapsedS}s)`)}`, - ); + s.stop(`Connected to ${data.team} as @${data.user}. ${k.dim(`(${elapsedS}s)`)}`); const info: WorkspaceInfo = { teamName: data.team, teamId: data.team_id ?? '', @@ -213,15 +266,30 @@ async function validateSlackToken(token: string): Promise { setupLog.step('slack-validate', 'failed', Date.now() - start, { ERROR: message, }); - await fail( - 'slack-validate', - "Couldn't reach Slack.", - 'Check your internet connection and retry setup.', - ); + await fail('slack-validate', "Couldn't reach Slack.", 'Check your internet connection and retry setup.'); } } -function showPostInstallChecklist(info: WorkspaceInfo): void { +function showPostInstallChecklist(info: WorkspaceInfo, mode: SlackMode): void { + if (mode === 'socket') { + p.note( + wrapForGutter( + [ + `The Slack adapter is installed in Socket Mode and your creds are saved. No public URL needed — ${info.teamName} reaches NanoClaw over an outbound WebSocket.`, + '', + ` 1. DM @${info.botName} from Slack once — that bootstraps the`, + ' messaging group. Then run `/manage-channels` in `claude` to', + ' wire an agent to it.', + '', + ' Note: keep the NanoClaw host running to hold the socket open —', + ' Slack does not retry delivery while it is down.', + ].join('\n'), + 6, + ), + 'Finish setting up Slack', + ); + return; + } p.note( wrapForGutter( [ diff --git a/src/channels/slack.ts b/src/channels/slack.ts index 2df566686..d1e1093be 100644 --- a/src/channels/slack.ts +++ b/src/channels/slack.ts @@ -1,6 +1,9 @@ /** * Slack channel adapter (v2) — uses Chat SDK bridge. * Self-registers on import. + * + * Socket Mode opt-in: set SLACK_APP_TOKEN (xapp-…) to receive events over an + * outbound WebSocket instead of an inbound HTTPS webhook. */ import { createSlackAdapter } from '@chat-adapter/slack'; @@ -10,11 +13,17 @@ import { registerChannelAdapter } from './channel-registry.js'; registerChannelAdapter('slack', { factory: () => { - const env = readEnvFile(['SLACK_BOT_TOKEN', 'SLACK_SIGNING_SECRET']); + const env = readEnvFile(['SLACK_BOT_TOKEN', 'SLACK_SIGNING_SECRET', 'SLACK_APP_TOKEN']); if (!env.SLACK_BOT_TOKEN) return null; + // SLACK_APP_TOKEN (xapp-…) enables Socket Mode: events arrive over an + // outbound WebSocket, so no public HTTPS endpoint is required. When set, + // the signing secret is optional (Slack signs socket frames separately). + const useSocketMode = Boolean(env.SLACK_APP_TOKEN); const slackAdapter = createSlackAdapter({ botToken: env.SLACK_BOT_TOKEN, signingSecret: env.SLACK_SIGNING_SECRET, + appToken: env.SLACK_APP_TOKEN, + mode: useSocketMode ? 'socket' : 'webhook', }); const bridge = createChatSdkBridge({ adapter: slackAdapter, concurrency: 'concurrent', supportsThreads: true }); bridge.resolveChannelName = async (platformId: string) => {