diff --git a/.claude/skills/add-whatsapp/SKILL.md b/.claude/skills/add-whatsapp/SKILL.md index 3f10ce1..232725f 100644 --- a/.claude/skills/add-whatsapp/SKILL.md +++ b/.claude/skills/add-whatsapp/SKILL.md @@ -57,7 +57,7 @@ groups: () => import('./groups.js'), ### 5. Install the adapter packages (pinned) ```bash -pnpm install @whiskeysockets/baileys@6.17.16 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0 +pnpm install @whiskeysockets/baileys@7.0.0-rc.9 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0 ``` ### 6. Build diff --git a/.claude/skills/manage-channels/SKILL.md b/.claude/skills/manage-channels/SKILL.md index 9d84d3d..0b348d1 100644 --- a/.claude/skills/manage-channels/SKILL.md +++ b/.claude/skills/manage-channels/SKILL.md @@ -11,7 +11,16 @@ Privilege is a **user-level** concept, not a channel-level one (see `src/db/user ## Assess Current State -Read the central DB (`data/v2.db`) — query `agent_groups`, `messaging_groups`, `messaging_group_agents`, `users`, and `user_roles` tables. Also check `.env` for channel tokens and `src/channels/index.ts` for uncommented imports. +Read the central DB (`data/v2.db`) using these canonical queries (column names match the schema, not the CLI flags — the `register` command's `--assistant-name` is stored in `agent_groups.name`): + +```sql +SELECT id, name AS assistant_name, folder, agent_provider FROM agent_groups; +SELECT id, channel_type, platform_id, name, unknown_sender_policy FROM messaging_groups; +SELECT messaging_group_id, agent_group_id, session_mode, priority FROM messaging_group_agents; +SELECT user_id, role, agent_group_id FROM user_roles ORDER BY role='owner' DESC; +``` + +Also check `.env` for channel tokens and `src/channels/index.ts` for uncommented imports. Categorize channels as: **wired** (has DB entities + messaging_group_agents row), **configured but unwired** (has credentials + barrel import, no DB entities), or **not configured**. diff --git a/README.md b/README.md index 69f9ea2..f364d27 100644 --- a/README.md +++ b/README.md @@ -215,3 +215,5 @@ See [CHANGELOG.md](CHANGELOG.md) for breaking changes, or the [full release hist ## License MIT + + diff --git a/migrate-v2.sh b/migrate-v2.sh index 2325edd..ef3bda8 100644 --- a/migrate-v2.sh +++ b/migrate-v2.sh @@ -450,7 +450,7 @@ ONECLI_OK=false ONECLI_URL_FROM_ENV=$(grep '^ONECLI_URL=' .env 2>/dev/null | head -1 | sed 's/^ONECLI_URL=//') ONECLI_URL_CHECK="${ONECLI_URL_FROM_ENV:-http://127.0.0.1:10254}" -if curl -sf "${ONECLI_URL_CHECK}/health" >/dev/null 2>&1; then +if curl -sf "${ONECLI_URL_CHECK}/api/health" >/dev/null 2>&1; then step_ok "OneCLI running at $(dim "$ONECLI_URL_CHECK")" ONECLI_OK=true log "OneCLI: running at $ONECLI_URL_CHECK" diff --git a/package.json b/package.json index 96f4ae9..3f4794c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.32", + "version": "2.0.33", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", diff --git a/setup/add-whatsapp.sh b/setup/add-whatsapp.sh old mode 100755 new mode 100644 index c7356af..be2dacc --- a/setup/add-whatsapp.sh +++ b/setup/add-whatsapp.sh @@ -16,7 +16,7 @@ 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" +BAILEYS_VERSION="@whiskeysockets/baileys@7.0.0-rc.9" QRCODE_VERSION="qrcode@1.5.4" QRCODE_TYPES_VERSION="@types/qrcode@1.5.6" PINO_VERSION="pino@9.6.0" diff --git a/setup/auto.ts b/setup/auto.ts index b57672f..91ad83a 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -29,6 +29,7 @@ import path from 'path'; import * as p from '@clack/prompts'; import k from 'kleur'; +import { BACK_TO_CHANNEL_SELECTION } from './lib/back-nav.js'; import { runDiscordChannel } from './channels/discord.js'; import { runIMessageChannel } from './channels/imessage.js'; import { runSignalChannel } from './channels/signal.js'; @@ -440,35 +441,45 @@ async function main(): Promise { let channelChoice: ChannelChoice = 'skip'; if (!skip.has('channel')) { - channelChoice = await askChannelChoice(); - if (channelChoice !== 'skip' && channelChoice !== 'other') { - await resolveDisplayName(); - } - if (channelChoice === 'telegram') { - await runTelegramChannel(displayName!); - } else if (channelChoice === 'discord') { - await runDiscordChannel(displayName!); - } else if (channelChoice === 'whatsapp') { - await runWhatsAppChannel(displayName!); - } else if (channelChoice === 'signal') { - await runSignalChannel(displayName!); - } else if (channelChoice === 'teams') { - await runTeamsChannel(displayName!); - } else if (channelChoice === 'slack') { - await runSlackChannel(displayName!); - } else if (channelChoice === 'imessage') { - await runIMessageChannel(displayName!); - } else if (channelChoice === 'other') { - await askOtherChannelName(); - } else { - p.log.info( - brandBody( - wrapForGutter( - 'No messaging app for now. You can add one later (like Telegram, Discord, WhatsApp, Teams, Slack, or iMessage).', - 4, + // Loop so a channel sub-flow can return BACK_TO_CHANNEL_SELECTION on + // its first prompt and bounce the user back to the chooser without + // restarting setup. Channels not yet wired with the back option just + // return void and the loop exits after one pass. + let backed = true; + while (backed) { + backed = false; + channelChoice = await askChannelChoice(); + if (channelChoice !== 'skip' && channelChoice !== 'other') { + await resolveDisplayName(); + } + let result: void | typeof BACK_TO_CHANNEL_SELECTION; + if (channelChoice === 'telegram') { + result = await runTelegramChannel(displayName!); + } else if (channelChoice === 'discord') { + result = await runDiscordChannel(displayName!); + } else if (channelChoice === 'whatsapp') { + result = await runWhatsAppChannel(displayName!); + } else if (channelChoice === 'signal') { + result = await runSignalChannel(displayName!); + } else if (channelChoice === 'teams') { + result = await runTeamsChannel(displayName!); + } else if (channelChoice === 'slack') { + result = await runSlackChannel(displayName!); + } else if (channelChoice === 'imessage') { + result = await runIMessageChannel(displayName!); + } else if (channelChoice === 'other') { + await askOtherChannelName(); + } else { + p.log.info( + brandBody( + wrapForGutter( + 'No messaging app for now. You can add one later (like Telegram, Discord, WhatsApp, Teams, Slack, or iMessage).', + 4, + ), ), - ), - ); + ); + } + if (result === BACK_TO_CHANNEL_SELECTION) backed = true; } } diff --git a/setup/channels/discord.ts b/setup/channels/discord.ts index 28c0254..ad9da17 100644 --- a/setup/channels/discord.ts +++ b/setup/channels/discord.ts @@ -27,6 +27,7 @@ import * as p from '@clack/prompts'; import k from 'kleur'; import * as setupLog from '../logs.js'; +import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js'; import { brightSelect } from '../lib/bright-select.js'; import { confirmThenOpen, formatNoteLink } from '../lib/browser.js'; import { askOperatorRole } from '../lib/role-prompt.js'; @@ -48,8 +49,10 @@ interface AppInfo { owner: { id: string; username: string } | null; } -export async function runDiscordChannel(displayName: string): Promise { - const hasBot = await askHasBotToken(); +export async function runDiscordChannel(displayName: string): Promise { + const choice = await askHasBotToken(); + if (choice === 'back') return BACK_TO_CHANNEL_SELECTION; + const hasBot = choice === 'yes'; if (!hasBot) { await walkThroughBotCreation(); } @@ -142,17 +145,18 @@ export async function runDiscordChannel(displayName: string): Promise { } } -async function askHasBotToken(): Promise { +async function askHasBotToken(): Promise<'yes' | 'no' | 'back'> { const answer = ensureAnswer( await brightSelect({ message: 'Do you already have a Discord bot?', options: [ { value: 'yes', label: 'Yes, I have a bot token ready' }, { value: 'no', label: "No, walk me through creating one" }, + { value: 'back', label: '← Back to channel selection' }, ], }), ); - return answer === 'yes'; + return answer as 'yes' | 'no' | 'back'; } async function walkThroughBotCreation(): Promise { diff --git a/setup/channels/imessage.ts b/setup/channels/imessage.ts index 8c0b78d..5730fca 100644 --- a/setup/channels/imessage.ts +++ b/setup/channels/imessage.ts @@ -33,6 +33,7 @@ import * as p from '@clack/prompts'; import k from 'kleur'; import * as setupLog from '../logs.js'; +import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js'; import { brightSelect } from '../lib/bright-select.js'; import { askOperatorRole } from '../lib/role-prompt.js'; import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js'; @@ -48,10 +49,11 @@ interface RemoteCreds { apiKey: string; } -export async function runIMessageChannel(displayName: string): Promise { +export async function runIMessageChannel(displayName: string): Promise { const isMac = os.platform() === 'darwin'; const mode = await askMode(isMac); + if (mode === 'back') return BACK_TO_CHANNEL_SELECTION; let remoteCreds: RemoteCreds | null = null; if (mode === 'local') { @@ -139,34 +141,38 @@ export async function runIMessageChannel(displayName: string): Promise { } } -async function askMode(isMac: boolean): Promise { +async function askMode(isMac: boolean): Promise { + const baseOptions = isMac + ? [ + { + value: 'local' as const, + label: 'Local (this Mac)', + hint: "uses this machine's iMessage account", + }, + { + value: 'remote' as const, + label: 'Remote (Photon API)', + hint: 'the bot lives on another server', + }, + ] + : [ + { + value: 'remote' as const, + label: 'Remote (Photon API)', + hint: 'only option off macOS', + }, + ]; const choice = ensureAnswer( - await brightSelect({ + await brightSelect({ message: 'How should iMessage run?', initialValue: isMac ? 'local' : 'remote', - options: isMac - ? [ - { - value: 'local', - label: 'Local (this Mac)', - hint: "uses this machine's iMessage account", - }, - { - value: 'remote', - label: 'Remote (Photon API)', - hint: 'the bot lives on another server', - }, - ] - : [ - { - value: 'remote', - label: 'Remote (Photon API)', - hint: 'only option off macOS', - }, - ], + options: [ + ...baseOptions, + { value: 'back', label: '← Back to channel selection' }, + ], }), ); - setupLog.userInput('imessage_mode', String(choice)); + if (choice !== 'back') setupLog.userInput('imessage_mode', String(choice)); return choice; } diff --git a/setup/channels/signal.ts b/setup/channels/signal.ts index 8462a56..cfdf9b2 100644 --- a/setup/channels/signal.ts +++ b/setup/channels/signal.ts @@ -33,6 +33,8 @@ import k from 'kleur'; import * as setupLog from '../logs.js'; import { getLaunchdLabel, getSystemdUnit } from '../../src/install-slug.js'; +import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js'; +import { brightSelect } from '../lib/bright-select.js'; import { type Block, type StepResult, @@ -48,7 +50,33 @@ import { accentGreen, fmtDuration, note } from '../lib/theme.js'; const DEFAULT_AGENT_NAME = 'Nano'; -export async function runSignalChannel(displayName: string): Promise { +export async function runSignalChannel(displayName: string): Promise { + note( + [ + "NanoClaw links to Signal as a *secondary* device on your existing", + "phone — no new number needed. Your assistant will send and receive", + "messages as the number on that phone.", + '', + "Here's what's about to happen — no input needed for any of it:", + '', + ' 1. Set up signal-cli (auto-installs if missing)', + ' 2. Install the Signal adapter', + ' 3. Show a QR code — scan it from Signal → Settings → Linked Devices', + ' 4. Wire your assistant and send a welcome message', + ].join('\n'), + 'Set up Signal', + ); + + const proceed = ensureAnswer(await brightSelect<'continue' | 'back'>({ + message: 'Ready to set up Signal?', + options: [ + { value: 'continue', label: 'Continue' }, + { value: 'back', label: '← Back to channel selection' }, + ], + initialValue: 'continue', + })); + if (proceed === 'back') return BACK_TO_CHANNEL_SELECTION; + await ensureSignalCli(); const install = await runQuietChild( @@ -134,42 +162,74 @@ export async function runSignalChannel(displayName: string): Promise { async function ensureSignalCli(): Promise { const cli = process.env.SIGNAL_CLI_PATH || 'signal-cli'; - const probe = spawnSync(cli, ['--version'], { - stdio: ['ignore', 'pipe', 'pipe'], - }); - if (!probe.error && probe.status === 0) return; + const probeFor = (): boolean => { + const r = spawnSync(cli, ['--version'], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + return !r.error && r.status === 0; + }; + if (probeFor()) return; + note( + [ + "NanoClaw talks to Signal through signal-cli, which isn't installed yet.", + "We'll install it for you now — about 30 seconds, one-time only.", + '', + process.platform === 'darwin' + ? "On this Mac we'll use Homebrew (no admin password needed)." + : "On Linux we'll grab the native release binary (no Java needed) and install it to ~/.local/bin.", + ].join('\n'), + 'Setting up signal-cli', + ); + + const install = await runQuietChild( + 'install-signal-cli', + 'bash', + ['setup/install-signal-cli.sh'], + { + running: 'Installing signal-cli…', + done: 'signal-cli installed.', + }, + ); + + if (install.ok && probeFor()) return; + + const reason = install.terminal?.fields.ERROR; if (process.platform === 'darwin') { note( [ - "NanoClaw talks to Signal through signal-cli, which isn't installed yet.", + "We couldn't install signal-cli automatically.", + reason === 'homebrew_not_installed' + ? ' Reason: Homebrew is not installed.' + : ` Reason: ${reason ?? 'unknown'}.`, '', - 'The quickest way on macOS is Homebrew:', + 'You can install it manually:', '', k.cyan(' brew install signal-cli'), '', - "Install it in another terminal, then re-run setup.", + 'Then re-run setup.', ].join('\n'), - 'signal-cli not found', + "Couldn't install signal-cli", ); } else { note( [ - "NanoClaw talks to Signal through signal-cli, which isn't installed yet.", + "We couldn't install signal-cli automatically.", + ` Reason: ${reason ?? 'unknown'}.`, '', - 'Grab the latest release from GitHub:', + 'You can install it manually from GitHub:', '', k.cyan(' https://github.com/AsamK/signal-cli/releases'), '', - "Install it, make sure `signal-cli --version` works, then re-run setup.", + 'Then re-run setup.', ].join('\n'), - 'signal-cli not found', + "Couldn't install signal-cli", ); } await fail( - 'signal-install', - 'signal-cli is required but not installed.', - 'Install it and re-run setup.', + 'install-signal-cli', + 'signal-cli is required but the auto-install failed.', + 'Install it manually and re-run setup.', ); } diff --git a/setup/channels/slack.ts b/setup/channels/slack.ts index 0e3f052..0918075 100644 --- a/setup/channels/slack.ts +++ b/setup/channels/slack.ts @@ -25,7 +25,10 @@ import * as p from '@clack/prompts'; import k from 'kleur'; import * as setupLog from '../logs.js'; -import { confirmThenOpen, formatNoteLink } from '../lib/browser.js'; +import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js'; +import { brightSelect } from '../lib/bright-select.js'; +import { formatNoteLink, openUrl } from '../lib/browser.js'; +import { isHeadless } from '../platform.js'; import { askOperatorRole } from '../lib/role-prompt.js'; import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js'; import { readEnvKey } from '../environment.js'; @@ -42,8 +45,9 @@ interface WorkspaceInfo { botUserId: string; } -export async function runSlackChannel(displayName: string): Promise { - await walkThroughAppCreation(); +export async function runSlackChannel(displayName: string): Promise { + const intro = await walkThroughAppCreation(); + if (intro === 'back') return BACK_TO_CHANNEL_SELECTION; const token = await collectBotToken(); const signingSecret = await collectSigningSecret(); @@ -121,7 +125,7 @@ export async function runSlackChannel(displayName: string): Promise { showPostInstallChecklist(info); } -async function walkThroughAppCreation(): Promise { +async function walkThroughAppCreation(): Promise<'continue' | 'back'> { note( [ "You'll create a Slack app that the assistant talks through.", @@ -140,7 +144,20 @@ async function walkThroughAppCreation(): Promise { ].filter((line): line is string => line !== null).join('\n'), 'Create a Slack app', ); - await confirmThenOpen(SLACK_APPS_URL, 'Press Enter to open Slack app settings'); + + // Back-aware gate replacing the old `confirmThenOpen` "Press Enter to open + // Slack app settings" so users can bail out of Slack before we open the + // browser or ask for tokens. + const choice = ensureAnswer(await brightSelect<'open' | 'back'>({ + message: 'Open Slack app settings in your browser?', + options: [ + { value: 'open', label: 'Open Slack app settings' }, + { value: 'back', label: '← Back to channel selection' }, + ], + initialValue: 'open', + })); + if (choice === 'back') return 'back'; + if (!isHeadless()) openUrl(SLACK_APPS_URL); ensureAnswer( await p.confirm({ @@ -148,6 +165,7 @@ async function walkThroughAppCreation(): Promise { initialValue: true, }), ); + return 'continue'; } async function collectBotToken(): Promise { diff --git a/setup/channels/teams.ts b/setup/channels/teams.ts index 41e2070..3691beb 100644 --- a/setup/channels/teams.ts +++ b/setup/channels/teams.ts @@ -30,6 +30,7 @@ import path from 'path'; import * as p from '@clack/prompts'; import k from 'kleur'; +import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js'; import { brightSelect } from '../lib/bright-select.js'; import { confirmThenOpen } from '../lib/browser.js'; import { @@ -57,18 +58,24 @@ interface Collected { agentName?: string; } -export async function runTeamsChannel(_displayName: string): Promise { +export async function runTeamsChannel(_displayName: string): Promise { const collected: Collected = {}; const completed: string[] = []; const existingAppId = readEnvKey('TEAMS_APP_ID'); const existingPassword = readEnvKey('TEAMS_APP_PASSWORD'); if (existingAppId && existingPassword) { - const reuse = ensureAnswer(await p.confirm({ + const choice = ensureAnswer(await brightSelect<'yes' | 'no' | 'back'>({ message: `Found existing Teams credentials (App ID: ${existingAppId.slice(0, 8)}…). Use them?`, - initialValue: true, + options: [ + { value: 'yes', label: 'Yes, use the existing credentials' }, + { value: 'no', label: "No, set up new ones" }, + { value: 'back', label: '← Back to channel selection' }, + ], + initialValue: 'yes', })); - if (reuse) { + if (choice === 'back') return BACK_TO_CHANNEL_SELECTION; + if (choice === 'yes') { collected.appId = existingAppId; collected.appPassword = existingPassword; collected.appType = (readEnvKey('TEAMS_APP_TYPE') as 'SingleTenant' | 'MultiTenant') || 'MultiTenant'; @@ -85,7 +92,8 @@ export async function runTeamsChannel(_displayName: string): Promise { printIntro(); - await confirmPrereqs({ collected, completed }); + const prereqsResult = await confirmPrereqs({ collected, completed }); + if (prereqsResult === 'back') return BACK_TO_CHANNEL_SELECTION; await stepPublicUrl({ collected, completed }); await stepAppRegistration({ collected, completed }); await stepClientSecret({ collected, completed }); @@ -116,7 +124,7 @@ function printIntro(): void { ); } -async function confirmPrereqs(args: { collected: Collected; completed: string[] }): Promise { +async function confirmPrereqs(args: { collected: Collected; completed: string[] }): Promise<'continue' | 'back'> { note( [ 'Before we start, confirm you have:', @@ -131,13 +139,36 @@ async function confirmPrereqs(args: { collected: Collected; completed: string[] 'Prereqs', ); - await stepGate({ - stepName: 'teams-prereqs', - stepDescription: 'confirming they have the right Microsoft 365 tenant and tunnel', - reshow: () => confirmPrereqs(args), - args, - }); + // Back-aware variant of stepGate — Back is only offered on the very first + // step of the Teams flow so users can bail out before any state is taken. + while (true) { + const choice = ensureAnswer( + await brightSelect<'done' | 'help' | 'reshow' | 'back'>({ + message: 'How did that go?', + options: [ + { value: 'done', label: "Done — let's continue" }, + { value: 'help', label: 'Stuck — hand me off to Claude' }, + { value: 'reshow', label: 'Show me the steps again' }, + { value: 'back', label: '← Back to channel selection' }, + ], + }), + ); + if (choice === 'back') return 'back'; + if (choice === 'done') break; + if (choice === 'help') { + await offerHandoff({ + step: 'teams-prereqs', + stepDescription: 'confirming they have the right Microsoft 365 tenant and tunnel', + args, + }); + continue; + } + if (choice === 'reshow') { + return confirmPrereqs(args); + } + } args.completed.push('Prereqs confirmed.'); + return 'continue'; } // ─── step: public URL ────────────────────────────────────────────────── diff --git a/setup/channels/telegram.ts b/setup/channels/telegram.ts index bf474f2..9faf3b2 100644 --- a/setup/channels/telegram.ts +++ b/setup/channels/telegram.ts @@ -21,7 +21,10 @@ import * as p from '@clack/prompts'; import k from 'kleur'; import * as setupLog from '../logs.js'; -import { confirmThenOpen, formatNoteLink } from '../lib/browser.js'; +import { isHeadless } from '../platform.js'; +import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js'; +import { confirmThenOpen, formatNoteLink, openUrl } from '../lib/browser.js'; +import { brightSelect } from '../lib/bright-select.js'; import { askOperatorRole } from '../lib/role-prompt.js'; import { type Block, @@ -38,8 +41,10 @@ import { accentGreen, brandBold, fitToWidth, fmtDuration, note } from '../lib/th const DEFAULT_AGENT_NAME = 'Nano'; -export async function runTelegramChannel(displayName: string): Promise { - const token = await collectTelegramToken(); +export async function runTelegramChannel(displayName: string): Promise { + const tokenOrBack = await collectTelegramToken(); + if (tokenOrBack === 'back') return BACK_TO_CHANNEL_SELECTION; + const token = tokenOrBack; const botUsername = await validateTelegramToken(token); // Deep-link the user into the bot's chat so they're on the right screen @@ -48,14 +53,37 @@ export async function runTelegramChannel(displayName: string): Promise { // 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}`; - note( - [ + // Two card variants — auto-open fires only on GUI, so headless users + // need full self-serve instructions inside the card itself, while GUI + // users get a leaner status line plus the auto-open + a single + // combined dim fallback line (URL + mobile alternative) on the + // confirm prompt below. + if (isHeadless()) { + note( + [ + `Open @${botUsername} in Telegram now — the pairing code is coming next, and that's where you'll send it.`, + '', + `Get started: ${botUrl}`, + '', + `Don't have Telegram installed here? Open it on any device and search for @${botUsername}`, + ].join('\n'), + 'Open Telegram', + ); + } else { + note( `Opening @${botUsername} in Telegram so it's ready when the pairing code shows up.`, - formatNoteLink(botUrl), - ].filter((line): line is string => line !== null).join('\n'), - 'Open Telegram', - ); - await confirmThenOpen(botUrl, 'Press Enter to open Telegram'); + 'Open Telegram', + ); + ensureAnswer( + await p.confirm({ + message: `Press Enter to open Telegram (must be installed here)\n${k.dim( + `If browser does not appear, please visit: ${botUrl} — or search for @${botUsername} in Telegram`, + )}`, + initialValue: true, + }), + ); + openUrl(botUrl); + } const install = await runQuietChild( 'telegram-install', @@ -131,17 +159,24 @@ export async function runTelegramChannel(displayName: string): Promise { } } -async function collectTelegramToken(): Promise { +async function collectTelegramToken(): Promise { const existing = readEnvKey('TELEGRAM_BOT_TOKEN'); if (existing && /^[0-9]+:[A-Za-z0-9_-]{35,}$/.test(existing)) { - const reuse = ensureAnswer(await p.confirm({ + const choice = ensureAnswer(await brightSelect<'yes' | 'no' | 'back'>({ message: `Found an existing Telegram bot token (${existing.slice(0, 8)}…). Use it?`, - initialValue: true, + options: [ + { value: 'yes', label: 'Yes, use the existing token' }, + { value: 'no', label: 'No, paste a new one' }, + { value: 'back', label: '← Back to channel selection' }, + ], + initialValue: 'yes', })); - if (reuse) { + if (choice === 'back') return 'back'; + if (choice === 'yes') { setupLog.userInput('telegram_token', 'reused-existing'); return existing; } + // 'no' falls through to the paste flow below } note( @@ -159,6 +194,19 @@ async function collectTelegramToken(): Promise { 'Set up your Telegram bot', ); + // Back-aware gate before the password prompt — `p.password` doesn't + // accept extra options, so we offer Back as a separate brightSelect + // immediately after the BotFather instructions and before the paste. + const proceed = ensureAnswer(await brightSelect<'continue' | 'back'>({ + message: 'Ready to paste your bot token?', + options: [ + { value: 'continue', label: 'Yes, paste it on the next prompt' }, + { value: 'back', label: '← Back to channel selection' }, + ], + initialValue: 'continue', + })); + if (proceed === 'back') return 'back'; + const answer = ensureAnswer( await p.password({ message: 'Paste your bot token', diff --git a/setup/channels/whatsapp.ts b/setup/channels/whatsapp.ts index 922c985..b8365b4 100644 --- a/setup/channels/whatsapp.ts +++ b/setup/channels/whatsapp.ts @@ -33,6 +33,7 @@ import * as p from '@clack/prompts'; import k from 'kleur'; import * as setupLog from '../logs.js'; +import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js'; import { brightSelect } from '../lib/bright-select.js'; import { getLaunchdLabel, getSystemdUnit } from '../../src/install-slug.js'; import { @@ -53,8 +54,9 @@ const AUTH_CREDS_PATH = path.join(process.cwd(), 'store', 'auth', 'creds.json'); type AuthMethod = 'qr' | 'pairing-code'; -export async function runWhatsAppChannel(displayName: string): Promise { +export async function runWhatsAppChannel(displayName: string): Promise { const method = await askAuthMethod(); + if (method === 'back') return BACK_TO_CHANNEL_SELECTION; const phone = method === 'pairing-code' ? await askPhoneNumber() : undefined; const install = await runQuietChild( @@ -148,7 +150,7 @@ export async function runWhatsAppChannel(displayName: string): Promise { } } -async function askAuthMethod(): Promise { +async function askAuthMethod(): Promise { const choice = ensureAnswer( await brightSelect({ message: 'How would you like to authenticate with WhatsApp?', @@ -163,10 +165,14 @@ async function askAuthMethod(): Promise { label: 'Enter a pairing code on your phone', hint: 'no camera needed', }, + { + value: 'back', + label: '← Back to channel selection', + }, ], }), - ) as AuthMethod; - setupLog.userInput('whatsapp_auth_method', choice); + ) as AuthMethod | 'back'; + if (choice !== 'back') setupLog.userInput('whatsapp_auth_method', choice); return choice; } @@ -312,7 +318,7 @@ async function renderQr(qr: string): Promise { 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.', + ' Open WhatsApp → You / Settings → Linked Devices → Link a Device → scan.', ); return [...qrText.trimEnd().split('\n'), '', caption]; } catch { @@ -328,7 +334,7 @@ function formatPairingCard(code: string): string { '', ` ${brandBold(spaced)}`, '', - k.dim(' Open WhatsApp → Settings → Linked Devices → Link a Device'), + k.dim(' Open WhatsApp → You / 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'); diff --git a/setup/install-signal-cli.sh b/setup/install-signal-cli.sh new file mode 100755 index 0000000..870220e --- /dev/null +++ b/setup/install-signal-cli.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# install-signal-cli.sh — auto-install signal-cli on the host. +# +# NanoClaw needs `signal-cli` on PATH to talk to Signal. Picks the right +# install method per platform: +# macOS → `brew install signal-cli` (bottled, no Java needed) +# Linux → download latest native binary from GitHub releases to +# ~/.local/bin/signal-cli (no Java, no sudo) +# +# Emits the standard NanoClaw STATUS block on success or failure so the +# `runQuietChild` driver can parse the outcome. + +set -euo pipefail + +VERSION="0.14.3" +INSTALL_DIR="${HOME}/.local/bin" + +emit_status() { + local status=$1 error=${2:-} + echo "=== NANOCLAW SETUP: INSTALL_SIGNAL_CLI ===" + echo "STATUS: ${status}" + [ -n "$error" ] && echo "ERROR: ${error}" + echo "=== END ===" +} + +log() { echo "[install-signal-cli] $*" >&2; } + +uname_s=$(uname) + +if [[ "${uname_s}" == "Darwin" ]]; then + if ! command -v brew >/dev/null 2>&1; then + emit_status failed "homebrew_not_installed" + exit 1 + fi + log "Installing signal-cli via Homebrew…" + brew install signal-cli >&2 || { + emit_status failed "brew_install_failed" + exit 1 + } + emit_status success + exit 0 +fi + +if [[ "${uname_s}" != "Linux" ]]; then + emit_status failed "unsupported_platform_${uname_s}" + exit 1 +fi + +# Linux native build (no Java required) → ~/.local/bin/signal-cli. +URL="https://github.com/AsamK/signal-cli/releases/download/v${VERSION}/signal-cli-${VERSION}-Linux-native.tar.gz" +TARBALL=$(mktemp -t signal-cli.XXXXXX.tar.gz) + +log "Downloading signal-cli v${VERSION} (~96MB)…" +if ! curl -fLsS -o "${TARBALL}" "${URL}"; then + rm -f "${TARBALL}" + emit_status failed "download_failed" + exit 1 +fi + +log "Extracting…" +EXTRACT_DIR=$(mktemp -d) +if ! tar -xzf "${TARBALL}" -C "${EXTRACT_DIR}"; then + rm -rf "${TARBALL}" "${EXTRACT_DIR}" + emit_status failed "extract_failed" + exit 1 +fi + +mkdir -p "${INSTALL_DIR}" +log "Installing to ${INSTALL_DIR}/signal-cli…" +if ! mv "${EXTRACT_DIR}/signal-cli" "${INSTALL_DIR}/signal-cli"; then + rm -rf "${TARBALL}" "${EXTRACT_DIR}" + emit_status failed "install_failed" + exit 1 +fi +chmod +x "${INSTALL_DIR}/signal-cli" +rm -rf "${TARBALL}" "${EXTRACT_DIR}" + +emit_status success diff --git a/setup/install-whatsapp.sh b/setup/install-whatsapp.sh old mode 100755 new mode 100644 index 1c62d65..f18b87a --- a/setup/install-whatsapp.sh +++ b/setup/install-whatsapp.sh @@ -66,7 +66,7 @@ if ! grep -q "'whatsapp-auth':" setup/index.ts; then fi echo "STEP: pnpm-install" -pnpm install @whiskeysockets/baileys@6.17.16 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0 +pnpm install @whiskeysockets/baileys@7.0.0-rc.9 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0 echo "STEP: pnpm-build" pnpm run build diff --git a/setup/lib/back-nav.ts b/setup/lib/back-nav.ts new file mode 100644 index 0000000..586d161 --- /dev/null +++ b/setup/lib/back-nav.ts @@ -0,0 +1,17 @@ +/** + * Channel-flow back-navigation sentinel. + * + * Each `runXxxChannel(displayName)` in `setup/channels/` may return either + * `void` (sub-flow completed normally) or `BACK_TO_CHANNEL_SELECTION` to + * signal "the user picked '← Back to channel selection' on my first + * prompt; please re-run the channel chooser." `setup/auto.ts` catches + * that signal and loops back to `askChannelChoice()`. + * + * Back is only offered on the *first* interactive prompt of each channel + * sub-flow — once the user has answered something, they're committed + * (subsequent steps may have side effects like opening browsers, hitting + * APIs, or installing adapter packages, none of which are easily undone). + */ +export const BACK_TO_CHANNEL_SELECTION = Symbol('BACK_TO_CHANNEL_SELECTION'); + +export type ChannelFlowResult = void | typeof BACK_TO_CHANNEL_SELECTION; diff --git a/src/host-sweep.test.ts b/src/host-sweep.test.ts index bd2e233..0249f4d 100644 --- a/src/host-sweep.test.ts +++ b/src/host-sweep.test.ts @@ -12,6 +12,7 @@ import { CLAIM_STUCK_MS, _resetStuckProcessingRowsForTesting, decideStuckAction, + parseSqliteUtc, } from './host-sweep.js'; import type { Session } from './types.js'; @@ -292,3 +293,44 @@ describe('resetStuckProcessingRows — orphan claim cleanup', () => { expect(row.tries).toBe(1); // not bumped, the skip path held }); }); + +describe('parseSqliteUtc', () => { + // Regression: SQLite TIMESTAMP strings have no zone marker, but Date.parse + // treats those as local time. On non-UTC hosts this made every claim look + // (TZ offset) hours stale and tripped kill-claim on freshly-claimed messages. + // The helper appends "Z" only when no marker is present, so parsing is + // always anchored to UTC regardless of host timezone. + + const utcMs = Date.parse('2026-04-20T12:00:00.000Z'); + + it('treats a SQLite-style timestamp (no zone) as UTC', () => { + expect(parseSqliteUtc('2026-04-20 12:00:00')).toBe(utcMs); + expect(parseSqliteUtc('2026-04-20T12:00:00')).toBe(utcMs); + expect(parseSqliteUtc('2026-04-20T12:00:00.000')).toBe(utcMs); + }); + + it('preserves an explicit Z marker', () => { + expect(parseSqliteUtc('2026-04-20T12:00:00.000Z')).toBe(utcMs); + expect(parseSqliteUtc('2026-04-20T12:00:00z')).toBe(utcMs); + }); + + it('preserves an explicit numeric offset', () => { + // 14:00+02:00 == 12:00 UTC + expect(parseSqliteUtc('2026-04-20T14:00:00+02:00')).toBe(utcMs); + expect(parseSqliteUtc('2026-04-20T14:00:00+0200')).toBe(utcMs); + // 07:00-05:00 == 12:00 UTC + expect(parseSqliteUtc('2026-04-20T07:00:00-05:00')).toBe(utcMs); + }); + + it('returns NaN for unparseable input', () => { + expect(Number.isNaN(parseSqliteUtc('not a date'))).toBe(true); + }); + + it('does not drift across host timezones for SQLite-style input', () => { + // The helper itself is timezone-independent because it forces UTC parsing. + // (Verifying the regex branch — without the helper, `Date.parse` of the + // bare string returns different values depending on the host TZ.) + const bare = '2026-04-20T12:00:00'; + expect(parseSqliteUtc(bare)).toBe(Date.parse(bare + 'Z')); + }); +}); diff --git a/src/host-sweep.ts b/src/host-sweep.ts index 93a7e87..fbdd7e6 100644 --- a/src/host-sweep.ts +++ b/src/host-sweep.ts @@ -47,6 +47,17 @@ import { openInboundDb, openOutboundDb, openOutboundDbRw, inboundDbPath, heartbe import { isContainerRunning, killContainer, wakeContainer } from './container-runner.js'; import type { Session } from './types.js'; +/** + * SQLite TIMESTAMP columns store UTC without a timezone marker. Date.parse + * treats timezoneless ISO strings as local time, so on non-UTC hosts every + * timestamp looks (TZ offset) hours stale — leading to spurious kill-claim + * decisions on freshly-claimed messages. Append "Z" when no zone marker is + * present so Date.parse interprets the string as UTC. + */ +export function parseSqliteUtc(s: string): number { + return Date.parse(/[zZ]|[+-]\d{2}:?\d{2}$/.test(s) ? s : s + 'Z'); +} + const SWEEP_INTERVAL_MS = 60_000; // Absolute idle ceiling for a running container. If the heartbeat file hasn't // been touched in this long, the container is either stuck or doing genuinely @@ -95,7 +106,7 @@ export function decideStuckAction(args: { const tolerance = Math.max(CLAIM_STUCK_MS, declaredBashMs ?? 0); for (const claim of claims) { - const claimedAt = Date.parse(claim.status_changed); + const claimedAt = parseSqliteUtc(claim.status_changed); if (Number.isNaN(claimedAt)) continue; const claimAge = now - claimedAt; if (claimAge <= tolerance) continue; @@ -275,7 +286,7 @@ function resetStuckProcessingRows( // Already rescheduled for a future retry — don't bump tries again. The // wake path (sweep step 2) will fire when process_after elapses and a // fresh container will clean the orphan claim on startup. - if (msg.processAfter && Date.parse(msg.processAfter) > now) continue; + if (msg.processAfter && parseSqliteUtc(msg.processAfter) > now) continue; if (msg.tries >= MAX_TRIES) { markMessageFailed(inDb, msg.id);