On GUI devices the URL was previously rendered dim inside the
instructional `note(...)` card, then `confirmThenOpen` printed
its prompt below: read the card, see the URL, then a separate
"Press Enter to open the X" prompt with no link near it. Two
visual moments for what's really one decision.
This PR pulls the URL out of the card on GUI devices and
relocates it directly under the action line of the confirm
prompt, separated only by a dim "If browser does not appear,
please visit: <url>" line:
│
◆ Press Enter to open the Developer Portal
│ If browser does not appear, please visit: … (dim)
│ ● Yes / ○ No
│
Action and fallback live as one prompt block — the user sees
both at the same time, no need to scroll back up to grab the
URL if the auto-open misses.
Headless behavior is unchanged: `formatNoteLink` still emits
"Get started: <url>" inside the card on headless devices (per
#2146), and `confirmThenOpen` still no-ops on headless (per
#2145). The only thing that changed for headless is the leading
`\n` in the helper output, which acts as a visual separator from
the steps above.
Five call sites adjusted (Discord ×3, Slack ×1, Telegram ×1) to
use `.filter((line) => line !== null)` so the now-nullable
`formatNoteLink` cleanly drops out of GUI-rendered cards.
420 lines
13 KiB
TypeScript
420 lines
13 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, formatNoteLink } from '../lib/browser.js';
|
|
import { askOperatorRole } from '../lib/role-prompt.js';
|
|
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
|
|
import { accentGreen, fmtDuration, note, 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> {
|
|
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-…)',
|
|
formatNoteLink(SLACK_APPS_URL),
|
|
].filter((line): line is string => line !== null).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 existing = process.env.SLACK_BOT_TOKEN?.trim();
|
|
if (existing && existing.startsWith('xoxb-') && existing.length >= 24) {
|
|
const reuse = ensureAnswer(await p.confirm({
|
|
message: `Found an existing Slack bot token (${existing.slice(0, 10)}…). Use it?`,
|
|
initialValue: true,
|
|
}));
|
|
if (reuse) {
|
|
setupLog.userInput('slack_bot_token', 'reused-existing');
|
|
return existing;
|
|
}
|
|
}
|
|
|
|
const answer = ensureAnswer(
|
|
await p.password({
|
|
message: 'Paste your Slack bot token',
|
|
clearOnError: true,
|
|
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 existing = process.env.SLACK_SIGNING_SECRET?.trim();
|
|
if (existing && /^[a-f0-9]{16,}$/i.test(existing)) {
|
|
const reuse = ensureAnswer(await p.confirm({
|
|
message: 'Found an existing Slack signing secret. Use it?',
|
|
initialValue: true,
|
|
}));
|
|
if (reuse) {
|
|
setupLog.userInput('slack_signing_secret', 'reused-existing');
|
|
return existing;
|
|
}
|
|
}
|
|
|
|
const answer = ensureAnswer(
|
|
await p.password({
|
|
message: 'Paste your Slack signing secret',
|
|
clearOnError: true,
|
|
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;
|
|
};
|
|
if (data.ok && data.team && data.user) {
|
|
s.stop(
|
|
`Connected to ${data.team} as @${data.user}. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`,
|
|
);
|
|
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) {
|
|
s.stop(`Couldn't reach Slack. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 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> {
|
|
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;
|
|
};
|
|
if (data.ok && data.channel?.id) {
|
|
s.stop(`DM channel ready. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`);
|
|
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) {
|
|
s.stop(`Couldn't reach Slack. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 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 ${accentGreen('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 {
|
|
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',
|
|
);
|
|
}
|