Ctrl K
ComunidadGuías…

src/telegram/api.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/**
 * Minimal Telegram Bot API client — only the methods the bridge needs.
 *
 * Everything here is fetch-to-api.telegram.org; there is no SDK, because the
 * surface is six methods and a Worker should not carry a Node-shaped dependency
 * for that.
 */

import { base32urlEncode, hmacSha256 } from "../lib/crypto";
import { fetchWithTimeout } from "../lib/runtime-config";

const TELEGRAM_API_BASE = "https://api.telegram.org";

/** Bot API hard limit on a text message. Longer text is rejected, not truncated. */
export const TELEGRAM_MAX_MESSAGE_CHARS = 4096;

/**
 * The credential, and nothing else. The allowlist used to live here and no longer
 * does -- see readTelegramConfig.
 */
export type TelegramConfig = {
	botToken: string;
};

/**
 * Either a button that comes back as a callback_query, or one that just opens a
 * link. The URL variant exists so a card can offer "open this in Reccado" without
 * spending a round trip through the webhook to send back a URL the button could
 * have carried itself.
 */
export type TelegramInlineButton =
	| { text: string; callback_data: string }
	| { text: string; url: string };

export type TelegramMessage = {
	message_id: number;
	message_thread_id?: number;
	chat: { id: number | string; type: string };
	from?: { id: number | string; is_bot?: boolean };
	date?: number;
	text?: string;
	caption?: string;
	entities?: TelegramEntity[];
	caption_entities?: TelegramEntity[];
	reply_to_message?: TelegramMessage;
	is_topic_message?: boolean;
};

export type TelegramEntity = {
	type: string;
	/** UTF-16 code-unit offset — the same units JS string indexes use. */
	offset: number;
	length: number;
	url?: string;
	language?: string;
};

export type TelegramCallbackQuery = {
	id: string;
	from: { id: number | string };
	data?: string;
	message?: TelegramMessage;
};

export type TelegramUpdate = {
	update_id: number;
	message?: TelegramMessage;
	edited_message?: TelegramMessage;
	callback_query?: TelegramCallbackQuery;
};

export type TelegramForumTopic = {
	message_thread_id: number;
	name: string;
};

/**
 * Label that separates this derivation from any other use of the bot token as a
 * key. Changing it rotates every deployment's webhook secret, so don't.
 */
const WEBHOOK_SECRET_LABEL = "reccado:telegram:webhook-secret:v1";

/**
 * The webhook secret, derived rather than configured.
 *
 * No human ever reads this value: the only two parties that need it are this
 * worker and Telegram, which echoes it back in X-Telegram-Bot-Api-Secret-Token.
 * Asking an operator to invent one, store it as a worker secret and re-supply it
 * to a setup script was three manual steps for a number with no human meaning --
 * and the step everyone forgets, which is how the bridge stayed dead in
 * production without a word. Deriving it from the bot token, the one Telegram
 * credential a human genuinely must provide, removes the step entirely, lets any
 * code path recompute it without stored state (so the cron can re-register a
 * drifted webhook), and rotates it automatically with the token.
 *
 * Deliberately NOT persisted in D1. This secret is what stops a forged update
 * from sending mail as the operator, and the allowlist such an update would have
 * to satisfy is itself a D1 row -- so storing it there would let a single
 * database read escalate from reading mail to sending it. A secret's home is
 * decided by what it protects, not by whichever store is most convenient.
 */
export async function deriveWebhookSecret(botToken: string): Promise<string> {
	return base32urlEncode(await hmacSha256(botToken, WEBHOOK_SECRET_LABEL));
}

/**
 * Label for the *fingerprint*, deliberately different from the secret's own.
 * Changing either string forces a re-registration on every deployment, so don't.
 */
const WEBHOOK_SECRET_FINGERPRINT_LABEL = "reccado:telegram:webhook-secret-fingerprint:v1";

/**
 * A name for the secret currently in force, safe to write down.
 *
 * The cron needs to answer "was the live webhook registered with *this* secret?",
 * and getWebhookInfo never echoes the secret back -- so the only way to know is to
 * have recorded what we registered with. Recording the secret itself would undo
 * the reason it is not in D1 at all (see deriveWebhookSecret): a database read
 * would escalate from reading mail to forging updates that send it.
 *
 * An HMAC under a different label is computationally independent of the secret,
 * so this value names the secret without revealing it. And an attacker who can
 * *write* D1 gains nothing either: the worst they can force is a re-registration
 * of our own URL with our own derived secret.
 */
export async function deriveWebhookSecretFingerprint(botToken: string): Promise<string> {
	return base32urlEncode(await hmacSha256(botToken, WEBHOOK_SECRET_FINGERPRINT_LABEL));
}

/**
 * Is the bridge on? Null when there is no bot token, which is the only thing that
 * can be answered without touching a database.
 *
 * This function used to also carry the operator allowlist and throw when it was
 * empty. Both had to go. The allowlist lives in D1 now (see telegram/operators.ts)
 * and cannot be read synchronously -- and, more importantly, "a bot token with no
 * operator" stopped being an error the moment pairing existed. It is the state
 * every deployment starts in and the state /start has to be answered from: a
 * bridge that refuses to serve the webhook until someone is linked can never link
 * anyone. So this stays synchronous and total, the operator set is resolved by
 * whoever needs it, and "on but unpaired" is reported as partial rather than
 * thrown.
 *
 * The webhook secret is still absent on purpose -- it is derived on demand by the
 * only code that needs it, and nothing that merely sends a notification should
 * hold it.
 */
export function readTelegramConfig(env: Env): TelegramConfig | null {
	const botToken = env.TELEGRAM_BOT_TOKEN?.trim();
	return botToken ? { botToken } : null;
}

export class TelegramApiError extends Error {
	constructor(
		readonly method: string,
		readonly statusCode: number,
		readonly description: string,
	) {
		super(`telegram ${method} failed (${statusCode}): ${description}`);
		this.name = "TelegramApiError";
	}
}

/**
 * "You addressed a topic that is not there any more."
 *
 * The Bot API has no error code for this -- a deleted forum topic comes back as a
 * generic 400 -- so the description is the only signal, and matching it is
 * matched narrowly on purpose. A topic the operator *closed* answers with
 * TOPIC_CLOSED and is deliberately not included: closing is a decision to make a
 * topic read-only, and silently recreating it would undo that decision. Only a
 * topic that no longer exists gets recreated.
 */
export function isMissingTopicError(error: unknown): boolean {
	if (!(error instanceof TelegramApiError) || error.statusCode !== 400) {
		return false;
	}
	const description = error.description.toLowerCase();
	return (
		description.includes("thread not found") ||
		description.includes("topic_deleted") ||
		description.includes("topic deleted")
	);
}

/**
 * "The edit you asked for would change nothing."
 *
 * Telegram answers 400 rather than 200 when editMessageText produces the text a
 * message already has, which is the ordinary outcome of reconciling the same
 * state twice — the operator archiving from the web an email Telegram already
 * showed as archived. Treating it as a failure would retry an edit that can only
 * ever be refused again, so callers read it as "already in that state".
 */
export function isNotModifiedError(error: unknown): boolean {
	return (
		error instanceof TelegramApiError &&
		error.statusCode === 400 &&
		error.description.toLowerCase().includes("message is not modified")
	);
}

/**
 * Bounded on purpose: these calls run inside the notification queue consumer,
 * whose invocation a hung request would hold open while the rest of the batch
 * waits. Failing fast costs one redelivery, which that queue is built for;
 * hanging costs the whole batch. (Before the notification queue existed this
 * bound protected the ingest ack path, which could not retry at all.)
 */
const TELEGRAM_TIMEOUT_MS = 10_000;

async function readTelegramResult<T>(method: string, response: Response): Promise<T> {
	const payload = (await response.json()) as {
		ok: boolean;
		result?: T;
		description?: string;
		error_code?: number;
	};
	if (!payload.ok) {
		throw new TelegramApiError(
			method,
			payload.error_code ?? response.status,
			payload.description ?? "unknown error",
		);
	}
	return payload.result as T;
}

export async function callTelegram<T>(
	botToken: string,
	method: string,
	params: Record<string, unknown>,
): Promise<T> {
	const response = await fetchWithTimeout(`${TELEGRAM_API_BASE}/bot${botToken}/${method}`, {
		method: "POST",
		headers: { "content-type": "application/json" },
		body: JSON.stringify(params),
		timeoutMs: TELEGRAM_TIMEOUT_MS,
	});
	return readTelegramResult<T>(method, response);
}

export function inlineKeyboard(buttons: TelegramInlineButton[][]): Record<string, unknown> {
	return { inline_keyboard: buttons };
}

export async function sendMessage(
	config: TelegramConfig,
	input: {
		chatId: string;
		text: string;
		messageThreadId?: number | null;
		replyToMessageId?: number | null;
		replyMarkup?: Record<string, unknown>;
	},
): Promise<TelegramMessage> {
	return callTelegram<TelegramMessage>(config.botToken, "sendMessage", {
		chat_id: input.chatId,
		text: input.text,
		parse_mode: "HTML",
		link_preview_options: { is_disabled: true },
		...(input.messageThreadId ? { message_thread_id: input.messageThreadId } : {}),
		...(input.replyToMessageId
			? {
					reply_parameters: {
						message_id: input.replyToMessageId,
						allow_sending_without_reply: true,
					},
				}
			: {}),
		...(input.replyMarkup ? { reply_markup: input.replyMarkup } : {}),
	});
}

/**
 * No message_thread_id here on purpose: chat_id plus message_id already names a
 * message wherever it lives, topic or not, and passing a thread the message is
 * not in is an error rather than a filter.
 *
 * An omitted replyMarkup drops the inline keyboard, which is the intended
 * behaviour for a card whose buttons no longer apply.
 */
export async function editMessageText(
	config: TelegramConfig,
	input: {
		chatId: string;
		messageId: number;
		text: string;
		replyMarkup?: Record<string, unknown>;
	},
): Promise<void> {
	await callTelegram(config.botToken, "editMessageText", {
		chat_id: input.chatId,
		message_id: input.messageId,
		text: input.text,
		parse_mode: "HTML",
		link_preview_options: { is_disabled: true },
		...(input.replyMarkup ? { reply_markup: input.replyMarkup } : {}),
	});
}

/**
 * A text file, for the bodies that do not belong in a chat as chat.
 *
 * Multipart rather than JSON because the document travels as a file part, so
 * this is the one Bot API call that cannot go through callTelegram. The upload is
 * an ordinary form field, which is why no boundary is set by hand: FormData in
 * Workers produces the header itself.
 */
export async function sendDocument(
	config: TelegramConfig,
	input: {
		chatId: string;
		filename: string;
		/**
		 * Text for a message body dumped to a file, bytes for a real attachment
		 * pulled out of R2. Both are the same form field; only the Blob's type
		 * changes, which is what decides whether Telegram previews it as a document
		 * or as an image.
		 */
		content: string | ArrayBuffer | ArrayBufferView;
		contentType?: string;
		caption?: string;
		messageThreadId?: number | null;
		replyToMessageId?: number | null;
	},
): Promise<TelegramMessage> {
	const form = new FormData();
	form.set("chat_id", input.chatId);
	if (input.caption) {
		form.set("caption", input.caption);
		form.set("parse_mode", "HTML");
	}
	if (input.messageThreadId) {
		form.set("message_thread_id", String(input.messageThreadId));
	}
	if (input.replyToMessageId) {
		form.set(
			"reply_parameters",
			JSON.stringify({ message_id: input.replyToMessageId, allow_sending_without_reply: true }),
		);
	}
	form.set(
		"document",
		new Blob([input.content as BlobPart], {
			type: input.contentType ?? "text/plain; charset=utf-8",
		}),
		input.filename,
	);
	const response = await fetchWithTimeout(
		`${TELEGRAM_API_BASE}/bot${config.botToken}/sendDocument`,
		{ method: "POST", body: form, timeoutMs: TELEGRAM_TIMEOUT_MS },
	);
	return readTelegramResult<TelegramMessage>("sendDocument", response);
}

export async function answerCallbackQuery(
	config: TelegramConfig,
	input: { callbackQueryId: string; text?: string },
): Promise<void> {
	await callTelegram(config.botToken, "answerCallbackQuery", {
		callback_query_id: input.callbackQueryId,
		...(input.text ? { text: input.text } : {}),
	});
}

export type TelegramChat = {
	id: number | string;
	type: string;
	/** Groups, supergroups and channels have one; a private chat does not. */
	title?: string;
	/** True only for a supergroup with topic mode turned on. */
	is_forum?: boolean;
	/** Default member permissions; decides whether a plain `member` may post. */
	permissions?: { can_send_messages?: boolean; can_manage_topics?: boolean };
};

/** The bot's own account, from getMe. */
export type TelegramBotUser = {
	id: number;
	is_bot: boolean;
	first_name: string;
	username?: string;
};

/**
 * One chat member, as getChatMember reports it. Only the fields the operator
 * routes read: which of the status variants this is, and the two rights that
 * decide whether the bridge can post (and create topics) there.
 */
export type TelegramChatMember = {
	status: "creator" | "administrator" | "member" | "restricted" | "left" | "kicked" | string;
	/** Administrators, and restricted members when the restriction allows it. */
	can_manage_topics?: boolean;
	/** Restricted members only. */
	can_send_messages?: boolean;
	/** Restricted members only: false once they have left. */
	is_member?: boolean;
};

/** Who this bot is. Read-only, and the only way to learn the @username from the token. */
export async function getMe(config: TelegramConfig): Promise<TelegramBotUser> {
	return callTelegram<TelegramBotUser>(config.botToken, "getMe", {});
}

/** What the bot may do in a chat. Read-only. */
export async function getChatMember(
	config: TelegramConfig,
	input: { chatId: string; userId: number },
): Promise<TelegramChatMember> {
	return callTelegram<TelegramChatMember>(config.botToken, "getChatMember", {
		chat_id: input.chatId,
		user_id: input.userId,
	});
}

/** What kind of chat this is — asked once at adoption, not once per email. */
export async function getChat(
	config: TelegramConfig,
	input: { chatId: string },
): Promise<TelegramChat> {
	return callTelegram<TelegramChat>(config.botToken, "getChat", { chat_id: input.chatId });
}

export async function createForumTopic(
	config: TelegramConfig,
	input: { chatId: string; name: string },
): Promise<TelegramForumTopic> {
	return callTelegram<TelegramForumTopic>(config.botToken, "createForumTopic", {
		chat_id: input.chatId,
		name: input.name.slice(0, 128),
	});
}

export async function closeForumTopic(
	config: TelegramConfig,
	input: { chatId: string; messageThreadId: number },
): Promise<void> {
	await callTelegram(config.botToken, "closeForumTopic", {
		chat_id: input.chatId,
		message_thread_id: input.messageThreadId,
	});
}

/**
 * The update kinds the bridge acts on.
 *
 * `edited_message` is here because correcting your own message is the second
 * gesture a phone suggests for fixing a draft, and Telegram delivers that as its
 * own update kind: without it in this list the bot never hears about the edit at
 * all, and the operator watches a preview that will not change.
 */
export const TELEGRAM_ALLOWED_UPDATES = ["message", "edited_message", "callback_query"] as const;

export type TelegramWebhookInfo = {
	url: string;
	pending_update_count?: number;
	last_error_message?: string;
	/**
	 * Absent means Telegram is using its default set, which is a superset of
	 * everything above — so absence is agreement, not drift.
	 */
	allowed_updates?: string[];
	/**
	 * When the last delivery attempt failed, in epoch *seconds* as Telegram sends
	 * it. This is the machine-readable half of the error: the message is prose
	 * Telegram may reword at any time, the date is a number that can be compared.
	 */
	last_error_date?: number;
};

export async function getWebhookInfo(config: TelegramConfig): Promise<TelegramWebhookInfo> {
	return callTelegram<TelegramWebhookInfo>(config.botToken, "getWebhookInfo", {});
}

export async function setWebhook(
	config: TelegramConfig,
	input: { url: string; secret: string },
): Promise<void> {
	await callTelegram(config.botToken, "setWebhook", {
		url: input.url,
		secret_token: input.secret,
		// Only the update kinds the bridge acts on. Anything else is bandwidth spent
		// to be ignored.
		allowed_updates: [...TELEGRAM_ALLOWED_UPDATES],
	});
}

/**
 * One entry of the bot's "/" menu.
 *
 * Telegram will not accept a command with anything but lowercase letters, digits
 * and underscores, which is why the accented Spanish that fits the descriptions
 * never appears in the command itself.
 */
export type TelegramBotCommand = {
	command: string;
	description: string;
};

export async function getMyCommands(config: TelegramConfig): Promise<TelegramBotCommand[]> {
	return callTelegram<TelegramBotCommand[]>(config.botToken, "getMyCommands", {});
}

export async function setMyCommands(
	config: TelegramConfig,
	commands: TelegramBotCommand[],
): Promise<void> {
	await callTelegram(config.botToken, "setMyCommands", { commands });
}