diff --git a/setup/add-telegram.sh b/setup/add-telegram.sh index 13ffaa9..262502d 100755 --- a/setup/add-telegram.sh +++ b/setup/add-telegram.sh @@ -17,7 +17,6 @@ CHANNELS_BRANCH="origin/channels" need_install() { [[ ! -f src/channels/telegram.ts ]] && return 0 - [[ ! -f setup/pair-telegram.ts ]] && return 0 ! grep -q "^import './telegram.js';" src/channels/index.ts 2>/dev/null && return 0 return 1 } @@ -26,14 +25,15 @@ if need_install; then echo "[add-telegram] Fetching channels branch…" git fetch origin channels >/dev/null 2>&1 + # pair-telegram.ts is maintained in this branch (setup-auto), so it's NOT + # in this list — do not overwrite the local version with the channels copy. echo "[add-telegram] Copying adapter files from $CHANNELS_BRANCH…" for f in \ src/channels/telegram.ts \ src/channels/telegram-pairing.ts \ src/channels/telegram-pairing.test.ts \ src/channels/telegram-markdown-sanitize.ts \ - src/channels/telegram-markdown-sanitize.test.ts \ - setup/pair-telegram.ts + src/channels/telegram-markdown-sanitize.test.ts do git show "$CHANNELS_BRANCH:$f" > "$f" done diff --git a/setup/auto.ts b/setup/auto.ts index 096368c..d1358ca 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -81,119 +81,6 @@ function runStep(name: string, extra: string[] = []): Promise { }); } -/** - * Variant of runStep for `pair-telegram`. The step emits machine-readable - * status blocks (PAIR_TELEGRAM_ISSUED, PAIR_TELEGRAM_ATTEMPT, etc.) meant - * for the /setup skill to parse and relay. Running it directly leaves the - * operator staring at noisy blocks — this filters them and renders a - * focused banner around the 4-digit code instead. - */ -function runPairTelegram(intent: string): Promise { - return new Promise((resolve) => { - console.log('\n── pair-telegram ───────────────────────────────'); - const args = [ - 'exec', 'tsx', 'setup/index.ts', - '--step', 'pair-telegram', - '--', '--intent', intent, - ]; - const child = spawn('pnpm', args, { stdio: ['inherit', 'pipe', 'inherit'] }); - - let buf = ''; - let partial = ''; - let inBlock = false; - let blockType = ''; - let blockFields: Record = {}; - - function handleLine(line: string): void { - if (line.startsWith('=== NANOCLAW SETUP:')) { - inBlock = true; - blockType = line.replace('=== NANOCLAW SETUP:', '').replace('===', '').trim(); - blockFields = {}; - return; - } - if (line.startsWith('=== END ===')) { - inBlock = false; - renderBlock(blockType, blockFields); - return; - } - if (inBlock) { - const idx = line.indexOf(':'); - if (idx > -1) { - blockFields[line.slice(0, idx).trim()] = line.slice(idx + 1).trim(); - } - return; - } - process.stdout.write(line + '\n'); - } - - function renderBlock(type: string, fields: Record): void { - switch (type) { - case 'PAIR_TELEGRAM_ISSUED': - printCodeBanner(fields.CODE ?? '????'); - break; - case 'PAIR_TELEGRAM_NEW_CODE': - console.log('\n Previous code invalidated. New code:'); - printCodeBanner(fields.CODE ?? '????'); - break; - case 'PAIR_TELEGRAM_ATTEMPT': - console.log( - ` Got "${fields.RECEIVED_CODE ?? '?'}" — doesn't match. A new code is on its way.`, - ); - break; - case 'PAIR_TELEGRAM': - if (fields.STATUS === 'success') { - console.log('\n ✓ Telegram paired.'); - } else if (fields.STATUS === 'failed') { - console.log(`\n ✗ Pairing failed: ${fields.ERROR ?? 'unknown'}`); - } - break; - default: { - // Forward unknown blocks verbatim (forward-compat). - const lines = [`=== NANOCLAW SETUP: ${type} ===`]; - for (const [k, v] of Object.entries(fields)) lines.push(`${k}: ${v}`); - lines.push('=== END ==='); - process.stdout.write(lines.join('\n') + '\n'); - } - } - } - - child.stdout.on('data', (chunk: Buffer) => { - const s = chunk.toString('utf-8'); - buf += s; - partial += s; - const lines = partial.split('\n'); - partial = lines.pop() ?? ''; - for (const line of lines) handleLine(line); - }); - child.on('close', (code) => { - if (partial) handleLine(partial); - const fields = parseStatus(buf); - resolve({ - ok: code === 0 && fields.STATUS === 'success', - fields, - exitCode: code ?? 1, - }); - }); - }); -} - -function printCodeBanner(code: string): void { - // Double-space between digits for readability in a 4-digit code. - const digits = code.trim().split('').join(' '); - const content = [ - '', - ` PAIRING CODE: ${digits}`, - '', - ' Send these digits from Telegram to your bot.', - '', - ]; - const width = Math.max(...content.map((l) => l.length)); - const top = ' ╔' + '═'.repeat(width + 2) + '╗'; - const bot = ' ╚' + '═'.repeat(width + 2) + '╝'; - const mid = content.map((l) => ' ║ ' + l.padEnd(width) + ' ║'); - console.log(['', top, ...mid, bot, ''].join('\n')); -} - /** * After installing Docker, this process's supplementary groups are still * frozen from login — subsequent steps that talk to /var/run/docker.sock @@ -423,7 +310,7 @@ async function main(): Promise { ); } - const pair = await runPairTelegram('main'); + const pair = await runStep('pair-telegram', ['--intent', 'main']); if (!pair.ok) { fail( 'Telegram pairing failed.', @@ -432,10 +319,10 @@ async function main(): Promise { } const platformId = pair.fields.PLATFORM_ID; - const adminUserId = pair.fields.ADMIN_USER_ID; - if (!platformId || !adminUserId) { + const pairedUserId = pair.fields.PAIRED_USER_ID; + if (!platformId || !pairedUserId) { fail( - 'pair-telegram succeeded but did not return PLATFORM_ID and ADMIN_USER_ID.', + 'pair-telegram succeeded but did not return PLATFORM_ID and PAIRED_USER_ID.', 'Re-run `pnpm exec tsx setup/index.ts --step pair-telegram -- --intent main` and capture the success block.', ); } @@ -447,7 +334,7 @@ async function main(): Promise { console.log('\n── wiring first agent ──────────────────────────'); const initCode = await runTsxScript('scripts/init-first-agent.ts', [ '--channel', 'telegram', - '--user-id', adminUserId, + '--user-id', pairedUserId, '--platform-id', platformId, '--display-name', displayName!, '--agent-name', agentName, @@ -455,7 +342,7 @@ async function main(): Promise { if (initCode !== 0) { fail( 'Wiring the Telegram agent failed.', - `Re-run \`pnpm exec tsx scripts/init-first-agent.ts --channel telegram --user-id "${adminUserId}" --platform-id "${platformId}" --display-name "${displayName!}" --agent-name "${agentName}"\`.`, + `Re-run \`pnpm exec tsx scripts/init-first-agent.ts --channel telegram --user-id "${pairedUserId}" --platform-id "${platformId}" --display-name "${displayName!}" --agent-name "${agentName}"\`.`, ); } diff --git a/setup/pair-telegram.ts b/setup/pair-telegram.ts new file mode 100644 index 0000000..cf7259b --- /dev/null +++ b/setup/pair-telegram.ts @@ -0,0 +1,124 @@ +/** + * Step: pair-telegram — issue a one-time pairing code and wait for the + * operator to send the code from the chat they want to register. + * + * Used exclusively by `setup:auto` / `bash nanoclaw.sh` on this branch. Human- + * facing output is a focused banner for the code (no parseable block), plus a + * short line for wrong attempts / regenerations. A single machine-readable + * PAIR_TELEGRAM status block is still emitted at the end so the parent driver + * can pick up PLATFORM_ID / PAIRED_USER_ID / IS_GROUP. + * + * Depends on src/channels/telegram-pairing.js, which setup/add-telegram.sh + * copies in from the `channels` branch before this step runs. setup/ is + * excluded from the host tsconfig, so this file's import resolves only at + * runtime — tsc won't complain on branches that haven't run add-telegram yet. + */ +import path from 'path'; + +import { + createPairing, + waitForPairing, + type PairingIntent, +} from '../src/channels/telegram-pairing.js'; +import { DATA_DIR } from '../src/config.js'; +import { initDb } from '../src/db/connection.js'; +import { runMigrations } from '../src/db/migrations/index.js'; + +import { emitStatus } from './status.js'; + +function parseArgs(args: string[]): PairingIntent { + let intent: PairingIntent = 'main'; + for (let i = 0; i < args.length; i++) { + if (args[i] === '--intent') { + const raw = args[++i] || 'main'; + if (raw === 'main') { + intent = 'main'; + } else if (raw.startsWith('wire-to:')) { + intent = { kind: 'wire-to', folder: raw.slice('wire-to:'.length) }; + } else if (raw.startsWith('new-agent:')) { + intent = { kind: 'new-agent', folder: raw.slice('new-agent:'.length) }; + } else { + throw new Error(`Unknown intent: ${raw}`); + } + } + } + return intent; +} + +function intentToString(intent: PairingIntent): string { + if (intent === 'main') return 'main'; + return `${intent.kind}:${intent.folder}`; +} + +function printCodeBanner(code: string): void { + const digits = code.split('').join(' '); + const content = [ + '', + ` PAIRING CODE: ${digits}`, + '', + ' Send these digits from Telegram to your bot.', + '', + ]; + const width = Math.max(...content.map((l) => l.length)); + const top = ' ╔' + '═'.repeat(width + 2) + '╗'; + const bot = ' ╚' + '═'.repeat(width + 2) + '╝'; + const mid = content.map((l) => ' ║ ' + l.padEnd(width) + ' ║'); + console.log(['', top, ...mid, bot, ''].join('\n')); +} + +export async function run(args: string[]): Promise { + const intent = parseArgs(args); + + // Pairing stores state under DATA_DIR; the DB isn't strictly needed for the + // pairing primitive itself, but the inbound interceptor running inside the + // live service needs migrations applied. Touch it here so a fresh install + // doesn't fail on the first code match. + const db = initDb(path.join(DATA_DIR, 'v2.db')); + runMigrations(db); + + const MAX_REGENERATIONS = 5; + let record = await createPairing(intent); + printCodeBanner(record.code); + + for (let regen = 0; regen <= MAX_REGENERATIONS; regen++) { + try { + const consumed = await waitForPairing(record.code, { + onAttempt: (a) => { + console.log( + ` Got "${a.candidate}" — doesn't match. A new code is on its way.`, + ); + }, + }); + + console.log('\n ✓ Telegram paired.\n'); + emitStatus('PAIR_TELEGRAM', { + STATUS: 'success', + CODE: record.code, + INTENT: intentToString(consumed.intent), + PLATFORM_ID: consumed.consumed!.platformId, + IS_GROUP: consumed.consumed!.isGroup, + PAIRED_USER_ID: consumed.consumed!.adminUserId + ? `telegram:${consumed.consumed!.adminUserId}` + : '', + }); + return; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const invalidated = /invalidated by wrong code/.test(message); + if (invalidated && regen < MAX_REGENERATIONS) { + record = await createPairing(intent); + console.log('\n Previous code invalidated. New code:'); + printCodeBanner(record.code); + continue; + } + const reason = invalidated ? 'max-regenerations-exceeded' : message; + console.error(`\n ✗ Pairing failed: ${reason}`); + emitStatus('PAIR_TELEGRAM', { + STATUS: 'failed', + CODE: record.code, + ERROR: reason, + }); + process.exit(2); + } + } +}