fix(setup): offer Slack Socket Mode in the guided setup flow

PR #2837 added Slack Socket Mode end-to-end ("adapter + guided setup") but
was merged into the `channels` branch, not `main`. As a result the
`setup:auto` Slack flow on main is webhook-only: it always collects a
signing secret and pushes the user toward a public Request URL, with no
Socket Mode option — even though setup/verify.ts already recognizes
SLACK_APP_TOKEN and the channels-branch adapter supports it.

Forward-port the setup-side of #2837 onto current main, re-authored on top
of main's current flow (back-nav, inline agent wiring, operator-role prompt,
welcome DM all preserved):

- setup/channels/slack.ts: add a mode picker (askSlackMode), mode-specific
  app-creation steps, collectAppToken() for the xapp- app-level token,
  conditional credential collection + env, and a mode-aware post-install
  checklist (Socket Mode skips the public-URL guidance).
- setup/add-slack.sh: require/persist either SLACK_APP_TOKEN (Socket Mode)
  or SLACK_SIGNING_SECRET (webhook) instead of mandating the signing secret.

Scope is setup-side only: the adapter's socket support already lives on
`channels` (#2837/#2839) and reaches users via /add-slack; the add-slack
SKILL.md doc is handled by #2700.
This commit is contained in:
Rob Stevenson
2026-06-29 14:45:31 +01:00
parent 8be5be93ba
commit cf8478ffbb
2 changed files with 137 additions and 26 deletions
+13 -5
View File
@@ -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
+124 -21
View File
@@ -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<ChannelFlowResult> {
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<string, string> = { 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<ChannelFlowR
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,
@@ -122,10 +135,45 @@ export async function runSlackChannel(displayName: string): Promise<ChannelFlowR
);
}
showPostInstallChecklist(info);
showPostInstallChecklist(info, mode);
}
async function walkThroughAppCreation(): Promise<'continue' | 'back'> {
async function askSlackMode(): Promise<SlackMode> {
const choice = ensureAnswer(
await brightSelect<SlackMode>({
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<string> {
return secret;
}
async function collectAppToken(): Promise<string> {
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<WorkspaceInfo> {
const s = p.spinner();
const start = Date.now();
@@ -416,7 +500,26 @@ async function resolveAgentName(): Promise<string> {
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(
[