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>
This commit is contained in:
277
setup/channels/telegram.ts
Normal file
277
setup/channels/telegram.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
Reference in New Issue
Block a user