Files
nanoclaw/setup/channels/discord.ts
gavrielc 4859d8fb2d feat(setup): Claude-assisted error recovery with resume-at-step retry
When a setup step fails — whether hard via fail() or soft via the
"What's left" / "Skipping the first chat" notes — offer to ask Claude
to diagnose. On consent, spawn `claude -p --output-format stream-json`
with a scrolling 3-line action window ("Reading x", "Running y") so
the 1–4 minute investigations feel active rather than hung. No hard
timeout: debugging can take time, Ctrl-C is the escape hatch.

The prompt is minimal: one-paragraph framing, failed step name + msg +
hint, and a list of file references (not contents). Claude's Read/Grep
tools fetch what they need. A per-step map in claude-assist.ts gives
the most relevant files per step; the rest is README + auto.ts +
logs/setup.log + the per-step raw log.

Claude responds with REASON + COMMAND lines. We show the reason in a
clack note, prefill the command via setup/run-suggested.sh (bash 4+
readline, 3.x fallback to Enter-to-run), and eval on the user's
confirm.

When the user runs a fix, fail() now offers to retry the failing step
rather than aborting. setup/logs.ts tracks successfully-completed step
names in-memory; fail() threads those as NANOCLAW_SKIP on a spawnSync
retry, so the child picks up exactly where the parent left off — no
rebuilding containers or reinstalling OneCLI.

Other polish in this change:
- fitToWidth + dimWrap in lib/theme.ts to prevent long spinner labels
  from soft-wrapping (each terminal row stacks a stale copy otherwise).
- Shorter container step label ("Preparing your assistant's sandbox…")
  so it fits on narrow terminals.
- Wordmark anchored in the clack intro line on every run.
- All 25 existing fail() call sites updated to await fail(...) since
  fail is now async.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 12:42:44 +03:00

441 lines
14 KiB
TypeScript

/**
* Discord channel flow for setup:auto.
*
* `runDiscordChannel(displayName)` owns the full branch from "do you have a
* bot?" through the welcome DM:
*
* 1. Ask if they have a bot already; walk them through Dev Portal creation
* if not
* 2. Paste the bot token (clack password) — format-validated
* 3. GET /users/@me to confirm the token and resolve bot username
* 4. GET /oauth2/applications/@me to derive application_id, verify_key
* (public key), and owner — no separate paste needed in the common case
* 5. Confirm owner identity (falls back to a manual user-id prompt with
* Developer Mode instructions if declined or if the app is team-owned)
* 6. Print the OAuth invite URL, open it, wait for "I've added the bot"
* 7. Install the adapter via setup/add-discord.sh (non-interactive)
* 8. POST /users/@me/channels to open the DM channel (yields dm channel id)
* 9. Ask for the messaging-agent name (defaulting to "Nano")
* 10. Wire the agent via scripts/init-first-agent.ts, which sends the welcome
* DM through the normal delivery path
*
* All output obeys the three-level contract: clack UI for the user, structured
* entries in logs/setup.log, full raw output in per-step files under
* logs/setup-steps/. 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 { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
const DEFAULT_AGENT_NAME = 'Nano';
const DISCORD_API = 'https://discord.com/api/v10';
// Send Messages (0x800) + Add Reactions (0x40) + Attach Files (0x8000)
// + Read Message History (0x10000) = 100416.
// Matches the permissions set documented in .claude/skills/add-discord/SKILL.md.
const INVITE_PERMISSIONS = '100416';
interface AppInfo {
applicationId: string;
publicKey: string;
owner: { id: string; username: string } | null;
}
export async function runDiscordChannel(displayName: string): Promise<void> {
if (!(await askHasBotToken())) {
await walkThroughBotCreation();
}
const token = await collectDiscordToken();
const botUsername = await validateDiscordToken(token);
const app = await fetchApplicationInfo(token);
const ownerUserId = await resolveOwnerUserId(app.owner);
await promptInviteBot(app.applicationId, botUsername);
const install = await runQuietChild(
'discord-install',
'bash',
['setup/add-discord.sh'],
{
running: `Connecting Discord to @${botUsername}`,
done: 'Discord connected.',
},
{
env: {
DISCORD_BOT_TOKEN: token,
DISCORD_APPLICATION_ID: app.applicationId,
DISCORD_PUBLIC_KEY: app.publicKey,
},
extraFields: {
BOT_USERNAME: botUsername,
APPLICATION_ID: app.applicationId,
},
},
);
if (!install.ok) {
await fail(
'discord-install',
"Couldn't connect Discord.",
'See logs/setup-steps/ for details, then retry setup.',
);
}
const dmChannelId = await openDmChannel(token, ownerUserId);
const platformId = `discord:@me:${dmChannelId}`;
const agentName = await resolveAgentName();
const init = await runQuietChild(
'init-first-agent',
'pnpm',
[
'exec', 'tsx', 'scripts/init-first-agent.ts',
'--channel', 'discord',
'--user-id', `discord:${ownerUserId}`,
'--platform-id', platformId,
'--display-name', displayName,
'--agent-name', agentName,
],
{
running: `Connecting ${agentName} to your Discord DMs…`,
done: `${agentName} is ready. Check Discord for a welcome message.`,
},
{
extraFields: {
CHANNEL: 'discord',
AGENT_NAME: agentName,
PLATFORM_ID: platformId,
},
},
);
if (!init.ok) {
await fail(
'init-first-agent',
`Couldn't finish connecting ${agentName}.`,
'Most likely the bot and you don\'t share a server yet — invite the bot, then retry later with `/manage-channels`.',
);
}
}
async function askHasBotToken(): Promise<boolean> {
const answer = ensureAnswer(
await p.select({
message: 'Do you already have a Discord bot?',
options: [
{ value: 'yes', label: 'Yes, I have a bot token ready' },
{ value: 'no', label: "No, walk me through creating one" },
],
}),
);
return answer === 'yes';
}
async function walkThroughBotCreation(): Promise<void> {
const url = 'https://discord.com/developers/applications';
p.note(
[
"You'll create a Discord bot in the Developer Portal. It's free and takes about a minute.",
'',
' 1. Click "New Application", give it a name (e.g. "NanoClaw")',
' 2. In the "Bot" tab, click "Reset Token" and copy the token',
' 3. On the same tab, enable "Message Content Intent"',
' (under Privileged Gateway Intents)',
'',
k.dim(url),
].join('\n'),
'Create a Discord bot',
);
await confirmThenOpen(url, 'Press Enter to open the Developer Portal');
ensureAnswer(
await p.confirm({
message: "Got your bot token?",
initialValue: true,
}),
);
}
async function collectDiscordToken(): Promise<string> {
const answer = ensureAnswer(
await p.password({
message: 'Paste your bot token',
validate: (v) => {
const t = (v ?? '').trim();
if (!t) return 'Token is required';
// Discord bot tokens are base64url segments separated by dots.
// Be lenient on length; the real check is /users/@me.
if (!/^[A-Za-z0-9._-]{50,}$/.test(t)) {
return "That doesn't look like a Discord bot token";
}
return undefined;
},
}),
);
const token = (answer as string).trim();
setupLog.userInput(
'discord_token',
`${token.slice(0, 10)}${token.slice(-4)}`,
);
return token;
}
async function validateDiscordToken(token: string): Promise<string> {
const s = p.spinner();
const start = Date.now();
s.start('Checking your bot token…');
try {
const res = await fetch(`${DISCORD_API}/users/@me`, {
headers: { Authorization: `Bot ${token}` },
});
const data = (await res.json()) as {
id?: string;
username?: string;
message?: string;
};
const elapsedS = Math.round((Date.now() - start) / 1000);
if (res.ok && data.username) {
s.stop(`Found your bot: @${data.username}. ${k.dim(`(${elapsedS}s)`)}`);
setupLog.step('discord-validate', 'success', Date.now() - start, {
BOT_USERNAME: data.username,
BOT_ID: data.id ?? '',
});
return data.username;
}
const reason = data.message ?? `HTTP ${res.status}`;
s.stop(`Discord didn't accept that token: ${reason}`, 1);
setupLog.step('discord-validate', 'failed', Date.now() - start, {
ERROR: reason,
});
await fail(
'discord-validate',
"Discord didn't accept that token.",
'Copy the token again from the Developer Portal and retry setup.',
);
} catch (err) {
const elapsedS = Math.round((Date.now() - start) / 1000);
s.stop(`Couldn't reach Discord. ${k.dim(`(${elapsedS}s)`)}`, 1);
const message = err instanceof Error ? err.message : String(err);
setupLog.step('discord-validate', 'failed', Date.now() - start, {
ERROR: message,
});
await fail(
'discord-validate',
"Couldn't reach Discord.",
'Check your internet connection and retry setup.',
);
}
}
async function fetchApplicationInfo(token: string): Promise<AppInfo> {
const s = p.spinner();
const start = Date.now();
s.start('Looking up your bot application…');
try {
const res = await fetch(`${DISCORD_API}/oauth2/applications/@me`, {
headers: { Authorization: `Bot ${token}` },
});
const data = (await res.json()) as {
id?: string;
verify_key?: string;
owner?: { id: string; username: string } | null;
team?: unknown;
message?: string;
};
const elapsedS = Math.round((Date.now() - start) / 1000);
if (!res.ok || !data.id || !data.verify_key) {
const reason = data.message ?? `HTTP ${res.status}`;
s.stop(`Couldn't read application info: ${reason}`, 1);
setupLog.step('discord-app-info', 'failed', Date.now() - start, {
ERROR: reason,
});
await fail(
'discord-app-info',
"Couldn't read your Discord application details.",
'Re-run setup. If it keeps failing, check the bot token has the right scopes.',
);
}
s.stop(`Got your application details. ${k.dim(`(${elapsedS}s)`)}`);
// owner is populated for solo applications; team-owned apps return a
// team object instead and we'll fall back to a manual user-id prompt.
const owner =
data.owner && data.owner.id && data.owner.username
? { id: data.owner.id, username: data.owner.username }
: null;
setupLog.step('discord-app-info', 'success', Date.now() - start, {
APPLICATION_ID: data.id,
OWNER_USERNAME: owner?.username ?? '',
TEAM_OWNED: data.team ? 'true' : 'false',
});
return {
applicationId: data.id,
publicKey: data.verify_key,
owner,
};
} catch (err) {
const elapsedS = Math.round((Date.now() - start) / 1000);
s.stop(`Couldn't reach Discord. ${k.dim(`(${elapsedS}s)`)}`, 1);
const message = err instanceof Error ? err.message : String(err);
setupLog.step('discord-app-info', 'failed', Date.now() - start, {
ERROR: message,
});
await fail(
'discord-app-info',
"Couldn't reach Discord.",
'Check your internet connection and retry setup.',
);
}
}
async function resolveOwnerUserId(
owner: { id: string; username: string } | null,
): Promise<string> {
if (owner) {
const confirmed = ensureAnswer(
await p.confirm({
message: `Is @${owner.username} your Discord account?`,
initialValue: true,
}),
);
if (confirmed === true) {
setupLog.userInput('discord_owner_confirmed', owner.username);
return owner.id;
}
} else {
p.log.info(
"Your bot is owned by a Developer Team, so we need your Discord user ID directly.",
);
}
return await promptForUserIdWithDevMode();
}
async function promptForUserIdWithDevMode(): Promise<string> {
p.note(
[
"To get your Discord user ID:",
'',
' 1. Open Discord → Settings (⚙️) → Advanced',
' 2. Turn on "Developer Mode"',
' 3. Right-click your own name/avatar → "Copy User ID"',
].join('\n'),
'Find your Discord user ID',
);
const answer = ensureAnswer(
await p.text({
message: 'Paste your Discord user ID',
validate: (v) => {
const t = (v ?? '').trim();
if (!t) return 'User ID is required';
if (!/^\d{17,20}$/.test(t)) {
return "That doesn't look like a Discord user ID (17-20 digits)";
}
return undefined;
},
}),
);
const id = (answer as string).trim();
setupLog.userInput('discord_user_id', id);
return id;
}
async function promptInviteBot(
applicationId: string,
botUsername: string,
): Promise<void> {
const url =
`https://discord.com/api/oauth2/authorize` +
`?client_id=${applicationId}` +
`&scope=bot` +
`&permissions=${INVITE_PERMISSIONS}`;
p.note(
[
`@${botUsername} needs to share a server with you before it can DM you.`,
'',
' 1. Pick any server you\'re in (a personal one is fine)',
' 2. Click "Authorize"',
'',
k.dim(url),
].join('\n'),
'Add bot to a server',
);
await confirmThenOpen(url, 'Press Enter to open the invite page');
ensureAnswer(
await p.confirm({
message: "I've added the bot to a server",
initialValue: true,
}),
);
}
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(`${DISCORD_API}/users/@me/channels`, {
method: 'POST',
headers: {
Authorization: `Bot ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ recipient_id: userId }),
});
const data = (await res.json()) as { id?: string; message?: string };
const elapsedS = Math.round((Date.now() - start) / 1000);
if (!res.ok || !data.id) {
const reason = data.message ?? `HTTP ${res.status}`;
s.stop(`Couldn't open a DM channel: ${reason}`, 1);
setupLog.step('discord-open-dm', 'failed', Date.now() - start, {
ERROR: reason,
});
await fail(
'discord-open-dm',
"Couldn't open a DM channel with you.",
'Make sure the bot is in a server you\'re also in, then retry setup.',
);
}
s.stop(`DM channel ready. ${k.dim(`(${elapsedS}s)`)}`);
setupLog.step('discord-open-dm', 'success', Date.now() - start, {
DM_CHANNEL_ID: data.id,
});
return data.id;
} catch (err) {
const elapsedS = Math.round((Date.now() - start) / 1000);
s.stop(`Couldn't reach Discord. ${k.dim(`(${elapsedS}s)`)}`, 1);
const message = err instanceof Error ? err.message : String(err);
setupLog.step('discord-open-dm', 'failed', Date.now() - start, {
ERROR: message,
});
await fail(
'discord-open-dm',
"Couldn't reach Discord.",
'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;
}