scripts/operator.ts
Rama main · réplica de lectura del repositorio original.
#!/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 <id> [--apply]
* pnpm operator telegram topic <mailboxId|address> (--name "..." | --adopt <threadId>) [--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<string, string> } {
const positional: string[] = [];
const flags: Record<string, string> = {};
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 <login|whoami|logout|telegram> [--env <env>] [--host <host>] [--email <owner>]
login mint a single-use pairing code in D1, exchange it for a session, store it (0600)
flags: --ttl <minutes, 1-60, default 10> --label <text> --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 <id|@username> [--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 <mailboxId|address> (--name "..." | --adopt <threadId> [--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<string, string>;
/** `pnpm operator telegram <sub>`: 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<number> {
const session = requireSession(host, env);
const json = flags.json === "true";
const apply = flags.apply === "true";
switch (sub) {
case "status": {
const status = await operatorJson<TelegramOperatorStatus>(
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<RebindResult>(
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<TopicMappingResult>(
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<TopicListing>(
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<number> {
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;
},
);