From 9b6e5b24a1ba80ce9bb0d4993cd530bf7f761e12 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 22 Apr 2026 10:45:05 +0300 Subject: [PATCH] feat(setup): optional Discord wiring in setup:auto MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror of the Telegram flow but without a pairing step — Discord exposes enough via the bot token that we only need one paste from the operator, with every other identity field derived: GET /users/@me → bot username (sanity check) GET /oauth2/applications/@me → application id, verify_key (public key), owner {id, username} POST /users/@me/channels → DM channel id After confirming "Is @ your Discord account?" the flow invites the bot to a server (OAuth URL + open + confirm, gating so the welcome DM can actually reach the operator), installs the adapter, opens the DM channel, and hands off to init-first-agent with --channel discord --platform-id discord:@me:. The existing init-first-agent welcome-over-CLI-socket path delivers the greeting through the normal adapter pipeline — no Discord-specific code in the welcome logic. Fallbacks: if the app is team-owned (no owner object) or the operator declines the confirmation, a Dev Mode walkthrough + user-id paste prompt takes over. Adds: - setup/add-discord.sh (non-interactive installer, mirror of add-telegram.sh minus pair-step registration) - setup/channels/discord.ts (operator-facing flow) - setup/auto.ts: Discord option in askChannelChoice + dispatch Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/add-discord.sh | 122 ++++++++++ setup/auto.ts | 10 +- setup/channels/discord.ts | 455 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 584 insertions(+), 3 deletions(-) create mode 100755 setup/add-discord.sh create mode 100644 setup/channels/discord.ts diff --git a/setup/add-discord.sh b/setup/add-discord.sh new file mode 100755 index 0000000..1cd247a --- /dev/null +++ b/setup/add-discord.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +# +# Install the Discord adapter, persist DISCORD_BOT_TOKEN / APPLICATION_ID / +# PUBLIC_KEY to .env + data/env/env, and restart the service. Non-interactive — +# the operator-facing "Create a bot" walkthrough, owner confirmation, and +# server-invite step live in setup/channels/discord.ts. Credentials come in via +# env vars: DISCORD_BOT_TOKEN, DISCORD_APPLICATION_ID, DISCORD_PUBLIC_KEY. +# +# Emits exactly one status block on stdout (ADD_DISCORD) at the end. All chatty +# progress messages go to stderr so setup:auto's raw-log capture sees the full +# story without cluttering the final block for the parser. +set -euo pipefail + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$PROJECT_ROOT" + +# Keep in sync with .claude/skills/add-discord/SKILL.md. +ADAPTER_VERSION="@chat-adapter/discord@4.26.0" +CHANNELS_BRANCH="origin/channels" + +emit_status() { + local status=$1 error=${2:-} + local already=${ADAPTER_ALREADY_INSTALLED:-false} + echo "=== NANOCLAW SETUP: ADD_DISCORD ===" + echo "STATUS: ${status}" + echo "ADAPTER_VERSION: ${ADAPTER_VERSION}" + echo "ADAPTER_ALREADY_INSTALLED: ${already}" + [ -n "$error" ] && echo "ERROR: ${error}" + echo "=== END ===" +} + +log() { echo "[add-discord] $*" >&2; } + +if [ -z "${DISCORD_BOT_TOKEN:-}" ]; then + emit_status failed "DISCORD_BOT_TOKEN env var not set" + exit 1 +fi +if [ -z "${DISCORD_APPLICATION_ID:-}" ]; then + emit_status failed "DISCORD_APPLICATION_ID env var not set" + exit 1 +fi +if [ -z "${DISCORD_PUBLIC_KEY:-}" ]; then + emit_status failed "DISCORD_PUBLIC_KEY env var not set" + exit 1 +fi + +need_install() { + [ ! -f src/channels/discord.ts ] && return 0 + ! grep -q "^import './discord.js';" src/channels/index.ts 2>/dev/null && return 0 + return 1 +} + +ADAPTER_ALREADY_INSTALLED=true +if need_install; then + ADAPTER_ALREADY_INSTALLED=false + log "Fetching channels branch…" + git fetch origin channels >&2 2>/dev/null || { + emit_status failed "git fetch origin channels failed" + exit 1 + } + + log "Copying adapter from ${CHANNELS_BRANCH}…" + git show "${CHANNELS_BRANCH}:src/channels/discord.ts" > src/channels/discord.ts + + # Append self-registration import if missing. + if ! grep -q "^import './discord.js';" src/channels/index.ts; then + echo "import './discord.js';" >> src/channels/index.ts + fi + + log "Installing ${ADAPTER_VERSION}…" + pnpm install "${ADAPTER_VERSION}" >&2 2>/dev/null || { + emit_status failed "pnpm install ${ADAPTER_VERSION} failed" + exit 1 + } + + log "Building…" + pnpm run build >&2 2>/dev/null || { + emit_status failed "pnpm run build failed" + exit 1 + } +else + log "Adapter files already installed — skipping install phase." +fi + +# Persist credentials. auto.ts validates before this point, so bad values here +# would be an internal bug rather than operator input. +touch .env +upsert_env() { + local key=$1 value=$2 + if grep -q "^${key}=" .env; then + awk -v k="$key" -v v="$value" \ + 'BEGIN{FS=OFS="="} $1==k {print k "=" v; next} {print}' \ + .env > .env.tmp && mv .env.tmp .env + else + echo "${key}=${value}" >> .env + fi +} +upsert_env DISCORD_BOT_TOKEN "$DISCORD_BOT_TOKEN" +upsert_env DISCORD_APPLICATION_ID "$DISCORD_APPLICATION_ID" +upsert_env DISCORD_PUBLIC_KEY "$DISCORD_PUBLIC_KEY" + +# Container reads from data/env/env (the host mounts it). +mkdir -p data/env +cp .env data/env/env + +log "Restarting service so the new adapter picks up the credentials…" +case "$(uname -s)" in + Darwin) + launchctl kickstart -k "gui/$(id -u)/com.nanoclaw" >&2 2>/dev/null || true + ;; + Linux) + systemctl --user restart nanoclaw >&2 2>/dev/null \ + || sudo systemctl restart nanoclaw >&2 2>/dev/null \ + || true + ;; +esac + +# Give the Discord adapter a moment to finish gateway handshake before +# init-first-agent attempts delivery. +sleep 5 + +emit_status success diff --git a/setup/auto.ts b/setup/auto.ts index a0068bb..97f38ac 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -25,6 +25,7 @@ import { spawn, spawnSync } from 'child_process'; import * as p from '@clack/prompts'; import k from 'kleur'; +import { runDiscordChannel } from './channels/discord.js'; import { runTelegramChannel } from './channels/telegram.js'; import * as setupLog from './logs.js'; import { ensureAnswer, fail, runQuietChild, runQuietStep } from './lib/runner.js'; @@ -195,9 +196,11 @@ async function main(): Promise { const choice = await askChannelChoice(); if (choice === 'telegram') { await runTelegramChannel(displayName!); + } else if (choice === 'discord') { + await runDiscordChannel(displayName!); } else { p.log.info( - "No messaging app for now. You can add one later (like Telegram, Slack, or Discord).", + "No messaging app for now. You can add one later (like Telegram, Discord, or Slack).", ); } } @@ -372,18 +375,19 @@ async function askDisplayName(fallback: string): Promise { return value; } -async function askChannelChoice(): Promise<'telegram' | 'skip'> { +async function askChannelChoice(): Promise<'telegram' | 'discord' | 'skip'> { const choice = ensureAnswer( await p.select({ message: 'Want to chat with your assistant from your phone?', options: [ { value: 'telegram', label: 'Yes, connect Telegram', hint: 'recommended' }, + { value: 'discord', label: 'Yes, connect Discord' }, { value: 'skip', label: 'Skip for now', hint: "I'll just use the terminal" }, ], }), ); setupLog.userInput('channel_choice', String(choice)); - return choice as 'telegram' | 'skip'; + return choice as 'telegram' | 'discord' | 'skip'; } // ─── interactive / env helpers ───────────────────────────────────────── diff --git a/setup/channels/discord.ts b/setup/channels/discord.ts new file mode 100644 index 0000000..cfc8155 --- /dev/null +++ b/setup/channels/discord.ts @@ -0,0 +1,455 @@ +/** + * 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 { spawn } from 'child_process'; + +import * as p from '@clack/prompts'; +import k from 'kleur'; + +import * as setupLog from '../logs.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 { + 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 { + 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 { + 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(`Opening ${url} …`), + ].join('\n'), + 'Create a Discord bot', + ); + openUrl(url); + + ensureAnswer( + await p.confirm({ + message: "Got your bot token?", + initialValue: true, + }), + ); +} + +async function collectDiscordToken(): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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(`Opening ${url}`), + ].join('\n'), + 'Add bot to a server', + ); + openUrl(url); + + ensureAnswer( + await p.confirm({ + message: "I've added the bot to a server", + initialValue: true, + }), + ); +} + +async function openDmChannel(token: string, userId: string): Promise { + 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 { + 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; +} + +/** Best-effort open of a URL in the user's default browser. Silent on failure. */ +function openUrl(url: string): void { + try { + const cmd = process.platform === 'darwin' ? 'open' : 'xdg-open'; + const child = spawn(cmd, [url], { stdio: 'ignore', detached: true }); + child.on('error', () => { + // Headless / no browser / unknown command — the URL is already + // printed in the note above, so the user can copy-paste. + }); + child.unref(); + } catch { + // swallow — URL is visible in the note. + } +}