#!/usr/bin/env tsx /** * `pnpm operator` — a control-plane (/api/*) session from the terminal. * * Replaces the hand-rolled "INSERT a pairing code with wrangler, curl * /api/auth/pairing, keep cookies.txt" dance. See scripts/lib/operator-session.ts * for the flow and the security notes. * * Usage: * pnpm operator login --env dev --host inbox.example.com [--email owner@example.com] * [--ttl 10] [--label my-task] [--allow-new-owner] * pnpm operator whoami --host inbox.example.com * pnpm operator logout --host inbox.example.com * pnpm operator telegram status [--json] bot, bound chat, rights, topics per mailbox * pnpm operator telegram rebind --chat [--apply] * pnpm operator telegram topic (--name "..." | --adopt ) [--replace] [--apply] * pnpm operator telegram topics * * The telegram subcommands call /api/telegram/* with the stored session; the * worker makes every Bot API call with its own (write-only) token. Mutating * ones are dry-run unless --apply is given. * * Defaults: --host from $RECCADO_HOST (or the env block's single custom-domain * route); --email from the first OWNER_BOOTSTRAP_EMAILS entry when set locally. * A localhost host targets the local D1 and http://. * * Never prints the pairing code or the session cookie. */ 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 { getSessionInfo, loadSession, login, logout, OperatorHttpError, operatorJson, requireSession, resolveHost, resolveOwnerEmail, sessionPathFor, } from "./lib/operator-session"; import { describeApiRefusal, formatRebind, formatTelegramStatus, formatTopicListing, formatTopicResult, parseChatFlag, parseTopicCommand, resolveMailboxRef, } from "./lib/telegram-operator-core"; function parseArgs(argv: string[]): { positional: string[]; flags: Record } { const positional: string[] = []; const flags: Record = {}; for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]; if (!arg) continue; if (!arg.startsWith("--")) { positional.push(arg); continue; } const [rawKey, inlineValue] = arg.slice(2).split("=", 2); if (!rawKey) continue; if (inlineValue !== undefined) { flags[rawKey] = inlineValue; continue; } const next = argv[i + 1]; if (!next || next.startsWith("--")) { flags[rawKey] = "true"; continue; } flags[rawKey] = next; i += 1; } return { positional, flags }; } const USAGE = `Usage: pnpm operator [--env ] [--host ] [--email ] login mint a single-use pairing code in D1, exchange it for a session, store it (0600) flags: --ttl --label --allow-new-owner whoami show the signed-in owner and session expiry, or "not signed in" logout sign the session out on the server and delete the local session file telegram status [--json] the bot (@username), the bound chat and the bot's rights there, the webhook, and each active mailbox's topic telegram rebind --chat [--apply] [--json] move the bridge to another chat (dry run by default: checks the chat and the bot's rights with Telegram and prints current -> new; --apply writes it) telegram topic (--name "..." | --adopt [--name "..."]) [--replace] [--apply] [--json] map a mailbox to a forum topic in the bound chat: create one under --name, or adopt an existing thread; the name is stored with the mapping, independent of the mailbox's display name (dry run by default) telegram topics [--json] list every topic mapping, marking the ones in the bound chat`; type Flags = Record; /** `pnpm operator telegram `: thin IO around /api/telegram/*; formatting is in the core. */ async function runTelegram( sub: string | undefined, positional: string[], flags: Flags, host: string, env: string | undefined, ): Promise { const session = requireSession(host, env); const json = flags.json === "true"; const apply = flags.apply === "true"; switch (sub) { case "status": { const status = await operatorJson( session, "/api/telegram/status", {}, { env }, ); if (json) console.log(JSON.stringify(status, null, 2)); else for (const line of formatTelegramStatus(status)) console.log(line); return status.bridge.ok ? 0 : 1; } case "rebind": { const chatId = parseChatFlag(flags.chat); const result = await operatorJson( session, "/api/telegram/rebind", { method: "POST", body: { chatId, dryRun: !apply } }, { env }, ); if (json) console.log(JSON.stringify(result, null, 2)); else for (const line of formatRebind(result)) console.log(line); return 0; } case "topic": { const command = parseTopicCommand(positional[2], flags); const { mailboxes } = await operatorJson<{ mailboxes: Array<{ mailbox_id: string; primary_address: string }>; }>(session, "/api/mailboxes", {}, { env }); const mailboxId = resolveMailboxRef(command.mailboxRef, mailboxes); const result = await operatorJson( session, "/api/telegram/topics", { method: "POST", body: { mailboxId, ...(command.name !== undefined ? { name: command.name } : {}), ...(command.adoptThreadId !== undefined ? { adoptThreadId: command.adoptThreadId } : {}), ...(command.replace ? { replace: true } : {}), dryRun: !apply, }, }, { env }, ); if (json) console.log(JSON.stringify(result, null, 2)); else for (const line of formatTopicResult(result)) console.log(line); return 0; } case "topics": { const listing = await operatorJson( session, "/api/telegram/topics", {}, { env }, ); if (json) console.log(JSON.stringify(listing, null, 2)); else for (const line of formatTopicListing(listing)) console.log(line); return 0; } default: console.error(`Unknown telegram subcommand "${sub ?? ""}".\n${USAGE}`); return 1; } } async function main(): Promise { const { positional, flags } = parseArgs(process.argv.slice(2)); const command = positional[0]; const env = flags.env && flags.env !== "true" ? flags.env : undefined; if (!command || command === "help" || flags.help) { console.log(USAGE); return command ? 0 : 1; } const host = resolveHost(flags.host, env); switch (command) { case "login": { const email = resolveOwnerEmail(flags.email); const ttlMinutes = flags.ttl ? Number(flags.ttl) : undefined; console.log(`Signing in to ${host} as ${email}...`); const result = await login({ env, host, email, ttlMinutes, label: flags.label, allowNewOwner: flags["allow-new-owner"] === "true", }); console.log(` code minted in: ${result.d1} (single-use, spent)`); if (result.claim === "linked") { console.log(` NOTE: ${email} was not an owner before; it is now linked as one.`); } console.log(` signed in as: ${result.session.email}`); if (result.session.expiresAt) console.log(` expires: ${result.session.expiresAt}`); console.log(` session file: ${result.path} (0600)`); return 0; } case "whoami": { const session = loadSession(host); if (!session) { console.log(`not signed in to ${host}`); return 1; } const info = await getSessionInfo(session); if (!info) { console.log(`not signed in to ${host} (stored session is no longer valid)`); return 1; } console.log(`signed in to ${host} as ${info.email}`); if (info.expiresAt) console.log(` expires: ${info.expiresAt}`); console.log(` session file: ${sessionPathFor(host)}`); return 0; } case "logout": { const result = await logout(host); console.log(`server sign-out: ${result.serverSignOut}`); console.log(`session file: ${result.fileRemoved ? "deleted" : "none"} (${result.path})`); return result.serverSignOut.startsWith("failed") ? 1 : 0; } case "telegram": try { return await runTelegram(positional[1], positional, flags, host, env); } catch (error) { // A refusal from the route (409 no_chat_bound, bot_not_eligible, ...) is an // answer, not a crash: print its message, not a stack or a raw body dump. if (error instanceof OperatorHttpError) { console.error(`operator telegram: ${describeApiRefusal(error.status, error.body)}`); return 1; } throw error; } default: console.error(`Unknown command "${command}".\n${USAGE}`); return 1; } } main().then( (code) => { process.exitCode = code; }, (error: unknown) => { // Typed errors (OperatorAuthError / OperatorInputError) already carry the fix; no stack. console.error(`operator: ${error instanceof Error ? error.message : String(error)}`); process.exitCode = 1; }, );