/** * Telegram bridge extension entrypoint and orchestration layer * Keeps the runtime wiring in one place while delegating reusable domain logic to /lib modules */ import * as Api from "./lib/api.ts"; import * as AttachmentHandlers from "./lib/attachment-handlers.ts"; import * as Broadcast from "./lib/broadcast.ts"; import * as Attachments from "./lib/attachments.ts"; import * as Commands from "./lib/commands.ts"; import * as CommandTemplates from "./lib/command-templates.ts"; import * as Config from "./lib/config.ts"; import * as Lifecycle from "./lib/lifecycle.ts"; import * as Locks from "./lib/locks.ts"; import * as Media from "./lib/media.ts"; import * as Menu from "./lib/menu.ts"; import * as Model from "./lib/model.ts"; import * as Pi from "./lib/pi.ts"; import * as Polling from "./lib/polling.ts"; import * as Preview from "./lib/preview.ts"; import * as Prompts from "./lib/prompts.ts"; import * as Queue from "./lib/queue.ts"; import * as Replies from "./lib/replies.ts"; import * as Runtime from "./lib/runtime.ts"; import * as Routing from "./lib/routing.ts"; import * as SessionRegistry from "./lib/session-registry.ts"; import * as SessionInbox from "./lib/session-inbox.ts"; import * as Setup from "./lib/setup.ts"; import * as OutboundHandlers from "./lib/outbound-handlers.ts"; import * as Status from "./lib/status.ts"; type ActivePiModel = NonNullable; type RuntimeTelegramQueueItem = Queue.TelegramQueueItem; // --- Extension Runtime --- export default function (pi: Pi.ExtensionAPI) { const piRuntime = Pi.createExtensionApiRuntimePorts(pi); const { getThinkingLevel, sendUserMessage, setModel, setThinkingLevel } = piRuntime; const bridgeRuntime = Runtime.createTelegramBridgeRuntime(); const { abort, lifecycle, queue, setup, typing } = bridgeRuntime; const configStore = Config.createTelegramConfigStore(); const sessionRegistry = SessionRegistry.createTelegramSessionRegistry(); let currentSessionKey: string | undefined; let currentRegisteredSession: SessionRegistry.TelegramRegisteredSession | undefined; let inboxConsumer: SessionInbox.TelegramInboxConsumer | undefined; const lockRuntime = Locks.createTelegramLockRuntime(); const activeTurnRuntime = Queue.createTelegramActiveTurnStore(); const buttonActionStore = OutboundHandlers.createTelegramButtonActionStore(); const pendingModelSwitchStore = Model.createPendingModelSwitchStore< Model.ScopedTelegramModel >(); const modelMenuRuntime = Menu.createTelegramModelMenuRuntime(); const runtimeEvents = Status.createTelegramRuntimeEventRecorder({ getBotToken: configStore.getBotToken, }); const usageLimitsStore = Status.createTelegramUsageLimitsStore(); pi.events.on("usage:update", (update) => { usageLimitsStore.update(update as Status.TelegramUsageLimitsUpdate); }); const recordRuntimeEvent = runtimeEvents.record; const getContextModel = Pi.getExtensionContextModel; const isIdle = Pi.isExtensionContextIdle; const hasPendingMessages = Pi.hasExtensionContextPendingMessages; const compact = Pi.compactExtensionContext; const mediaGroupRuntime = Media.createTelegramMediaGroupController< Api.TelegramMessage, Pi.ExtensionContext >(); const telegramQueueStore = Queue.createTelegramQueueStore(); const deferredQueueDispatchRuntime = Queue.createTelegramDeferredQueueDispatchRuntime({ recordRuntimeEvent, }); const pollingControllerState = Polling.createTelegramPollingControllerState(); const isPollingActive = Polling.createTelegramPollingActivityReader( pollingControllerState, ); const { getStatusLines, updateStatus } = Status.createTelegramBridgeStatusRuntime< Pi.ExtensionContext, RuntimeTelegramQueueItem >({ getConfig: configStore.get, isPollingActive, getActiveSourceMessageIds: activeTurnRuntime.getSourceMessageIds, hasActiveTurn: activeTurnRuntime.has, hasDispatchPending: lifecycle.hasDispatchPending, isCompactionInProgress: lifecycle.isCompactionInProgress, getActiveToolExecutions: lifecycle.getActiveToolExecutions, hasPendingModelSwitch: pendingModelSwitchStore.has, getQueuedItems: telegramQueueStore.getQueuedItems, formatQueuedStatus: Queue.formatQueuedTelegramItemsStatus, getRecentRuntimeEvents: runtimeEvents.getEvents, getRuntimeLockState: lockRuntime.getStatusLabel, getFooterState: () => { const connected = isPollingActive(); return { hasBotToken: configStore.hasBotToken(), connected, role: connected ? "router" : "target", topic: currentRegisteredSession?.binding ? { threadId: currentRegisteredSession.binding.threadId, topicName: currentRegisteredSession.binding.topicName, } : undefined, bindCode: currentRegisteredSession?.bindCode && currentRegisteredSession.bindCodeExpiresAt !== undefined && currentRegisteredSession.bindCodeExpiresAt >= Date.now() ? currentRegisteredSession.bindCode : undefined, broadcastEnabled: currentRegisteredSession?.broadcastEnabled ?? false, queueCount: telegramQueueStore.getQueuedItems().length, active: activeTurnRuntime.has(), }; }, }); const currentModelRuntime = Model.createCurrentModelRuntime< Pi.ExtensionContext, ActivePiModel >({ getContextModel, updateStatus, }); const queueMutationRuntime = Queue.createTelegramQueueMutationController({ ...telegramQueueStore, getNextPriorityReactionOrder: queue.getNextPriorityReactionOrder, incrementNextPriorityReactionOrder: queue.incrementNextPriorityReactionOrder, updateStatus, }); const attachmentHandlerRuntime = AttachmentHandlers.createTelegramAttachmentHandlerRuntime( { getHandlers: configStore.getAttachmentHandlers, execCommand: CommandTemplates.execCommandTemplate, getCwd: Pi.getExtensionContextCwd, recordRuntimeEvent, }, ); // --- Telegram API --- const { callMultipart, deleteWebhook, getUpdates, setMyCommands, sendTypingAction, sendMessageDraft, sendMessage, downloadFile: downloadTelegramBridgeFile, editMessageText: editTelegramMessageText, answerCallbackQuery, prepareTempDir, } = Api.createDefaultTelegramBridgeApiRuntime({ getBotToken: configStore.getBotToken, recordRuntimeEvent, }); // --- Message Delivery & Preview --- const promptDispatchRuntime = Runtime.createTelegramPromptDispatchRuntime({ lifecycle, typing, getDefaultChatId: activeTurnRuntime.getChatId, sendTypingAction, updateStatus, recordRuntimeEvent, }); // --- Reply Runtime Wiring --- const { replyTransport, sendTextReply, sendMarkdownReply, editInteractiveMessage, sendInteractiveMessage, } = Replies.createTelegramRenderedMessageDeliveryRuntime( { sendMessage, editMessage: editTelegramMessageText, }, ); const dispatchNextQueuedTelegramTurn = Queue.createTelegramQueueDispatchRuntime({ ...telegramQueueStore, isCompactionInProgress: lifecycle.isCompactionInProgress, hasActiveTurn: activeTurnRuntime.has, hasDispatchPending: lifecycle.hasDispatchPending, isIdle, hasPendingMessages, hasDispatchContext: deferredQueueDispatchRuntime.isBound, updateStatus, sendTextReply, recordRuntimeEvent, ...promptDispatchRuntime, sendUserMessage, }).dispatchNext; const previewRuntime = Preview.createTelegramAssistantPreviewRuntime({ getActiveTurn: activeTurnRuntime.get, isAssistantMessage: Replies.isAssistantAgentMessage, getMessageText: Replies.getAgentMessageText, getDefaultReplyToMessageId: activeTurnRuntime.getReplyToMessageId, sendDraft: sendMessageDraft, sendMessage, editMessageText: editTelegramMessageText, ...replyTransport, }); // --- Bridge Setup --- const modelSwitchController = Model.createTelegramModelSwitchControllerRuntime< Pi.ExtensionContext, Model.ScopedTelegramModel >({ isIdle, getPendingModelSwitch: pendingModelSwitchStore.get, setPendingModelSwitch: pendingModelSwitchStore.set, getActiveTurn: activeTurnRuntime.get, getAbortHandler: abort.getHandler, hasAbortHandler: abort.hasHandler, getActiveToolExecutions: lifecycle.getActiveToolExecutions, allocateItemOrder: queue.allocateItemOrder, allocateControlOrder: queue.allocateControlOrder, appendQueuedItem: queueMutationRuntime.append, updateStatus, }); const menuActions = Menu.createTelegramMenuActionRuntimeWithStateBuilder< ActivePiModel, Pi.ExtensionContext >({ runtime: modelMenuRuntime, createSettingsManager: Pi.createSettingsManager, getActiveModel: currentModelRuntime.get, getThinkingLevel, buildStatusHtml: Status.createTelegramStatusHtmlBuilder({ getActiveModel: currentModelRuntime.get, getUsageLimits: usageLimitsStore.get, }), storeModelMenuState: modelMenuRuntime.storeState, isIdle, canOfferInFlightModelSwitch: modelSwitchController.canOfferInFlightSwitch, sendTextReply, editInteractiveMessage, sendInteractiveMessage, }); // --- Polling --- const topicRouter = Routing.createTelegramTopicRouter({ bindTopicByCode: sessionRegistry.bindTopicByCode, findSessionByTopic: sessionRegistry.findByTopic, appendInbox: SessionInbox.appendTelegramInboxItem, sendTextReply, isPidAlive: (pid) => { try { process.kill(pid, 0); return true; } catch { return false; } }, now: Date.now, }); const inboundRouteRuntime = Routing.createTelegramInboundRouteRuntime< Api.TelegramUpdate, Api.TelegramMessage, Api.TelegramCallbackQuery, Pi.ExtensionContext, ActivePiModel >({ configStore, bridgeRuntime, activeTurnRuntime, mediaGroupRuntime, telegramQueueStore, queueMutationRuntime, modelMenuRuntime, currentModelRuntime, modelSwitchController, menuActions, buttonActionStore, attachmentHandlerRuntime, updateStatus, dispatchNextQueuedTelegramTurn, answerCallbackQuery, sendTextReply, setMyCommands, downloadFile: downloadTelegramBridgeFile, getThinkingLevel, setThinkingLevel, setModel, isIdle, hasPendingMessages, compact, topicRouter, recordRuntimeEvent, }); const pollingRuntime = Polling.createTelegramPollingControllerRuntime< Api.TelegramUpdate, Pi.ExtensionContext >({ state: pollingControllerState, getConfig: configStore.get, hasBotToken: configStore.hasBotToken, deleteWebhook, getUpdates, persistConfig: configStore.persist, handleUpdate: inboundRouteRuntime.handleUpdate, stopTypingLoop: typing.stop, updateStatus, recordRuntimeEvent, }); const lockedPollingRuntime = Locks.createTelegramLockedPollingRuntime({ lock: lockRuntime, hasBotToken: configStore.hasBotToken, startPolling: pollingRuntime.start, stopPolling: pollingRuntime.stop, updateStatus, recordRuntimeEvent, }); const queueSessionLifecycle = Queue.createTelegramSessionLifecycleRuntime< Pi.ExtensionContext, RuntimeTelegramQueueItem, ActivePiModel >({ getCurrentModel: getContextModel, loadConfig: configStore.load, setQueuedItems: telegramQueueStore.setQueuedItems, setCurrentModel: currentModelRuntime.set, setPendingModelSwitch: pendingModelSwitchStore.set, syncCounters: queue.syncCounters, syncFlags: lifecycle.syncFlags, bindDeferredDispatchContext: deferredQueueDispatchRuntime.bind, prepareTempDir, updateStatus, unbindDeferredDispatchContext: deferredQueueDispatchRuntime.unbind, clearPendingMediaGroups: mediaGroupRuntime.clear, clearModelMenuState: modelMenuRuntime.clear, getActiveTurnChatId: activeTurnRuntime.getChatId, clearPreview: previewRuntime.clear, clearActiveTurn: activeTurnRuntime.clear, clearAbort: abort.clearHandler, stopPolling: lockedPollingRuntime.suspend, recordRuntimeEvent, }); async function registerCurrentSession( ctx: Pi.ExtensionContext, ): Promise { const sessionFile = Pi.getExtensionContextSessionFile(ctx); const key = SessionRegistry.makeTelegramSessionKey({ pid: process.pid, cwd: ctx.cwd, sessionFile, }); currentSessionKey = key; currentRegisteredSession = await sessionRegistry.register({ key, pid: process.pid, cwd: ctx.cwd, sessionFile, inboxPath: SessionRegistry.makeTelegramSessionInboxPath( sessionRegistry.getAgentDir(), key, ), updatedAt: Date.now(), }); return currentRegisteredSession; } function requireCurrentSessionKey(): string { if (!currentSessionKey) throw new Error("Telegram session is not registered yet."); return currentSessionKey; } async function startCurrentSessionInboxConsumer( session: SessionRegistry.TelegramRegisteredSession, ctx: Pi.ExtensionContext, ): Promise { inboxConsumer?.stop(); const initialCursor = session.inboxCursor ?? await SessionInbox.getTelegramInboxSize(session.inboxPath); if (session.inboxCursor === undefined) { await sessionRegistry.updateInboxCursor(session.key, initialCursor); } inboxConsumer = SessionInbox.createTelegramInboxConsumer({ inboxPath: session.inboxPath, initialCursor, onUpdate: (update) => inboundRouteRuntime.handleUpdate(update, ctx), onInvalidLine: (invalid) => recordRuntimeEvent("routing", new Error(invalid.error), { line: invalid.line.slice(0, 120), }), onCursor: async (cursor) => { await sessionRegistry.updateInboxCursor(session.key, cursor); }, }); inboxConsumer.start(); } function generateBindCodeText(): string { return Math.random().toString(36).slice(2, 8).toUpperCase(); } const sessionLifecycleRuntime = Lifecycle.appendTelegramLifecycleHooks( Lifecycle.appendTelegramLifecycleHooks(queueSessionLifecycle, { onSessionStart: async (_event, ctx) => { const session = await registerCurrentSession(ctx); await startCurrentSessionInboxConsumer(session, ctx); updateStatus(ctx); }, onSessionShutdown: async (_event, _ctx) => { inboxConsumer?.stop(); inboxConsumer = undefined; if (currentSessionKey) await sessionRegistry.unregister(currentSessionKey); currentSessionKey = undefined; currentRegisteredSession = undefined; }, }), { onSessionStart: lockedPollingRuntime.onSessionStart }, ); // --- Extension API Bindings --- Attachments.registerTelegramAttachmentTool(pi, { getActiveTurn: activeTurnRuntime.get, recordRuntimeEvent, }); Commands.registerTelegramBridgeCommands(pi, { promptForConfig: Setup.createTelegramSetupPromptRuntime({ getConfig: configStore.get, setConfig: configStore.set, setupGuard: setup, getMe: Api.fetchTelegramBotIdentity, persistConfig: configStore.persist, startPolling: lockedPollingRuntime.start, updateStatus, recordRuntimeEvent, }), getStatusLines, bindCurrentTopic: async (binding) => { configStore.update((config) => { config.currentTopicBinding = binding; }); await configStore.persist(); if (currentSessionKey) { currentRegisteredSession = await sessionRegistry.bindTopic(currentSessionKey, { chatId: binding.chatId, threadId: binding.threadId, chatTitle: binding.title, updatedAt: binding.updatedAt, }); } }, reloadConfig: configStore.load, hasBotToken: configStore.hasBotToken, startPolling: lockedPollingRuntime.start, stopPolling: lockedPollingRuntime.stop, updateStatus, generateBindCode: async (ctx) => { await registerCurrentSession(ctx); const key = requireCurrentSessionKey(); const code = generateBindCodeText(); currentRegisteredSession = await sessionRegistry.setBindCode(key, code, 10 * 60_000); return code; }, unbindCurrentTopic: async (ctx) => { await registerCurrentSession(ctx); const session = await sessionRegistry.unbindTopic(requireCurrentSessionKey()); currentRegisteredSession = session; if (!session) return "Telegram session is not registered."; return "Telegram topic binding removed."; }, getCurrentTopicStatus: async (ctx) => { await registerCurrentSession(ctx); const session = await sessionRegistry.getSession(requireCurrentSessionKey()); currentRegisteredSession = session; if (!session?.binding) return "No Telegram topic is bound to this pi session."; const topic = session.binding.topicName ? `${session.binding.threadId ?? "main"} “${session.binding.topicName}”` : `${session.binding.threadId ?? "main"}`; return `Bound to chat ${session.binding.chatId} topic ${topic}. Broadcast ${session.broadcastEnabled ? "on" : "off"}.`; }, setBroadcastEnabled: async (ctx, enabled) => { await registerCurrentSession(ctx); const key = requireCurrentSessionKey(); const session = await sessionRegistry.getSession(key); if (!session?.binding) return "Cannot enable Telegram broadcast: no topic is bound to this pi session."; currentRegisteredSession = await sessionRegistry.setBroadcast(key, enabled); return `Telegram broadcast ${enabled ? "enabled" : "disabled"}.`; }, getBroadcastStatus: async (ctx) => { await registerCurrentSession(ctx); const session = await sessionRegistry.getSession(requireCurrentSessionKey()); currentRegisteredSession = session; return `Telegram broadcast ${session?.broadcastEnabled ? "on" : "off"}.`; }, }); // --- Lifecycle Hooks --- const agentEndResetter = Runtime.createTelegramAgentEndResetter({ abort, typing, clearActiveTurn: activeTurnRuntime.clear, resetToolExecutions: lifecycle.resetActiveToolExecutions, clearPendingModelSwitch: modelSwitchController.clearPendingSwitch, clearDispatchPending: lifecycle.clearDispatchPending, }); const queuedAttachmentSender = Attachments.createTelegramQueuedAttachmentSender({ sendMultipart: callMultipart, sendTextReply, recordRuntimeEvent, }); const outboundReplyPlanner = OutboundHandlers.createTelegramOutboundReplyPlanner(buttonActionStore); const outboundReplyArtifactSender = OutboundHandlers.createTelegramOutboundReplyArtifactSender({ execCommand: CommandTemplates.execCommandTemplate, sendMultipart: callMultipart, sendTextReply, getHandlers: configStore.getOutboundHandlers, recordRuntimeEvent, }); const agentLifecycleHooks = Queue.createTelegramAgentLifecycleHooks< Queue.PendingTelegramTurn, Pi.ExtensionContext, unknown >({ setAbortHandler: Runtime.createTelegramContextAbortHandlerSetter(abort), getQueuedItems: telegramQueueStore.getQueuedItems, hasPendingDispatch: lifecycle.hasDispatchPending, hasActiveTurn: activeTurnRuntime.has, resetToolExecutions: lifecycle.resetActiveToolExecutions, resetPendingModelSwitch: modelSwitchController.clearPendingSwitch, setQueuedItems: telegramQueueStore.setQueuedItems, clearDispatchPending: lifecycle.clearDispatchPending, setActiveTurn: activeTurnRuntime.set, createPreviewState: previewRuntime.resetState, startTypingLoop: promptDispatchRuntime.startTypingLoop, updateStatus, getActiveTurn: activeTurnRuntime.get, extractAssistant: Replies.extractLatestAssistantMessageText, getPreserveQueuedTurnsAsHistory: lifecycle.shouldPreserveQueuedTurnsAsHistory, resetRuntimeState: agentEndResetter, dispatchNextQueuedTelegramTurn, requestDeferredDispatchNextQueuedTelegramTurn: deferredQueueDispatchRuntime.request, clearPreview: previewRuntime.clear, setPreviewPendingText: previewRuntime.setPendingText, finalizeMarkdownPreview: previewRuntime.finalizeMarkdown, sendMarkdownReply, sendTextReply, sendQueuedAttachments: queuedAttachmentSender, planOutboundReply: outboundReplyPlanner, sendOutboundReplyArtifacts: outboundReplyArtifactSender, getActiveToolExecutions: lifecycle.getActiveToolExecutions, setActiveToolExecutions: lifecycle.setActiveToolExecutions, triggerPendingModelSwitchAbort: modelSwitchController.triggerPendingAbort, }); async function sendBroadcast(role: Broadcast.TelegramBroadcastRole, text: string): Promise { const session = currentRegisteredSession; if (!session?.broadcastEnabled || !session.binding) return; if (!Broadcast.shouldBroadcastLocalMessage(text)) return; await sendMessage({ chat_id: session.binding.chatId, text: Broadcast.buildTelegramBroadcastMessage(role, text), ...(session.binding.threadId !== undefined ? { message_thread_id: session.binding.threadId } : {}), }); } const beforeAgentStartHook = Prompts.createTelegramBeforeAgentStartHook(); Lifecycle.registerTelegramLifecycleHooks(pi, { ...sessionLifecycleRuntime, ...agentLifecycleHooks, onBeforeAgentStart: async (event, ctx) => { const prompt = (event as { prompt?: unknown }).prompt; if (typeof prompt === "string") await sendBroadcast("user", prompt); return beforeAgentStartHook(event); }, onAgentEnd: async (event, ctx) => { await agentLifecycleHooks.onAgentEnd(event, ctx); const { text } = Replies.extractLatestAssistantMessageText(event.messages); if (text) await sendBroadcast("assistant", text); }, onModelSelect: currentModelRuntime.onModelSelect, onMessageStart: previewRuntime.onMessageStart, onMessageUpdate: previewRuntime.onMessageUpdate, }); }