Files
nanoclaw/setup/channels/telegram.ts
gavrielc 9b7d4d50e4 refactor(setup): split auto.ts into runner + theme + telegram channel
auto.ts had grown to 923 lines with ~10 interleaved responsibilities.
Split into three focused modules, keeping auto.ts as a pure step
sequencer:

- setup/lib/runner.ts (325 lines) — spawn + stream-parse + spinner-wrap
  primitives. Exports: spawnStep, spawnQuiet, runQuietStep,
  runQuietChild, runUnderSpinner (internal), StatusStream, types
  (Fields, Block, StepResult, SpinnerLabels, QuietChildResult),
  writeStepEntry, summariseTerminalFields, dumpTranscriptOnFailure,
  fail(), ensureAnswer().

- setup/lib/theme.ts (39 lines) — brand palette (brand, brandBold,
  brandChip) with USE_ANSI / TRUECOLOR gating, so both auto.ts and
  channel flows can render the NanoClaw cyan without duplicating the
  detection.

- setup/channels/telegram.ts (277 lines) — runTelegramChannel(displayName)
  owns the full flow: BotFather instructions, token paste + validation
  (via getMe), install script, pair-telegram streaming UI (code card +
  attempt checkpoints), agent-name prompt, init-first-agent wiring.

auto.ts drops to 376 lines. main() reads as a clean sequence of
`if (!skip.has(X)) await Xstep(...)` blocks.

fail() now takes the step name explicitly — no module-level
failingStep state. Every call site is grep-friendly and self-contained
(fail('container', msg, hint)).

Typechecks clean. Smoke-tested end-to-end: intro, mounts step,
progression log, and outro all render the same as before the split.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 02:26:50 +03:00

278 lines
8.6 KiB
TypeScript

/**
* Telegram channel flow for setup:auto.
*
* `runTelegramChannel(displayName)` owns the full branch from the
* BotFather instructions through the welcome DM:
*
* 1. BotFather instructions (clack note)
* 2. Paste the bot token (clack password) — format-validated
* 3. getMe via the Bot API to resolve the bot's username
* 4. Install the adapter (setup/add-telegram.sh, non-interactive)
* 5. Run the pair-telegram step, rendering code events as clack notes
* 6. Ask for the messaging-agent name (defaulting to "Nano")
* 7. Wire the agent via scripts/init-first-agent.ts
*
* 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 * 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';
export async function runTelegramChannel(displayName: string): Promise<void> {
const token = await collectTelegramToken();
const botUsername = await validateTelegramToken(token);
const install = await runQuietChild(
'telegram-install',
'bash',
['setup/add-telegram.sh'],
{
running: `Installing Telegram adapter and wiring @${botUsername}`,
done: 'Telegram adapter ready.',
},
{
env: { TELEGRAM_BOT_TOKEN: token },
extraFields: { BOT_USERNAME: botUsername },
},
);
if (!install.ok) {
fail(
'telegram-install',
'Telegram install failed.',
'Check the raw log under logs/setup-steps/, then retry `pnpm run setup:auto`.',
);
}
const pair = await runPairTelegram();
if (!pair.ok) {
fail(
'pair-telegram',
'Telegram pairing failed.',
'Re-run `pnpm exec tsx setup/index.ts --step pair-telegram -- --intent main`.',
);
}
const platformId = pair.terminal?.fields.PLATFORM_ID;
const pairedUserId = pair.terminal?.fields.PAIRED_USER_ID;
if (!platformId || !pairedUserId) {
fail(
'pair-telegram',
'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.',
);
}
const agentName = await resolveAgentName();
const init = await runQuietChild(
'init-first-agent',
'pnpm',
[
'exec', 'tsx', 'scripts/init-first-agent.ts',
'--channel', 'telegram',
'--user-id', pairedUserId,
'--platform-id', platformId,
'--display-name', displayName,
'--agent-name', agentName,
],
{
running: `Wiring ${agentName} to your Telegram chat…`,
done: `${agentName} is wired — welcome DM incoming.`,
},
{
extraFields: { CHANNEL: 'telegram', AGENT_NAME: agentName, PLATFORM_ID: platformId },
},
);
if (!init.ok) {
fail(
'init-first-agent',
'Wiring the Telegram agent failed.',
`Re-run \`pnpm exec tsx scripts/init-first-agent.ts --channel telegram --user-id "${pairedUserId}" --platform-id "${platformId}" --display-name "${displayName}" --agent-name "${agentName}"\`.`,
);
}
}
async function collectTelegramToken(): Promise<string> {
p.note(
[
'1. Open Telegram and message @BotFather',
'2. Send: /newbot',
'3. Follow the prompts (name + username ending in "bot")',
'4. Copy the token it gives you (format: <digits>:<chars>)',
'',
k.dim('Optional, but recommended for groups:'),
k.dim(' @BotFather → /mybots → Bot Settings → Group Privacy → OFF'),
].join('\n'),
'Create a Telegram bot',
);
const answer = ensureAnswer(
await p.password({
message: 'Paste your bot token',
validate: (v) => {
if (!v || !v.trim()) return 'Token is required';
if (!/^[0-9]+:[A-Za-z0-9_-]{35,}$/.test(v.trim())) {
return 'Format looks wrong — expected <digits>:<chars>';
}
return undefined;
},
}),
);
const token = (answer as string).trim();
setupLog.userInput(
'telegram_token',
`${token.slice(0, 12)}${token.slice(-4)}`,
);
return token;
}
async function validateTelegramToken(token: string): Promise<string> {
const s = p.spinner();
const start = Date.now();
s.start('Validating token with Telegram…');
try {
const res = await fetch(`https://api.telegram.org/bot${token}/getMe`);
const data = (await res.json()) as {
ok?: boolean;
result?: { username?: string; id?: number };
description?: string;
};
const elapsedS = Math.round((Date.now() - start) / 1000);
if (data.ok && data.result?.username) {
const username = data.result.username;
s.stop(`Bot is @${username}. ${k.dim(`(${elapsedS}s)`)}`);
setupLog.step('telegram-validate', 'success', Date.now() - start, {
BOT_USERNAME: username,
BOT_ID: data.result.id ?? '',
});
return username;
}
const reason = data.description ?? 'token rejected by Telegram';
s.stop(`Telegram rejected the token: ${reason}`, 1);
setupLog.step('telegram-validate', 'failed', Date.now() - start, {
ERROR: reason,
});
fail(
'telegram-validate',
'Telegram rejected the token.',
'Double-check the token (copy it again from @BotFather) and retry.',
);
} catch (err) {
const elapsedS = Math.round((Date.now() - start) / 1000);
s.stop(`Could not reach Telegram. ${k.dim(`(${elapsedS}s)`)}`, 1);
const message = err instanceof Error ? err.message : String(err);
setupLog.step('telegram-validate', 'failed', Date.now() - start, {
ERROR: message,
});
fail(
'telegram-validate',
'Telegram API unreachable.',
'Check your network connection and retry.',
);
}
}
async function runPairTelegram(): Promise<
StepResult & { rawLog: string; durationMs: number }
> {
const rawLog = setupLog.stepRawLog('pair-telegram');
const start = Date.now();
const s = p.spinner();
s.start('Creating pairing code…');
let spinnerActive = true;
const stopSpinner = (msg: string, code?: number) => {
if (spinnerActive) {
s.stop(msg, code);
spinnerActive = false;
}
};
const result = await spawnStep(
'pair-telegram',
['--intent', 'main'],
(block: Block) => {
if (block.type === 'PAIR_TELEGRAM_CODE') {
const reason = block.fields.REASON ?? 'initial';
if (reason === 'initial') {
stopSpinner('Pairing code ready.');
} else {
stopSpinner('Previous code invalidated. New code below.');
}
p.note(formatCodeCard(block.fields.CODE ?? '????'), 'Pairing code');
s.start('Waiting for the code from Telegram…');
spinnerActive = true;
} else if (block.type === 'PAIR_TELEGRAM_ATTEMPT') {
stopSpinner(`Received "${block.fields.CANDIDATE ?? '?'}" — doesn't match.`);
s.start('Waiting for the correct code…');
spinnerActive = true;
} else if (block.type === 'PAIR_TELEGRAM') {
if (block.fields.STATUS === 'success') {
stopSpinner('Telegram paired.');
} else {
stopSpinner(`Pairing failed: ${block.fields.ERROR ?? 'unknown'}`, 1);
}
}
},
rawLog,
);
const durationMs = Date.now() - start;
// Safety net: if the child died without emitting a terminal block, make
// sure we don't leave the spinner running.
if (spinnerActive) {
stopSpinner(
result.ok ? 'Done.' : 'Pairing exited unexpectedly.',
result.ok ? 0 : 1,
);
if (!result.ok) dumpTranscriptOnFailure(result.transcript);
}
writeStepEntry('pair-telegram', result, durationMs, rawLog);
return { ...result, rawLog, durationMs };
}
function formatCodeCard(code: string): string {
const spaced = code.split('').join(' ');
return [
'',
` ${brandBold(spaced)}`,
'',
k.dim(' Send these digits from Telegram to your bot.'),
].join('\n');
}
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 messaging agent 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;
}