Ctrl K
ComunidadGuías…

src/telegram/binding.ts

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

Archivo original ↗Preguntar a KiBot
/**
 * Which chat this deployment talks to, and how it came to know that.
 *
 * Kept out of api.ts (which is pure Bot API plumbing) and out of webhook.ts (which
 * would make it unreachable from the notification path) so both the update handler
 * and the notifier can agree on one answer.
 */

import {
	adoptRuntimeConfig,
	getRuntimeConfig,
	RUNTIME_CONFIG_KEYS,
	setRuntimeConfig,
} from "../db/runtime-config";
import { getChat, type TelegramConfig } from "./api";

/**
 * The chat that receives new-mail cards, as observed rather than declared.
 *
 * There is no configured fallback on purpose: a deployment that has never been
 * spoken to has no chat, and saying so is more useful than sending mail to an id
 * someone typed into a manifest months ago.
 */
export async function resolveTelegramChatId(env: Env): Promise<string | null> {
	return getRuntimeConfig(env.INDEX_DB, RUNTIME_CONFIG_KEYS.telegramChatId);
}

export type ChatAdoption = {
	/** The chat this deployment is bound to after the attempt. */
	chatId: string;
	/** False when another chat already held the binding, so the caller can say so. */
	adopted: boolean;
};

/**
 * Binds this deployment to a chat the first time an allowlisted operator speaks.
 *
 * This is the whole reason TELEGRAM_CHAT_ID no longer needs to exist: the value
 * arrives inside the update, so the bot already knows it. The old flow had the bot
 * print the chat id in a message and ask the human to copy it into a config file
 * and redeploy -- the system observing a value, telling a human, and then asking
 * that human to tell it back.
 */
export async function adoptTelegramChat(env: Env, chatId: string): Promise<ChatAdoption> {
	const effective = await adoptRuntimeConfig(
		env.INDEX_DB,
		RUNTIME_CONFIG_KEYS.telegramChatId,
		chatId,
	);
	return { chatId: effective, adopted: effective === chatId };
}

/**
 * Whether the bound chat can hold topics, as last observed.
 *
 * Read on the notification path, which is why it is a stored fact and not a
 * getChat call: Telegram accepts roughly one message per second per chat, and
 * asking "is this a forum?" before every email would spend half that budget
 * re-learning something that changes about once in the life of a deployment.
 */
export async function chatSupportsTopics(env: Env): Promise<boolean> {
	return (await getRuntimeConfig(env.INDEX_DB, RUNTIME_CONFIG_KEYS.telegramChatIsForum)) === "1";
}

/**
 * Re-observes whether the chat is a forum, and remembers the answer.
 *
 * Called from /start -- the one moment the operator is already waiting on a round
 * trip and an extra API call costs nothing -- so turning topic mode on (or off)
 * in Telegram and sending /start again is the whole reconfiguration procedure.
 * There is no TELEGRAM_TOPICS var to set, because a var would only ever be a
 * human transcribing what getChat already knows, and transcriptions go stale
 * silently.
 *
 * Never throws: a getChat that fails must not cost the operator the adoption
 * itself. The previously stored answer is kept rather than being clobbered with a
 * guess, since a failed probe is not evidence that the chat stopped being a forum.
 */
export async function refreshChatTopicSupport(
	env: Env,
	config: TelegramConfig,
	chatId: string,
): Promise<boolean> {
	let chat: Awaited<ReturnType<typeof getChat>>;
	try {
		chat = await getChat(config, { chatId });
	} catch (error) {
		console.warn("telegram.get_chat_failed", {
			chatId,
			error: error instanceof Error ? error.message : String(error),
		});
		return chatSupportsTopics(env);
	}
	// Both halves matter: is_forum is only ever true on a supergroup, and a plain
	// private chat with the bot has no topics to post into at all.
	const isForum = chat.type === "supergroup" && chat.is_forum === true;
	await setRuntimeConfig(
		env.INDEX_DB,
		RUNTIME_CONFIG_KEYS.telegramChatIsForum,
		isForum ? "1" : "0",
	);
	return isForum;
}