/** * 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 { 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 { 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 { 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 { let chat: Awaited>; 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; }