scripts/lib/telegram-operator-core.ts
Rama main · réplica de lectura del repositorio original.
/**
* 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 <mailboxId|address> (--name "..." | --adopt <threadId>) [--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<string, string>,
): TopicCommand {
if (!mailboxRef) {
throw new OperatorInputError(
'telegram topic needs a mailbox: pnpm operator telegram topic <mailboxId|address> (--name "..." | --adopt <threadId>).',
);
}
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 <threadId> 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<TopicMappingResult["outcome"], string> = {
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 <id> 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<RebindResult["outcome"], string> = {
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;
}