/** * The pure half of `pnpm operator telegram ...` and `pnpm smoke:telegram`: * argument checks and the text printed for each /api/telegram/* answer. * * No node:* imports, so it runs in the Workers Vitest pool (same split as * ./operator-session-core.ts). The IO -- the session file and the HTTP calls -- * lives in scripts/operator.ts and scripts/smoke-telegram.ts. * * The CLI never talks to Telegram itself: TELEGRAM_BOT_TOKEN is a write-only * worker secret, so every Telegram call happens inside the worker, behind the * owner session. */ import type { DeliveryTestResult } from "../../src/telegram/admin/delivery-test"; import type { RebindResult } from "../../src/telegram/admin/rebind"; import type { TelegramOperatorStatus } from "../../src/telegram/admin/status"; import type { TopicListing, TopicMappingResult } from "../../src/telegram/admin/topics"; import { OperatorInputError } from "./operator-session-core"; /** Telegram's own limit on a forum topic name (the route enforces it too). */ const TOPIC_NAME_MAX = 128; export type TopicCommand = { /** A mailbox id or its primary address, as typed. */ mailboxRef: string; name?: string; adoptThreadId?: number; replace: boolean; }; /** * `telegram topic (--name "..." | --adopt ) [--replace]`. * --adopt may carry a --name too: the label stored for that thread (and reused * if the topic is ever recreated). */ export function parseTopicCommand( mailboxRef: string | undefined, flags: Record, ): TopicCommand { if (!mailboxRef) { throw new OperatorInputError( 'telegram topic needs a mailbox: pnpm operator telegram topic (--name "..." | --adopt ).', ); } const name = flags.name === undefined || flags.name === "true" ? undefined : flags.name.trim(); if (flags.name !== undefined && !name) { throw new OperatorInputError('--name needs a value, e.g. --name "imsanti".'); } if (name && name.length > TOPIC_NAME_MAX) { throw new OperatorInputError( `--name is ${name.length} characters; Telegram allows ${TOPIC_NAME_MAX}.`, ); } let adoptThreadId: number | undefined; if (flags.adopt !== undefined) { adoptThreadId = Number(flags.adopt); if (!Number.isInteger(adoptThreadId) || adoptThreadId <= 0) { throw new OperatorInputError( `--adopt ${flags.adopt}: expected the topic's positive message_thread_id.`, ); } } if (name === undefined && adoptThreadId === undefined) { throw new OperatorInputError( 'Give --name "..." to create a topic, or --adopt to map an existing one.', ); } return { mailboxRef, ...(name !== undefined ? { name } : {}), ...(adoptThreadId !== undefined ? { adoptThreadId } : {}), replace: flags.replace === "true", }; } /** One line per mailbox, `PASS`/`FAIL` first, so a scroll-back reads at a glance. */ export function formatDeliveryTest(result: DeliveryTestResult): string[] { const lines = [ `Delivery test in ${result.chatId} (${result.isForum ? "forum: one topic per mailbox" : "no topics: flat chat"})`, ]; if (result.results.length === 0) lines.push(" (no active mailboxes to test)"); for (const entry of result.results) { const pass = entry.outcome === "delivered_to_topic" || entry.outcome === "delivered_to_chat"; const where = entry.outcome === "delivered_to_topic" ? `topic ${entry.topicId}` : entry.outcome === "delivered_to_chat" ? "the chat" : entry.outcome; lines.push( ` ${pass ? "PASS" : "FAIL"} ${entry.address.padEnd(32)} ${where}${entry.reason ? ` — ${entry.reason}` : ""}`, ); } lines.push( "", result.ok ? "All test messages landed where real cards would." : "Some mailboxes would not get their cards where expected (see FAIL lines).", "Test messages create no reply link: answering one in Telegram sends no email.", ); return lines; } /** A mailbox id, or a primary address resolved against GET /api/mailboxes. */ export function resolveMailboxRef( ref: string, mailboxes: ReadonlyArray<{ mailbox_id: string; primary_address: string; status?: string }>, ): string { const trimmed = ref.trim(); if (!trimmed.includes("@")) { if (mailboxes.some((m) => m.mailbox_id === trimmed)) return trimmed; throw new OperatorInputError(`No mailbox with id ${trimmed}.`); } const match = mailboxes.find((m) => m.primary_address.toLowerCase() === trimmed.toLowerCase()); if (!match) { throw new OperatorInputError( `No mailbox has the primary address ${trimmed} (aliases are not accepted here).`, ); } return match.mailbox_id; } export function formatTopicResult(result: TopicMappingResult): string[] { const heading: Record = { would_create: "Dry run — would create a forum topic and map it:", would_adopt: "Dry run — would map an existing forum topic:", created: "Created the forum topic and mapped it:", adopted: "Mapped the existing forum topic:", already: "Already mapped; nothing to do:", }; const lines = [heading[result.outcome], ` chat: ${result.chatId}`]; if (result.mapping) { lines.push( ` mapping: ${result.mapping.mailboxId} -> topic ${result.mapping.topicId} "${result.mapping.effectiveName}"${result.mapping.topicName ? "" : " (follows the mailbox name)"}`, ); } if (result.replaced) { lines.push( ` replaces: topic ${result.replaced.topicId} "${result.replaced.effectiveName}" (left in Telegram, no longer used)`, ); } if (result.reason) lines.push(` note: ${result.reason}`); if (result.dryRun && result.outcome !== "already") { lines.push("", "Re-run with --apply to perform it."); } return lines; } export function formatTopicListing(listing: TopicListing): string[] { const lines = [ `bound chat: ${listing.binding.chatId ?? "(none)"} (forum: ${yesNo(listing.binding.isForum)})`, ]; if (listing.topics.length === 0) lines.push(" (no topic mappings)"); for (const topic of listing.topics) { lines.push( ` ${topic.inBoundChat ? "*" : " "} ${topic.chatId.padEnd(16)} ${(topic.mailboxAddress ?? topic.mailboxId).padEnd(32)} topic ${String(topic.topicId).padEnd(6)} "${topic.effectiveName}"${topic.topicName ? "" : " (follows the mailbox name)"}`, ); } lines.push(" (* = in the bound chat; others are kept for a rebind back)"); return lines; } /** * `--chat` as typed: a numeric chat id or a public @username. The same rule * the route enforces, checked first so a typo fails before any request. */ export function parseChatFlag(value: string | undefined): string { const chat = value?.trim(); if (!chat || chat === "true") { throw new OperatorInputError("--chat is required (e.g. --chat -1001234567890)."); } if (!/^(-?\d{1,20}|@[A-Za-z0-9_]{4,32})$/.test(chat)) { throw new OperatorInputError( `--chat ${chat}: expected a numeric Telegram chat id (e.g. -1001234567890) or a public @username.`, ); } return chat; } function describeBinding(binding: { chatId: string | null; isForum: boolean | null }): string { if (!binding.chatId) return "(no chat bound)"; return `${binding.chatId} (forum: ${yesNo(binding.isForum)})`; } export function formatRebind(result: RebindResult): string[] { const heading: Record = { would_rebind: "Dry run — would rebind the Telegram bridge:", would_be_unchanged: "Dry run — already bound to that chat; nothing would change:", rebound: "Rebound the Telegram bridge:", unchanged: "Already bound to that chat; nothing changed:", }; const lines = [heading[result.outcome]]; lines.push(` current: ${describeBinding(result.previous)}`); lines.push( ` new: ${describeBinding(result.current)} · ${result.current.type}${result.current.title ? ` "${result.current.title}"` : ""}`, ); if (result.membership) { lines.push( ` bot: ${result.membership.status} · can_manage_topics: ${yesNo(result.membership.canManageTopics)}`, ); } lines.push( ` topics: ${result.topics.mappedInNewChat} mapped in the new chat; ${result.topics.keptForOtherChats} kept for other chats`, ); for (const note of result.notes) lines.push(` note: ${note}`); if (result.dryRun && result.outcome === "would_rebind") { lines.push("", "Re-run with --apply to rebind."); } return lines; } /** * A non-2xx answer from /api/telegram/* as one line: the route's own * `{ error, message }` when it sent one, else the status and a bounded body. */ export function describeApiRefusal(status: number, body: string): string { try { const parsed = JSON.parse(body) as { error?: unknown; message?: unknown }; if (typeof parsed.error === "string") { return `${parsed.error} (HTTP ${status})${typeof parsed.message === "string" ? `: ${parsed.message}` : ""}`; } } catch { // Not JSON: fall through to the raw form. } return `HTTP ${status}: ${body.slice(0, 300)}`; } function yesNo(value: boolean | null | undefined): string { if (value === null || value === undefined) return "unknown"; return value ? "yes" : "no"; } export function formatTelegramStatus(status: TelegramOperatorStatus): string[] { const lines: string[] = []; lines.push( `bridge: ${status.bridge.mode}${status.bridge.reason ? ` — ${status.bridge.reason}` : ""}`, ); if (!status.bot) { lines.push("bot: (bridge off: TELEGRAM_BOT_TOKEN is not set)"); } else if (status.bot.ok) { lines.push( `bot: @${status.bot.username ?? "?"} (id ${status.bot.id}, "${status.bot.firstName}")`, ); } else { lines.push(`bot: getMe failed: ${status.bot.error}`); } const { binding } = status; lines.push( `bound: ${binding.chatId ?? "(no chat bound)"}${binding.chatId ? ` · stored forum flag: ${yesNo(binding.isForum)}` : ""}`, ); if (status.chat) { if (status.chat.ok) { lines.push( `chat: ${status.chat.type}${status.chat.title ? ` "${status.chat.title}"` : ""} · forum: ${yesNo(status.chat.isForum)}`, ); if (binding.isForum !== null && binding.isForum !== status.chat.isForum) { lines.push( ` WARNING: the stored forum flag (${yesNo(binding.isForum)}) disagrees with Telegram (${yesNo(status.chat.isForum)}); cards follow the stored flag. Re-observe with: pnpm operator telegram rebind --chat ${binding.chatId} --apply`, ); } } else { lines.push(`chat: getChat failed: ${status.chat.error}`); } } if (status.membership) { if (status.membership.ok) { lines.push( `bot role: ${status.membership.status} · can_manage_topics: ${yesNo(status.membership.canManageTopics)} · can post: ${yesNo(status.membership.canSendMessages)}`, ); if (!status.membership.eligible || !status.membership.canManageTopics) { lines.push(` ${status.membership.reason}`); } } else { lines.push(`bot role: getChatMember failed: ${status.membership.error}`); } } const { registration, observation } = status.webhook; lines.push( `webhook: ${registration ? `registered ${registration.registeredAt} -> ${registration.url}` : "not registered by this deployment yet"}`, ); if (observation) { lines.push( ` observed ${observation.observedAt}: ${observation.pendingUpdateCount} pending${observation.lastErrorAt ? `, last error ${observation.lastErrorAt}: ${observation.lastErrorMessage ?? "?"}` : ", no delivery error"}`, ); } lines.push("mailboxes:"); if (status.mailboxes.length === 0) lines.push(" (no active mailboxes)"); for (const mailbox of status.mailboxes) { const topic = mailbox.topic ? `topic ${mailbox.topic.topicId} "${mailbox.topic.effectiveName}"${mailbox.topic.topicName ? "" : " (follows the mailbox name)"}` : binding.isForum ? "no topic mapped (the next card creates one)" : "no topics (not a forum)"; lines.push(` ${mailbox.address.padEnd(32)} ${mailbox.mailboxId.padEnd(24)} ${topic}`); } lines.push(` note: topics unverified — ${status.topicVerification.reason}`); return lines; }