WhatsApp (community/Baileys) joins the setup:auto channel picker, with
the same clack-native UX discipline as Telegram and Discord:
- setup/channels/whatsapp.ts — driver. Collects auth method (QR terminal
or pairing code), runs the auth step, renders QR blocks in-place with
ANSI cursor-rewind on rotation so the terminal doesn't fill up with
stale codes, reads creds.me.id for the bot phone, restarts the service,
asks for the operator's personal phone (defaulting to the authed
number), writes ASSISTANT_HAS_OWN_NUMBER=true when they differ
(dedicated mode), and hands off to init-first-agent.
- setup/whatsapp-auth.ts — forked standalone auth step. Channels-branch
version had a browser-QR path with an HTTP server + <canvas> QR
renderer; stripped entirely (headless/SSH users hit dead ends too
often, and the extra deps complicate install). The remaining terminal
QR emits raw QR strings in WHATSAPP_AUTH_QR blocks so the parent
driver owns the rendering. Pairing-code path retained. Status blocks
now use the runner's vocabulary (success/skipped/failed) so spawnStep
sets ok correctly; WhatsApp-specific UI text ("WhatsApp linked", "You
chat") lives in the driver.
- setup/add-whatsapp.sh — non-interactive installer, mirror of
add-telegram.sh. Fetches the adapter + groups step from the channels
branch (whatsapp-auth.ts stays local, pair-telegram.ts pattern),
installs pinned baileys/qrcode/pino, registers the steps in
setup/index.ts's STEPS map. No service restart (adapter factory
returns null until creds exist).
Cross-channel fixes bundled:
- scripts/init-first-agent.ts: always addMember(user, agentGroup) for
the target user so subsequent wirings (not the first) pass the access
gate. Telegram wiring first → Discord/WhatsApp second was dropping
every inbound with accessReason='not_member' because only the first
user gets owner. namespacedPlatformId also passes through JID-format
raws (contains '@') so WhatsApp's bare <phone>@s.whatsapp.net matches
what the adapter stores.
- setup/service.ts: launchctl unload-then-load instead of bare load (bare
load errors 'already loaded' when a prior plist was cached, keeping
launchd on the OLD ProgramArguments even after the file on disk
changed). systemctl start → restart (start is a no-op on an active
unit, swallowing unit-file edits).
- setup/add-telegram.sh: removed the in-script open "tg://resolve"
block. The driver (setup/channels/telegram.ts) now owns the deep-link,
gated on a p.confirm so the browser can't steal focus unexpectedly.
- setup/channels/discord.ts + setup/channels/telegram.ts: every browser
open goes through confirmThenOpen (new shared helper in
setup/lib/browser.ts) — operator presses Enter before their browser
takes focus. Telegram switched from tg://resolve?domain= to
https://t.me/<bot> which works everywhere.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
441 lines
14 KiB
TypeScript
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) {
|
|
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) {
|
|
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,
|
|
});
|
|
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,
|
|
});
|
|
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,
|
|
});
|
|
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,
|
|
});
|
|
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,
|
|
});
|
|
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,
|
|
});
|
|
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;
|
|
}
|
|
|