/** * Telegram inbound routing composition * Wires authorized updates into menus, commands, media grouping, and prompt queueing */ import * as OutboundHandlers from "./outbound-handlers.ts"; import * as Commands from "./commands.ts"; import type { TelegramConfigStore } from "./config.ts"; import type { TelegramAttachmentHandlerRuntime } from "./attachment-handlers.ts"; import type { TelegramRegisteredSession, TelegramTopicBinding } from "./session-registry.ts"; import * as Media from "./media.ts"; import * as Menu from "./menu.ts"; import * as Model from "./model.ts"; import * as Queue from "./queue.ts"; import type { TelegramBridgeRuntime } from "./runtime.ts"; import * as Turns from "./turns.ts"; import * as Updates from "./updates.ts"; export type TelegramRoutedMessage = Updates.TelegramUpdateMessage & Media.TelegramMediaMessage & Media.TelegramMediaGroupMessage & Commands.TelegramCommandRuntimeMessage & Turns.TelegramTurnMessage; export type TelegramRoutedCallbackQuery = Updates.TelegramCallbackQuery & Menu.MenuCallbackQuery; export type TelegramTopicInboxUpdate = | { message: TMessage } | { callback_query: TCallbackQuery }; export interface TelegramTopicCallbackQuery { message?: { chat: { id: number; type: string; title?: string }; message_id?: number; message_thread_id?: number; }; } export interface TelegramTopicRouterDeps< TMessage, TCallbackQuery extends TelegramTopicCallbackQuery = TelegramTopicCallbackQuery, > { bindTopicByCode: (code: string, binding: TelegramTopicBinding) => Promise; findSessionByTopic: (chatId: number, threadId: number | undefined) => Promise; appendInbox: (path: string, item: { update: TelegramTopicInboxUpdate; receivedAt: number }) => Promise; sendTextReply: (chatId: number, replyToMessageId: number, text: string, options?: { threadId?: number }) => Promise; isPidAlive: (pid: number) => boolean; now: () => number; } export interface TelegramTopicRouter { handleGroupTopicMessage: (message: TMessage) => Promise; handleGroupTopicCallbackQuery: (query: TCallbackQuery) => Promise; } function getMessageText(message: { text?: string; caption?: string }): string { return message.text ?? message.caption ?? ""; } function parseBindText(text: string): string | undefined { const match = text.trim().match(/^\/bind(?:@\w+)?\s+([a-zA-Z0-9]{4,12})$/); return match?.[1]?.toUpperCase(); } function isTelegramGroupChat(chatType: string | undefined): boolean { return chatType === "group" || chatType === "supergroup"; } function isRoutedTelegramUpdate(value: { __piTelegramRouted?: boolean }): boolean { return value.__piTelegramRouted === true; } export function createTelegramTopicRouter< TMessage extends { chat: { id: number; type: string; title?: string }; message_id: number; message_thread_id?: number; text?: string; caption?: string; }, TCallbackQuery extends TelegramTopicCallbackQuery = TelegramTopicCallbackQuery, >(deps: TelegramTopicRouterDeps): TelegramTopicRouter { return { handleGroupTopicMessage: async (message) => { const chatType = message.chat.type; if (!isTelegramGroupChat(chatType)) return false; const text = getMessageText(message); const bindCode = parseBindText(text); const replyOptions = { threadId: message.message_thread_id }; if (bindCode) { const bound = await deps.bindTopicByCode(bindCode, { chatId: message.chat.id, threadId: message.message_thread_id, chatTitle: message.chat.title, updatedAt: deps.now(), }); await deps.sendTextReply( message.chat.id, message.message_id, bound ? "✅ Topic bound to pi session." : "❌ Bind code not found or expired.", replyOptions, ); return true; } const session = await deps.findSessionByTopic(message.chat.id, message.message_thread_id); if (!session) { await deps.sendTextReply( message.chat.id, message.message_id, "This Telegram topic is not bound to a pi session. Run /tg-bind-code in pi, then send /bind here.", replyOptions, ); return true; } if (!deps.isPidAlive(session.pid)) { await deps.sendTextReply( message.chat.id, message.message_id, "The pi session bound to this topic is offline. Generate a new /tg-bind-code in a live pi session and bind again.", replyOptions, ); return true; } await deps.appendInbox(session.inboxPath, { update: { message: { ...message, __piTelegramRouted: true } }, receivedAt: deps.now(), }); return true; }, handleGroupTopicCallbackQuery: async (query) => { if (isRoutedTelegramUpdate(query as { __piTelegramRouted?: boolean })) return false; const message = query.message; const chat = message?.chat; if (!chat || !isTelegramGroupChat(chat.type)) return false; const session = await deps.findSessionByTopic(chat.id, message?.message_thread_id); if (!session || !deps.isPidAlive(session.pid)) return false; await deps.appendInbox(session.inboxPath, { update: { callback_query: { ...query, __piTelegramRouted: true, }, }, receivedAt: deps.now(), }); return true; }, }; } export interface TelegramInboundRouteRuntimeDeps< TUpdate extends Updates.TelegramUpdateFlow & { message?: TMessage; edited_message?: TMessage; callback_query?: TCallbackQuery; }, TMessage extends TelegramRoutedMessage, TCallbackQuery extends TelegramRoutedCallbackQuery, TContext, TModel extends Model.MenuModel, > { configStore: Pick< TelegramConfigStore, "get" | "update" | "getAllowedUserId" | "setAllowedUserId" | "persist" >; bridgeRuntime: TelegramBridgeRuntime; activeTurnRuntime: Queue.TelegramActiveTurnStore; mediaGroupRuntime: Media.TelegramMediaGroupController; telegramQueueStore: Queue.TelegramQueueStateStore; queueMutationRuntime: Queue.TelegramQueueMutationController; modelMenuRuntime: Menu.TelegramModelMenuRuntime; currentModelRuntime: Model.CurrentModelRuntime; modelSwitchController: Model.TelegramModelSwitchController< TContext, Model.ScopedTelegramModel >; menuActions: Menu.TelegramMenuActionRuntime; buttonActionStore?: OutboundHandlers.TelegramButtonActionStore; attachmentHandlerRuntime: TelegramAttachmentHandlerRuntime; updateStatus: (ctx: TContext, error?: string) => void; dispatchNextQueuedTelegramTurn: (ctx: TContext) => void; answerCallbackQuery: ( callbackQueryId: string, text?: string, ) => Promise; sendTextReply: ( chatId: number, replyToMessageId: number, text: string, options?: { threadId?: number }, ) => Promise; setMyCommands: Commands.TelegramBotCommandRegistrationDeps["setMyCommands"]; downloadFile: Media.DownloadTelegramMessageFilesDeps["downloadFile"]; getThinkingLevel: () => Model.ThinkingLevel; setThinkingLevel: (level: Model.ThinkingLevel) => void; setModel: (model: TModel) => Promise; isIdle: (ctx: TContext) => boolean; hasPendingMessages: (ctx: TContext) => boolean; compact: ( ctx: TContext, callbacks: { onComplete: () => void; onError: (error: unknown) => void }, ) => void; topicRouter?: TelegramTopicRouter; recordRuntimeEvent?: ( category: string, error: unknown, details?: Record, ) => void; } export function createTelegramInboundRouteRuntime< TUpdate extends Updates.TelegramUpdateFlow & { message?: TMessage; edited_message?: TMessage; callback_query?: TCallbackQuery; }, TMessage extends TelegramRoutedMessage, TCallbackQuery extends TelegramRoutedCallbackQuery, TContext, TModel extends Model.MenuModel, >( deps: TelegramInboundRouteRuntimeDeps< TUpdate, TMessage, TCallbackQuery, TContext, TModel >, ): Updates.TelegramUpdateRuntimeController { const menuCallbackHandler = Menu.createTelegramMenuCallbackHandlerForContext< TCallbackQuery, TContext, TModel >({ getStoredModelMenuState: deps.modelMenuRuntime.getState, getActiveModel: deps.currentModelRuntime.get, getThinkingLevel: deps.getThinkingLevel, setThinkingLevel: deps.setThinkingLevel, updateStatus: deps.updateStatus, updateModelMenuMessage: deps.menuActions.updateModelMenuMessage, updateThinkingMenuMessage: deps.menuActions.updateThinkingMenuMessage, updateStatusMessage: deps.menuActions.updateStatusMessage, answerCallbackQuery: deps.answerCallbackQuery, isIdle: deps.isIdle, hasActiveTelegramTurn: deps.activeTurnRuntime.has, hasAbortHandler: deps.bridgeRuntime.abort.hasHandler, getActiveToolExecutions: deps.bridgeRuntime.lifecycle.getActiveToolExecutions, setModel: deps.setModel, setCurrentModel: deps.currentModelRuntime.setCurrentModel, stagePendingModelSwitch: deps.modelSwitchController.stagePendingSwitch, restartInterruptedTelegramTurn: deps.modelSwitchController.restartInterruptedTurn, }); const callbackHandler = async ( query: TCallbackQuery, ctx: TContext, ): Promise => { const routedCallback = (query as { __piTelegramRouted?: boolean }).__piTelegramRouted; if (deps.topicRouter && !routedCallback) { const routed = await deps.topicRouter.handleGroupTopicCallbackQuery(query); if (routed) return; } if (deps.buttonActionStore) { const handled = await OutboundHandlers.handleTelegramButtonCallbackQuery( query, ctx, { resolveAction: deps.buttonActionStore.resolve, answerCallbackQuery: deps.answerCallbackQuery, enqueueButtonPrompt: (buttonQuery, action, context) => { const chatId = buttonQuery.message?.chat?.id; const messageId = buttonQuery.message?.message_id; if (typeof chatId !== "number" || typeof messageId !== "number") return; const queueOrder = deps.bridgeRuntime.queue.allocateItemOrder(); deps.queueMutationRuntime.append( OutboundHandlers.createTelegramButtonPromptTurn({ chatId, replyToMessageId: messageId, queueOrder, action, }), context, ); deps.updateStatus(context); deps.dispatchNextQueuedTelegramTurn(context); }, }, ); if (handled) return; } await menuCallbackHandler(query, ctx); }; const commandHandler = Commands.createTelegramCommandHandlerTargetRuntime< TMessage, TContext >({ hasAbortHandler: deps.bridgeRuntime.abort.hasHandler, clearPendingModelSwitch: deps.modelSwitchController.clearPendingSwitch, hasQueuedTelegramItems: deps.telegramQueueStore.hasQueuedItems, clearQueuedTelegramItems: deps.queueMutationRuntime.clear, setPreserveQueuedTurnsAsHistory: deps.bridgeRuntime.lifecycle.setPreserveQueuedTurnsAsHistory, abortCurrentTurn: deps.bridgeRuntime.abort.abortTurn, isIdle: deps.isIdle, hasPendingMessages: deps.hasPendingMessages, hasActiveTelegramTurn: deps.activeTurnRuntime.has, hasDispatchPending: deps.bridgeRuntime.lifecycle.hasDispatchPending, isCompactionInProgress: deps.bridgeRuntime.lifecycle.isCompactionInProgress, setCompactionInProgress: deps.bridgeRuntime.lifecycle.setCompactionInProgress, updateStatus: deps.updateStatus, dispatchNextQueuedTelegramTurn: deps.dispatchNextQueuedTelegramTurn, compact: deps.compact, allocateItemOrder: deps.bridgeRuntime.queue.allocateItemOrder, allocateControlOrder: deps.bridgeRuntime.queue.allocateControlOrder, appendControlItem: deps.queueMutationRuntime.append, showStatus: deps.menuActions.sendStatusMessage, openModelMenu: deps.menuActions.openModelMenu, getAllowedUserId: deps.configStore.getAllowedUserId, setAllowedUserId: deps.configStore.setAllowedUserId, setMyCommands: deps.setMyCommands, persistConfig: deps.configStore.persist, bindCurrentTopic: async (binding) => { deps.configStore.update((config) => { config.currentTopicBinding = binding; }); await deps.configStore.persist(); }, sendTextReply: deps.sendTextReply, recordRuntimeEvent: deps.recordRuntimeEvent, }); const promptEnqueue = Queue.createTelegramPromptEnqueueController< TMessage, TContext >({ ...deps.telegramQueueStore, getPreserveQueuedTurnsAsHistory: deps.bridgeRuntime.lifecycle.shouldPreserveQueuedTurnsAsHistory, setPreserveQueuedTurnsAsHistory: deps.bridgeRuntime.lifecycle.setPreserveQueuedTurnsAsHistory, createTurn: Turns.createTelegramPromptTurnRuntimeBuilder< TMessage, TContext >({ allocateQueueOrder: deps.bridgeRuntime.queue.allocateItemOrder, downloadFile: deps.downloadFile, processAttachments: deps.attachmentHandlerRuntime.process, }), updateStatus: deps.updateStatus, dispatchNextQueuedTelegramTurn: deps.dispatchNextQueuedTelegramTurn, }).enqueue; const commandOrPrompt = Commands.createTelegramCommandOrPromptRuntime< TMessage, TContext >({ extractRawText: Media.extractFirstTelegramMessageText, handleCommand: commandHandler, enqueueTurn: promptEnqueue, }); const mediaDispatch = Media.createTelegramMediaGroupDispatchRuntime< TMessage, TContext >({ mediaGroups: deps.mediaGroupRuntime, dispatchMessages: commandOrPrompt.dispatchMessages, }); const editRuntime = Turns.createTelegramQueuedPromptEditRuntime< TMessage, TContext >({ ...deps.telegramQueueStore, updateStatus: deps.updateStatus, }); const topicAwareMessageHandler = async (message: TMessage, ctx: TContext): Promise => { const chatType = message.chat?.type; const isGroupTopicMessage = chatType === "group" || chatType === "supergroup"; const alreadyRouted = (message as { __piTelegramRouted?: boolean }).__piTelegramRouted; if (isGroupTopicMessage && deps.topicRouter && !alreadyRouted) { const routed = await deps.topicRouter.handleGroupTopicMessage(message); if (routed) return; } await mediaDispatch.handleMessage(message, ctx); }; return Updates.createTelegramPairedUpdateRuntime({ getAllowedUserId: deps.configStore.getAllowedUserId, setAllowedUserId: deps.configStore.setAllowedUserId, persistConfig: deps.configStore.persist, updateStatus: deps.updateStatus, removePendingMediaGroupMessages: deps.mediaGroupRuntime.removeMessages, removeQueuedTelegramTurnsByMessageIds: deps.queueMutationRuntime.removeByMessageIds, clearQueuedTelegramTurnPriorityByMessageId: deps.queueMutationRuntime.clearPriorityByMessageId, prioritizeQueuedTelegramTurnByMessageId: deps.queueMutationRuntime.prioritizeByMessageId, answerCallbackQuery: deps.answerCallbackQuery, handleAuthorizedTelegramCallbackQuery: callbackHandler, sendTextReply: deps.sendTextReply, handleAuthorizedTelegramMessage: topicAwareMessageHandler, handleAuthorizedTelegramEditedMessage: editRuntime.updateFromEditedMessage, }); }