Ctrl K
ComunidadGuías…

src/telegram/cards.ts

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

Archivo original ↗Preguntar a KiBot
/**
 * The inbound card as an object the operator can act on, instead of a dead end.
 *
 * Most mail is not answered, it is dispatched — and until these buttons existed
 * the only thing Telegram could do with an email was reply to it, so the surface
 * served the fifth of the inbox that needs a sentence and ignored the rest.
 *
 * Two things live here: what a card looks like after something happens to it
 * (renderInboundNotification plus a status line, re-rendered from the same D1 row
 * that produced it, so an edit never rewrites what the operator was told), and
 * the two entry points that produce such an edit — a button press arriving on the
 * webhook, and a state change on another surface arriving on the notify queue.
 */

import type { TelegramCardRefreshQueueMessage } from "../cloudflare/types";
import {
	getMailbox,
	getTelegramLinkByMessage,
	insertTelegramLink,
	type TelegramLinkRow,
} from "../db/d1";
import { getRuntimeConfig, RUNTIME_CONFIG_KEYS } from "../db/runtime-config";
import {
	answerCallbackQuery,
	editMessageText,
	inlineKeyboard,
	isNotModifiedError,
	readTelegramConfig,
	sendDocument,
	sendMessage,
	type TelegramCallbackQuery,
	type TelegramConfig,
	type TelegramInlineButton,
	type TelegramMessage,
} from "./api";
import { deliverAttachments, describeDelivery } from "./attachments";
import { resolveTelegramChatId } from "./binding";
import {
	chunkTelegramText,
	type InboundNotification,
	renderInboundNotification,
	type TelegramCardStatus,
} from "./format";
import { getMailIndexRow, isFirstContact, type MailIndexRow } from "./index-rows";
import { findCardForMessage, findCardForThread } from "./links";
import {
	applyMailboxMessageAction,
	fetchMailboxAttachments,
	fetchMailboxMessage,
} from "./messages";
import { getRetainedItem, isSenderMuted, toggleSenderMute } from "./noise";

/**
 * The whole callback protocol: a version and a verb, two bytes of payload.
 *
 * Deliberately not the token table the send-confirmation buttons use. That one
 * exists because callback_data is capped at 64 bytes and a mailbox id plus a
 * draft id do not fit — and because a Send button carries the authority to mail
 * a stranger, so its token is bound to the account it was shown to. A card button
 * needs neither: the callback query carries query.message.message_id, and
 * (chat_id, message_id) is already a row in telegram_links naming the mailbox,
 * the message and the thread. Storing a token to rediscover what Telegram just
 * told us would be a table with no question to answer.
 */
export const CARD_CALLBACK_PREFIX = "v1:";

const CARD_VERBS = {
	a: "archive",
	r: "mark_read",
	x: "read_full",
	/** Stop announcing this sender. The mail keeps arriving and keeps being stored. */
	m: "toggle_mute",
	/** Push the email's files into the chat as documents. */
	f: "attachments",
} as const;

type CardVerb = keyof typeof CARD_VERBS;

/**
 * Trash is missing on purpose. A delete button one thumb-width from archive, on a
 * phone, is a machine for mistakes — and the two mistakes are not symmetric: an
 * email archived by accident costs a search, an email trashed by accident costs
 * the email. Archiving is the verb that makes the inbox empty; deleting is the
 * verb that makes mail disappear, and that one keeps needing a bigger screen.
 */
function inboundCardKeyboard(input: {
	mailboxId: string;
	threadId: string;
	origin: string | null;
	muted: boolean;
	hasAttachments: boolean;
}): TelegramInlineButton[][] {
	const buttons: TelegramInlineButton[] = [
		{ text: "📥 Archivar", callback_data: `${CARD_CALLBACK_PREFIX}a` },
		{ text: "📄 Leer completo", callback_data: `${CARD_CALLBACK_PREFIX}x` },
	];
	// No origin means nobody has ever reached this deployment over its public
	// hostname, so there is no URL to offer. A button pointing at a guess would be
	// a broken link on every card until someone noticed.
	if (input.origin) {
		buttons.push({
			text: "↗ Abrir",
			url: `${input.origin}/mailboxes/${input.mailboxId}/${input.threadId}`,
		});
	}
	// Second row, and deliberately quieter than the first: these two answer
	// questions about the sender and the payload rather than about the mail's
	// state, and neither should sit a thumb-width from Archivar.
	const secondary: TelegramInlineButton[] = [
		{
			text: input.muted ? "🔔 Reactivar" : "🔕 Silenciar",
			callback_data: `${CARD_CALLBACK_PREFIX}m`,
		},
	];
	if (input.hasAttachments) {
		secondary.push({ text: "📎 Adjuntos", callback_data: `${CARD_CALLBACK_PREFIX}f` });
	}
	return [buttons, secondary];
}

/**
 * Statuses that close a card. Archived and trashed mail is dealt with, and
 * leaving buttons under it invites a second decision about a first one that was
 * already made.
 */
const CLOSED_STATUSES = new Set<TelegramCardStatus>(["archived", "trashed"]);

export type CardKeyboardInput = {
	mailboxId: string;
	threadId: string;
	fromAddr: string;
	hasAttachments: boolean;
	status: TelegramCardStatus | null;
};

async function cardKeyboardFor(
	env: Env,
	input: CardKeyboardInput,
): Promise<Record<string, unknown> | undefined> {
	if (input.status && CLOSED_STATUSES.has(input.status)) {
		return undefined;
	}
	// Both reads are indexed single-row lookups, and both have to happen at draw
	// time rather than at delivery time: the mute button is a toggle, so a card
	// restated after a press must come back with the opposite label or the operator
	// cannot tell whether the press did anything.
	const [origin, muted] = await Promise.all([
		getRuntimeConfig(env.INDEX_DB, RUNTIME_CONFIG_KEYS.deploymentOrigin),
		isSenderMuted(env.INDEX_DB, input.fromAddr),
	]);
	return inlineKeyboard(inboundCardKeyboard({ ...input, origin, muted }));
}

/** The buttons a freshly delivered card carries. */
export async function inboundNotificationKeyboard(
	env: Env,
	input: Omit<CardKeyboardInput, "status">,
): Promise<Record<string, unknown> | undefined> {
	return cardKeyboardFor(env, { ...input, status: null });
}

function firstRecipient(toJson: string | null): string | null {
	if (!toJson) return null;
	try {
		const parsed = JSON.parse(toJson) as unknown;
		if (!Array.isArray(parsed)) return null;
		const first = parsed.find((entry) => typeof entry === "string" && entry.trim().length > 0);
		return typeof first === "string" ? first : null;
	} catch {
		return null;
	}
}

/**
 * The card's content, re-read rather than remembered.
 *
 * message_index holds exactly the four facts the card shows, written by the
 * ingest consumer before the notification was ever queued — so re-rendering from
 * it reproduces the original card instead of approximating it, and no copy of the
 * card text has to be carried through a queue or stored a second time.
 */
export async function loadCardContent(
	env: Env,
	input: { mailboxId: string; messageLocalId: string },
): Promise<InboundNotification | null> {
	const row = await getMailIndexRow(env.INDEX_DB, input.mailboxId, input.messageLocalId);
	if (!row) return null;
	return cardContentFor(env, row);
}

/**
 * One message_index row as a card.
 *
 * Shared by every producer of cards — the notifier, a search hit, an item pulled
 * out of a digest, a restatement after a button — because "a retrieved card is
 * indistinguishable from one that arrived" is the property the whole retrieval
 * feature rests on. Two renderers would have drifted apart within a week.
 */
export async function cardContentFor(env: Env, row: MailIndexRow): Promise<InboundNotification> {
	const mailboxAddress =
		firstRecipient(row.to_json) ??
		(await getMailbox(env.INDEX_DB, row.mailbox_id))?.primary_address ??
		row.mailbox_id;
	const [attachments, firstContact] = await Promise.all([
		row.has_attachments === 1
			? fetchMailboxAttachments(env, row.mailbox_id, row.message_local_id)
			: Promise.resolve(null),
		isFirstContact(env.INDEX_DB, {
			fromAddr: row.from_addr,
			messageLocalId: row.message_local_id,
		}),
	]);
	return {
		fromAddr: row.from_addr,
		mailboxAddress,
		subject: row.subject,
		snippet: row.snippet,
		hasAttachments: row.has_attachments === 1,
		attachments: attachments?.map((attachment) => ({
			filename: attachment.filename,
			size: attachment.size,
		})),
		firstContact,
	};
}

export type CardEditOutcome = "edited" | "unchanged" | "unknown_message";

/**
 * Restates one card with the state its email is actually in.
 *
 * Throws whatever the Bot API throws, deliberately — on the queue that is the
 * signal to redeliver, and the one caller that cannot retry (a button press)
 * catches it itself.
 */
async function restateCard(
	env: Env,
	config: TelegramConfig,
	input: { link: TelegramLinkRow; status: TelegramCardStatus | null },
): Promise<CardEditOutcome> {
	const content = await loadCardContent(env, {
		mailboxId: input.link.mailbox_id,
		messageLocalId: input.link.message_local_id,
	});
	if (!content) {
		return "unknown_message";
	}
	try {
		await editMessageText(config, {
			chatId: input.link.chat_id,
			messageId: input.link.message_id,
			text: renderInboundNotification({ ...content, status: input.status }),
			replyMarkup: await cardKeyboardFor(env, {
				mailboxId: input.link.mailbox_id,
				threadId: input.link.thread_id,
				fromAddr: content.fromAddr,
				hasAttachments: content.hasAttachments,
				status: input.status,
			}),
		});
		return "edited";
	} catch (error) {
		// Reconciling a state the card already shows is the ordinary outcome of two
		// surfaces agreeing, not a failure worth a redelivery.
		if (isNotModifiedError(error)) {
			return "unchanged";
		}
		throw error;
	}
}

// --- reconciliation from other surfaces ------------------------------------

/**
 * "Something happened to this email somewhere else."
 *
 * Identified by message when the actor knew which message it touched, and by
 * thread when it did not: a reply confirmed on the web or through MCP answers a
 * conversation, and the card worth marking is the newest one of that thread.
 */
export type TelegramCardRefresh = {
	mailboxId: string;
	messageLocalId: string | null;
	threadId: string | null;
	status: TelegramCardStatus;
};

/** Which status an API triage verb leaves the card in. */
export const CARD_STATUS_FOR_ACTION = {
	archive: "archived",
	trash: "trashed",
	restore_inbox: "inbox",
	mark_read: "read",
	mark_unread: "unread",
} as const satisfies Record<string, TelegramCardStatus>;

export type CardRefreshOutcome =
	| { status: "edited" | "unchanged" }
	| {
			status: "skipped";
			reason: "bridge_disabled" | "no_chat" | "no_card" | "unknown_message";
	  };

/**
 * Edits the card that announced an email whose state changed elsewhere.
 *
 * Every skip here is permanent by construction — a bridge with no token, a
 * deployment with no adopted chat, an email Telegram never announced — so the
 * consumer acks them. Only transport failures throw, and those are exactly the
 * ones worth redelivering.
 */
export async function refreshTelegramCard(
	env: Env,
	refresh: TelegramCardRefresh,
): Promise<CardRefreshOutcome> {
	const config = readTelegramConfig(env);
	if (!config) {
		return { status: "skipped", reason: "bridge_disabled" };
	}
	const chatId = await resolveTelegramChatId(env);
	if (!chatId) {
		return { status: "skipped", reason: "no_chat" };
	}
	const link = refresh.messageLocalId
		? await findCardForMessage(env.INDEX_DB, {
				chatId,
				mailboxId: refresh.mailboxId,
				messageLocalId: refresh.messageLocalId,
			})
		: refresh.threadId
			? await findCardForThread(env.INDEX_DB, {
					chatId,
					mailboxId: refresh.mailboxId,
					threadId: refresh.threadId,
				})
			: null;
	// Most mail is never announced in Telegram — the bridge may have been off, the
	// card may predate this chat. Nothing failed; there is simply nothing to edit.
	if (!link) {
		return { status: "skipped", reason: "no_card" };
	}
	const outcome = await restateCard(env, config, { link, status: refresh.status });
	return outcome === "unknown_message"
		? { status: "skipped", reason: "unknown_message" }
		: { status: outcome };
}

/**
 * Queues a card edit, best effort.
 *
 * On the notify queue rather than inline: it is the one place that already
 * serializes work per chat and backs off on 429, and Telegram counts an edit
 * against the same ~1 message per second the cards themselves spend. Doing it
 * inline would also make an API request wait on Telegram to answer something the
 * caller does not care about.
 *
 * Never throws, and never enqueues on a deployment with no bot token: archiving
 * an email must not fail because a chat bridge is unavailable or absent.
 */
export async function enqueueTelegramCardRefresh(
	env: Env,
	refresh: TelegramCardRefresh,
): Promise<void> {
	if (!readTelegramConfig(env)) {
		return;
	}
	try {
		await env.NOTIFY_QUEUE.send({
			schemaVersion: 1,
			eventType: "telegram.card_refresh.v1",
			refresh,
		} satisfies TelegramCardRefreshQueueMessage);
	} catch (error) {
		console.error("telegram.card_refresh_enqueue_failed", {
			mailboxId: refresh.mailboxId,
			status: refresh.status,
			error: error instanceof Error ? error.message : String(error),
		});
	}
}

// --- cards for mail nobody just received -----------------------------------

/**
 * Posts a card for an email the operator went looking for, and records the link
 * that makes it operable.
 *
 * The link insert is the whole idea. A card is not a rendering, it is an object:
 * quoting it composes a reply, its buttons archive and read and silence, and all
 * of that resolves through (chat_id, message_id) in telegram_links. Emitting the
 * row at the same moment as the message means a search hit, a digest item and an
 * email that arrived on its own are the same thing to every code path downstream
 * — instead of retrieval being a second, read-only rendering of the inbox that
 * quietly does nothing when you reply to it.
 */
export async function postMailCard(
	env: Env,
	config: TelegramConfig,
	input: {
		chatId: string;
		topicId: number | null;
		row: MailIndexRow;
		replyToMessageId?: number | null;
	},
): Promise<TelegramMessage> {
	const content = await cardContentFor(env, input.row);
	const sent = await sendMessage(config, {
		chatId: input.chatId,
		text: renderInboundNotification(content),
		messageThreadId: input.topicId,
		replyToMessageId: input.replyToMessageId ?? null,
		replyMarkup: await inboundNotificationKeyboard(env, {
			mailboxId: input.row.mailbox_id,
			threadId: input.row.thread_id,
			fromAddr: input.row.from_addr,
			hasAttachments: input.row.has_attachments === 1,
		}),
	});
	await insertTelegramLink(env.INDEX_DB, {
		chat_id: input.chatId,
		message_id: sent.message_id,
		mailbox_id: input.row.mailbox_id,
		thread_id: input.row.thread_id,
		message_local_id: input.row.message_local_id,
		topic_id: input.topicId,
	});
	return sent;
}

/**
 * Turns one numbered line of a digest back into a card.
 *
 * The summary is an index, not a copy: it carries nothing but sender and subject,
 * and the row id in its buttons is the address of the real email. Expanding one
 * posts the ordinary card — link included — so the night's mail is triaged with
 * exactly the buttons the operator already knows.
 */
export async function handleDigestExpand(
	env: Env,
	config: TelegramConfig,
	query: TelegramCallbackQuery,
	retainedId: string,
): Promise<void> {
	const item = await getRetainedItem(env.INDEX_DB, retainedId);
	if (!item) {
		await answer(config, query.id, "Ese resumen ya no está disponible.");
		return;
	}
	const row = await getMailIndexRow(env.INDEX_DB, item.mailbox_id, item.message_local_id);
	if (!row) {
		await answer(config, query.id, "Ese correo ya no está en el índice.");
		return;
	}
	await postMailCard(env, config, {
		chatId: item.chat_id,
		topicId: query.message?.message_thread_id ?? null,
		row,
		replyToMessageId: query.message?.message_id ?? null,
	});
	await answer(config, query.id);
}

// --- reconciliation from the buttons themselves ----------------------------

/** More than this many pieces and the body stops being a chat and becomes a file. */
const MAX_BODY_CHUNKS = 3;

export function isCardCallback(data: string): boolean {
	return data.startsWith(CARD_CALLBACK_PREFIX);
}

function verbOf(data: string): CardVerb | null {
	const verb = data.slice(CARD_CALLBACK_PREFIX.length);
	return verb in CARD_VERBS ? (verb as CardVerb) : null;
}

/** Answers the press, so Telegram stops spinning whatever the outcome was. */
async function answer(config: TelegramConfig, queryId: string, text?: string): Promise<void> {
	await answerCallbackQuery(config, { callbackQueryId: queryId, text });
}

/**
 * Restates the card after an action that already succeeded.
 *
 * A failed edit is reported instead of thrown: the Durable Object has already
 * changed the message, and letting the webhook return non-2xx would have Telegram
 * redeliver the update — replaying the action and, for "leer completo", dumping
 * the body into the chat a second time.
 */
async function restateAfterAction(
	env: Env,
	config: TelegramConfig,
	input: { link: TelegramLinkRow; status: TelegramCardStatus | null; done: string },
): Promise<string> {
	try {
		await restateCard(env, config, input);
		return input.done;
	} catch (error) {
		console.warn("telegram.card_edit_failed", {
			chatId: input.link.chat_id,
			messageId: input.link.message_id,
			error: error instanceof Error ? error.message : String(error),
		});
		return `${input.done} (la tarjeta no se pudo actualizar)`;
	}
}

/** Sends the body under the card: inline while it is readable, as a file when it isn't. */
async function deliverFullBody(
	config: TelegramConfig,
	link: TelegramLinkRow,
	body: string,
): Promise<void> {
	const chunks = chunkTelegramText(body);
	if (chunks.length > MAX_BODY_CHUNKS) {
		// Four screens of quoted newsletter is not something anyone reads in a chat,
		// and posting it would spend the chat's whole rate-limit budget to do it.
		await sendDocument(config, {
			chatId: link.chat_id,
			filename: `${link.message_local_id}.txt`,
			content: body,
			caption: `📄 Cuerpo completo (${body.length} caracteres)`,
			messageThreadId: link.topic_id,
			replyToMessageId: link.message_id,
		});
		return;
	}
	for (const chunk of chunks) {
		await sendMessage(config, {
			chatId: link.chat_id,
			text: chunk,
			messageThreadId: link.topic_id,
			replyToMessageId: link.message_id,
		});
	}
}

/**
 * Runs a card button.
 *
 * The caller has already established that the presser is an operator -- the verb
 * carries no authority of its own, so that check is the whole guard -- and that
 * the data is ours. Everything else is established here.
 */
export async function handleCardCallback(
	env: Env,
	config: TelegramConfig,
	query: TelegramCallbackQuery,
): Promise<void> {
	const verb = verbOf(query.data ?? "");
	// The card is the address: without the message this press came from there is
	// nothing to look up, and Telegram omits it only for presses old enough that
	// acting on them blind would be worse than saying so.
	if (!verb || !query.message) {
		await answer(config, query.id, "Esta tarjeta ya no está disponible.");
		return;
	}
	const chatId = String(query.message.chat.id);
	const link = await getTelegramLinkByMessage(env.INDEX_DB, chatId, query.message.message_id);
	if (!link) {
		await answer(config, query.id, "No sé de qué correo es esta tarjeta.");
		return;
	}

	if (verb === "x") {
		const message = await fetchMailboxMessage(env, link.mailbox_id, link.message_local_id);
		const body = message?.body_text?.trim();
		if (!body) {
			await answer(config, query.id, "Ese correo no tiene cuerpo de texto que mostrar.");
			return;
		}
		try {
			await deliverFullBody(config, link, body);
		} catch (error) {
			// Answered rather than thrown: a non-2xx webhook has Telegram redeliver the
			// update, and a redelivered "leer completo" would dump the body into the
			// chat a second time. The operator is told, and one more tap retries it.
			console.warn("telegram.card_body_failed", {
				chatId: link.chat_id,
				messageId: link.message_id,
				error: error instanceof Error ? error.message : String(error),
			});
			await answer(config, query.id, "No he podido enviar el cuerpo. Inténtalo otra vez.");
			return;
		}
		// Reading it here finally counts as reading it. The card's own argument for
		// not shipping bodies by default was that reading the whole message should be
		// an explicit act — pressing a button is one, and an act the rest of Reccado
		// should know about.
		const marked = await applyMailboxMessageAction(env, {
			mailboxId: link.mailbox_id,
			messageLocalId: link.message_local_id,
			action: "mark_read",
		});
		await answer(
			config,
			query.id,
			marked
				? await restateAfterAction(env, config, { link, status: "read", done: "Leído" })
				: "Enviado, pero no pude marcarlo como leído.",
		);
		return;
	}

	if (verb === "m") {
		const content = await loadCardContent(env, {
			mailboxId: link.mailbox_id,
			messageLocalId: link.message_local_id,
		});
		if (!content) {
			await answer(config, query.id, "No encuentro ese correo en el índice.");
			return;
		}
		const muted = await toggleSenderMute(env.INDEX_DB, {
			sender: content.fromAddr,
			mutedBy: String(query.from.id),
		});
		// Restated with no status change: the only thing that moved is the button's
		// own label, which is also the only place the mute is visible at all — so a
		// restatement that failed is worth saying out loud.
		const restated = await restateAfterAction(env, config, {
			link,
			status: null,
			done: muted
				? `🔕 ${content.fromAddr}: se guardará, no avisaré. Pulsa 🔔 para deshacer.`
				: `🔔 ${content.fromAddr}: vuelvo a avisar.`,
		});
		await answer(config, query.id, restated);
		return;
	}

	if (verb === "f") {
		try {
			const delivery = await deliverAttachments(env, config, link);
			await answer(
				config,
				query.id,
				delivery ? describeDelivery(delivery) : "Ese correo no trae adjuntos.",
			);
		} catch (error) {
			// Answered rather than thrown, for the reason "leer completo" is: a non-2xx
			// makes Telegram redeliver the press, and a redelivered press would upload
			// every file a second time.
			console.warn("telegram.card_attachments_failed", {
				chatId: link.chat_id,
				messageId: link.message_id,
				error: error instanceof Error ? error.message : String(error),
			});
			await answer(config, query.id, "No he podido enviar los adjuntos. Inténtalo otra vez.");
		}
		return;
	}

	const action = verb === "a" ? "archive" : "mark_read";
	const applied = await applyMailboxMessageAction(env, {
		mailboxId: link.mailbox_id,
		messageLocalId: link.message_local_id,
		action,
	});
	if (!applied) {
		await answer(config, query.id, "El buzón no aceptó la acción.");
		return;
	}
	await answer(
		config,
		query.id,
		await restateAfterAction(
			env,
			config,
			verb === "a"
				? { link, status: "archived", done: "Archivado" }
				: { link, status: "read", done: "Marcado como leído" },
		),
	);
}