/** * Telegram updates domain helpers * Owns update extraction, authorization, classification, execution planning, and runtime execution for Telegram updates */ import { createTelegramUserPairingRuntime, getTelegramAuthorizationState, type TelegramAuthorizationState, type TelegramUserPairingRuntimeDeps, } from "./config.ts"; // --- Extraction --- export interface TelegramReactionTypeEmoji { type: "emoji"; emoji: string; } export interface TelegramReactionTypeNonEmoji { type: string; } export type TelegramReactionType = | TelegramReactionTypeEmoji | TelegramReactionTypeNonEmoji; export interface TelegramUpdateDeletion { deleted_business_messages?: { message_ids?: unknown }; } function isTelegramMessageIdList(value: unknown): value is number[] { return Array.isArray(value) && value.every((item) => Number.isInteger(item)); } export function normalizeTelegramReactionEmoji(emoji: string): string { return emoji.replace(/\uFE0F/g, ""); } export function collectTelegramReactionEmojis( reactions: TelegramReactionType[], ): Set { return new Set( reactions .filter( (reaction): reaction is TelegramReactionTypeEmoji => reaction.type === "emoji", ) .map((reaction) => normalizeTelegramReactionEmoji(reaction.emoji)), ); } export function extractDeletedTelegramMessageIds( update: TelegramUpdateDeletion, ): number[] { const deletedBusinessMessageIds = update.deleted_business_messages?.message_ids; if (isTelegramMessageIdList(deletedBusinessMessageIds)) { return deletedBusinessMessageIds; } return []; } // --- Routing --- export interface TelegramUser { id: number; is_bot: boolean; } export interface TelegramChat { id?: number; type: string; title?: string; } export interface TelegramUpdateMessage { chat: TelegramChat; from?: TelegramUser; message_id?: number; message_thread_id?: number; text?: string; caption?: string; } export interface TelegramCallbackQuery { id?: string; from: TelegramUser; message?: TelegramUpdateMessage; data?: string; } export interface TelegramUpdateRouting { message?: TelegramUpdateMessage; edited_message?: TelegramUpdateMessage; callback_query?: TelegramCallbackQuery; } export function getAuthorizedTelegramCallbackQuery( update: TelegramUpdateRouting, ): TelegramCallbackQuery | undefined { const query = update.callback_query; if (!query) return undefined; const message = query.message; if (!message || !isSupportedTelegramChat(message.chat.type) || query.from.is_bot) { return undefined; } return query; } function isSupportedTelegramChat(type: string): boolean { return type === "private" || type === "group" || type === "supergroup"; } export function getAuthorizedTelegramMessage( update: TelegramUpdateRouting, ): TelegramUpdateMessage | undefined { const message = update.message; if ( !message || !isSupportedTelegramChat(message.chat.type) || !message.from || message.from.is_bot ) { return undefined; } return message; } export function getAuthorizedTelegramEditedMessage( update: TelegramUpdateRouting, ): TelegramUpdateMessage | undefined { const message = update.edited_message; if ( !message || !isSupportedTelegramChat(message.chat.type) || !message.from || message.from.is_bot ) { return undefined; } return message; } // --- Flow --- export interface TelegramMessageReactionUpdated { chat: { type: string }; user?: TelegramUser; message_id: number; old_reaction: TelegramReactionType[]; new_reaction: TelegramReactionType[]; } export interface TelegramUpdateFlow extends TelegramUpdateRouting, TelegramUpdateDeletion { message_reaction?: TelegramMessageReactionUpdated; } export type TelegramUpdateFlowAction< TReactionUpdate extends TelegramMessageReactionUpdated = TelegramMessageReactionUpdated, TCallbackQuery extends TelegramCallbackQuery = TelegramCallbackQuery, TMessage extends TelegramUpdateMessage = TelegramUpdateMessage, > = | { kind: "ignore" } | { kind: "deleted"; messageIds: number[] } | { kind: "reaction"; reactionUpdate: TReactionUpdate } | { kind: "callback"; query: TCallbackQuery; authorization: TelegramAuthorizationState; } | { kind: "message"; message: TMessage & { from: TelegramUser }; authorization: TelegramAuthorizationState; } | { kind: "edited-message"; message: TMessage & { from: TelegramUser }; authorization: TelegramAuthorizationState; }; export function buildTelegramUpdateFlowAction< TUpdate extends TelegramUpdateFlow, >( update: TUpdate, allowedUserId?: number, ): TelegramUpdateFlowAction< NonNullable, NonNullable, NonNullable > { const deletedMessageIds = extractDeletedTelegramMessageIds(update); if (deletedMessageIds.length > 0) { return { kind: "deleted", messageIds: deletedMessageIds }; } if (update.message_reaction) { return { kind: "reaction", reactionUpdate: update.message_reaction }; } const query = getAuthorizedTelegramCallbackQuery(update); if (query) { return { kind: "callback", query: query as NonNullable, authorization: getTelegramAuthorizationState( query.from.id, allowedUserId, ), }; } const message = getAuthorizedTelegramMessage(update); if (message?.from) { return { kind: "message", message: message as NonNullable< TUpdate["message"] | TUpdate["edited_message"] > & { from: TelegramUser }, authorization: getTelegramAuthorizationState( message.from.id, allowedUserId, ), }; } const editedMessage = getAuthorizedTelegramEditedMessage(update); if (editedMessage?.from) { return { kind: "edited-message", message: editedMessage as NonNullable< TUpdate["message"] | TUpdate["edited_message"] > & { from: TelegramUser }, authorization: getTelegramAuthorizationState( editedMessage.from.id, allowedUserId, ), }; } return { kind: "ignore" }; } // --- Execution Planning --- export type TelegramUpdateExecutionPlan< TReactionUpdate extends TelegramMessageReactionUpdated = TelegramMessageReactionUpdated, TCallbackQuery extends TelegramCallbackQuery = TelegramCallbackQuery, TMessage extends TelegramUpdateMessage = TelegramUpdateMessage, > = | { kind: "ignore" } | { kind: "deleted"; messageIds: number[] } | { kind: "reaction"; reactionUpdate: TReactionUpdate; } | { kind: "callback"; query: TCallbackQuery; shouldPair: boolean; shouldDeny: boolean; } | { kind: "message"; message: TMessage & { from: TelegramUser }; shouldPair: boolean; shouldNotifyPaired: boolean; shouldDeny: boolean; } | { kind: "edited-message"; message: TMessage & { from: TelegramUser }; shouldPair: boolean; shouldDeny: boolean; }; export function buildTelegramUpdateExecutionPlan< TReactionUpdate extends TelegramMessageReactionUpdated, TCallbackQuery extends TelegramCallbackQuery, TMessage extends TelegramUpdateMessage, >( action: TelegramUpdateFlowAction, ): TelegramUpdateExecutionPlan { switch (action.kind) { case "ignore": return { kind: "ignore" }; case "deleted": return { kind: "deleted", messageIds: action.messageIds }; case "reaction": return { kind: "reaction", reactionUpdate: action.reactionUpdate }; case "callback": return { kind: "callback", query: action.query, shouldPair: action.authorization.kind === "pair", shouldDeny: action.authorization.kind === "deny", }; case "message": return { kind: "message", message: action.message, shouldPair: action.authorization.kind === "pair", shouldNotifyPaired: action.authorization.kind === "pair", shouldDeny: action.authorization.kind === "deny", }; case "edited-message": return { kind: "edited-message", message: action.message, shouldPair: action.authorization.kind === "pair", shouldDeny: action.authorization.kind === "deny", }; } } export function buildTelegramUpdateExecutionPlanFromUpdate< TUpdate extends TelegramUpdateFlow, >( update: TUpdate, allowedUserId?: number, ): TelegramUpdateExecutionPlan< NonNullable, NonNullable, NonNullable > { return buildTelegramUpdateExecutionPlan( buildTelegramUpdateFlowAction(update, allowedUserId), ); } // --- Runtime --- export interface TelegramUpdateRuntimeDeps< TContext = unknown, TReactionUpdate extends TelegramMessageReactionUpdated = TelegramMessageReactionUpdated, TCallbackQuery extends TelegramCallbackQuery = TelegramCallbackQuery, TMessage extends TelegramUpdateMessage = TelegramUpdateMessage, > { ctx: TContext; removePendingMediaGroupMessages: (messageIds: number[]) => void; removeQueuedTelegramTurnsByMessageIds: ( messageIds: number[], ctx: TContext, ) => number; handleAuthorizedTelegramReactionUpdate: ( reactionUpdate: TReactionUpdate, ctx: TContext, ) => Promise; pairTelegramUserIfNeeded: (userId: number, ctx: TContext) => Promise; answerCallbackQuery: ( callbackQueryId: string, text?: string, ) => Promise; handleAuthorizedTelegramCallbackQuery: ( query: TCallbackQuery, ctx: TContext, ) => Promise; sendTextReply: ( chatId: number, replyToMessageId: number, text: string, ) => Promise; handleAuthorizedTelegramMessage: ( message: TMessage, ctx: TContext, ) => Promise; handleAuthorizedTelegramEditedMessage: ( message: TMessage, ctx: TContext, ) => unknown; } export interface TelegramUpdateRuntimeControllerDeps< TContext = unknown, TCallbackQuery extends TelegramCallbackQuery = TelegramCallbackQuery, TMessage extends TelegramUpdateMessage = TelegramUpdateMessage, > { getAllowedUserId: () => number | undefined; removePendingMediaGroupMessages: (messageIds: number[]) => void; removeQueuedTelegramTurnsByMessageIds: ( messageIds: number[], ctx: TContext, ) => number; clearQueuedTelegramTurnPriorityByMessageId: ( messageId: number, ctx: TContext, ) => boolean; prioritizeQueuedTelegramTurnByMessageId: ( messageId: number, ctx: TContext, ) => boolean; pairTelegramUserIfNeeded: (userId: number, ctx: TContext) => Promise; answerCallbackQuery: ( callbackQueryId: string, text?: string, ) => Promise; handleAuthorizedTelegramCallbackQuery: ( query: TCallbackQuery, ctx: TContext, ) => Promise; sendTextReply: ( chatId: number, replyToMessageId: number, text: string, ) => Promise; handleAuthorizedTelegramMessage: ( message: TMessage, ctx: TContext, ) => Promise; handleAuthorizedTelegramEditedMessage: ( message: TMessage, ctx: TContext, ) => unknown; } export interface TelegramUpdateRuntimeController< TContext = unknown, TUpdate extends TelegramUpdateFlow = TelegramUpdateFlow, > { handleAuthorizedReactionUpdate: ( reactionUpdate: NonNullable, ctx: TContext, ) => Promise; handleUpdate: (update: TUpdate, ctx: TContext) => Promise; } function getTelegramCallbackQueryId( query: TelegramCallbackQuery, ): string | undefined { return typeof query.id === "string" ? query.id : undefined; } function getTelegramMessageReplyTarget( message: TelegramUpdateMessage, ): { chatId: number; messageId: number } | undefined { if ( typeof message.chat.id !== "number" || typeof message.message_id !== "number" ) { return undefined; } return { chatId: message.chat.id, messageId: message.message_id, }; } export async function executeTelegramUpdate< TUpdate extends TelegramUpdateFlow, TContext = unknown, >( update: TUpdate, allowedUserId: number | undefined, deps: TelegramUpdateRuntimeDeps< TContext, NonNullable, NonNullable, NonNullable >, ): Promise { await executeTelegramUpdatePlan( buildTelegramUpdateExecutionPlanFromUpdate(update, allowedUserId), deps, ); } export type TelegramPairedUpdateRuntimeControllerDeps< TContext = unknown, TUpdate extends TelegramUpdateFlow = TelegramUpdateFlow, > = Omit< TelegramUpdateRuntimeControllerDeps< TContext, NonNullable, NonNullable >, "pairTelegramUserIfNeeded" > & TelegramUserPairingRuntimeDeps; export function createTelegramPairedUpdateRuntime< TContext = unknown, TUpdate extends TelegramUpdateFlow = TelegramUpdateFlow, >( deps: TelegramPairedUpdateRuntimeControllerDeps, ): TelegramUpdateRuntimeController { return createTelegramUpdateRuntime({ getAllowedUserId: deps.getAllowedUserId, removePendingMediaGroupMessages: deps.removePendingMediaGroupMessages, removeQueuedTelegramTurnsByMessageIds: deps.removeQueuedTelegramTurnsByMessageIds, clearQueuedTelegramTurnPriorityByMessageId: deps.clearQueuedTelegramTurnPriorityByMessageId, prioritizeQueuedTelegramTurnByMessageId: deps.prioritizeQueuedTelegramTurnByMessageId, pairTelegramUserIfNeeded: createTelegramUserPairingRuntime({ getAllowedUserId: deps.getAllowedUserId, setAllowedUserId: deps.setAllowedUserId, persistConfig: deps.persistConfig, updateStatus: deps.updateStatus, }).pairIfNeeded, answerCallbackQuery: deps.answerCallbackQuery, handleAuthorizedTelegramCallbackQuery: deps.handleAuthorizedTelegramCallbackQuery, sendTextReply: deps.sendTextReply, handleAuthorizedTelegramMessage: deps.handleAuthorizedTelegramMessage, handleAuthorizedTelegramEditedMessage: deps.handleAuthorizedTelegramEditedMessage, }); } export function createTelegramUpdateRuntime< TContext = unknown, TUpdate extends TelegramUpdateFlow = TelegramUpdateFlow, >( deps: TelegramUpdateRuntimeControllerDeps< TContext, NonNullable, NonNullable >, ): TelegramUpdateRuntimeController { const handleAuthorizedReactionUpdate = async ( reactionUpdate: NonNullable, ctx: TContext, ): Promise => { await handleAuthorizedTelegramReactionUpdate(reactionUpdate, { allowedUserId: deps.getAllowedUserId(), ctx, removePendingMediaGroupMessages: deps.removePendingMediaGroupMessages, removeQueuedTelegramTurnsByMessageIds: deps.removeQueuedTelegramTurnsByMessageIds, clearQueuedTelegramTurnPriorityByMessageId: deps.clearQueuedTelegramTurnPriorityByMessageId, prioritizeQueuedTelegramTurnByMessageId: deps.prioritizeQueuedTelegramTurnByMessageId, }); }; return { handleAuthorizedReactionUpdate, handleUpdate: (update, ctx) => executeTelegramUpdate(update, deps.getAllowedUserId(), { ctx, removePendingMediaGroupMessages: deps.removePendingMediaGroupMessages, removeQueuedTelegramTurnsByMessageIds: deps.removeQueuedTelegramTurnsByMessageIds, handleAuthorizedTelegramReactionUpdate: handleAuthorizedReactionUpdate, pairTelegramUserIfNeeded: deps.pairTelegramUserIfNeeded, answerCallbackQuery: deps.answerCallbackQuery, handleAuthorizedTelegramCallbackQuery: deps.handleAuthorizedTelegramCallbackQuery, sendTextReply: deps.sendTextReply, handleAuthorizedTelegramMessage: deps.handleAuthorizedTelegramMessage, handleAuthorizedTelegramEditedMessage: deps.handleAuthorizedTelegramEditedMessage, }), }; } export interface AuthorizedTelegramReactionUpdateDeps { allowedUserId?: number; ctx: TContext; removePendingMediaGroupMessages: (messageIds: number[]) => void; removeQueuedTelegramTurnsByMessageIds: ( messageIds: number[], ctx: TContext, ) => number; clearQueuedTelegramTurnPriorityByMessageId: ( messageId: number, ctx: TContext, ) => boolean; prioritizeQueuedTelegramTurnByMessageId: ( messageId: number, ctx: TContext, ) => boolean; } export async function handleAuthorizedTelegramReactionUpdate( reactionUpdate: TelegramMessageReactionUpdated, deps: AuthorizedTelegramReactionUpdateDeps, ): Promise { const reactionUser = reactionUpdate.user; if ( reactionUpdate.chat.type !== "private" || !reactionUser || reactionUser.is_bot || reactionUser.id !== deps.allowedUserId ) { return; } const oldEmojis = collectTelegramReactionEmojis(reactionUpdate.old_reaction); const newEmojis = collectTelegramReactionEmojis(reactionUpdate.new_reaction); const dislikeAdded = !oldEmojis.has("👎") && newEmojis.has("👎"); if (dislikeAdded) { deps.removePendingMediaGroupMessages([reactionUpdate.message_id]); deps.removeQueuedTelegramTurnsByMessageIds( [reactionUpdate.message_id], deps.ctx, ); return; } const likeRemoved = oldEmojis.has("👍") && !newEmojis.has("👍"); if (likeRemoved) { deps.clearQueuedTelegramTurnPriorityByMessageId( reactionUpdate.message_id, deps.ctx, ); } const likeAdded = !oldEmojis.has("👍") && newEmojis.has("👍"); if (!likeAdded) return; deps.prioritizeQueuedTelegramTurnByMessageId( reactionUpdate.message_id, deps.ctx, ); } export async function executeTelegramUpdatePlan< TContext = unknown, TReactionUpdate extends TelegramMessageReactionUpdated = TelegramMessageReactionUpdated, TCallbackQuery extends TelegramCallbackQuery = TelegramCallbackQuery, TMessage extends TelegramUpdateMessage = TelegramUpdateMessage, >( plan: TelegramUpdateExecutionPlan, deps: TelegramUpdateRuntimeDeps< TContext, TReactionUpdate, TCallbackQuery, TMessage >, ): Promise { if (plan.kind === "ignore") return; if (plan.kind === "deleted") { deps.removePendingMediaGroupMessages(plan.messageIds); deps.removeQueuedTelegramTurnsByMessageIds(plan.messageIds, deps.ctx); return; } if (plan.kind === "reaction") { await deps.handleAuthorizedTelegramReactionUpdate( plan.reactionUpdate, deps.ctx, ); return; } if (plan.kind === "callback") { if (plan.shouldPair) { await deps.pairTelegramUserIfNeeded(plan.query.from.id, deps.ctx); } if (plan.shouldDeny) { const callbackQueryId = getTelegramCallbackQueryId(plan.query); if (callbackQueryId) { await deps.answerCallbackQuery( callbackQueryId, "This bot is not authorized for your account.", ); } return; } await deps.handleAuthorizedTelegramCallbackQuery(plan.query, deps.ctx); return; } const pairedNow = plan.shouldPair ? await deps.pairTelegramUserIfNeeded(plan.message.from.id, deps.ctx) : false; const replyTarget = getTelegramMessageReplyTarget(plan.message); if ( plan.kind === "message" && pairedNow && plan.shouldNotifyPaired && replyTarget ) { await deps.sendTextReply( replyTarget.chatId, replyTarget.messageId, "Telegram bridge paired with this account.", ); } if (plan.shouldDeny) { if (replyTarget) { await deps.sendTextReply( replyTarget.chatId, replyTarget.messageId, "This bot is not authorized for your account.", ); } return; } if (plan.kind === "edited-message") { await deps.handleAuthorizedTelegramEditedMessage(plan.message, deps.ctx); return; } await deps.handleAuthorizedTelegramMessage(plan.message, deps.ctx); }