mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-04 10:14:47 +08:00
feat(setup): optional WhatsApp wiring + cross-channel UX polish
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>
This commit is contained in:
@@ -43,6 +43,7 @@ import {
|
|||||||
} from '../src/db/messaging-groups.js';
|
} from '../src/db/messaging-groups.js';
|
||||||
import { runMigrations } from '../src/db/migrations/index.js';
|
import { runMigrations } from '../src/db/migrations/index.js';
|
||||||
import { normalizeName } from '../src/modules/agent-to-agent/db/agent-destinations.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 { grantRole, hasAnyOwner } from '../src/modules/permissions/db/user-roles.js';
|
||||||
import { upsertUser } from '../src/modules/permissions/db/users.js';
|
import { upsertUser } from '../src/modules/permissions/db/users.js';
|
||||||
import { initGroupFilesystem } from '../src/group-init.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 {
|
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: <phone>@s.whatsapp.net,
|
||||||
|
// <groupId>@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 {
|
function generateId(prefix: string): string {
|
||||||
@@ -202,6 +209,19 @@ async function main(): Promise<void> {
|
|||||||
'When the user first reaches out (or you receive a system welcome prompt), introduce yourself briefly and invite them to chat. Keep replies concise.',
|
'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.
|
// 3. DM messaging group.
|
||||||
const platformId = namespacedPlatformId(args.channel, args.platformId);
|
const platformId = namespacedPlatformId(args.channel, args.platformId);
|
||||||
let dmMg = getMessagingGroupByPlatform(args.channel, platformId);
|
let dmMg = getMessagingGroupByPlatform(args.channel, platformId);
|
||||||
|
|||||||
+6
-18
@@ -119,7 +119,8 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Look up the bot username (auto.ts already validated; we re-query here so
|
# 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 \
|
INFO=$(curl -fsS --max-time 8 \
|
||||||
"https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/getMe" 2>/dev/null || true)
|
"https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/getMe" 2>/dev/null || true)
|
||||||
BOT_USERNAME=""
|
BOT_USERNAME=""
|
||||||
@@ -131,23 +132,10 @@ fi
|
|||||||
mkdir -p data/env
|
mkdir -p data/env
|
||||||
cp .env data/env/env
|
cp .env data/env/env
|
||||||
|
|
||||||
# Deep-link into the bot's chat so the user is already on the right screen
|
# Browser/app deep-link is done by the parent driver (setup/channels/telegram.ts)
|
||||||
# when pair-telegram prints the code. Silent best-effort — runs under a
|
# BEFORE this script runs — gated on a clack confirm so focus-stealing doesn't
|
||||||
# spinner, any output (from `open` / `xdg-open`) goes to the raw log.
|
# surprise the user. Keeping it out of here means this script stays pure
|
||||||
if [ -n "$BOT_USERNAME" ]; then
|
# non-interactive install.
|
||||||
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
|
|
||||||
|
|
||||||
log "Restarting service so the new adapter picks up the token…"
|
log "Restarting service so the new adapter picks up the token…"
|
||||||
case "$(uname -s)" in
|
case "$(uname -s)" in
|
||||||
|
|||||||
Executable
+114
@@ -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
|
||||||
+7
-3
@@ -27,6 +27,7 @@ import k from 'kleur';
|
|||||||
|
|
||||||
import { runDiscordChannel } from './channels/discord.js';
|
import { runDiscordChannel } from './channels/discord.js';
|
||||||
import { runTelegramChannel } from './channels/telegram.js';
|
import { runTelegramChannel } from './channels/telegram.js';
|
||||||
|
import { runWhatsAppChannel } from './channels/whatsapp.js';
|
||||||
import { pingCliAgent, type PingResult } from './lib/agent-ping.js';
|
import { pingCliAgent, type PingResult } from './lib/agent-ping.js';
|
||||||
import * as setupLog from './logs.js';
|
import * as setupLog from './logs.js';
|
||||||
import { ensureAnswer, fail, runQuietChild, runQuietStep } from './lib/runner.js';
|
import { ensureAnswer, fail, runQuietChild, runQuietStep } from './lib/runner.js';
|
||||||
@@ -209,10 +210,12 @@ async function main(): Promise<void> {
|
|||||||
await runTelegramChannel(displayName!);
|
await runTelegramChannel(displayName!);
|
||||||
} else if (choice === 'discord') {
|
} else if (choice === 'discord') {
|
||||||
await runDiscordChannel(displayName!);
|
await runDiscordChannel(displayName!);
|
||||||
|
} else if (choice === 'whatsapp') {
|
||||||
|
await runWhatsAppChannel(displayName!);
|
||||||
} else {
|
} else {
|
||||||
p.log.info(
|
p.log.info(
|
||||||
wrapForGutter(
|
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,
|
4,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -493,19 +496,20 @@ async function askDisplayName(fallback: string): Promise<string> {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function askChannelChoice(): Promise<'telegram' | 'discord' | 'skip'> {
|
async function askChannelChoice(): Promise<'telegram' | 'discord' | 'whatsapp' | 'skip'> {
|
||||||
const choice = ensureAnswer(
|
const choice = ensureAnswer(
|
||||||
await p.select({
|
await p.select({
|
||||||
message: 'Want to chat with your assistant from your phone?',
|
message: 'Want to chat with your assistant from your phone?',
|
||||||
options: [
|
options: [
|
||||||
{ value: 'telegram', label: 'Yes, connect Telegram', hint: 'recommended' },
|
{ value: 'telegram', label: 'Yes, connect Telegram', hint: 'recommended' },
|
||||||
{ value: 'discord', label: 'Yes, connect Discord' },
|
{ 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" },
|
{ value: 'skip', label: 'Skip for now', hint: "I'll just use the terminal" },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
setupLog.userInput('channel_choice', String(choice));
|
setupLog.userInput('channel_choice', String(choice));
|
||||||
return choice as 'telegram' | 'discord' | 'skip';
|
return choice as 'telegram' | 'discord' | 'whatsapp' | 'skip';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── interactive / env helpers ─────────────────────────────────────────
|
// ─── interactive / env helpers ─────────────────────────────────────────
|
||||||
|
|||||||
@@ -23,12 +23,11 @@
|
|||||||
* entries in logs/setup.log, full raw output in per-step files under
|
* entries in logs/setup.log, full raw output in per-step files under
|
||||||
* logs/setup-steps/. See docs/setup-flow.md.
|
* logs/setup-steps/. See docs/setup-flow.md.
|
||||||
*/
|
*/
|
||||||
import { spawn } from 'child_process';
|
|
||||||
|
|
||||||
import * as p from '@clack/prompts';
|
import * as p from '@clack/prompts';
|
||||||
import k from 'kleur';
|
import k from 'kleur';
|
||||||
|
|
||||||
import * as setupLog from '../logs.js';
|
import * as setupLog from '../logs.js';
|
||||||
|
import { confirmThenOpen } from '../lib/browser.js';
|
||||||
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
|
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
|
||||||
|
|
||||||
const DEFAULT_AGENT_NAME = 'Nano';
|
const DEFAULT_AGENT_NAME = 'Nano';
|
||||||
@@ -147,11 +146,11 @@ async function walkThroughBotCreation(): Promise<void> {
|
|||||||
' 3. On the same tab, enable "Message Content Intent"',
|
' 3. On the same tab, enable "Message Content Intent"',
|
||||||
' (under Privileged Gateway Intents)',
|
' (under Privileged Gateway Intents)',
|
||||||
'',
|
'',
|
||||||
k.dim(`Opening ${url} …`),
|
k.dim(url),
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
'Create a Discord bot',
|
'Create a Discord bot',
|
||||||
);
|
);
|
||||||
openUrl(url);
|
await confirmThenOpen(url, 'Press Enter to open the Developer Portal');
|
||||||
|
|
||||||
ensureAnswer(
|
ensureAnswer(
|
||||||
await p.confirm({
|
await p.confirm({
|
||||||
@@ -360,11 +359,11 @@ async function promptInviteBot(
|
|||||||
' 1. Pick any server you\'re in (a personal one is fine)',
|
' 1. Pick any server you\'re in (a personal one is fine)',
|
||||||
' 2. Click "Authorize"',
|
' 2. Click "Authorize"',
|
||||||
'',
|
'',
|
||||||
k.dim(`Opening ${url}`),
|
k.dim(url),
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
'Add bot to a server',
|
'Add bot to a server',
|
||||||
);
|
);
|
||||||
openUrl(url);
|
await confirmThenOpen(url, 'Press Enter to open the invite page');
|
||||||
|
|
||||||
ensureAnswer(
|
ensureAnswer(
|
||||||
await p.confirm({
|
await p.confirm({
|
||||||
@@ -439,17 +438,3 @@ async function resolveAgentName(): Promise<string> {
|
|||||||
return 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.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -7,10 +7,11 @@
|
|||||||
* 1. BotFather instructions (clack note)
|
* 1. BotFather instructions (clack note)
|
||||||
* 2. Paste the bot token (clack password) — format-validated
|
* 2. Paste the bot token (clack password) — format-validated
|
||||||
* 3. getMe via the Bot API to resolve the bot's username
|
* 3. getMe via the Bot API to resolve the bot's username
|
||||||
* 4. Install the adapter (setup/add-telegram.sh, non-interactive)
|
* 4. Confirm + deep-link into the bot's Telegram chat (tg://resolve)
|
||||||
* 5. Run the pair-telegram step, rendering code events as clack notes
|
* 5. Install the adapter (setup/add-telegram.sh, non-interactive)
|
||||||
* 6. Ask for the messaging-agent name (defaulting to "Nano")
|
* 6. Run the pair-telegram step, rendering code events as clack notes
|
||||||
* 7. Wire the agent via scripts/init-first-agent.ts
|
* 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,
|
* 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
|
* 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 k from 'kleur';
|
||||||
|
|
||||||
import * as setupLog from '../logs.js';
|
import * as setupLog from '../logs.js';
|
||||||
|
import { confirmThenOpen } from '../lib/browser.js';
|
||||||
import {
|
import {
|
||||||
type Block,
|
type Block,
|
||||||
type StepResult,
|
type StepResult,
|
||||||
@@ -38,6 +40,22 @@ export async function runTelegramChannel(displayName: string): Promise<void> {
|
|||||||
const token = await collectTelegramToken();
|
const token = await collectTelegramToken();
|
||||||
const botUsername = await validateTelegramToken(token);
|
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/<bot> 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(
|
const install = await runQuietChild(
|
||||||
'telegram-install',
|
'telegram-install',
|
||||||
'bash',
|
'bash',
|
||||||
|
|||||||
@@ -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<void> {
|
||||||
|
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<AuthMethod> {
|
||||||
|
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<string> {
|
||||||
|
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<StepResult & { rawLog: string; durationMs: number }> {
|
||||||
|
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<string[]> {
|
||||||
|
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:<device>@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<void> {
|
||||||
|
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<string> {
|
||||||
|
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<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;
|
||||||
|
}
|
||||||
@@ -14,6 +14,8 @@ const STEPS: Record<
|
|||||||
environment: () => import('./environment.js'),
|
environment: () => import('./environment.js'),
|
||||||
container: () => import('./container.js'),
|
container: () => import('./container.js'),
|
||||||
register: () => import('./register.js'),
|
register: () => import('./register.js'),
|
||||||
|
groups: () => import('./groups.js'),
|
||||||
|
'whatsapp-auth': () => import('./whatsapp-auth.js'),
|
||||||
mounts: () => import('./mounts.js'),
|
mounts: () => import('./mounts.js'),
|
||||||
service: () => import('./service.js'),
|
service: () => import('./service.js'),
|
||||||
verify: () => import('./verify.js'),
|
verify: () => import('./verify.js'),
|
||||||
|
|||||||
@@ -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<void> {
|
||||||
|
ensureAnswer(
|
||||||
|
await p.confirm({
|
||||||
|
message,
|
||||||
|
initialValue: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
openUrl(url);
|
||||||
|
}
|
||||||
+26
-4
@@ -116,13 +116,30 @@ function setupLaunchd(
|
|||||||
fs.writeFileSync(plistPath, plist);
|
fs.writeFileSync(plistPath, plist);
|
||||||
log.info('Wrote launchd plist', { plistPath });
|
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 {
|
try {
|
||||||
execSync(`launchctl load ${JSON.stringify(plistPath)}`, {
|
execSync(`launchctl load ${JSON.stringify(plistPath)}`, {
|
||||||
stdio: 'ignore',
|
stdio: 'ignore',
|
||||||
});
|
});
|
||||||
log.info('launchctl load succeeded');
|
log.info('launchctl load succeeded');
|
||||||
} catch {
|
} catch (err) {
|
||||||
log.warn('launchctl load failed (may already be loaded)');
|
log.error('launchctl load failed', { err });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify
|
// Verify
|
||||||
@@ -316,10 +333,15 @@ WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`;
|
|||||||
log.error('systemctl enable failed', { err });
|
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 {
|
try {
|
||||||
execSync(`${systemctlPrefix} start nanoclaw`, { stdio: 'ignore' });
|
execSync(`${systemctlPrefix} restart nanoclaw`, { stdio: 'ignore' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error('systemctl start failed', { err });
|
log.error('systemctl restart failed', { err });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify
|
// Verify
|
||||||
|
|||||||
@@ -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: "<raw>" } — repeats
|
||||||
|
* WHATSAPP_AUTH_PAIRING_CODE { CODE: "XXXX-XXXX" } — one-shot
|
||||||
|
* WHATSAPP_AUTH { STATUS: success } — terminal
|
||||||
|
* { STATUS: skipped, AUTH_DIR, REASON }
|
||||||
|
* { STATUS: failed, ERROR: <reason> }
|
||||||
|
*
|
||||||
|
* 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<string, unknown>;
|
||||||
|
_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<void> {
|
||||||
|
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<void>((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<void> {
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user