/** * Telegram inbound routing composition * Zones: telegram inbound, orchestration, queue/menu/command composition * Wires authorized updates into menus, commands, media grouping, and prompt queueing, and owns exact assistant-output target/route authority capture */ import { readFile } from "node:fs/promises"; import { basename, dirname } from "node:path"; import * as Bus from "./bus.ts"; import * as Commands from "./commands.ts"; import type { TelegramConfigStore } from "./config.ts"; import type { TelegramInboundHandlerRuntime } from "./inbound.ts"; import * as Media from "./media.ts"; import * as Menu from "./menu.ts"; import * as Model from "./model.ts"; import * as OutboundHandlers from "./outbound.ts"; import * as PromptTemplates from "./prompt-templates.ts"; import * as Queue from "./queue.ts"; import type { TelegramBridgeRuntime } from "./runtime.ts"; import type { TelegramSectionRegistry } from "./sections.ts"; import * as TextGroups from "./text-groups.ts"; import * as ThreadReconciler from "./thread-reconciler.ts"; import type { TelegramInstanceThreadIdentityCandidate, TelegramTopicTargetRecord, } from "./threads.ts"; import * as Turns from "./turns.ts"; interface TelegramPromptPeerView { id?: unknown; is_bot?: unknown; username?: unknown; first_name?: unknown; last_name?: unknown; title?: unknown; } function formatTelegramPromptPeer( peer: TelegramPromptPeerView | undefined, ): string | undefined { if (!peer) return undefined; if (typeof peer.username === "string" && peer.username.length > 0) { return peer.username; } const displayName = [peer.first_name, peer.last_name] .filter( (part): part is string => typeof part === "string" && part.length > 0, ) .join(" "); if (displayName) return displayName; if (typeof peer.title === "string" && peer.title.length > 0) { return peer.title; } return typeof peer.id === "number" ? String(peer.id) : undefined; } function isTelegramPromptOwnerPeer( peer: TelegramPromptPeerView | undefined, ownerUserId: number | undefined, ): boolean { return ownerUserId !== undefined && peer?.id === ownerUserId; } function isTelegramPromptBotPeer( peer: TelegramPromptPeerView | undefined, ): boolean { return peer?.is_bot === true; } export function resolveTelegramGuestPromptPeer(input: { chatType?: string; chat?: TelegramPromptPeerView; from?: TelegramPromptPeerView; replyFrom?: TelegramPromptPeerView; guestBotCallerUser?: TelegramPromptPeerView; guestBotCallerChat?: TelegramPromptPeerView; ownerUserId?: number; }): string | undefined { if (input.chatType !== "private") { return formatTelegramPromptPeer(input.chat); } if ( !isTelegramPromptOwnerPeer(input.from, input.ownerUserId) && !isTelegramPromptBotPeer(input.from) ) { return formatTelegramPromptPeer(input.from); } for (const candidate of [ input.chat, input.guestBotCallerUser, input.guestBotCallerChat, input.replyFrom, ]) { if ( isTelegramPromptOwnerPeer(candidate, input.ownerUserId) || isTelegramPromptBotPeer(candidate) ) { continue; } const peer = formatTelegramPromptPeer(candidate); if (peer) return peer; } return undefined; } function appendTelegramSourceAttachmentSection( text: string, from: string | undefined, files: Pick[], ): string { if (files.length === 0) return text; const dirs = [...new Set(files.map((file) => dirname(file.path)))]; const sameDir = dirs.length === 1; const source = from ? `|from:${from}` : ""; const header = sameDir ? `[attachments${source}] ${dirs[0]}` : `[attachments${source}]`; const items = sameDir ? files.map((file) => `/${basename(file.path)}`) : files.map((file) => file.path); const prefix = text.length > 0 ? `${text}\n\n` : ""; return `${prefix}${header}\n${items.map((item) => `- ${item}`).join("\n")}`; } function getContextCwd(ctx: unknown): string | undefined { if (!ctx || typeof ctx !== "object") return undefined; const cwd = (ctx as { cwd?: unknown }).cwd; return typeof cwd === "string" && cwd.length > 0 ? cwd : undefined; } function getLeaderTopicProfileKey( ctx: unknown, instanceId: string | undefined, ): string | undefined { const cwd = getContextCwd(ctx); if (cwd) return `cwd:${cwd}`; return instanceId ? `leader:${instanceId}` : undefined; } function isCurrentLeaderTopicRecord( record: Threads.TelegramTopicTargetRecord, profileKey: string | undefined, instanceId: string | undefined, ): boolean { if (instanceId && record.instanceId === instanceId) return true; return !!profileKey && record.profileKey === profileKey; } function hasActiveLeaderTopic( records: Threads.TelegramTopicTargetRecord[], profileKey: string | undefined, instanceId: string | undefined, ): boolean { return records.some((record) => { if (record.status !== "active") return false; return isCurrentLeaderTopicRecord(record, profileKey, instanceId); }); } function escapeHtml(text: string): string { return text .replace(/&/g, "&") .replace(//g, ">"); } const TELEGRAM_UNBOUND_REROUTE_CALLBACK_PREFIX = "reroute:"; const TELEGRAM_UNBOUND_REROUTE_RESTORE_MENU_CALLBACK_PREFIX = "rerouterestore:"; const TELEGRAM_UNBOUND_REROUTE_NEW_SLOT_CALLBACK_PREFIX = "reroutenew:"; function formatTelegramUnboundRerouteCallbackData( rerouteId: string, threadId: number, ): string { return `${TELEGRAM_UNBOUND_REROUTE_CALLBACK_PREFIX}${rerouteId}:${threadId}`; } function formatTelegramUnboundRerouteRestoreMenuCallbackData( rerouteId: string, ): string { return `${TELEGRAM_UNBOUND_REROUTE_RESTORE_MENU_CALLBACK_PREFIX}${rerouteId}`; } function formatTelegramUnboundRerouteNewSlotCallbackData( rerouteId: string, threadId: number, ): string { return `${TELEGRAM_UNBOUND_REROUTE_NEW_SLOT_CALLBACK_PREFIX}${rerouteId}:${threadId}`; } function parseTelegramUnboundRerouteRestoreMenuCallbackData( data: string | undefined, ): { rerouteId: string } | undefined { const match = data?.match(/^rerouterestore:([a-z0-9]+)$/); const rerouteId = match?.[1]; return rerouteId ? { rerouteId } : undefined; } function parseTelegramUnboundRerouteCallbackData( data: string | undefined, ): { rerouteId: string; threadId: number; useNewSlot: boolean } | undefined { const match = data?.match(/^(reroute|reroutenew):([a-z0-9]+):(\d+)$/); const prefix = match?.[1]; const rerouteId = match?.[2]; const threadId = Number(match?.[3]); if (!prefix || !rerouteId || !Number.isSafeInteger(threadId)) return undefined; return { rerouteId, threadId, useNewSlot: prefix === "reroutenew" }; } function getTelegramThreadRecordLabel( record: Threads.TelegramTopicTargetRecord, ): string { return getRestoredThreadName(record, record.slot ?? ""); } function getTelegramRouteThreadButtonLabel( record: Threads.TelegramTopicTargetRecord, ): string { return `↪️ ${getTelegramThreadRecordLabel(record)}`; } function getTelegramReplaceThreadButtonLabel( record: Threads.TelegramTopicTargetRecord, ): string { return `➡️ ${getTelegramThreadRecordLabel(record)}`; } function getNextTelegramSlotPreference( slot: string | undefined, ): string | undefined { if (!slot || !/^[A-Z]$/.test(slot)) return undefined; const index = slot.charCodeAt(0) - "A".charCodeAt(0); return String.fromCharCode("A".charCodeAt(0) + ((index + 1) % 26)); } function getRestoredThreadName( record: Threads.TelegramTopicTargetRecord, slot: string, ): string { return record.threadName && Threads.isTelegramTopicThreadNameValidForSlot(record.threadName, slot) ? record.threadName : (Threads.chooseTelegramThreadName({ slot }) ?? "Pi"); } function isTelegramLiveThreadTarget( record: Threads.TelegramTopicTargetRecord, liveTargets: readonly Queue.TelegramQueueTarget[] | undefined, ): boolean { if (!liveTargets) return record.status === "active"; return liveTargets.some( (target) => target.chatId === record.target.chatId && target.threadId === record.target.threadId, ); } function getTelegramRoutableThreadRecords( records: readonly Threads.TelegramTopicTargetRecord[], liveTargets: readonly Queue.TelegramQueueTarget[] | undefined, ): Threads.TelegramTopicTargetRecord[] { return records.filter( (record) => record.status === "active" && isTelegramLiveThreadTarget(record, liveTargets), ); } function formatTelegramAllTabMenuChooserText(command: string): string { return [ "🧵 Choose target thread", "", `You used /${escapeHtml(command)} from the All tab.`, "Select the Pi thread that should handle it:", ].join("\n"); } function buildTelegramUnboundRerouteChooserMarkup( rerouteId: string, records: readonly Threads.TelegramTopicTargetRecord[], _options: { currentLeaderProfileKey?: string; currentInstanceId?: string; } = {}, ): Menu.TelegramReplyMarkup { const activeRecords = records.filter((record) => record.status === "active"); const canRestoreAnyLiveThread = activeRecords.length > 0; const rows = activeRecords.map((record) => [ { text: getTelegramRouteThreadButtonLabel(record), callback_data: formatTelegramUnboundRerouteCallbackData( rerouteId, record.target.threadId, ), }, ]); return { inline_keyboard: canRestoreAnyLiveThread ? [ ...rows, [ { text: "🔁 Replace/restore thread…", callback_data: formatTelegramUnboundRerouteRestoreMenuCallbackData(rerouteId), }, ], ] : rows, }; } function buildTelegramUnboundRerouteRestoreChooserMarkup( rerouteId: string, records: readonly Threads.TelegramTopicTargetRecord[], ): Menu.TelegramReplyMarkup { return { inline_keyboard: records .filter((record) => record.status === "active") .map((record) => [ { text: getTelegramReplaceThreadButtonLabel(record), callback_data: formatTelegramUnboundRerouteNewSlotCallbackData( rerouteId, record.target.threadId, ), }, ]), }; } function formatTelegramUnboundRerouteRestoreChooserText(): string { return [ "🧵 Replace/restore Telegram thread", "", "Choose the Pi instance to move to this new Telegram thread:", ].join("\n"); } function formatTelegramUnboundTopicGuidance(): string { return [ "⚠️ New thread is not a Pi instance", "", "To create a bound Telegram tab:", "1. Start another Pi instance in your terminal.", "2. Run /telegram-connect in that instance.", "3. The bridge will create and bind a fresh Telegram tab for it.", ].join("\n"); } function formatTelegramTargetKey(target: Queue.TelegramQueueTarget): string { return `${target.chatId}:${target.threadId ?? "all"}`; } function formatTelegramUnboundRerouteChooserText( _records: readonly Threads.TelegramTopicTargetRecord[], options: { includeGuidance?: boolean } = {}, ): string { const rerouteText = [ "🧵 Choose target thread", "", "Your message is still in this Telegram thread.", "Select the Pi thread that should handle it:", ].join("\n"); return options.includeGuidance === false ? rerouteText : [formatTelegramUnboundTopicGuidance(), "", rerouteText].join("\n"); } import * as Threads from "./threads.ts"; import type { TelegramUser } from "./updates.ts"; import * as Updates from "./updates.ts"; import { getTelegramVoiceReplyMode } from "./voice.ts"; async function deleteReservedTelegramTopicThroughReconciler( deps: { callApi?: ( method: string, body: Record, ) => Promise; threadStore?: Pick< Threads.TelegramTopicTargetStore, | "list" | "listReservations" | "listSyncObservations" | "markStaleByTarget" | "persist" >; getCurrentLeaderEpoch?: () => number | string | undefined; getThreadReconciliationMachineState?: () => ThreadReconciler.ThreadReconciliationMachineState | undefined; recordThreadReconciliationPlan?: ( plan: ThreadReconciler.ThreadReconciliationPlan, ) => void; recordRuntimeEvent?: ( category: string, error: unknown, details?: Record, ) => void; }, target: { chatId: number; threadId: number }, messageId: number, ): Promise { if (!deps.threadStore) return false; const nowMs = Date.now(); const currentLeaderEpoch = deps.getCurrentLeaderEpoch?.(); const plan = ThreadReconciler.planThreadReconciliation({ nowMs, currentLeaderEpoch, previousState: deps.getThreadReconciliationMachineState?.(), records: deps.threadStore.list(), reservations: deps.threadStore.listReservations(), observations: deps.threadStore.listSyncObservations(), reservedMessages: [ { target, observedAtMs: nowMs, messageId, ...(currentLeaderEpoch !== undefined ? { leaderEpoch: currentLeaderEpoch } : {}), }, ], }); deps.recordThreadReconciliationPlan?.(plan); await ThreadReconciler.applyThreadReconciliationPlan(plan, { callApi: deps.callApi, markStaleByTarget: (staleTarget, syncStatus, lastSyncError) => deps.threadStore?.markStaleByTarget( staleTarget, syncStatus, lastSyncError, ) ?? false, persist: () => deps.threadStore?.persist() ?? Promise.resolve(), getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch, recordRuntimeEvent: deps.recordRuntimeEvent, }); return plan.actions.some( (action) => action.kind === "close-delete-reserved-topic", ); } export type TelegramRoutedMessage = Updates.TelegramUpdateMessage & Media.TelegramMediaMessage & Media.TelegramMediaGroupMessage & Commands.TelegramCommandRuntimeMessage & Turns.TelegramTurnMessage; export type TelegramRoutedCallbackQuery = Updates.TelegramCallbackQuery & Menu.MenuCallbackQuery; export interface TelegramInboundBusProjectionRuntime { getTargetOwnership: Updates.TelegramTargetOwnershipLookup; getLiveThreadTargets(): Queue.TelegramQueueTarget[]; getLocalThreadLabelForTarget( target: Queue.TelegramQueueTarget, ): string | undefined; } export function createTelegramInboundBusProjectionRuntime(deps: { instanceId: string; listFollowers(): readonly Bus.TelegramBusFollowerView[]; listThreadRecords(): readonly TelegramTopicTargetRecord[]; getLeaderTarget(): Queue.TelegramQueueTarget | undefined; isFollowerRegistered(): boolean; getFollowerTarget(): Queue.TelegramQueueTarget | undefined; getCurrentIdentity( target?: Queue.TelegramQueueTarget, ): TelegramInstanceThreadIdentityCandidate; }): TelegramInboundBusProjectionRuntime { return { getTargetOwnership(target) { return Bus.getTelegramFollowerTargetOwnership({ target, followers: deps.listFollowers(), activeThreadRecords: deps.listThreadRecords(), currentInstanceId: deps.instanceId, }); }, getLiveThreadTargets() { return Bus.listTelegramBusLiveThreadTargets({ leaderTarget: deps.getLeaderTarget(), followers: deps.listFollowers(), }); }, getLocalThreadLabelForTarget(target) { const followerTarget = deps.getFollowerTarget(); const leaderTarget = deps.getLeaderTarget(); const isLocalFollowerTarget = deps.isFollowerRegistered() && followerTarget?.chatId === target.chatId && followerTarget.threadId === target.threadId; const isLocalLeaderTarget = leaderTarget?.chatId === target.chatId && leaderTarget.threadId === target.threadId; if (!isLocalFollowerTarget && !isLocalLeaderTarget) return undefined; return deps.getCurrentIdentity(target).threadName; }, }; } export interface TelegramInboundRouteRuntimeDeps< TMessage extends TelegramRoutedMessage, TCallbackQuery extends TelegramRoutedCallbackQuery, TContext, TModel extends Model.MenuModel, > { configStore: Pick< TelegramConfigStore, "get" | "getAllowedUserId" | "setAllowedUserId" | "persist" > & { set?: TelegramConfigStore["set"] }; callApi?: ( method: string, body: Record, ) => Promise; getCurrentInstanceId?: () => string | undefined; getAdmissionScope?: () => string | undefined; getAdmissionJournalBinding?: () => string | undefined; getMessageOwnership?: Updates.TelegramMessageOwnershipLookup; getTargetOwnership?: Updates.TelegramTargetOwnershipLookup; recordMessageOwnership?: Updates.TelegramMessageOwnershipRecorder; getLiveThreadTargets?: () => Queue.TelegramQueueTarget[]; getLocalThreadLabelForTarget?: ( target: Queue.TelegramQueueTarget, ) => string | undefined; getCurrentLeaderEpoch?: () => number | string | undefined; setCurrentLeaderIdentity?: (identity: { target: Queue.TelegramQueueTarget; slot?: string; threadName?: string; }) => void; getThreadReconciliationMachineState?: () => ThreadReconciler.ThreadReconciliationMachineState | undefined; recordThreadReconciliationPlan?: ( plan: ThreadReconciler.ThreadReconciliationPlan, ) => void; handleTelegramTopicLifecycleUpdate?: ( lifecycle: Updates.TelegramTopicLifecycleUpdate, ctx: TContext, ) => Promise | void; handleTelegramThreadTargetObserved?: ( target: Threads.TelegramTopicTargetRecord["target"], ctx: TContext, ) => Promise | void; foreignOwnedUpdateForwarder?: Updates.TelegramForeignOwnedUpdateForwarder< TContext, Updates.TelegramMessageReactionUpdated, TCallbackQuery, TMessage >; replaceFollowerThreadTarget?: (input: { record: Threads.TelegramTopicTargetRecord; target: Threads.TelegramTopicTargetRecord["target"]; oldTarget: Threads.TelegramTopicTargetRecord["target"]; }) => Promise; bridgeRuntime: TelegramBridgeRuntime; activeTurnRuntime: Queue.TelegramActiveTurnStore; mediaGroupRuntime: Media.TelegramMediaGroupController; textGroupRuntime: TextGroups.TelegramTextGroupController; telegramQueueStore: Queue.TelegramQueueStateStore; queueMutationRuntime: Queue.TelegramQueueMutationController; modelMenuRuntime: Menu.TelegramModelMenuRuntime; currentModelRuntime: Model.CurrentModelRuntime; modelSwitchController: Model.TelegramModelSwitchController< TContext, Model.ScopedTelegramModel >; menuActions: Menu.TelegramMenuActionRuntime; updateSettingsMenuMessage?: ( state: Menu.TelegramModelMenuState, ctx: TContext, ) => Promise; openQueueMenu: ( chatId: number, replyToMessageId: number, ctx: TContext, ) => Promise; openSettingsMenu?: ( chatId: number, replyToMessageId: number, ctx: TContext, ) => Promise; settingsMenuCallbackHandler?: ( query: TCallbackQuery, ctx: TContext, ) => Promise; queueMenuCallbackHandler: ( query: TCallbackQuery, ctx: TContext, ) => Promise; buttonActionStore?: OutboundHandlers.TelegramButtonActionStore; invokeBoundButtonAction?: ( action: OutboundHandlers.TelegramOutboundButtonAction, query: TCallbackQuery, ctx: TContext, ) => Promise; inboundHandlerRuntime: TelegramInboundHandlerRuntime; threadStore?: Threads.TelegramTopicTargetStore; updateStatus: (ctx: TContext, error?: string) => void; isContextActive?: (ctx: TContext) => boolean; dispatchNextQueuedTelegramTurn: (ctx: TContext) => void; requestDeferredDispatchNextQueuedTelegramTurn?: ( dispatch: (ctx: TContext) => void, ) => void; hasDeferredDispatchContext?: () => boolean; startTypingLoop?: ( ctx: TContext, chatId?: number, options?: { target?: { chatId: number; threadId?: number } }, ) => void; stopTypingLoop?: () => void; answerCallbackQuery: ( callbackQueryId: string, text?: string, ) => Promise; editInteractiveMessage?: ( chatId: number, messageId: number, text: string, mode: "markdown" | "html" | "plain", replyMarkup: Menu.TelegramReplyMarkup, ) => Promise; editMessageReplyMarkup?: ( chatId: number, messageId: number, replyMarkup: OutboundHandlers.TelegramOutboundButtonMarkup, ) => Promise; sendInteractiveMessage?: ( chatId: number, text: string, mode: "markdown" | "html" | "plain", replyMarkup: Menu.TelegramReplyMarkup, options?: { target?: Queue.TelegramQueueTarget; replyToMessageId?: number }, ) => Promise; deleteMessage?: (chatId: number, messageId: number) => Promise; answerGuestQuery: (guestQueryId: string, text?: string) => Promise; sendTextReply: ( chatId: number, replyToMessageId: number, text: string, options?: { parseMode?: "HTML"; target?: Queue.TelegramQueueTarget }, ) => Promise; setMyCommands: Commands.TelegramBotCommandRegistrationDeps["setMyCommands"]; getCommands: () => Parameters< typeof PromptTemplates.getTelegramPromptTemplateCommands >[0]; downloadFile: Media.DownloadTelegramMessageFilesDeps["downloadFile"]; resolveTimeLine?: (chatId: number) => string | null; getThinkingLevel: () => Model.ThinkingLevel; setThinkingLevel: (level: Model.ThinkingLevel) => void; persistScopedModelPatterns?: ( patterns: string[], ctx: TContext, ) => Promise; setModel: (model: TModel) => Promise; sendUserMessage?: ( message: string, options?: Queue.TelegramPromptDeliveryOptions, ) => void; isIdle: (ctx: TContext) => boolean; hasPendingMessages: (ctx: TContext) => boolean; compact: ( ctx: TContext, callbacks: { onComplete: () => void; onError: (error: unknown) => void }, ) => void; recordRuntimeEvent?: ( category: string, error: unknown, details?: Record, ) => void; sectionRegistry?: TelegramSectionRegistry; } const TELEGRAM_OWNED_CALLBACK_PREFIXES = [ "allmenu:", TELEGRAM_UNBOUND_REROUTE_CALLBACK_PREFIX, "compact:", "menu:", "model:", "queue:", "section:", "settings:", "status:", "tgbtn:", "thinking:", ] as const; function isTelegramOwnedCallbackData(data: string): boolean { return TELEGRAM_OWNED_CALLBACK_PREFIXES.some((prefix) => data.startsWith(prefix), ); } 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< TMessage, TCallbackQuery, TContext, TModel >, ): Updates.TelegramUpdateRuntimeController { type PendingRerouteCleanup = | { kind: "unbound"; target: { chatId: number; threadId: number }; messageId?: number; } | { kind: "previous-leader"; target: { chatId: number; threadId: number }; } | { kind: "replaced-follower"; target: { chatId: number; threadId: number }; instanceId?: string; }; type PendingUnboundReroute = { messages: TMessage[]; createdAtMs: number; dispatchKind: "prompt" | "command"; cleanup?: PendingRerouteCleanup; foreignRetry?: { instanceId: string; threadId: number; cleanup: PendingRerouteCleanup; }; finalizeMessage?: string; }; const pendingUnboundReroutes = new Map(); const guidedUnboundTopicKeys = new Set(); let nextUnboundRerouteId = 0; const requestDispatchNextQueuedTelegramTurn = (ctx: TContext): void => { deps.dispatchNextQueuedTelegramTurn(ctx); if ( deps.requestDeferredDispatchNextQueuedTelegramTurn && deps.hasDeferredDispatchContext?.() !== false ) { deps.requestDeferredDispatchNextQueuedTelegramTurn( deps.dispatchNextQueuedTelegramTurn, ); } }; const createAdmissionReceipts = ( queueKind: Queue.TelegramQueueItemKind, sources: readonly unknown[], ): Queue.TelegramQueueAdmissionReceipt[] => { const sourceUpdateIds = Updates.collectTelegramAdmissionSourceUpdateIds(sources); if (sourceUpdateIds.length === 0) return []; const receipt = Queue.createTelegramQueueAdmissionReceipt({ queueKind, scope: deps.getAdmissionScope?.() ?? "", sourceUpdateIds, }); const journalBindingKey = deps.getAdmissionJournalBinding?.(); return receipt ? [{ ...receipt, ...(journalBindingKey ? { journalBindingKey } : {}), }] : []; }; const reportQueueAdmission = ( sources: readonly unknown[], receipts: readonly Queue.TelegramQueueAdmissionReceipt[], ): void => { Updates.reportTelegramQueueAdmission(sources, receipts); }; const prunePendingUnboundReroutes = () => { const nowMs = Date.now(); for (const [id, entry] of pendingUnboundReroutes) { if (nowMs - entry.createdAtMs > 30 * 60_000) { pendingUnboundReroutes.delete(id); } } while (pendingUnboundReroutes.size > 100) { const oldest = pendingUnboundReroutes.keys().next().value; if (!oldest) break; pendingUnboundReroutes.delete(oldest); } }; const storePendingUnboundReroute = ( messages: TMessage[], dispatchKind: "prompt" | "command" = "prompt", ): string => { prunePendingUnboundReroutes(); nextUnboundRerouteId += 1; const id = nextUnboundRerouteId.toString(36); pendingUnboundReroutes.set(id, { messages, createdAtMs: Date.now(), dispatchKind, }); return id; }; const pendingUnboundRerouteMediaGroups = new Map< string, { messages: TMessage[]; timer: ReturnType; } >(); 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, updateSettingsMenuMessage: deps.updateSettingsMenuMessage, answerCallbackQuery: deps.answerCallbackQuery, isIdle: deps.isIdle, hasActiveTelegramTurn: deps.activeTurnRuntime.has, hasAbortHandler: deps.bridgeRuntime.abort.hasHandler, getActiveToolExecutions: deps.bridgeRuntime.lifecycle.getActiveToolExecutions, persistScopedModelPatterns: deps.persistScopedModelPatterns, setModel: deps.setModel, setCurrentModel: deps.currentModelRuntime.setCurrentModel, stagePendingModelSwitch: deps.modelSwitchController.stagePendingSwitch, restartInterruptedTelegramTurn: deps.modelSwitchController.restartInterruptedTurn, sectionRegistry: deps.sectionRegistry, editInteractiveMessage: deps.editInteractiveMessage, sendInteractiveMessage: deps.sendInteractiveMessage, deleteMessage: deps.deleteMessage, enqueueSectionPrompt: async ( prompt: string, ctx: TContext, target?: Queue.TelegramQueueTarget, source?: unknown, ) => { const chatId = target?.chatId ?? deps.configStore.getAllowedUserId(); if (typeof chatId !== "number") return; const order = deps.bridgeRuntime.queue.allocateItemOrder(); const admissionReceipts = createAdmissionReceipts( "prompt", source === undefined ? [] : [source], ); const turn: Queue.PendingTelegramTurn = { kind: "prompt", chatId, ...(target ? { target } : {}), replyToMessageId: 0, sourceMessageIds: [], queueOrder: order, queueLane: "default", laneOrder: order, queuedAttachments: [], content: [ { type: "text", text: `[telegram] ${prompt}`, }, ], historyText: Turns.truncateTelegramQueueSummary(prompt), statusSummary: Turns.truncateTelegramQueueSummary(prompt), ...(admissionReceipts.length > 0 ? { admissionReceipts } : {}), }; deps.queueMutationRuntime.append(turn, ctx); reportQueueAdmission( source === undefined ? [] : [source], admissionReceipts, ); deps.updateStatus(ctx); requestDispatchNextQueuedTelegramTurn(ctx); }, }); const cloneTelegramMessagesForThread = ( messages: TMessage[], threadId: number, ): TMessage[] => { return messages.map( (message) => Updates.carryTelegramUpdateExecutionFence( message, { ...message, message_id: 0, message_thread_id: threadId, reply_to_message: undefined, } as TMessage, ), ); }; const applyThreadCleanupPlan = async ( plan: ThreadReconciler.ThreadReconciliationPlan, assertExecutionCurrent?: () => void, ): Promise => { assertExecutionCurrent?.(); deps.recordThreadReconciliationPlan?.(plan); const result = await ThreadReconciler.applyThreadReconciliationPlan(plan, { callApi: deps.callApi, markStaleByTarget: (staleTarget, syncStatus, lastSyncError) => deps.threadStore?.markStaleByTarget( staleTarget, syncStatus, lastSyncError, ) ?? false, persist: () => deps.threadStore?.persist() ?? Promise.resolve(), removePendingProvisionById: (id) => deps.threadStore?.removePendingProvision(id) ?? false, getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch, recordRuntimeEvent: deps.recordRuntimeEvent, }); assertExecutionCurrent?.(); return (result.incompleteActions?.length ?? 0) === 0; }; const dismissRerouteChooserMessage = async ( query: TCallbackQuery, assertExecutionCurrent?: () => void, ): Promise => { const chatId = query.message?.chat?.id; const messageId = query.message?.message_id; if ( typeof chatId !== "number" || typeof messageId !== "number" || !deps.deleteMessage ) { return false; } try { assertExecutionCurrent?.(); await deps.deleteMessage(chatId, messageId); assertExecutionCurrent?.(); return true; } catch (error) { deps.recordRuntimeEvent?.("telegram", error, { phase: "reroute-chooser-delete", chatId, messageId, threadId: query.message?.message_thread_id, }); return false; } }; const closeReroutedUnboundTopic = async ( target: { chatId: number; threadId: number } | undefined, messageId: number | undefined, assertExecutionCurrent?: () => void, ): Promise => { if (!target || !deps.threadStore) return true; const nowMs = Date.now(); const currentLeaderEpoch = deps.getCurrentLeaderEpoch?.(); const plan = ThreadReconciler.planThreadReconciliation({ nowMs, currentLeaderEpoch, previousState: deps.getThreadReconciliationMachineState?.(), records: deps.threadStore.list(), reservations: deps.threadStore.listReservations(), pendingProvisions: deps.threadStore.listPendingProvisions(), unboundMessages: [ { target, observedAtMs: nowMs, ...(typeof messageId === "number" ? { messageId } : {}), ...(currentLeaderEpoch !== undefined ? { leaderEpoch: currentLeaderEpoch } : {}), }, ], }); return applyThreadCleanupPlan(plan, assertExecutionCurrent); }; const closePreviousLeaderThread = async ( target: { chatId: number; threadId: number } | undefined, assertExecutionCurrent?: () => void, ): Promise => { if (!target || !deps.threadStore) return true; const currentLeaderEpoch = deps.getCurrentLeaderEpoch?.(); return applyThreadCleanupPlan({ actions: [ { kind: "close-delete-previous-leader-topic", target, reason: "previous-leader", instanceId: deps.getCurrentInstanceId?.(), ...(currentLeaderEpoch !== undefined ? { leaderEpoch: currentLeaderEpoch } : {}), }, ], }, assertExecutionCurrent); }; const closeReplacedFollowerThread = async ( target: { chatId: number; threadId: number } | undefined, instanceId: string | undefined, assertExecutionCurrent?: () => void, ): Promise => { if (!target || !deps.threadStore) return true; const currentLeaderEpoch = deps.getCurrentLeaderEpoch?.(); return applyThreadCleanupPlan({ actions: [ { kind: "close-delete-replaced-follower-topic", target, reason: "replaced-follower", instanceId, ...(currentLeaderEpoch !== undefined ? { leaderEpoch: currentLeaderEpoch } : {}), }, ], }, assertExecutionCurrent); }; const retryPendingRerouteCleanup = async ( cleanup: PendingRerouteCleanup, assertExecutionCurrent?: () => void, ): Promise => { if (cleanup.kind === "unbound") { return closeReroutedUnboundTopic( cleanup.target, cleanup.messageId, assertExecutionCurrent, ); } if (cleanup.kind === "previous-leader") { return closePreviousLeaderThread(cleanup.target, assertExecutionCurrent); } return closeReplacedFollowerThread( cleanup.target, cleanup.instanceId, assertExecutionCurrent, ); }; let dispatchReroutedCommandMessages: | ((messages: TMessage[], ctx: TContext) => Promise) | undefined; const dispatchPendingRerouteMessages = async ( pending: { dispatchKind: "prompt" | "command" }, messages: TMessage[], ctx: TContext, ): Promise => { if (pending.dispatchKind === "command" && dispatchReroutedCommandMessages) { await dispatchReroutedCommandMessages(messages, ctx); return; } await promptEnqueue(messages, ctx); }; const finalizePendingReroute = async ( rerouteId: string, pending: PendingUnboundReroute, query: TCallbackQuery, successMessage: string, assertExecutionCurrent?: () => void, ): Promise => { const dismissed = await dismissRerouteChooserMessage( query, assertExecutionCurrent, ); if (dismissed) { pendingUnboundReroutes.delete(rerouteId); await deps.answerCallbackQuery(query.id, successMessage); return; } pending.finalizeMessage = successMessage; await deps.answerCallbackQuery( query.id, `${successMessage} Chooser cleanup is still pending. Try again.`, ); }; const forwardPendingRerouteMessages = async ( pending: PendingUnboundReroute, instanceId: string, threadId: number, ctx: TContext, assertExecutionCurrent?: () => void, ): Promise => { assertExecutionCurrent?.(); const forwardMessage = deps.foreignOwnedUpdateForwarder?.forwardMessage; if (!forwardMessage) return false; const messages = cloneTelegramMessagesForThread(pending.messages, threadId); const outcomes = await Promise.allSettled( messages.map((message) => forwardMessage({ message, ownership: { instanceId }, ctx, }), ), ); assertExecutionCurrent?.(); pending.messages = pending.messages.filter((_, index) => { const outcome = outcomes[index]; if ( outcome?.status === "fulfilled" && outcome.value.status === "accepted" ) { return false; } if (outcome?.status === "rejected") { deps.recordRuntimeEvent?.("bus", outcome.reason, { phase: "reroute-foreign-forward", instanceId, threadId, messageIndex: index, }); } return true; }); return pending.messages.length === 0; }; const handleUnboundRerouteRestoreMenuCallback = async ( query: TCallbackQuery, _ctx: TContext, ): Promise => { const parsed = parseTelegramUnboundRerouteRestoreMenuCallbackData( query.data, ); if (!parsed) return false; const chatId = query.message?.chat?.id; const messageId = query.message?.message_id; const pending = pendingUnboundReroutes.get(parsed.rerouteId); if ( typeof chatId !== "number" || typeof messageId !== "number" || !deps.threadStore || !pending ) { await deps.answerCallbackQuery(query.id, "Message route expired."); return true; } await deps.threadStore.load(); const activeRecords = getTelegramRoutableThreadRecords( deps.threadStore.list(), deps.getLiveThreadTargets?.(), ); const replyMarkup = buildTelegramUnboundRerouteRestoreChooserMarkup( parsed.rerouteId, activeRecords, ); if (deps.editInteractiveMessage) { await deps.editInteractiveMessage( chatId, messageId, formatTelegramUnboundRerouteRestoreChooserText(), "html", replyMarkup, ); } else if (deps.sendInteractiveMessage) { await deps.sendInteractiveMessage( chatId, formatTelegramUnboundRerouteRestoreChooserText(), "html", replyMarkup, typeof query.message?.message_thread_id === "number" ? { target: { chatId, threadId: query.message.message_thread_id }, replyToMessageId: messageId, } : undefined, ); } await deps.answerCallbackQuery(query.id, "Choose instance to restore."); return true; }; const handleUnboundRerouteCallback = async ( query: TCallbackQuery, ctx: TContext, ): Promise => { const parsed = parseTelegramUnboundRerouteCallbackData(query.data); if (!parsed) return false; const assertExecutionCurrent = Updates.createTelegramUpdateExecutionFenceGuard(query); assertExecutionCurrent(); const chatId = query.message?.chat?.id; const pending = pendingUnboundReroutes.get(parsed.rerouteId); if (typeof chatId !== "number" || !deps.threadStore || !pending) { await deps.answerCallbackQuery(query.id, "Message route expired."); return true; } await deps.threadStore.load(); assertExecutionCurrent(); if (pending.finalizeMessage) { await finalizePendingReroute( parsed.rerouteId, pending, query, pending.finalizeMessage, assertExecutionCurrent, ); return true; } if (pending.foreignRetry) { const retry = pending.foreignRetry; const allForwarded = await forwardPendingRerouteMessages( pending, retry.instanceId, retry.threadId, ctx, assertExecutionCurrent, ); if (!allForwarded) { await deps.answerCallbackQuery( query.id, "Target thread is unavailable; retrying will send only remaining messages.", ); return true; } pending.foreignRetry = undefined; if (retry.cleanup) pending.cleanup = retry.cleanup; } if (pending.cleanup) { const cleanupComplete = await retryPendingRerouteCleanup( pending.cleanup, assertExecutionCurrent, ); if (!cleanupComplete) { await deps.answerCallbackQuery( query.id, "Message routed, but thread cleanup is still pending. Try again.", ); return true; } pending.cleanup = undefined; await finalizePendingReroute( parsed.rerouteId, pending, query, "Thread cleanup completed.", assertExecutionCurrent, ); return true; } const record = getTelegramRoutableThreadRecords( deps.threadStore.list(), deps.getLiveThreadTargets?.(), ).find( (candidate) => candidate.target.chatId === chatId && candidate.target.threadId === parsed.threadId, ); if (!record) { await deps.answerCallbackQuery(query.id, "Thread is not active yet."); return true; } const reroutedMessages = cloneTelegramMessagesForThread( pending.messages, parsed.threadId, ); const sourceTarget = typeof query.message?.message_thread_id === "number" ? { chatId, threadId: query.message.message_thread_id } : undefined; const sourceMessageId = query.message?.message_id; const currentInstanceId = deps.getCurrentInstanceId?.(); const leaderProfileKey = getLeaderTopicProfileKey(ctx, currentInstanceId); const isCurrentLeaderRecord = isCurrentLeaderTopicRecord( record, leaderProfileKey, currentInstanceId, ); if (parsed.useNewSlot && !isCurrentLeaderRecord) { if ( !sourceTarget || !deps.replaceFollowerThreadTarget || !deps.foreignOwnedUpdateForwarder?.forwardMessage ) { await deps.answerCallbackQuery( query.id, "Follower thread restore is not available yet.", ); return true; } assertExecutionCurrent(); const replaced = await deps.replaceFollowerThreadTarget({ record, target: sourceTarget, oldTarget: record.target, }); assertExecutionCurrent(); if (!replaced) { await deps.answerCallbackQuery( query.id, "Follower thread is unavailable.", ); return true; } const nowMs = Date.now(); const slot = record.slot ?? "?"; const threadName = getRestoredThreadName(record, slot); assertExecutionCurrent(); deps.threadStore.markStaleByTarget( record.target, "deleted", "Follower thread was replaced by restore source.", ); deps.threadStore.upsert({ ...record, target: sourceTarget, status: "active", syncStatus: "open", updatedAtMs: nowMs, threadName, lastSyncObservedAtMs: nowMs, lastReconcileAction: "follower-thread-restore", rerouteConfirmedAtMs: nowMs, }); await deps.threadStore.persist(); assertExecutionCurrent(); if (deps.callApi) { try { assertExecutionCurrent(); await deps.callApi("editForumTopic", { chat_id: sourceTarget.chatId, message_thread_id: sourceTarget.threadId, name: Threads.getTelegramTopicTitleForThreadName(threadName, slot), }); assertExecutionCurrent(); } catch (renameError) { deps.recordRuntimeEvent?.("telegram", renameError, { phase: "follower-topic-reroute-restore-rename", chatId: sourceTarget.chatId, threadId: sourceTarget.threadId, slot: record.slot, }); } } const cleanup: PendingRerouteCleanup = { kind: "replaced-follower", target: record.target, instanceId: record.instanceId, }; const allForwarded = await forwardPendingRerouteMessages( pending, record.instanceId!, sourceTarget.threadId, ctx, assertExecutionCurrent, ); if (!allForwarded) { pending.foreignRetry = { instanceId: record.instanceId!, threadId: sourceTarget.threadId, cleanup, }; await deps.answerCallbackQuery( query.id, "Thread restored; retrying will send only remaining messages before old-thread cleanup.", ); return true; } const cleanupComplete = await retryPendingRerouteCleanup( cleanup, assertExecutionCurrent, ); if (!cleanupComplete) { pending.cleanup = cleanup; await deps.answerCallbackQuery( query.id, "Thread restored, but old-thread cleanup is still pending. Try again.", ); return true; } await finalizePendingReroute( parsed.rerouteId, pending, query, "Message routed.", ); return true; } if ( record.instanceId && record.instanceId !== currentInstanceId && !isCurrentLeaderRecord ) { if (!deps.foreignOwnedUpdateForwarder?.forwardMessage) { await deps.answerCallbackQuery( query.id, "Open that thread and resend the message there.", ); return true; } const allForwarded = await forwardPendingRerouteMessages( pending, record.instanceId, parsed.threadId, ctx, assertExecutionCurrent, ); if (!allForwarded) { await deps.answerCallbackQuery( query.id, "Target thread is unavailable; retrying will send only remaining messages.", ); return true; } const cleanupComplete = await closeReroutedUnboundTopic( sourceTarget, sourceMessageId, assertExecutionCurrent, ); if (!cleanupComplete && sourceTarget) { pending.cleanup = { kind: "unbound", target: sourceTarget, ...(typeof sourceMessageId === "number" ? { messageId: sourceMessageId } : {}), }; await deps.answerCallbackQuery( query.id, "Message routed, but thread cleanup is still pending. Try again.", ); return true; } await finalizePendingReroute( parsed.rerouteId, pending, query, "Message routed.", ); return true; } if ( sourceTarget && isCurrentLeaderRecord && parsed.useNewSlot && (record.target.chatId !== sourceTarget.chatId || record.target.threadId !== sourceTarget.threadId) ) { deps.threadStore.markStaleByTarget( record.target, "deleted", "Current leader thread was replaced by a new-slot reroute source.", ); const slot = deps.threadStore.allocateSlot( leaderProfileKey ?? record.profileKey, getNextTelegramSlotPreference(record.slot), ) ?? record.slot ?? "?"; const nowMs = Date.now(); const threadName = getRestoredThreadName(record, slot); deps.threadStore.upsert({ ...record, target: sourceTarget, status: "active", updatedAtMs: nowMs, threadName, instanceId: currentInstanceId, slot, lastReconcileAction: "reroute-new-slot", rerouteConfirmedAtMs: nowMs, }); await deps.threadStore.persist(); deps.setCurrentLeaderIdentity?.({ target: sourceTarget, slot, threadName, }); if (deps.callApi) { try { assertExecutionCurrent(); await deps.callApi("editForumTopic", { chat_id: sourceTarget.chatId, message_thread_id: sourceTarget.threadId, name: Threads.getTelegramTopicTitleForThreadName(threadName, slot), }); assertExecutionCurrent(); } catch (renameError) { deps.recordRuntimeEvent?.("telegram", renameError, { phase: "leader-topic-reroute-reclaim-rename", chatId: sourceTarget.chatId, threadId: sourceTarget.threadId, slot, }); } } deps.recordRuntimeEvent?.( "bus", "Bus leader reclaimed reroute source thread", { phase: "leader-topic-reroute-reclaim", chatId: sourceTarget.chatId, threadId: sourceTarget.threadId, staleThreadId: record.target.threadId, slot, }, ); await dispatchPendingRerouteMessages( pending, cloneTelegramMessagesForThread(pending.messages, sourceTarget.threadId), ctx, ); pending.messages = []; const cleanupComplete = await closePreviousLeaderThread( record.target, assertExecutionCurrent, ); if (!cleanupComplete) { pending.cleanup = { kind: "previous-leader", target: record.target, }; await deps.answerCallbackQuery( query.id, "Thread restored, but old-thread cleanup is still pending. Try again.", ); return true; } await finalizePendingReroute( parsed.rerouteId, pending, query, "Message routed.", ); return true; } await dispatchPendingRerouteMessages(pending, reroutedMessages, ctx); pending.messages = []; const cleanupComplete = await closeReroutedUnboundTopic( sourceTarget, sourceMessageId, assertExecutionCurrent, ); if (!cleanupComplete && sourceTarget) { pending.cleanup = { kind: "unbound", target: sourceTarget, ...(typeof sourceMessageId === "number" ? { messageId: sourceMessageId } : {}), }; await deps.answerCallbackQuery( query.id, "Message routed, but thread cleanup is still pending. Try again.", ); return true; } await finalizePendingReroute( parsed.rerouteId, pending, query, "Message routed.", ); return true; }; const callbackHandler = async ( query: TCallbackQuery, ctx: TContext, ): Promise => { const assertExecutionCurrent = Updates.createTelegramUpdateExecutionFenceGuard(query); assertExecutionCurrent(); if (await handleUnboundRerouteRestoreMenuCallback(query, ctx)) return; if (await handleUnboundRerouteCallback(query, ctx)) return; if (deps.buttonActionStore) { const handled = await OutboundHandlers.handleTelegramButtonCallbackQuery( query, ctx, { resolveAction: deps.buttonActionStore.resolve, answerCallbackQuery: deps.answerCallbackQuery, ...(deps.invokeBoundButtonAction ? { invokeBoundAction: (buttonQuery, action, context) => deps.invokeBoundButtonAction!( action, buttonQuery as TCallbackQuery, context, ), } : {}), editMessageReplyMarkup: deps.editMessageReplyMarkup ? async (chatId, messageId, replyMarkup) => { try { await deps.editMessageReplyMarkup?.( chatId, messageId, replyMarkup, ); } catch (error) { deps.recordRuntimeEvent?.("telegram", error, { phase: "button-selection-mark", chatId, messageId, }); } } : undefined, enqueueButtonPrompt: (buttonQuery, action, context) => { const chatId = buttonQuery.message?.chat?.id; const messageId = buttonQuery.message?.message_id; if (typeof chatId !== "number" || typeof messageId !== "number") return false; const queueOrder = deps.bridgeRuntime.queue.allocateItemOrder(); const admissionReceipts = createAdmissionReceipts("prompt", [ buttonQuery, ]); const turn: Queue.PendingTelegramTurn = { ...OutboundHandlers.createTelegramButtonPromptTurn({ chatId, target: typeof buttonQuery.message?.message_thread_id === "number" ? { chatId, threadId: buttonQuery.message.message_thread_id, } : { chatId }, replyToMessageId: messageId, queueOrder, action, }), ...(admissionReceipts.length > 0 ? { admissionReceipts } : {}), }; const result = Queue.appendTelegramPromptTurnOnce( deps.telegramQueueStore.getQueuedItems(), turn, ); if (!result.appended) { reportQueueAdmission([buttonQuery], admissionReceipts); return false; } Updates.assertTelegramUpdateExecutionCurrent(buttonQuery); deps.telegramQueueStore.setQueuedItems(result.items); reportQueueAdmission([buttonQuery], admissionReceipts); deps.updateStatus(context); requestDispatchNextQueuedTelegramTurn(context); return true; }, }, ); assertExecutionCurrent(); if (handled) return; } const handledByCompact = await Commands.handleTelegramCompactConfirmationCallback(query, { ctx, answerCallbackQuery: deps.answerCallbackQuery, editInteractiveMessage: deps.editInteractiveMessage ?? (async () => {}), runCompact: async (compactCtx, chatId, replyToMessageId, target) => { await Commands.handleTelegramCompactCommand({ isIdle: () => deps.isIdle(compactCtx), hasPendingMessages: () => deps.hasPendingMessages(compactCtx), hasActiveTelegramTurn: deps.activeTurnRuntime.has, hasDispatchPending: deps.bridgeRuntime.lifecycle.hasDispatchPending, hasQueuedTelegramItems: deps.telegramQueueStore.hasQueuedItems, isCompactionInProgress: deps.bridgeRuntime.lifecycle.isCompactionInProgress, setCompactionInProgress: deps.bridgeRuntime.lifecycle.setCompactionInProgress, updateStatus: () => deps.updateStatus(compactCtx), dispatchNextQueuedTelegramTurn: () => deps.dispatchNextQueuedTelegramTurn(compactCtx), requestDeferredDispatchNextQueuedTelegramTurn: deps.requestDeferredDispatchNextQueuedTelegramTurn ? (dispatch) => deps.requestDeferredDispatchNextQueuedTelegramTurn?.(() => dispatch(), ) : undefined, compact: (callbacks) => deps.compact(compactCtx, callbacks), startTypingLoop: deps.startTypingLoop ? () => deps.startTypingLoop?.(compactCtx, chatId, { target, }) : undefined, stopTypingLoop: deps.stopTypingLoop, sendTextReply: (text, options) => deps .sendTextReply(chatId, replyToMessageId, text, { target, parseMode: options?.parseMode, }) .then(() => {}), suppressStartNotice: true, recordRuntimeEvent: deps.recordRuntimeEvent, }); }, }); assertExecutionCurrent(); if (handledByCompact) return; const handledByQueue = await deps.queueMenuCallbackHandler(query, ctx); assertExecutionCurrent(); if (handledByQueue) return; const handledBySettings = await deps.settingsMenuCallbackHandler?.( query, ctx, ); assertExecutionCurrent(); if (handledBySettings) return; const callbackData = query.data; if (callbackData && !isTelegramOwnedCallbackData(callbackData)) { const chatId = query.message?.chat?.id; const messageId = query.message?.message_id; if (typeof chatId === "number" && typeof messageId === "number") { const queueOrder = deps.bridgeRuntime.queue.allocateItemOrder(); const target = typeof query.message?.message_thread_id === "number" ? { chatId, threadId: query.message.message_thread_id } : { chatId }; const admissionReceipts = createAdmissionReceipts("prompt", [query]); const turn: Queue.PendingTelegramTurn = { kind: "prompt", chatId, target, replyToMessageId: messageId, sourceMessageIds: [messageId], queueOrder, queueLane: "priority", laneOrder: queueOrder, queuedAttachments: [], content: [{ type: "text", text: `[callback] ${callbackData}` }], historyText: callbackData, statusSummary: callbackData, ...(admissionReceipts.length > 0 ? { admissionReceipts } : {}), }; const result = Queue.appendTelegramPromptTurnOnce( deps.telegramQueueStore.getQueuedItems(), turn, ); if (result.appended) { Updates.assertTelegramUpdateExecutionCurrent(query); deps.telegramQueueStore.setQueuedItems(result.items); reportQueueAdmission([query], admissionReceipts); deps.updateStatus(ctx); requestDispatchNextQueuedTelegramTurn(ctx); } else { reportQueueAdmission([query], admissionReceipts); } } await deps.answerCallbackQuery(query.id); return; } await menuCallbackHandler(query, ctx); }; const promptTurnBuilder = Turns.createTelegramPromptTurnRuntimeBuilder< TMessage, TContext >({ allocateQueueOrder: deps.bridgeRuntime.queue.allocateItemOrder, downloadFile: deps.downloadFile, processAttachments: deps.inboundHandlerRuntime.process, resolveTimeLine: deps.resolveTimeLine, getAllowedUserId: deps.configStore.getAllowedUserId, getAdmissionScope: deps.getAdmissionScope, getAdmissionJournalBinding: deps.getAdmissionJournalBinding, assertExecutionCurrent(message) { Updates.assertTelegramUpdateExecutionCurrent(message); }, // Voice policy resolves missing, invalid, and legacy manual config to hidden. getVoiceReplyMode: () => getTelegramVoiceReplyMode(deps.configStore.get()), getTelegramThreadLabel(message) { if (!deps.threadStore) return undefined; const chatId = message.chat.id; const threadId = message.message_thread_id; if (!threadId) return undefined; const localLabel = deps.getLocalThreadLabelForTarget?.({ chatId, threadId, }); if (localLabel) return localLabel; const records = deps.threadStore.list(); const currentInstanceId = deps.getCurrentInstanceId?.(); for (const r of records) { if (r.target.chatId !== chatId || r.target.threadId !== threadId) continue; if ( currentInstanceId && r.instanceId && r.instanceId !== currentInstanceId ) continue; return r.threadName && Threads.isTelegramTopicThreadNameValidForSlot(r.threadName, r.slot) ? r.threadName : getRestoredThreadName(r, r.slot ?? ""); } return undefined; }, }); const enqueueContinueTurn = async ( message: TMessage, ctx: TContext, ): Promise => { deps.bridgeRuntime.lifecycle.setFoldQueuedPromptsIntoHistory(false); const continueMessage = { ...message, text: "continue", caption: undefined, } as TMessage; const turn = await promptTurnBuilder([continueMessage], [], ctx); const continueTurn = { ...turn, queueLane: "control" as const, laneOrder: deps.bridgeRuntime.queue.allocateControlOrder(), statusSummary: "continue", }; Updates.assertTelegramUpdateExecutionCurrent(message); deps.queueMutationRuntime.append(continueTurn, ctx); reportQueueAdmission( [continueMessage], continueTurn.admissionReceipts ?? [], ); requestDispatchNextQueuedTelegramTurn(ctx); }; const reservedCommandNames = () => new Set(Commands.getTelegramReservedCommandNames()); const getPromptTemplateCommands = () => PromptTemplates.getTelegramPromptTemplateCommands( deps.getCommands(), reservedCommandNames(), ); const commandHandler = Commands.createTelegramCommandHandlerTargetRuntime< TMessage, TContext >({ assertExecutionCurrent(message) { Updates.assertTelegramUpdateExecutionCurrent(message); }, hasAbortHandler: deps.bridgeRuntime.abort.hasHandler, clearPendingModelSwitch: deps.modelSwitchController.clearPendingSwitch, hasQueuedTelegramItems: deps.telegramQueueStore.hasQueuedItems, clearQueuedTelegramItems: deps.queueMutationRuntime.clear, setFoldQueuedPromptsIntoHistory: deps.bridgeRuntime.lifecycle.setFoldQueuedPromptsIntoHistory, 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, isContextActive: deps.isContextActive, dispatchNextQueuedTelegramTurn: deps.dispatchNextQueuedTelegramTurn, requestDeferredDispatchNextQueuedTelegramTurn: deps.requestDeferredDispatchNextQueuedTelegramTurn, startTypingLoop: deps.startTypingLoop, stopTypingLoop: deps.stopTypingLoop, enqueueContinueTurn, compact: deps.compact, allocateItemOrder: deps.bridgeRuntime.queue.allocateItemOrder, allocateControlOrder: deps.bridgeRuntime.queue.allocateControlOrder, appendControlItem: deps.queueMutationRuntime.append, getAdmissionScope: deps.getAdmissionScope, getAdmissionJournalBinding: deps.getAdmissionJournalBinding, onControlQueued: (message, receipt) => reportQueueAdmission([message], [receipt]), showStatus: deps.menuActions.sendStatusMessage, openModelMenu: deps.menuActions.openModelMenu, openThinkingMenu: (message, ctx) => { const chatId = (message as { chat: { id: number } }).chat.id; return deps.menuActions.openThinkingMenu(chatId, message.message_id, ctx); }, openQueueMenu: (message, ctx) => { const chatId = (message as { chat: { id: number } }).chat.id; return deps.openQueueMenu(chatId, message.message_id, ctx); }, openSettingsMenu: deps.openSettingsMenu, getAllowedUserId: deps.configStore.getAllowedUserId, setAllowedUserId: deps.configStore.setAllowedUserId, setMyCommands: deps.setMyCommands, getPromptTemplateCommands, persistConfig: deps.configStore.persist, sendTextReply: deps.sendTextReply, sendInteractiveMessage: deps.sendInteractiveMessage, recordRuntimeEvent: deps.recordRuntimeEvent, }); const promptEnqueueController = Queue.createTelegramPromptEnqueueController({ ...deps.telegramQueueStore, getFoldQueuedPromptsIntoHistory: deps.bridgeRuntime.lifecycle.shouldFoldQueuedPromptsIntoHistory, setFoldQueuedPromptsIntoHistory: deps.bridgeRuntime.lifecycle.setFoldQueuedPromptsIntoHistory, createTurn: async (messages, historyTurns, turnCtx) => { const turn = await promptTurnBuilder(messages, historyTurns, turnCtx); return turn.replyToMessageId > 0 ? turn : { ...turn, replyToMessageId: 0 }; }, updateStatus: deps.updateStatus, dispatchNextQueuedTelegramTurn: requestDispatchNextQueuedTelegramTurn, assertExecutionCurrent: (messages) => Updates.assertTelegramUpdateExecutionCurrent(messages[0]), }); const promptEnqueue = async ( messages: TMessage[], ctx: TContext, ): Promise => { return promptEnqueueController.enqueue(messages, ctx, (turn) => { reportQueueAdmission(messages, turn.admissionReceipts ?? []); }); }; const sendUnboundRerouteChooserNow = async ( messages: TMessage[], ctx: TContext, reportDeferred = true, ): Promise => { const message = messages[0]; if (!message || !deps.threadStore) return; const records = deps.threadStore.list(); const activeRecords = getTelegramRoutableThreadRecords( records, deps.getLiveThreadTargets?.(), ); const sourceTarget = typeof message.message_thread_id === "number" ? { chatId: message.chat.id, threadId: message.message_thread_id } : undefined; const sourceKey = sourceTarget ? formatTelegramTargetKey(sourceTarget) : undefined; const includeGuidance = sourceKey ? !guidedUnboundTopicKeys.has(sourceKey) : true; if (sourceKey) guidedUnboundTopicKeys.add(sourceKey); if (activeRecords.length === 0) { await deps.sendTextReply( message.chat.id, message.message_id, [ includeGuidance ? formatTelegramUnboundTopicGuidance() : undefined, "This thread is not bound to a Pi instance. Open an active Pi thread or run /telegram-connect from a Pi session to bind one.", ] .filter((line): line is string => typeof line === "string") .join("\n\n"), { parseMode: "HTML", target: sourceTarget }, ); return; } const rerouteId = storePendingUnboundReroute(messages); if (reportDeferred) { for (const source of messages) { Updates.reportTelegramUpdateDeferred(source); } } const text = formatTelegramUnboundRerouteChooserText(activeRecords, { includeGuidance, }); const currentInstanceId = deps.getCurrentInstanceId?.(); const replyMarkup = buildTelegramUnboundRerouteChooserMarkup( rerouteId, activeRecords, { currentLeaderProfileKey: getLeaderTopicProfileKey( ctx, currentInstanceId, ), currentInstanceId, }, ); if (deps.sendInteractiveMessage) { await deps.sendInteractiveMessage( message.chat.id, text, "html", replyMarkup, sourceTarget ? { target: sourceTarget, replyToMessageId: message.message_id } : { replyToMessageId: message.message_id }, ); return; } await deps.sendTextReply(message.chat.id, message.message_id, text, { parseMode: "HTML", target: sourceTarget, }); }; const sendUnboundRerouteChooser = async ( message: TMessage, ctx: TContext, ): Promise => { const groupKey = Media.getTelegramMediaGroupKey(message); if (!groupKey) { await sendUnboundRerouteChooserNow([message], ctx); return; } const existing = pendingUnboundRerouteMediaGroups.get(groupKey); if (existing) clearTimeout(existing.timer); const messages = [...(existing?.messages ?? []), message]; const timer = setTimeout(() => { pendingUnboundRerouteMediaGroups.delete(groupKey); void sendUnboundRerouteChooserNow(messages, ctx, false); }, 1200); timer.unref?.(); pendingUnboundRerouteMediaGroups.set(groupKey, { messages, timer }); Updates.reportTelegramUpdateDeferred(message); }; const getKnownTelegramAllTabCommand = ( text: string, ): Commands.ParsedTelegramCommand | undefined => { const command = Commands.parseTelegramCommand(text); if (!command) return undefined; if (reservedCommandNames().has(command.name)) return command; if (Commands.findTelegramExtensionCommand(command.name)) return command; if ( getPromptTemplateCommands().some( (template) => template.command === command.name, ) ) { return command; } return undefined; }; const sendAllTabCommandChooser = async ( command: Commands.ParsedTelegramCommand, commandText: string, message: TMessage, options: { replyToSource?: boolean; target?: Queue.TelegramQueueTarget; } = {}, ): Promise => { if (!deps.threadStore) return false; const records = deps.threadStore.list(); const activeRecords = getTelegramRoutableThreadRecords( records, deps.getLiveThreadTargets?.(), ); if (activeRecords.length === 0) return false; const commandMessage = { ...message, text: commandText, caption: undefined, } as TMessage; const rerouteId = storePendingUnboundReroute([commandMessage], "command"); Updates.reportTelegramUpdateDeferred(commandMessage); const text = formatTelegramAllTabMenuChooserText(command.name); const replyMarkup = buildTelegramUnboundRerouteChooserMarkup( rerouteId, activeRecords, ); if (deps.sendInteractiveMessage) { await deps.sendInteractiveMessage( message.chat.id, text, "html", replyMarkup, options.target || options.replyToSource ? { ...(options.target ? { target: options.target } : {}), ...(options.replyToSource ? { replyToMessageId: message.message_id } : {}), } : undefined, ); return true; } if (deps.callApi) { await deps.callApi("sendMessage", { chat_id: message.chat.id, text, parse_mode: "HTML", reply_markup: replyMarkup, ...(typeof options.target?.threadId === "number" ? { message_thread_id: options.target.threadId } : {}), ...(options.replyToSource ? { reply_parameters: { message_id: message.message_id, allow_sending_without_reply: true, }, } : {}), }); return true; } await deps.sendTextReply(message.chat.id, message.message_id, text, { parseMode: "HTML", target: options.target, }); return true; }; const commandOrPrompt = Commands.createTelegramCommandOrPromptRuntime< TMessage, TContext >({ extractRawText: Media.extractFirstTelegramMessageText, assertExecutionCurrent(message) { Updates.assertTelegramUpdateExecutionCurrent(message); }, shouldIgnoreMessages: (messages) => !Media.hasTelegramMessagesPromptContent(messages), handleCommand: commandHandler, executeExtensionCommand: async (command, message, ctx) => { const extensionCommand = Commands.findTelegramExtensionCommand( command.name, ); if (!extensionCommand) return false; const sourceTarget = Updates.getTelegramMessageTarget(message); const assertExecutionCurrent = Updates.createTelegramUpdateExecutionFenceGuard(message); try { assertExecutionCurrent(); await extensionCommand.handler({ name: command.name, args: command.args, reply: async (text) => { assertExecutionCurrent(); await deps.sendTextReply( message.chat.id, message.message_id, text, { target: sourceTarget }, ); assertExecutionCurrent(); }, enqueuePrompt: async (prompt) => { assertExecutionCurrent(); await promptEnqueue( [ { ...message, text: prompt, caption: undefined, } as TMessage, ], ctx, ); }, }); assertExecutionCurrent(); } catch (error) { deps.recordRuntimeEvent?.("telegram-command", error, { command: command.name, }); assertExecutionCurrent(); await deps.sendTextReply( message.chat.id, message.message_id, "Command failed.", { target: sourceTarget }, ); } return true; }, expandPromptTemplateCommand: (commandName, args) => PromptTemplates.expandTelegramPromptTemplateCommand( commandName, args, getPromptTemplateCommands(), ), replaceMessageText: (message, text) => ({ ...message, text, caption: undefined }) as TMessage, enqueueTurn: async (messages, ctx) => { await promptEnqueue(messages, ctx); }, }); dispatchReroutedCommandMessages = (messages, ctx) => commandOrPrompt.dispatchMessages(messages, ctx); const mediaDispatch = Media.createTelegramMediaGroupDispatchRuntime< TMessage, TContext >({ mediaGroups: deps.mediaGroupRuntime, dispatchMessages: commandOrPrompt.dispatchMessages, onDeferredMessage: Updates.reportTelegramUpdateDeferred, }); const textDispatch = TextGroups.createTelegramTextGroupDispatchRuntime< TMessage, TContext >({ textGroups: deps.textGroupRuntime, dispatchMessages: commandOrPrompt.dispatchMessages, dispatchSingleMessage: mediaDispatch.handleMessage, onDeferredMessage: Updates.reportTelegramUpdateDeferred, }); const editRuntime = Turns.createTelegramQueuedPromptEditRuntime< TMessage, TContext >({ ...deps.telegramQueueStore, updateStatus: deps.updateStatus, }); const handleTelegramTopicLifecycleUpdate = async ( lifecycle: Updates.TelegramTopicLifecycleUpdate, ctx: TContext, ): Promise => { const assertExecutionCurrent = Updates.createTelegramUpdateExecutionFenceGuard(lifecycle.message); assertExecutionCurrent(); await deps.handleTelegramTopicLifecycleUpdate?.(lifecycle, ctx); assertExecutionCurrent(); if (lifecycle.kind !== "created" || !deps.threadStore) { return; } await deps.threadStore.load(); assertExecutionCurrent(); }; const handleAuthorizedTelegramGuestMessage = async ( guestMessage: Updates.TelegramGuestMessage & { from: TelegramUser }, ctx: TContext, ): Promise => { const assertExecutionCurrent = Updates.createTelegramUpdateExecutionFenceGuard(guestMessage); assertExecutionCurrent(); const text = guestMessage.text ?? ""; const gm = guestMessage as unknown as Record; // Build telegram prefix with guest context const chatRaw = gm.chat as Record; const chatType = chatRaw?.type as string; const fromRaw = gm.from as Record | undefined; const replyMsg = gm.reply_to_message as Record | undefined; const replyFromRaw = replyMsg?.from as Record | undefined; const guestBotCallerUser = gm.guest_bot_caller_user as Record | undefined; const guestBotCallerChat = gm.guest_bot_caller_chat as Record | undefined; const ownerUserId = deps.configStore.getAllowedUserId(); const replyPeer = formatTelegramPromptPeer(replyFromRaw); const guestPeer = resolveTelegramGuestPromptPeer({ chatType, chat: chatRaw, from: fromRaw, replyFrom: replyFromRaw, guestBotCallerUser, guestBotCallerChat, ownerUserId, }); const prefixParts = ["telegram"]; if (guestPeer) { prefixParts.push(`guest:${guestPeer}`); } else if (chatType === "private") { deps.recordRuntimeEvent?.( "guest", new Error("Private Guest Mode remote peer could not be resolved"), { phase: "peer-attribution", chatId: typeof chatRaw?.id === "number" ? chatRaw.id : undefined, fromId: typeof fromRaw?.id === "number" ? fromRaw.id : undefined, hasReplyFrom: !!replyFromRaw, hasCallerUser: !!guestBotCallerUser, hasCallerChat: !!guestBotCallerChat, }, ); } const telegramPrefix = `[${prefixParts.join("|")}]`; // Extract reply context const replyText = replyMsg ? ((replyMsg.text as string) || (replyMsg.caption as string) || "").trim() : ""; // Download files, run inbound handlers const guestMsg = guestMessage as unknown as Media.TelegramMediaMessage; const replyFiles = guestMsg.reply_to_message ? await Media.downloadTelegramMessageFiles( [guestMsg.reply_to_message as Media.TelegramMediaMessage], { downloadFile: deps.downloadFile }, ) : []; assertExecutionCurrent(); const files = await Media.downloadTelegramMessageFiles([guestMsg], { downloadFile: deps.downloadFile, }); assertExecutionCurrent(); const processed = await deps.inboundHandlerRuntime.process( files, text, ctx, ); assertExecutionCurrent(); const rawText = processed.rawText || text; let sourceContext = ""; if (replyMsg) { const replyHeader = replyPeer ? `[reply|from:${replyPeer}]` : "[reply]"; const replyBlock = replyText ? `${replyHeader} ${replyText}` : replyHeader; sourceContext = appendTelegramSourceAttachmentSection( replyBlock, replyPeer, replyFiles, ); } const promptText = Turns.buildTelegramTurnPrompt({ telegramPrefix, rawText, files, promptFiles: processed.promptFiles, handlerOutputs: processed.handlerOutputs, sourceContext, }); const order = deps.bridgeRuntime.queue.allocateItemOrder(); const content: Queue.TelegramPromptContent[] = [ { type: "text", text: promptText }, ]; for (const file of processed.promptFiles) { if (file.isImage && file.mimeType) { try { const buffer = await readFile(file.path); assertExecutionCurrent(); content.push({ type: "image", data: Buffer.from(buffer).toString("base64"), mimeType: file.mimeType, }); } catch { // skip unreadable files } } } const admissionReceipts = createAdmissionReceipts("prompt", [guestMessage]); const guestTurn: Queue.PendingTelegramTurn = { kind: "prompt", chatId: 0, replyToMessageId: 0, guestQueryId: guestMessage.guest_query_id, sourceMessageIds: [], queueOrder: order, queueLane: "default", laneOrder: order, queuedAttachments: [], content, historyText: Turns.formatTelegramTurnStatusSummary( processed.rawText || text, processed.promptFiles, processed.handlerOutputs, ), statusSummary: Turns.truncateTelegramQueueSummary( processed.rawText || text, ), ...(admissionReceipts.length > 0 ? { admissionReceipts } : {}), }; const items = deps.telegramQueueStore.getQueuedItems(); Updates.assertTelegramUpdateExecutionCurrent(guestMessage); deps.telegramQueueStore.setQueuedItems( Queue.appendTelegramQueueItem(items, guestTurn), ); reportQueueAdmission([guestMessage], admissionReceipts); deps.updateStatus(ctx); requestDispatchNextQueuedTelegramTurn(ctx); }; return Updates.createTelegramPairedUpdateRuntime({ getAllowedUserId: deps.configStore.getAllowedUserId, getCurrentInstanceId: deps.getCurrentInstanceId, getMessageOwnership: deps.getMessageOwnership, getTargetOwnership: deps.getTargetOwnership, recordMessageOwnership: deps.recordMessageOwnership, handleTelegramTopicLifecycleUpdate, foreignOwnedUpdateForwarder: deps.foreignOwnedUpdateForwarder, setAllowedUserId: deps.configStore.setAllowedUserId, persistConfig: deps.configStore.persist, updateStatus: deps.updateStatus, removePendingMediaGroupMessages: deps.mediaGroupRuntime.removeMessages, flushPendingMediaGroupMessage: deps.mediaGroupRuntime.flushMessage, flushPendingTextGroupMessage: deps.textGroupRuntime.flushMessage, removeQueuedTelegramTurnsByMessageIds: deps.queueMutationRuntime.removeByMessageIds, applyQueuedTelegramTurnReactionByMessageId: deps.queueMutationRuntime.applyReactionByMessageId, answerCallbackQuery: deps.answerCallbackQuery, answerGuestQuery: deps.answerGuestQuery, handleAuthorizedTelegramCallbackQuery: callbackHandler, sendTextReply: deps.sendTextReply, handleAuthorizedTelegramMessage: async (message, ctx) => { const assertExecutionCurrent = Updates.createTelegramUpdateExecutionFenceGuard(message); assertExecutionCurrent(); if (typeof message.message_thread_id === "number") { await deps.handleTelegramThreadTargetObserved?.( { chatId: message.chat.id, threadId: message.message_thread_id, }, ctx, ); assertExecutionCurrent(); } const text = Media.extractFirstTelegramMessageText([ message as TMessage, ]).trim(); if (deps.threadStore && typeof message.message_thread_id !== "number") { await deps.threadStore.load(); assertExecutionCurrent(); if (deps.threadStore.getBotState().threadMode === "disabled") { await textDispatch.handleMessage(message as TMessage, ctx); return; } const records = deps.threadStore.list(); const bindings = getTelegramRoutableThreadRecords( records, deps.getLiveThreadTargets?.(), ); const command = getKnownTelegramAllTabCommand(text); if (bindings.length > 0 && command && command.name !== "thread") { if ( await sendAllTabCommandChooser(command, text, message as TMessage, { replyToSource: true, }) ) { return; } } if (bindings.length > 0 && !text.startsWith("/")) { const probeTarget = bindings[0]?.target; if (probeTarget?.threadId && deps.callApi) { try { await deps.callApi("sendChatAction", { chat_id: probeTarget.chatId, message_thread_id: probeTarget.threadId, action: "typing", }); } catch (error) { if ( Threads.isTelegramTopicModeUnavailableError(error) || Threads.isTelegramTopicTargetStaleError(error) ) { deps.threadStore.setBotState({ threadMode: "disabled", updatedAtMs: Date.now(), lastReconcileAction: "thread-mode-unavailable-threadless-prompt", }); await deps.threadStore.persist(); assertExecutionCurrent(); await textDispatch.handleMessage(message as TMessage, ctx); return; } deps.recordRuntimeEvent?.("telegram", error, { phase: "threadless-topic-capability-check", chatId: probeTarget.chatId, threadId: probeTarget.threadId, }); } } await deps.sendTextReply( message.chat.id, message.message_id, "This bot is in threaded multi-instance mode. Send prompts in a bound Pi thread tab so they route to the right instance.", ); return; } } await textDispatch.handleMessage(message as TMessage, ctx); }, handleAuthorizedTelegramEditedMessage: editRuntime.updateFromEditedMessage, handleAuthorizedTelegramGuestMessage, handleUnboundTelegramTopicMessage: async (message, ctx) => { const assertExecutionCurrent = Updates.createTelegramUpdateExecutionFenceGuard(message); assertExecutionCurrent(); if (!deps.threadStore) { await textDispatch.handleMessage(message as TMessage, ctx); return; } await deps.threadStore.load(); assertExecutionCurrent(); if (deps.threadStore.getBotState().threadMode === "disabled") { await textDispatch.handleMessage(message as TMessage, ctx); return; } const target = Updates.getTelegramMessageTarget(message); if (!target?.threadId) { await textDispatch.handleMessage(message as TMessage, ctx); return; } const text = Media.extractFirstTelegramMessageText([ message as TMessage, ]).trim(); const instanceId = deps.getCurrentInstanceId?.(); const leaderProfileKey = getLeaderTopicProfileKey(ctx, instanceId); const records = deps.threadStore.list(); const routableRecords = getTelegramRoutableThreadRecords( records, deps.getLiveThreadTargets?.(), ); const hasAnyRoutableThread = routableRecords.length > 0; const existing = records.find((r) => { return ( r.target.chatId === target.chatId && r.target.threadId === target.threadId ); }); if (existing) { const isLeaderTopic = (instanceId && existing.instanceId === instanceId) || (!!leaderProfileKey && existing.profileKey === leaderProfileKey); if (existing.status === "active" && isLeaderTopic) { if (typeof existing.rerouteConfirmedAtMs !== "number") { const nowMs = Date.now(); deps.threadStore.upsert({ ...existing, updatedAtMs: nowMs, rerouteConfirmedAtMs: nowMs, }); await deps.threadStore.persist(); assertExecutionCurrent(); } await textDispatch.handleMessage(message as TMessage, ctx); return; } if (existing.status === "starting") { await deps.sendTextReply( target.chatId, message.message_id, "Instance " + getTelegramThreadRecordLabel(existing) + " is starting. Please wait…", { target }, ); return; } if (existing.status === "active") { await deps.sendTextReply( target.chatId, message.message_id, "Instance " + getTelegramThreadRecordLabel(existing) + " is not connected to the Telegram bus yet. Run /telegram-connect in that Pi instance; keeping this thread.", { target }, ); return; } if ( (existing.status === "stale" || existing.status === "offline") && leaderProfileKey && !hasActiveLeaderTopic( deps.threadStore.list(), leaderProfileKey, instanceId, ) && !hasAnyRoutableThread ) { const priorLeaderRecord = deps.threadStore.getByProfileKey(leaderProfileKey); const slot = deps.threadStore.allocateSlot( leaderProfileKey, priorLeaderRecord?.slot ?? existing.slot, ) ?? priorLeaderRecord?.slot ?? existing.slot ?? "A"; const threadName = priorLeaderRecord?.threadName ?? existing.threadName ?? Threads.chooseTelegramThreadName({ slot }) ?? "Pi"; deps.threadStore.upsert({ profileKey: leaderProfileKey, owner: { kind: "leader", cwd: typeof (ctx as { cwd?: unknown }).cwd === "string" ? (ctx as { cwd?: string }).cwd : undefined, instanceId, }, target: { chatId: target.chatId, threadId: target.threadId }, status: "active", createdAtMs: priorLeaderRecord?.createdAtMs ?? existing.createdAtMs, updatedAtMs: Date.now(), threadName, instanceId, slot, }); await deps.threadStore.persist(); assertExecutionCurrent(); deps.setCurrentLeaderIdentity?.({ target: { chatId: target.chatId, threadId: target.threadId }, slot, threadName, }); deps.recordRuntimeEvent?.( "bus", "Bus leader reclaimed unbound thread", { phase: "leader-topic-reclaim", chatId: target.chatId, threadId: target.threadId, slot, profileKey: leaderProfileKey, }, ); await textDispatch.handleMessage(message as TMessage, ctx); return; } await deps.sendTextReply( target.chatId, message.message_id, "Topic " + (existing.slot ?? "?") + " is " + existing.status + ". Start a Pi instance to claim it.", { target }, ); return; } const deletedObservation = deps.threadStore .listSyncObservations() .find( (observation) => observation.syncStatus === "deleted" && observation.target.chatId === target.chatId && observation.target.threadId === target.threadId, ); if (deletedObservation) { deps.recordRuntimeEvent?.( "inbound-worker", "Discarded update from a confirmed deleted Telegram thread", { phase: "discard-deleted-thread", chatId: target.chatId, threadId: target.threadId, messageId: message.message_id, }, ); return; } const reservations = deps.threadStore.listReservations(); const reservation = reservations.find( (reservation) => reservation.target.chatId === target.chatId && reservation.target.threadId === target.threadId, ); if (reservation) { await deps.sendTextReply( target.chatId, message.message_id, "Previous leader thread (" + (reservation.slot ?? "?") + "). Closing and deleting this old topic. Use the current thread tab instead.", { target }, ); await deleteReservedTelegramTopicThroughReconciler( deps, { chatId: target.chatId, threadId: target.threadId }, message.message_id, ); return; } const command = getKnownTelegramAllTabCommand(text); if (command && hasAnyRoutableThread) { if ( await sendAllTabCommandChooser(command, text, message as TMessage, { target: { chatId: target.chatId, threadId: target.threadId }, replyToSource: true, }) ) { return; } } if (leaderProfileKey && deps.callApi) { const currentLeaderRecord = records.find((record) => { if (record.status !== "active") return false; if (instanceId && record.instanceId === instanceId) return true; return record.profileKey === leaderProfileKey; }); if ( currentLeaderRecord && (currentLeaderRecord.target.chatId !== target.chatId || currentLeaderRecord.target.threadId !== target.threadId) ) { let currentLeaderIsStale = false; try { await deps.callApi("sendChatAction", { chat_id: currentLeaderRecord.target.chatId, message_thread_id: currentLeaderRecord.target.threadId, action: "typing", }); } catch (error) { currentLeaderIsStale = Threads.isTelegramTopicTargetStaleError(error); if (!currentLeaderIsStale) throw error; } if (currentLeaderIsStale) { deps.threadStore.markStaleByTarget( currentLeaderRecord.target, "deleted", "Current leader thread is stale during unbound prompt routing.", ); const slot = currentLeaderRecord.slot ?? "A"; const threadName = getRestoredThreadName(currentLeaderRecord, slot); deps.threadStore.upsert({ ...currentLeaderRecord, profileKey: leaderProfileKey, owner: { kind: "leader", cwd: typeof (ctx as { cwd?: unknown }).cwd === "string" ? (ctx as { cwd?: string }).cwd : undefined, instanceId, }, target: { chatId: target.chatId, threadId: target.threadId }, status: "active", updatedAtMs: Date.now(), threadName, instanceId, slot, }); await deps.threadStore.persist(); assertExecutionCurrent(); deps.setCurrentLeaderIdentity?.({ target: { chatId: target.chatId, threadId: target.threadId }, slot, threadName, }); deps.recordRuntimeEvent?.( "bus", "Bus leader reclaimed stale-current unbound thread", { phase: "leader-topic-unbound-stale-reclaim", chatId: target.chatId, threadId: target.threadId, staleThreadId: currentLeaderRecord.target.threadId, slot, profileKey: leaderProfileKey, }, ); await textDispatch.handleMessage(message as TMessage, ctx); return; } } } if ( leaderProfileKey && !hasActiveLeaderTopic(records, leaderProfileKey, instanceId) && !hasAnyRoutableThread ) { const priorLeaderRecord = deps.threadStore.getByProfileKey(leaderProfileKey); const priorLeaderIdentity = deps.threadStore.getIdentityByProfileKey(leaderProfileKey); const slot = deps.threadStore.allocateSlot( leaderProfileKey, priorLeaderRecord?.slot ?? priorLeaderIdentity?.slot, ) ?? priorLeaderRecord?.slot ?? priorLeaderIdentity?.slot ?? "A"; const identityThreadName = priorLeaderIdentity?.threadName && Threads.isTelegramTopicThreadNameValidForSlot( priorLeaderIdentity.threadName, slot, ) ? priorLeaderIdentity.threadName : undefined; const threadName = priorLeaderRecord?.threadName ?? identityThreadName ?? Threads.chooseTelegramThreadName({ slot }) ?? "Pi"; deps.threadStore.upsert({ profileKey: leaderProfileKey, owner: { kind: "leader", cwd: typeof (ctx as { cwd?: unknown }).cwd === "string" ? (ctx as { cwd?: string }).cwd : undefined, instanceId, }, target: { chatId: target.chatId, threadId: target.threadId }, status: "active", createdAtMs: priorLeaderRecord?.createdAtMs ?? Date.now(), updatedAtMs: Date.now(), threadName, instanceId, slot, }); await deps.threadStore.persist(); assertExecutionCurrent(); deps.setCurrentLeaderIdentity?.({ target: { chatId: target.chatId, threadId: target.threadId }, slot, threadName, }); deps.recordRuntimeEvent?.( "bus", "Bus leader reclaimed unbound thread", { phase: "leader-topic-reclaim", chatId: target.chatId, threadId: target.threadId, slot, profileKey: leaderProfileKey, }, ); await textDispatch.handleMessage(message as TMessage, ctx); return; } await sendUnboundRerouteChooser(message as TMessage, ctx); return; }, }); } // --- Assistant Output Delivery Authority --- export interface TelegramAssistantOutputAuthority { transportStamp: TTransportStamp; route: "direct" | "follower" | "none"; directEpoch?: number | string; followerGeneration?: string; target?: Queue.TelegramQueueTarget; } export interface TelegramAssistantOutputAuthorityRuntime { captureAuthority: () => TelegramAssistantOutputAuthority; isAuthorityActive: ( authority: TelegramAssistantOutputAuthority, ) => boolean; canDeliver: () => boolean; } export function createTelegramAssistantOutputAuthorityRuntime< TTransportStamp, >(deps: { getPreferredTarget: () => Queue.TelegramQueueTarget | undefined; getFallbackChatId: () => number | undefined; getTransportStamp: () => TTransportStamp; isTransportStampActive: (stamp: TTransportStamp) => boolean; ownsDirect: () => boolean; getDirectEpoch: () => number | string | undefined; isFollowerRegistered: () => boolean; getFollowerGeneration: () => string | undefined; }): TelegramAssistantOutputAuthorityRuntime { const getCurrentTarget = (): Queue.TelegramQueueTarget | undefined => { const preferred = deps.getPreferredTarget(); if (preferred) return { ...preferred }; const chatId = deps.getFallbackChatId(); return chatId === undefined ? undefined : { chatId }; }; return { captureAuthority() { const target = getCurrentTarget(); const directEpoch = deps.ownsDirect() ? deps.getDirectEpoch() : undefined; const followerGeneration = deps.isFollowerRegistered() ? deps.getFollowerGeneration() : undefined; return { transportStamp: deps.getTransportStamp(), route: directEpoch !== undefined ? "direct" : followerGeneration !== undefined ? "follower" : "none", directEpoch, followerGeneration, target, }; }, isAuthorityActive(authority) { if (!deps.isTransportStampActive(authority.transportStamp)) return false; const target = getCurrentTarget(); if ( authority.target === undefined || target?.chatId !== authority.target.chatId || target?.threadId !== authority.target.threadId ) { return false; } if (authority.route === "direct") { return ( deps.ownsDirect() && deps.getDirectEpoch() === authority.directEpoch ); } if (authority.route === "follower") { return ( !deps.ownsDirect() && deps.isFollowerRegistered() && deps.getFollowerGeneration() === authority.followerGeneration ); } return false; }, canDeliver() { return deps.ownsDirect() || deps.isFollowerRegistered(); }, }; }