diff --git a/scripts/init-first-agent.ts b/scripts/init-first-agent.ts index c634851..c4dfdc2 100644 --- a/scripts/init-first-agent.ts +++ b/scripts/init-first-agent.ts @@ -43,6 +43,7 @@ import { } from '../src/db/messaging-groups.js'; import { runMigrations } from '../src/db/migrations/index.js'; import { normalizeName } from '../src/modules/agent-to-agent/db/agent-destinations.js'; +import { addMember } from '../src/modules/permissions/db/agent-group-members.js'; import { grantRole, hasAnyOwner } from '../src/modules/permissions/db/user-roles.js'; import { upsertUser } from '../src/modules/permissions/db/users.js'; import { initGroupFilesystem } from '../src/group-init.js'; @@ -118,7 +119,13 @@ function namespacedUserId(channel: string, raw: string): string { } function namespacedPlatformId(channel: string, raw: string): string { - return raw.startsWith(`${channel}:`) ? raw : `${channel}:${raw}`; + if (raw.startsWith(`${channel}:`)) return raw; + // Adapters using native JID format (WhatsApp: @s.whatsapp.net, + // @g.us) store platform_id without a channel prefix. The '@' is + // the discriminator — telegram/discord platform_ids don't contain it + // except after a channel prefix, which is already handled above. + if (raw.includes('@')) return raw; + return `${channel}:${raw}`; } function generateId(prefix: string): string { @@ -202,6 +209,19 @@ async function main(): Promise { 'When the user first reaches out (or you receive a system welcome prompt), introduce yourself briefly and invite them to chat. Keep replies concise.', }); + // 2b. Grant the user access to this agent group. Owner role is only + // assigned to the first user (above); subsequent DMs need explicit + // membership or the strict unknown_sender_policy on the DM messaging + // group will drop every message with accessReason='not_member'. addMember + // is INSERT OR IGNORE — idempotent when the global owner already has + // access by virtue of their role. + addMember({ + user_id: userId, + agent_group_id: ag.id, + added_by: null, + added_at: now, + }); + // 3. DM messaging group. const platformId = namespacedPlatformId(args.channel, args.platformId); let dmMg = getMessagingGroupByPlatform(args.channel, platformId); diff --git a/setup/add-telegram.sh b/setup/add-telegram.sh index 5036bd4..361960f 100755 --- a/setup/add-telegram.sh +++ b/setup/add-telegram.sh @@ -119,7 +119,8 @@ else fi # Look up the bot username (auto.ts already validated; we re-query here so -# standalone invocations still work). +# standalone invocations still work — BOT_USERNAME is emitted in the status +# block for parent drivers to display). INFO=$(curl -fsS --max-time 8 \ "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/getMe" 2>/dev/null || true) BOT_USERNAME="" @@ -131,23 +132,10 @@ fi mkdir -p data/env cp .env data/env/env -# Deep-link into the bot's chat so the user is already on the right screen -# when pair-telegram prints the code. Silent best-effort — runs under a -# spinner, any output (from `open` / `xdg-open`) goes to the raw log. -if [ -n "$BOT_USERNAME" ]; then - case "$(uname -s)" in - Darwin) - open "tg://resolve?domain=${BOT_USERNAME}" >&2 2>/dev/null \ - || open "https://t.me/${BOT_USERNAME}" >&2 2>/dev/null \ - || true - ;; - Linux) - xdg-open "tg://resolve?domain=${BOT_USERNAME}" >&2 2>/dev/null \ - || xdg-open "https://t.me/${BOT_USERNAME}" >&2 2>/dev/null \ - || true - ;; - esac -fi +# Browser/app deep-link is done by the parent driver (setup/channels/telegram.ts) +# BEFORE this script runs — gated on a clack confirm so focus-stealing doesn't +# surprise the user. Keeping it out of here means this script stays pure +# non-interactive install. log "Restarting service so the new adapter picks up the token…" case "$(uname -s)" in diff --git a/setup/add-whatsapp.sh b/setup/add-whatsapp.sh new file mode 100755 index 0000000..d04d372 --- /dev/null +++ b/setup/add-whatsapp.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# +# Install the native WhatsApp (Baileys) adapter and its whatsapp-auth + groups +# setup steps. No credentials in env — WhatsApp uses linked-device auth, run +# by the whatsapp-auth step as a separate process. The adapter's factory +# returns null until store/auth/creds.json exists, so it's safe to install +# this before auth runs; the driver restarts the service *after* auth +# succeeds. +# +# Emits exactly one status block on stdout (ADD_WHATSAPP) 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-whatsapp/SKILL.md. +BAILEYS_VERSION="@whiskeysockets/baileys@6.17.16" +QRCODE_VERSION="qrcode@1.5.4" +QRCODE_TYPES_VERSION="@types/qrcode@1.5.6" +PINO_VERSION="pino@9.6.0" +CHANNELS_BRANCH="origin/channels" + +emit_status() { + local status=$1 error=${2:-} + local already=${ADAPTER_ALREADY_INSTALLED:-false} + echo "=== NANOCLAW SETUP: ADD_WHATSAPP ===" + echo "STATUS: ${status}" + echo "ADAPTER_ALREADY_INSTALLED: ${already}" + [ -n "$error" ] && echo "ERROR: ${error}" + echo "=== END ===" +} + +log() { echo "[add-whatsapp] $*" >&2; } + +need_install() { + [ ! -f src/channels/whatsapp.ts ] && return 0 + [ ! -f setup/groups.ts ] && return 0 + ! grep -q "^import './whatsapp.js';" src/channels/index.ts 2>/dev/null && return 0 + ! grep -q "'whatsapp-auth':" setup/index.ts 2>/dev/null && return 0 + ! grep -q "^ groups:" setup/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 + } + + # whatsapp-auth.ts is maintained in this branch (setup-auto) — do not copy + # from channels. Matches the pair-telegram.ts pattern. + log "Copying adapter + group step from ${CHANNELS_BRANCH}…" + git show "${CHANNELS_BRANCH}:src/channels/whatsapp.ts" > src/channels/whatsapp.ts + git show "${CHANNELS_BRANCH}:setup/groups.ts" > setup/groups.ts + + # Append self-registration import if missing. + if ! grep -q "^import './whatsapp.js';" src/channels/index.ts; then + echo "import './whatsapp.js';" >> src/channels/index.ts + fi + + # Register the setup steps in setup/index.ts's STEPS map. node (not sed) — + # sed's in-place + escape semantics differ between BSD (macOS) and GNU. + node -e ' + const fs = require("fs"); + const p = "setup/index.ts"; + let s = fs.readFileSync(p, "utf-8"); + let changed = false; + if (!s.includes("\047whatsapp-auth\047:")) { + s = s.replace( + /(register: \(\) => import\(\x27\.\/register\.js\x27\),)/, + "$1\n \x27whatsapp-auth\x27: () => import(\x27./whatsapp-auth.js\x27)," + ); + changed = true; + } + if (!/^\s*groups:\s/m.test(s)) { + s = s.replace( + /(register: \(\) => import\(\x27\.\/register\.js\x27\),)/, + "$1\n groups: () => import(\x27./groups.js\x27)," + ); + changed = true; + } + if (changed) fs.writeFileSync(p, s); + ' + + log "Installing Baileys + QR + pino (pinned)…" + pnpm install \ + "${BAILEYS_VERSION}" \ + "${QRCODE_VERSION}" \ + "${QRCODE_TYPES_VERSION}" \ + "${PINO_VERSION}" \ + >&2 2>/dev/null || { + emit_status failed "pnpm install failed" + exit 1 + } + + log "Building…" + pnpm run build >&2 2>/dev/null || { + emit_status failed "pnpm run build failed" + exit 1 + } +else + log "Adapter + setup steps already installed — skipping install phase." +fi + +# No service restart here — the adapter factory returns null without +# store/auth/creds.json, so restarting now would no-op. The driver restarts +# the service AFTER whatsapp-auth completes so the adapter picks up creds. + +emit_status success diff --git a/setup/auto.ts b/setup/auto.ts index 49be3f3..2191e9a 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -27,6 +27,7 @@ import k from 'kleur'; import { runDiscordChannel } from './channels/discord.js'; import { runTelegramChannel } from './channels/telegram.js'; +import { runWhatsAppChannel } from './channels/whatsapp.js'; import { pingCliAgent, type PingResult } from './lib/agent-ping.js'; import * as setupLog from './logs.js'; import { ensureAnswer, fail, runQuietChild, runQuietStep } from './lib/runner.js'; @@ -209,10 +210,12 @@ async function main(): Promise { await runTelegramChannel(displayName!); } else if (choice === 'discord') { await runDiscordChannel(displayName!); + } else if (choice === 'whatsapp') { + await runWhatsAppChannel(displayName!); } else { p.log.info( wrapForGutter( - 'No messaging app for now. You can add one later (like Telegram, Discord, or Slack).', + 'No messaging app for now. You can add one later (like Telegram, Discord, WhatsApp, or Slack).', 4, ), ); @@ -493,19 +496,20 @@ async function askDisplayName(fallback: string): Promise { return value; } -async function askChannelChoice(): Promise<'telegram' | 'discord' | 'skip'> { +async function askChannelChoice(): Promise<'telegram' | 'discord' | 'whatsapp' | '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: 'whatsapp', label: 'Yes, connect WhatsApp' }, { value: 'skip', label: 'Skip for now', hint: "I'll just use the terminal" }, ], }), ); setupLog.userInput('channel_choice', String(choice)); - return choice as 'telegram' | 'discord' | 'skip'; + return choice as 'telegram' | 'discord' | 'whatsapp' | 'skip'; } // ─── interactive / env helpers ───────────────────────────────────────── diff --git a/setup/channels/discord.ts b/setup/channels/discord.ts index cfc8155..f384902 100644 --- a/setup/channels/discord.ts +++ b/setup/channels/discord.ts @@ -23,12 +23,11 @@ * 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 { confirmThenOpen } from '../lib/browser.js'; import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js'; const DEFAULT_AGENT_NAME = 'Nano'; @@ -147,11 +146,11 @@ async function walkThroughBotCreation(): Promise { ' 3. On the same tab, enable "Message Content Intent"', ' (under Privileged Gateway Intents)', '', - k.dim(`Opening ${url} …`), + k.dim(url), ].join('\n'), 'Create a Discord bot', ); - openUrl(url); + await confirmThenOpen(url, 'Press Enter to open the Developer Portal'); ensureAnswer( await p.confirm({ @@ -360,11 +359,11 @@ async function promptInviteBot( ' 1. Pick any server you\'re in (a personal one is fine)', ' 2. Click "Authorize"', '', - k.dim(`Opening ${url}`), + k.dim(url), ].join('\n'), 'Add bot to a server', ); - openUrl(url); + await confirmThenOpen(url, 'Press Enter to open the invite page'); ensureAnswer( await p.confirm({ @@ -439,17 +438,3 @@ async function resolveAgentName(): Promise { 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. - } -} diff --git a/setup/channels/telegram.ts b/setup/channels/telegram.ts index 348cd05..7fe5d26 100644 --- a/setup/channels/telegram.ts +++ b/setup/channels/telegram.ts @@ -7,10 +7,11 @@ * 1. BotFather instructions (clack note) * 2. Paste the bot token (clack password) — format-validated * 3. getMe via the Bot API to resolve the bot's username - * 4. Install the adapter (setup/add-telegram.sh, non-interactive) - * 5. Run the pair-telegram step, rendering code events as clack notes - * 6. Ask for the messaging-agent name (defaulting to "Nano") - * 7. Wire the agent via scripts/init-first-agent.ts + * 4. Confirm + deep-link into the bot's Telegram chat (tg://resolve) + * 5. Install the adapter (setup/add-telegram.sh, non-interactive) + * 6. Run the pair-telegram step, rendering code events as clack notes + * 7. Ask for the messaging-agent name (defaulting to "Nano") + * 8. Wire the agent via scripts/init-first-agent.ts * * 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 @@ -20,6 +21,7 @@ import * as p from '@clack/prompts'; import k from 'kleur'; import * as setupLog from '../logs.js'; +import { confirmThenOpen } from '../lib/browser.js'; import { type Block, type StepResult, @@ -38,6 +40,22 @@ export async function runTelegramChannel(displayName: string): Promise { const token = await collectTelegramToken(); const botUsername = await validateTelegramToken(token); + // Deep-link the user into the bot's chat so they're on the right screen + // by the time pair-telegram prints the code. https://t.me/ works + // everywhere: browsers show an "Open in Telegram" button when the app is + // installed, or the bot's web profile if not. tg://resolve?domain= is + // more direct but silently fails when the scheme isn't registered. + const botUrl = `https://t.me/${botUsername}`; + p.note( + [ + `Opening @${botUsername} in Telegram so it's ready when the pairing code shows up.`, + '', + k.dim(botUrl), + ].join('\n'), + 'Open Telegram', + ); + await confirmThenOpen(botUrl, 'Press Enter to open Telegram'); + const install = await runQuietChild( 'telegram-install', 'bash', diff --git a/setup/channels/whatsapp.ts b/setup/channels/whatsapp.ts new file mode 100644 index 0000000..4d8290f --- /dev/null +++ b/setup/channels/whatsapp.ts @@ -0,0 +1,464 @@ +/** + * WhatsApp (community/Baileys) channel flow for setup:auto. + * + * `runWhatsAppChannel(displayName)` owns the full branch from auth-method + * picker through the welcome DM: + * + * 1. Ask how to authenticate (QR code in terminal, default, or pairing code) + * 2. If pairing-code: collect the phone number + * 3. Install the adapter + Baileys + QR + pino via setup/add-whatsapp.sh + * 4. Run the whatsapp-auth step, rendering status blocks as clack UI: + * - WHATSAPP_AUTH_QR (repeating): render the QR as terminal block art + * inside a clack note. On rotation we clear the previous QR in-place + * via ANSI escapes so the terminal doesn't fill up with stale codes. + * - WHATSAPP_AUTH_PAIRING_CODE (one-shot): centred code card. + * 5. Read store/auth/creds.json → extract the authenticated (bot) phone + * 6. Kick the service so the adapter picks up the new credentials + * 7. Ask the operator for the phone they'll chat from (defaults to the + * authed number). Different number ⇒ dedicated mode ⇒ also writes + * ASSISTANT_HAS_OWN_NUMBER=true so outbound replies aren't prefixed + * 8. Ask for the messaging-agent name (defaulting to "Nano") + * 9. Wire the agent via scripts/init-first-agent.ts; the existing welcome + * DM path delivers the greeting through the adapter + * + * 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 { spawnSync } from 'child_process'; +import fs from 'fs'; +import path from 'path'; + +import * as p from '@clack/prompts'; +import k from 'kleur'; + +import * as setupLog from '../logs.js'; +import { + type Block, + type StepResult, + dumpTranscriptOnFailure, + ensureAnswer, + fail, + runQuietChild, + spawnStep, + writeStepEntry, +} from '../lib/runner.js'; +import { brandBold } from '../lib/theme.js'; + +const DEFAULT_AGENT_NAME = 'Nano'; +const AUTH_CREDS_PATH = path.join(process.cwd(), 'store', 'auth', 'creds.json'); + +type AuthMethod = 'qr' | 'pairing-code'; + +export async function runWhatsAppChannel(displayName: string): Promise { + const method = await askAuthMethod(); + const phone = method === 'pairing-code' ? await askPhoneNumber() : undefined; + + const install = await runQuietChild( + 'whatsapp-install', + 'bash', + ['setup/add-whatsapp.sh'], + { + running: 'Installing the WhatsApp adapter…', + done: 'WhatsApp adapter installed.', + skipped: 'WhatsApp adapter already installed.', + }, + ); + if (!install.ok) { + fail( + 'whatsapp-install', + "Couldn't install the WhatsApp adapter.", + 'See logs/setup-steps/ for details, then retry setup.', + ); + } + + const auth = await runWhatsAppAuth(method, phone); + if (!auth.ok) { + const reason = auth.terminal?.fields.ERROR ?? 'unknown'; + fail( + 'whatsapp-auth', + `WhatsApp authentication failed (${reason}).`, + reason === 'qr_timeout' || reason === 'timeout' + ? 'The code expired. Re-run setup to get a fresh one.' + : 'Re-run setup to try again.', + ); + } + + const botPhone = readAuthedPhone(); + if (!botPhone) { + fail( + 'whatsapp-auth', + "Authenticated but couldn't read your WhatsApp number from the saved credentials.", + 'Re-run setup to try again.', + ); + } + + await restartService(); + + const chatPhone = await askChatPhone(botPhone); + const isDedicated = chatPhone !== botPhone; + if (isDedicated) { + writeAssistantHasOwnNumber(); + } + + const agentName = await resolveAgentName(); + + const platformId = `${chatPhone}@s.whatsapp.net`; + + const init = await runQuietChild( + 'init-first-agent', + 'pnpm', + [ + 'exec', 'tsx', 'scripts/init-first-agent.ts', + '--channel', 'whatsapp', + '--user-id', platformId, + '--platform-id', platformId, + '--display-name', displayName, + '--agent-name', agentName, + ], + { + running: `Connecting ${agentName} to WhatsApp…`, + done: isDedicated + ? `${agentName} is ready. Check WhatsApp for a welcome message.` + : `${agentName} is ready. Look in your "You" chat on WhatsApp for the welcome.`, + }, + { + extraFields: { + CHANNEL: 'whatsapp', + AGENT_NAME: agentName, + PLATFORM_ID: platformId, + MODE: isDedicated ? 'dedicated' : 'shared', + }, + }, + ); + if (!init.ok) { + fail( + 'init-first-agent', + `Couldn't finish connecting ${agentName}.`, + 'You can retry later with `/manage-channels`.', + ); + } +} + +async function askAuthMethod(): Promise { + const choice = ensureAnswer( + await p.select({ + message: 'How would you like to authenticate with WhatsApp?', + options: [ + { + value: 'qr', + label: 'Scan a QR code in this terminal', + hint: 'recommended', + }, + { + value: 'pairing-code', + label: 'Enter a pairing code on your phone', + hint: 'no camera needed', + }, + ], + }), + ) as AuthMethod; + setupLog.userInput('whatsapp_auth_method', choice); + return choice; +} + +async function askPhoneNumber(): Promise { + p.note( + [ + "Enter your phone number the way WhatsApp expects it:", + '', + ' • Digits only — no +, spaces, or dashes', + ' • Country code first, then the rest of the number', + '', + k.dim('Example: 14155551234 (country code 1, then 4155551234)'), + ].join('\n'), + 'Your phone number', + ); + const answer = ensureAnswer( + await p.text({ + message: 'Phone number', + validate: (v) => { + const t = (v ?? '').trim(); + if (!t) return 'Phone number is required'; + if (!/^\d{8,15}$/.test(t)) { + return "That doesn't look right. Digits only, country code included."; + } + return undefined; + }, + }), + ); + const phone = (answer as string).trim(); + setupLog.userInput('whatsapp_phone', phone); + return phone; +} + +async function runWhatsAppAuth( + method: AuthMethod, + phone: string | undefined, +): Promise { + const rawLog = setupLog.stepRawLog('whatsapp-auth'); + const start = Date.now(); + const s = p.spinner(); + s.start('Starting WhatsApp authentication…'); + let spinnerActive = true; + + const stopSpinner = (msg: string, code?: number) => { + if (spinnerActive) { + s.stop(msg, code); + spinnerActive = false; + } + }; + + // Tracks the QR render so we can overwrite it in-place on rotation. null + // before the first QR is printed. + let qrLinesPrinted = 0; + + const extra = + method === 'pairing-code' && phone + ? ['--method', 'pairing-code', '--phone', phone] + : ['--method', 'qr']; + + const result = await spawnStep( + 'whatsapp-auth', + extra, + (block: Block) => { + if (block.type === 'WHATSAPP_AUTH_QR') { + const qr = block.fields.QR ?? ''; + if (!qr) return; + // Fire-and-forget — await inside spawnStep's sync onBlock is fine + // since spawnStep's own logic keeps running in parallel. + void renderQr(qr).then((lines) => { + if (qrLinesPrinted === 0) { + stopSpinner('QR code ready — scan with WhatsApp.'); + } else { + // Cursor up N lines + clear from there to end of screen. Wipes + // the previous QR + caption so the new one renders in place. + process.stdout.write(`\x1b[${qrLinesPrinted}A\x1b[0J`); + } + process.stdout.write(lines.join('\n') + '\n'); + qrLinesPrinted = lines.length; + }); + } else if (block.type === 'WHATSAPP_AUTH_PAIRING_CODE') { + const code = block.fields.CODE ?? '????'; + stopSpinner('Your pairing code is ready.'); + p.note(formatPairingCard(code), 'Pairing code'); + s.start('Waiting for you to enter the code…'); + spinnerActive = true; + } else if (block.type === 'WHATSAPP_AUTH') { + const status = block.fields.STATUS; + if (status === 'skipped') { + stopSpinner('WhatsApp is already authenticated.'); + } else if (status === 'success') { + // Erase the QR block if one was on screen — it's served its purpose. + if (qrLinesPrinted > 0) { + process.stdout.write(`\x1b[${qrLinesPrinted}A\x1b[0J`); + qrLinesPrinted = 0; + } + // In QR flow the spinner was stopped when the first QR landed. + // Fall back to a plain success line so the user sees confirmation. + if (spinnerActive) { + stopSpinner('WhatsApp linked.'); + } else { + p.log.success('WhatsApp linked.'); + } + } else if (status === 'failed') { + if (qrLinesPrinted > 0) { + process.stdout.write(`\x1b[${qrLinesPrinted}A\x1b[0J`); + qrLinesPrinted = 0; + } + const err = block.fields.ERROR ?? 'unknown'; + if (spinnerActive) { + stopSpinner(`Authentication failed: ${err}`, 1); + } else { + p.log.error(`Authentication failed: ${err}`); + } + } + } + }, + rawLog, + ); + const durationMs = Date.now() - start; + + // Safety net — if the step died without emitting a terminal block, don't + // leave the spinner running. + if (spinnerActive) { + stopSpinner( + result.ok ? 'Done.' : 'Authentication ended unexpectedly.', + result.ok ? 0 : 1, + ); + if (!result.ok) dumpTranscriptOnFailure(result.transcript); + } + + writeStepEntry('whatsapp-auth', result, durationMs, rawLog); + return { ...result, rawLog, durationMs }; +} + +/** + * Render the raw QR string to an array of terminal lines (block-art QR + + * a caption). Returned as an array so the caller can count lines for the + * in-place rewrite on rotation. Uses the small-mode QR to keep the height + * manageable on 24-row terminals. + */ +async function renderQr(qr: string): Promise { + try { + const QRCode = await import('qrcode'); + const qrText = await QRCode.toString(qr, { type: 'terminal', small: true }); + const caption = k.dim( + ' Open WhatsApp → Settings → Linked Devices → Link a Device → scan.', + ); + return [...qrText.trimEnd().split('\n'), '', caption]; + } catch { + return ['QR code (raw): ' + qr]; + } +} + +function formatPairingCard(code: string): string { + // WhatsApp pairing codes are 8 characters; render with two-wide gap so the + // digits read clearly in the terminal. + const spaced = code.split('').join(' '); + return [ + '', + ` ${brandBold(spaced)}`, + '', + k.dim(' Open WhatsApp → Settings → Linked Devices → Link a Device'), + k.dim(' → "Link with phone number instead" → enter this code.'), + k.dim(' It expires in ~60 seconds.'), + ].join('\n'); +} + +/** + * Pull the authenticated WhatsApp phone out of store/auth/creds.json. + * `creds.me.id` looks like `14155551234:@s.whatsapp.net` — we want + * just the leading digit run. + */ +function readAuthedPhone(): string { + try { + const raw = fs.readFileSync(AUTH_CREDS_PATH, 'utf-8'); + const creds = JSON.parse(raw) as { me?: { id?: string } }; + const id = creds.me?.id; + if (!id) return ''; + return id.split(':')[0].split('@')[0]; + } catch { + return ''; + } +} + +async function restartService(): Promise { + const s = p.spinner(); + s.start('Restarting NanoClaw so it sees your WhatsApp credentials…'); + const start = Date.now(); + const platform = process.platform; + try { + if (platform === 'darwin') { + spawnSync( + 'launchctl', + ['kickstart', '-k', `gui/${process.getuid?.() ?? 501}/com.nanoclaw`], + { stdio: 'ignore' }, + ); + } else if (platform === 'linux') { + const user = spawnSync( + 'systemctl', + ['--user', 'restart', 'nanoclaw'], + { stdio: 'ignore' }, + ); + if (user.status !== 0) { + spawnSync('sudo', ['systemctl', 'restart', 'nanoclaw'], { + stdio: 'ignore', + }); + } + } + // Give the adapter a moment to reconnect before init-first-agent's + // welcome DM hits the delivery path. + await new Promise((r) => setTimeout(r, 5000)); + const elapsed = Math.round((Date.now() - start) / 1000); + s.stop(`NanoClaw restarted. ${k.dim(`(${elapsed}s)`)}`); + setupLog.step('whatsapp-restart', 'success', Date.now() - start, { + PLATFORM: platform, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + s.stop(`Restart may have failed: ${message}`, 1); + setupLog.step('whatsapp-restart', 'failed', Date.now() - start, { + ERROR: message, + }); + // Non-fatal — the user can restart manually if init-first-agent fails. + } +} + +async function askChatPhone(authedPhone: string): Promise { + p.note( + [ + `Authenticated with ${k.cyan('+' + authedPhone)}.`, + '', + "What's the phone number you'll chat with your agent from?", + '', + k.dim( + 'Same number = messages will land in your "You" / self-chat on WhatsApp\n' + + "(you won't be able to reply to yourself — use a different number for a\n" + + 'two-way chat).', + ), + ].join('\n'), + 'Your chat number', + ); + const answer = ensureAnswer( + await p.text({ + message: 'Your personal phone number', + placeholder: authedPhone, + defaultValue: authedPhone, + validate: (v) => { + const t = (v ?? authedPhone).trim(); + if (!/^\d{8,15}$/.test(t)) { + return 'Digits only, country code included.'; + } + return undefined; + }, + }), + ); + const phone = ((answer as string) || authedPhone).trim(); + setupLog.userInput('whatsapp_chat_phone', phone); + return phone; +} + +/** Persist ASSISTANT_HAS_OWN_NUMBER=true to .env and data/env/env. */ +function writeAssistantHasOwnNumber(): void { + const envPath = path.join(process.cwd(), '.env'); + let contents = ''; + try { + contents = fs.readFileSync(envPath, 'utf-8'); + } catch { + contents = ''; + } + if (/^ASSISTANT_HAS_OWN_NUMBER=/m.test(contents)) { + contents = contents.replace( + /^ASSISTANT_HAS_OWN_NUMBER=.*$/m, + 'ASSISTANT_HAS_OWN_NUMBER=true', + ); + } else { + if (contents.length > 0 && !contents.endsWith('\n')) contents += '\n'; + contents += 'ASSISTANT_HAS_OWN_NUMBER=true\n'; + } + fs.writeFileSync(envPath, contents); + + // Container reads from data/env/env. + const containerEnvDir = path.join(process.cwd(), 'data', 'env'); + fs.mkdirSync(containerEnvDir, { recursive: true }); + fs.copyFileSync(envPath, path.join(containerEnvDir, 'env')); +} + +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; +} diff --git a/setup/index.ts b/setup/index.ts index 2112cd1..25d1934 100644 --- a/setup/index.ts +++ b/setup/index.ts @@ -14,6 +14,8 @@ const STEPS: Record< environment: () => import('./environment.js'), container: () => import('./container.js'), register: () => import('./register.js'), + groups: () => import('./groups.js'), + 'whatsapp-auth': () => import('./whatsapp-auth.js'), mounts: () => import('./mounts.js'), service: () => import('./service.js'), verify: () => import('./verify.js'), diff --git a/setup/lib/browser.ts b/setup/lib/browser.ts new file mode 100644 index 0000000..9d801fa --- /dev/null +++ b/setup/lib/browser.ts @@ -0,0 +1,51 @@ +/** + * Browser-open helpers shared across channel setup flows. + * + * `openUrl` is best-effort — silent on failure, so headless/SSH/WSL + * environments where `open`/`xdg-open` isn't wired up don't crash the + * setup. The URL should always be visible in the clack note that calls + * this so the user can copy-paste if the auto-open doesn't land. + * + * `confirmThenOpen` pauses for the operator before triggering the open — + * the browser tends to steal focus when it pops, and a split-second + * "wait what just happened" moment is worse than letting the user hit + * Enter when they're ready. + */ +import { spawn } from 'child_process'; + +import * as p from '@clack/prompts'; + +import { ensureAnswer } from './runner.js'; + +/** Best-effort open of a URL in the user's default browser. Silent on failure. */ +export 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 — URL is printed in the + // calling note so the user can copy-paste. + }); + child.unref(); + } catch { + // swallow — URL is visible in the note. + } +} + +/** + * Gate a browser-open on a confirm so the user is ready for their browser + * to take focus. Proceeds on cancel as well — the user can always copy the + * URL from the note that precedes the prompt. + */ +export async function confirmThenOpen( + url: string, + message = 'Press Enter to open your browser', +): Promise { + ensureAnswer( + await p.confirm({ + message, + initialValue: true, + }), + ); + openUrl(url); +} diff --git a/setup/service.ts b/setup/service.ts index 56bf393..f5ad855 100644 --- a/setup/service.ts +++ b/setup/service.ts @@ -116,13 +116,30 @@ function setupLaunchd( fs.writeFileSync(plistPath, plist); log.info('Wrote launchd plist', { plistPath }); + // Unload first to force launchd to drop any cached plist and re-read from + // disk. Bare `launchctl load` on an already-loaded plist errors with + // "already loaded" and keeps the ORIGINAL plist's ProgramArguments / + // WorkingDirectory in memory — even if the file on disk changed. That + // bit us when the plist target shifted between installs: kickstart kept + // relaunching the old binary and the CLI socket landed in the wrong dir. + // unload succeeds whether or not the service was previously loaded; the + // failure case is "Could not find specified service" which is harmless. + try { + execSync(`launchctl unload ${JSON.stringify(plistPath)}`, { + stdio: 'ignore', + }); + log.info('launchctl unload succeeded'); + } catch { + log.info('launchctl unload noop (plist was not previously loaded)'); + } + try { execSync(`launchctl load ${JSON.stringify(plistPath)}`, { stdio: 'ignore', }); log.info('launchctl load succeeded'); - } catch { - log.warn('launchctl load failed (may already be loaded)'); + } catch (err) { + log.error('launchctl load failed', { err }); } // Verify @@ -316,10 +333,15 @@ WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`; log.error('systemctl enable failed', { err }); } + // restart (not start) so a previously-running instance picks up edits to + // the unit file. `start` on an active unit is a no-op, which would leave + // the old ExecStart / WorkingDirectory in effect even after daemon-reload. + // `restart` on a stopped unit is equivalent to `start`, so this is safe + // as a first-install path too. try { - execSync(`${systemctlPrefix} start nanoclaw`, { stdio: 'ignore' }); + execSync(`${systemctlPrefix} restart nanoclaw`, { stdio: 'ignore' }); } catch (err) { - log.error('systemctl start failed', { err }); + log.error('systemctl restart failed', { err }); } // Verify diff --git a/setup/whatsapp-auth.ts b/setup/whatsapp-auth.ts new file mode 100644 index 0000000..47bfc6e --- /dev/null +++ b/setup/whatsapp-auth.ts @@ -0,0 +1,221 @@ +/** + * Step: whatsapp-auth — standalone WhatsApp (Baileys) authentication. + * + * Forked from the channels-branch version so setup:auto's driver can render + * the terminal UX itself (inside clack) instead of the step dumping a raw QR + * to stdout. The browser method has been dropped — one less moving part and + * it kept biting headless/SSH users. + * + * Methods: + * --method qr (default) Emit each rotating QR as a status block + * with the raw QR string. Driver renders. + * --method pairing-code --phone Request a pairing code. Emitted in a + * status block once the Baileys call returns. + * + * Block schema (parent parses these): + * WHATSAPP_AUTH_QR { QR: "" } — repeats + * WHATSAPP_AUTH_PAIRING_CODE { CODE: "XXXX-XXXX" } — one-shot + * WHATSAPP_AUTH { STATUS: success } — terminal + * { STATUS: skipped, AUTH_DIR, REASON } + * { STATUS: failed, ERROR: } + * + * STATUS values are kept in the runner's vocabulary (success/skipped/failed) + * so `spawnStep` recognises them and sets `ok` correctly; WhatsApp-specific + * UI text (e.g. "WhatsApp linked") lives in the driver's block handler. + * + * On success, credentials land in store/auth/ and the process exits 0. + */ +import fs from 'fs'; +import path from 'path'; +import { createRequire } from 'module'; +// Named import (not default) — pino's d.ts under NodeNext resolves the +// default export to `typeof pino` (namespace), which isn't callable. The +// named `pino` export resolves to the callable function. +import { pino } from 'pino'; + +import { + makeWASocket, + Browsers, + DisconnectReason, + fetchLatestWaWebVersion, + makeCacheableSignalKeyStore, + useMultiFileAuthState, +} from '@whiskeysockets/baileys'; +import { emitStatus } from './status.js'; + +const AUTH_DIR = path.join(process.cwd(), 'store', 'auth'); +const PAIRING_CODE_FILE = path.join(process.cwd(), 'store', 'pairing-code.txt'); +const baileysLogger = pino({ level: 'silent' }); + +// Baileys v6 bug: getPlatformId sends charCode (49) instead of enum value (1). +// Fixed in Baileys 7.x but not backported. Without this patch pairing codes +// fail with "couldn't link device" because WhatsApp receives an invalid +// platform id. createRequire because proto is not a named ESM export. +const _require = createRequire(import.meta.url); +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const { proto } = _require('@whiskeysockets/baileys') as { proto: any }; +try { + const _generics = _require( + '@whiskeysockets/baileys/lib/Utils/generics', + ) as Record; + _generics.getPlatformId = (browser: string): string => { + const platformType = + proto.DeviceProps.PlatformType[ + browser.toUpperCase() as keyof typeof proto.DeviceProps.PlatformType + ]; + return platformType ? platformType.toString() : '1'; + }; +} catch { + // If CJS require fails, QR auth still works; only pairing code may be affected. +} + +type AuthMethod = 'qr' | 'pairing-code'; + +function parseArgs(args: string[]): { method: AuthMethod; phone?: string } { + let method: AuthMethod = 'qr'; + let phone: string | undefined; + + for (let i = 0; i < args.length; i++) { + switch (args[i]) { + case '--method': { + const raw = args[++i]; + if (raw === 'qr' || raw === 'pairing-code') { + method = raw; + } else { + console.error(`Unknown --method: ${raw} (expected 'qr' or 'pairing-code')`); + process.exit(1); + } + break; + } + case '--phone': + phone = args[++i]; + break; + } + } + + if (method === 'pairing-code' && !phone) { + console.error('--phone is required for pairing-code method'); + process.exit(1); + } + + return { method, phone }; +} + +export async function run(args: string[]): Promise { + const { method, phone } = parseArgs(args); + + if (fs.existsSync(path.join(AUTH_DIR, 'creds.json'))) { + emitStatus('WHATSAPP_AUTH', { + STATUS: 'skipped', + REASON: 'already-authenticated', + AUTH_DIR, + }); + return; + } + + fs.mkdirSync(AUTH_DIR, { recursive: true }); + + return new Promise((resolve) => { + const timeout = setTimeout(() => { + emitStatus('WHATSAPP_AUTH', { STATUS: 'failed', ERROR: 'timeout' }); + process.exit(1); + }, 120_000); + + let succeeded = false; + function succeed(): void { + if (succeeded) return; + succeeded = true; + clearTimeout(timeout); + try { + if (fs.existsSync(PAIRING_CODE_FILE)) fs.unlinkSync(PAIRING_CODE_FILE); + } catch { + // ignore — the pairing code file is best-effort cleanup + } + emitStatus('WHATSAPP_AUTH', { STATUS: 'success' }); + resolve(); + // Give a moment for creds to flush before exiting. + setTimeout(() => process.exit(0), 1000); + } + + async function connectSocket(isReconnect = false): Promise { + const { state, saveCreds } = await useMultiFileAuthState(AUTH_DIR); + const { version } = await fetchLatestWaWebVersion({}).catch(() => ({ + version: undefined, + })); + + const sock = makeWASocket({ + version, + auth: { + creds: state.creds, + keys: makeCacheableSignalKeyStore(state.keys, baileysLogger), + }, + printQRInTerminal: false, + logger: baileysLogger, + browser: Browsers.macOS('Chrome'), + }); + + // Request pairing code only on first connect (not reconnect after 515). + if ( + !isReconnect && + method === 'pairing-code' && + phone && + !state.creds.registered + ) { + setTimeout(async () => { + try { + const code = await sock.requestPairingCode(phone); + fs.writeFileSync(PAIRING_CODE_FILE, code, 'utf-8'); + emitStatus('WHATSAPP_AUTH_PAIRING_CODE', { CODE: code }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + emitStatus('WHATSAPP_AUTH', { STATUS: 'failed', ERROR: message }); + process.exit(1); + } + }, 3000); + } + + sock.ev.on('connection.update', (update) => { + const { connection, lastDisconnect, qr } = update; + + // QR method: emit each rotation as a block. Parent renders. + if (qr && method === 'qr') { + emitStatus('WHATSAPP_AUTH_QR', { QR: qr }); + } + + if (connection === 'open') { + succeed(); + sock.end(undefined); + } + + if (connection === 'close') { + const reason = ( + lastDisconnect?.error as { output?: { statusCode?: number } } + )?.output?.statusCode; + if (reason === DisconnectReason.loggedOut) { + clearTimeout(timeout); + emitStatus('WHATSAPP_AUTH', { + STATUS: 'failed', + ERROR: 'logged_out', + }); + process.exit(1); + } else if (reason === DisconnectReason.timedOut) { + clearTimeout(timeout); + emitStatus('WHATSAPP_AUTH', { + STATUS: 'failed', + ERROR: 'qr_timeout', + }); + process.exit(1); + } else if (reason === 515) { + // 515 = stream error after pairing succeeds but before registration + // completes. Reconnect to finish the handshake. + connectSocket(true); + } + } + }); + + sock.ev.on('creds.update', saveCreds); + } + + connectSocket(); + }); +}