Slack interactive buttons (channel approval cards) require Interactivity to be enabled in the app settings. Without it, button clicks silently fail to reach the host. Added the step to both the setup wizard post-install checklist and the add-slack SKILL.md. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
399 lines
12 KiB
TypeScript
399 lines
12 KiB
TypeScript
/**
|
|
* Slack channel flow for setup:auto.
|
|
*
|
|
* `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
|
|
*
|
|
* The welcome DM is sent via outbound delivery (chat.postMessage), which
|
|
* works without Event Subscriptions being configured. The user sees the
|
|
* greeting in Slack immediately; inbound replies require webhooks, so the
|
|
* post-install note covers that.
|
|
*
|
|
* All output obeys the three-level contract. See docs/setup-flow.md.
|
|
*/
|
|
import * as p from '@clack/prompts';
|
|
import k from 'kleur';
|
|
|
|
import * as setupLog from '../logs.js';
|
|
import { confirmThenOpen } from '../lib/browser.js';
|
|
import { askOperatorRole } from '../lib/role-prompt.js';
|
|
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
|
|
import { wrapForGutter } from '../lib/theme.js';
|
|
|
|
const SLACK_API = 'https://slack.com/api';
|
|
const SLACK_APPS_URL = 'https://api.slack.com/apps';
|
|
const DEFAULT_AGENT_NAME = 'Nano';
|
|
|
|
interface WorkspaceInfo {
|
|
teamName: string;
|
|
teamId: string;
|
|
botName: string;
|
|
botUserId: string;
|
|
}
|
|
|
|
export async function runSlackChannel(displayName: string): Promise<void> {
|
|
await walkThroughAppCreation();
|
|
|
|
const token = await collectBotToken();
|
|
const signingSecret = await collectSigningSecret();
|
|
const info = await validateSlackToken(token);
|
|
|
|
const install = await runQuietChild(
|
|
'slack-install',
|
|
'bash',
|
|
['setup/add-slack.sh'],
|
|
{
|
|
running: `Connecting Slack to @${info.botName} (${info.teamName})…`,
|
|
done: 'Slack adapter installed.',
|
|
},
|
|
{
|
|
env: {
|
|
SLACK_BOT_TOKEN: token,
|
|
SLACK_SIGNING_SECRET: signingSecret,
|
|
},
|
|
extraFields: {
|
|
BOT_NAME: info.botName,
|
|
TEAM_NAME: info.teamName,
|
|
TEAM_ID: info.teamId,
|
|
},
|
|
},
|
|
);
|
|
if (!install.ok) {
|
|
await fail(
|
|
'slack-install',
|
|
"Couldn't connect Slack.",
|
|
'See logs/setup-steps/ for details, then retry setup.',
|
|
);
|
|
}
|
|
|
|
const ownerUserId = await collectSlackUserId();
|
|
const dmChannelId = await openDmChannel(token, ownerUserId);
|
|
const platformId = `slack:${dmChannelId}`;
|
|
|
|
const role = await askOperatorRole('Slack');
|
|
setupLog.userInput('slack_role', role);
|
|
|
|
const agentName = await resolveAgentName();
|
|
|
|
const init = await runQuietChild(
|
|
'init-first-agent',
|
|
'pnpm',
|
|
[
|
|
'exec', 'tsx', 'scripts/init-first-agent.ts',
|
|
'--channel', 'slack',
|
|
'--user-id', `slack:${ownerUserId}`,
|
|
'--platform-id', platformId,
|
|
'--display-name', displayName,
|
|
'--agent-name', agentName,
|
|
'--role', role,
|
|
],
|
|
{
|
|
running: `Wiring ${agentName} to your Slack DMs…`,
|
|
done: 'Agent wired.',
|
|
},
|
|
{
|
|
extraFields: {
|
|
CHANNEL: 'slack',
|
|
AGENT_NAME: agentName,
|
|
PLATFORM_ID: platformId,
|
|
},
|
|
},
|
|
);
|
|
if (!init.ok) {
|
|
await fail(
|
|
'init-first-agent',
|
|
`Couldn't finish connecting ${agentName}.`,
|
|
'You can retry later with `/init-first-agent` in Claude Code.',
|
|
);
|
|
}
|
|
|
|
showPostInstallChecklist(info);
|
|
}
|
|
|
|
async function walkThroughAppCreation(): Promise<void> {
|
|
p.note(
|
|
[
|
|
"You'll create a Slack app that the assistant talks through.",
|
|
"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:',
|
|
' chat:write, im:write, channels:history, groups:history,',
|
|
' im:history, 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-…)',
|
|
'',
|
|
k.dim(SLACK_APPS_URL),
|
|
].join('\n'),
|
|
'Create a Slack app',
|
|
);
|
|
await confirmThenOpen(SLACK_APPS_URL, 'Press Enter to open Slack app settings');
|
|
|
|
ensureAnswer(
|
|
await p.confirm({
|
|
message: 'Got your bot token and signing secret?',
|
|
initialValue: true,
|
|
}),
|
|
);
|
|
}
|
|
|
|
async function collectBotToken(): Promise<string> {
|
|
const answer = ensureAnswer(
|
|
await p.password({
|
|
message: 'Paste your Slack bot token',
|
|
validate: (v) => {
|
|
const t = (v ?? '').trim();
|
|
if (!t) return 'Token is required';
|
|
if (!t.startsWith('xoxb-')) return 'Bot tokens start with xoxb-';
|
|
if (t.length < 24) return "That's shorter than a real Slack bot token";
|
|
return undefined;
|
|
},
|
|
}),
|
|
);
|
|
const token = (answer as string).trim();
|
|
setupLog.userInput(
|
|
'slack_bot_token',
|
|
`${token.slice(0, 10)}…${token.slice(-4)}`,
|
|
);
|
|
return token;
|
|
}
|
|
|
|
async function collectSigningSecret(): Promise<string> {
|
|
const answer = ensureAnswer(
|
|
await p.password({
|
|
message: 'Paste your Slack signing secret',
|
|
validate: (v) => {
|
|
const t = (v ?? '').trim();
|
|
if (!t) return 'Signing secret is required';
|
|
// Slack signing secrets are 32-char hex strings, but newer apps
|
|
// sometimes emit longer variants — leniently require hex only.
|
|
if (!/^[a-f0-9]{16,}$/i.test(t)) {
|
|
return 'Signing secrets are a string of hex characters';
|
|
}
|
|
return undefined;
|
|
},
|
|
}),
|
|
);
|
|
const secret = (answer as string).trim();
|
|
setupLog.userInput(
|
|
'slack_signing_secret',
|
|
`${secret.slice(0, 4)}…${secret.slice(-4)}`,
|
|
);
|
|
return secret;
|
|
}
|
|
|
|
async function validateSlackToken(token: string): Promise<WorkspaceInfo> {
|
|
const s = p.spinner();
|
|
const start = Date.now();
|
|
s.start('Checking your bot token…');
|
|
try {
|
|
const res = await fetch(`${SLACK_API}/auth.test`, {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
},
|
|
});
|
|
const data = (await res.json()) as {
|
|
ok?: boolean;
|
|
team?: string;
|
|
team_id?: string;
|
|
user?: string;
|
|
user_id?: string;
|
|
error?: string;
|
|
};
|
|
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)`)}`,
|
|
);
|
|
const info: WorkspaceInfo = {
|
|
teamName: data.team,
|
|
teamId: data.team_id ?? '',
|
|
botName: data.user,
|
|
botUserId: data.user_id ?? '',
|
|
};
|
|
setupLog.step('slack-validate', 'success', Date.now() - start, {
|
|
BOT_NAME: info.botName,
|
|
BOT_USER_ID: info.botUserId,
|
|
TEAM_NAME: info.teamName,
|
|
TEAM_ID: info.teamId,
|
|
});
|
|
return info;
|
|
}
|
|
const reason = data.error ?? `HTTP ${res.status}`;
|
|
s.stop(`Slack didn't accept that token: ${reason}`, 1);
|
|
setupLog.step('slack-validate', 'failed', Date.now() - start, {
|
|
ERROR: reason,
|
|
});
|
|
await fail(
|
|
'slack-validate',
|
|
"Slack didn't accept that token.",
|
|
reason === 'invalid_auth' || reason === 'token_revoked'
|
|
? 'Copy the token again from OAuth & Permissions and retry setup.'
|
|
: `Slack said "${reason}". Check the token scopes and workspace install, then retry.`,
|
|
);
|
|
} catch (err) {
|
|
const elapsedS = Math.round((Date.now() - start) / 1000);
|
|
s.stop(`Couldn't reach Slack. ${k.dim(`(${elapsedS}s)`)}`, 1);
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
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.',
|
|
);
|
|
}
|
|
}
|
|
|
|
async function collectSlackUserId(): Promise<string> {
|
|
p.note(
|
|
[
|
|
"To get your Slack member ID:",
|
|
'',
|
|
' 1. In Slack, click your profile picture (top right)',
|
|
' 2. Click "Profile"',
|
|
' 3. Click the three dots (⋯) → "Copy member ID"',
|
|
].join('\n'),
|
|
'Find your Slack user ID',
|
|
);
|
|
const answer = ensureAnswer(
|
|
await p.text({
|
|
message: 'Paste your Slack member ID',
|
|
validate: (v) => {
|
|
const t = (v ?? '').trim();
|
|
if (!t) return 'Member ID is required';
|
|
if (!/^U[A-Z0-9]{8,}$/.test(t)) {
|
|
return "That doesn't look like a Slack member ID (starts with U)";
|
|
}
|
|
return undefined;
|
|
},
|
|
}),
|
|
);
|
|
const id = (answer as string).trim();
|
|
setupLog.userInput('slack_user_id', id);
|
|
return id;
|
|
}
|
|
|
|
async function openDmChannel(token: string, userId: string): Promise<string> {
|
|
const s = p.spinner();
|
|
const start = Date.now();
|
|
s.start('Opening a DM channel…');
|
|
try {
|
|
const res = await fetch(`${SLACK_API}/conversations.open`, {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ users: userId }),
|
|
});
|
|
const data = (await res.json()) as {
|
|
ok?: boolean;
|
|
channel?: { id?: string };
|
|
error?: string;
|
|
};
|
|
const elapsedS = Math.round((Date.now() - start) / 1000);
|
|
if (data.ok && data.channel?.id) {
|
|
s.stop(`DM channel ready. ${k.dim(`(${elapsedS}s)`)}`);
|
|
setupLog.step('slack-open-dm', 'success', Date.now() - start, {
|
|
DM_CHANNEL_ID: data.channel.id,
|
|
});
|
|
return data.channel.id;
|
|
}
|
|
const reason = data.error ?? `HTTP ${res.status}`;
|
|
s.stop(`Couldn't open a DM channel: ${reason}`, 1);
|
|
setupLog.step('slack-open-dm', 'failed', Date.now() - start, {
|
|
ERROR: reason,
|
|
});
|
|
if (reason === 'missing_scope') {
|
|
await fail(
|
|
'slack-open-dm',
|
|
"Your Slack app is missing the im:write scope.",
|
|
'Go to OAuth & Permissions in your Slack app settings, add the im:write scope, reinstall the app, then retry setup.',
|
|
);
|
|
}
|
|
await fail(
|
|
'slack-open-dm',
|
|
"Couldn't open a DM channel with you.",
|
|
`Slack said "${reason}". Check the member ID and app permissions, then retry.`,
|
|
);
|
|
} catch (err) {
|
|
const elapsedS = Math.round((Date.now() - start) / 1000);
|
|
s.stop(`Couldn't reach Slack. ${k.dim(`(${elapsedS}s)`)}`, 1);
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
setupLog.step('slack-open-dm', 'failed', Date.now() - start, {
|
|
ERROR: message,
|
|
});
|
|
await fail(
|
|
'slack-open-dm',
|
|
"Couldn't reach Slack.",
|
|
'Check your internet connection and retry setup.',
|
|
);
|
|
}
|
|
}
|
|
|
|
async function resolveAgentName(): Promise<string> {
|
|
const preset = process.env.NANOCLAW_AGENT_NAME?.trim();
|
|
if (preset) {
|
|
setupLog.userInput('agent_name', preset);
|
|
return preset;
|
|
}
|
|
const answer = ensureAnswer(
|
|
await p.text({
|
|
message: 'What should your assistant be called?',
|
|
placeholder: DEFAULT_AGENT_NAME,
|
|
defaultValue: DEFAULT_AGENT_NAME,
|
|
}),
|
|
);
|
|
const value = (answer as string).trim() || DEFAULT_AGENT_NAME;
|
|
setupLog.userInput('agent_name', value);
|
|
return value;
|
|
}
|
|
|
|
function showPostInstallChecklist(info: WorkspaceInfo): void {
|
|
p.note(
|
|
wrapForGutter(
|
|
[
|
|
`Your agent is wired to Slack and a welcome DM is on its way.`,
|
|
`To receive replies, Slack needs a public URL for delivering events:`,
|
|
'',
|
|
' 1. Expose NanoClaw\'s webhook server (port 3000) via ngrok,',
|
|
' Cloudflare Tunnel, or a reverse proxy on a VPS.',
|
|
'',
|
|
' 2. In your Slack app → Event Subscriptions:',
|
|
' • Toggle "Enable Events" on',
|
|
` • Request URL: https://<your-public-host>/webhook/slack`,
|
|
' • Subscribe to bot events: message.channels, message.groups,',
|
|
' message.im, app_mention',
|
|
' • Save Changes',
|
|
'',
|
|
' 3. In your Slack app → Interactivity & Shortcuts:',
|
|
' • Toggle "Interactivity" on',
|
|
` • Request URL: https://<your-public-host>/webhook/slack`,
|
|
' • Save Changes',
|
|
'',
|
|
' 4. Slack will prompt you to reinstall the app — do it to apply',
|
|
' the new settings',
|
|
].join('\n'),
|
|
6,
|
|
),
|
|
'Finish setting up Slack',
|
|
);
|
|
}
|