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 bb6750667..712f0e69d 100644 --- a/setup/channels/slack.ts +++ b/setup/channels/slack.ts @@ -4,15 +4,18 @@ * `runSlackChannel(displayName)` owns the full branch from creating a * Slack app through the welcome DM: * - * 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. Ask for the operator's Slack user ID - * 6. conversations.open to get the DM channel ID - * 7. Ask for the messaging-agent name (defaulting to "Nano") - * 8. Wire the agent via scripts/init-first-agent.ts + * 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. Ask for the operator's Slack user ID + * 7. conversations.open to get the DM channel ID + * 8. Ask for the messaging-agent name (defaulting to "Nano") + * 9. Wire the agent via scripts/init-first-agent.ts * * The welcome DM is sent via outbound delivery (chat.postMessage), which * works without Event Subscriptions being configured. The user sees the @@ -45,14 +48,26 @@ 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'; + export async function runSlackChannel(displayName: string): Promise { - const intro = await walkThroughAppCreation(); + const mode = await askSlackMode(); + const intro = await walkThroughAppCreation(mode); if (intro === 'back') return BACK_TO_CHANNEL_SELECTION; 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', @@ -62,11 +77,9 @@ export async function runSlackChannel(displayName: string): 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<'continue' | 'back'> { + 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-…)', + ]; // Bright-white ANSI overrides the surrounding brand-cyan from `note()`'s // per-line formatter so the URL stands out against the rest of the body. const linkBlock = isHeadless() @@ -149,8 +197,7 @@ async function walkThroughAppCreation(): Promise<'continue' | 'back'> { ' • files:read, files: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, ].join('\n'), 'Create a Slack app', ); @@ -171,7 +218,10 @@ async function walkThroughAppCreation(): Promise<'continue' | 'back'> { 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, }), ); @@ -249,6 +299,40 @@ async function collectSigningSecret(): Promise { return secret; } +async function collectAppToken(): Promise { + const existing = readEnvKey('SLACK_APP_TOKEN'); + if (existing && existing.startsWith('xapp-') && existing.length >= 24) { + const reuse = ensureAnswer(await p.confirm({ + message: `Found an existing Slack app-level token (${existing.slice(0, 10)}…). Use it?`, + initialValue: true, + })); + if (reuse) { + setupLog.userInput('slack_app_token', 'reused-existing'); + return existing; + } + } + + const answer = ensureAnswer( + await p.password({ + message: 'Paste your Slack app-level token (Socket Mode)', + clearOnError: true, + 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(); @@ -416,7 +500,26 @@ async function resolveAgentName(): Promise { return value; } -function showPostInstallChecklist(info: WorkspaceInfo): void { +function showPostInstallChecklist(info: WorkspaceInfo, mode: SlackMode): void { + if (mode === 'socket') { + note( + wrapForGutter( + [ + `Your agent is wired to Slack and a welcome DM is on its way.`, + `Socket Mode is on — ${info.teamName} reaches NanoClaw over an outbound`, + `WebSocket, so there's no public URL to configure.`, + '', + ' • Just DM @' + info.botName + ' from Slack — replies flow straight away.', + '', + ' • 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; + } note( wrapForGutter( [