/** * Telegram preview streaming helpers * Owns preview transport selection, runtime updates, and preview finalization */ import type { TelegramEditMessageTextBody, TelegramReplyParameters, TelegramSendMessageBody, TelegramSentMessage, } from "./api.ts"; import { buildTelegramPreviewSnapshot, MAX_MESSAGE_LENGTH, renderMarkdownPreviewText, renderTelegramMessage, type TelegramPreviewRenderStrategy, type TelegramPreviewSnapshot, type TelegramRenderedChunk, type TelegramRenderMode, } from "./rendering.ts"; import { buildTelegramReplyParameters } from "./replies.ts"; import { stripTelegramCommentMarkupForPreview } from "./outbound-handlers.ts"; const TELEGRAM_PREVIEW_THROTTLE_MS = 750; const TELEGRAM_DRAFT_ID_MAX = 2_147_483_647; export type TelegramDraftSupport = "unknown" | "supported" | "unsupported"; export interface TelegramPreviewState { mode: "draft" | "message"; draftId?: number; messageId?: number; pendingText: string; lastSentText: string; lastSentParseMode?: "HTML"; lastSentStrategy?: TelegramPreviewRenderStrategy; } export interface TelegramPreviewRuntimeState extends TelegramPreviewState { flushTimer?: ReturnType; flushPromise?: Promise; flushRequested?: boolean; } export type TelegramSentPreviewMessage = TelegramSentMessage; export type TelegramPreviewReplyMarkup = any; export interface TelegramPreviewRuntimeDeps { getState: () => TelegramPreviewRuntimeState | undefined; setState: (state: TelegramPreviewRuntimeState | undefined) => void; clearScheduledFlush: (state: TelegramPreviewRuntimeState) => void; maxMessageLength: number; renderPreviewText: (markdown: string) => string; getDraftSupport: () => TelegramDraftSupport; setDraftSupport: (support: TelegramDraftSupport) => void; allocateDraftId: () => number; sendDraft: ( chatId: number, draftId: number, text: string, ) => Promise; sendMessage: ( chatId: number, text: string, options?: { parseMode?: "HTML" }, ) => Promise; editMessageText: ( chatId: number, messageId: number, text: string, options?: { parseMode?: "HTML" }, ) => Promise; renderTelegramMessage: ( text: string, options?: { mode?: TelegramRenderMode }, ) => TelegramRenderedChunk[]; sendRenderedChunks: ( chatId: number, chunks: TelegramRenderedChunk[], options?: { replyMarkup?: TelegramPreviewReplyMarkup }, ) => Promise; editRenderedMessage: ( chatId: number, messageId: number, chunks: TelegramRenderedChunk[], options?: { replyMarkup?: TelegramPreviewReplyMarkup }, ) => Promise; } export interface TelegramPreviewActiveTurn { chatId: number; } export interface TelegramAssistantMessagePreviewStartDeps { getActiveTurn: () => TelegramPreviewActiveTurn | undefined; isAssistantMessage: (message: TMessage) => boolean; getState: () => TelegramPreviewRuntimeState | undefined; setState: (state: TelegramPreviewRuntimeState | undefined) => void; createPreviewState: () => TelegramPreviewRuntimeState; finalizePreview: (chatId: number) => Promise; finalizeMarkdownPreview: ( chatId: number, markdown: string, ) => Promise; } export interface TelegramAssistantMessagePreviewUpdateDeps { getActiveTurn: () => TelegramPreviewActiveTurn | undefined; isAssistantMessage: (message: TMessage) => boolean; getState: () => TelegramPreviewRuntimeState | undefined; setState: (state: TelegramPreviewRuntimeState | undefined) => void; createPreviewState: () => TelegramPreviewRuntimeState; getMessageText: (message: TMessage) => string; schedulePreviewFlush: (chatId: number) => void; } export type TelegramAssistantMessagePreviewHookDeps = TelegramAssistantMessagePreviewStartDeps & TelegramAssistantMessagePreviewUpdateDeps; export interface TelegramAssistantMessagePreviewHookEvent { message: TMessage; } export interface TelegramAssistantMessagePreviewHooks { onMessageStart: ( event: TelegramAssistantMessagePreviewHookEvent, ) => Promise; onMessageUpdate: ( event: TelegramAssistantMessagePreviewHookEvent, ) => Promise; } export interface TelegramPreviewControllerDeps { getDefaultReplyToMessageId?: () => number | undefined; maxMessageLength?: number; renderPreviewText?: (markdown: string) => string; initialDraftSupport?: TelegramDraftSupport; sendDraft: ( chatId: number, draftId: number, text: string, ) => Promise; sendMessage: ( chatId: number, text: string, options: { parseMode?: "HTML" } | undefined, replyToMessageId: number | undefined, ) => Promise; editMessageText: ( chatId: number, messageId: number, text: string, options?: { parseMode?: "HTML" }, ) => Promise; renderTelegramMessage?: ( text: string, options?: { mode?: TelegramRenderMode }, ) => TelegramRenderedChunk[]; sendRenderedChunks: ( chatId: number, chunks: TelegramRenderedChunk[], replyToMessageId: number | undefined, options?: { replyMarkup?: TelegramPreviewReplyMarkup }, ) => Promise; editRenderedMessage: ( chatId: number, messageId: number, chunks: TelegramRenderedChunk[], options?: { replyMarkup?: TelegramPreviewReplyMarkup }, ) => Promise; throttleMs?: number; maxDraftId?: number; setTimer?: ( callback: () => void, ms: number, ) => ReturnType; clearTimer?: (timer: ReturnType) => void; } export interface TelegramPreviewController { getState: () => TelegramPreviewRuntimeState | undefined; setState: (state: TelegramPreviewRuntimeState | undefined) => void; setPendingText: (text: string) => void; createState: () => TelegramPreviewRuntimeState; resetState: () => void; clear: (chatId: number) => Promise; flush: (chatId: number) => Promise; scheduleFlush: (chatId: number) => void; finalize: (chatId: number, replyToMessageId?: number) => Promise; finalizeMarkdown: ( chatId: number, markdown: string, replyToMessageId?: number, options?: { replyMarkup?: TelegramPreviewReplyMarkup }, ) => Promise; } export interface TelegramPreviewMessageTransportDeps { sendMessage: (body: TelegramSendMessageBody) => Promise; editMessageText: (body: TelegramEditMessageTextBody) => Promise; buildReplyParameters?: ( replyToMessageId: number | undefined, ) => TelegramReplyParameters | undefined; } export function createTelegramPreviewMessageTransport( deps: TelegramPreviewMessageTransportDeps, ): Pick { const getReplyParameters = deps.buildReplyParameters ?? buildTelegramReplyParameters; return { sendMessage: (chatId, text, options, replyToMessageId) => { const replyParameters = getReplyParameters(replyToMessageId); return deps.sendMessage({ chat_id: chatId, text, parse_mode: options?.parseMode, ...(replyParameters ? { reply_parameters: replyParameters } : {}), }); }, editMessageText: (chatId, messageId, text, options) => deps.editMessageText({ chat_id: chatId, message_id: messageId, text, parse_mode: options?.parseMode, }), }; } export interface TelegramPreviewRenderedChunkTransportDeps { sendRenderedChunks: ( chatId: number, chunks: TelegramRenderedChunk[], options?: { replyToMessageId?: number; replyMarkup?: TelegramPreviewReplyMarkup; }, ) => Promise; editRenderedMessage: ( chatId: number, messageId: number, chunks: TelegramRenderedChunk[], options?: { replyMarkup?: TelegramPreviewReplyMarkup }, ) => Promise; } export function createTelegramPreviewRenderedChunkTransport( deps: TelegramPreviewRenderedChunkTransportDeps, ): Pick< TelegramPreviewControllerDeps, "sendRenderedChunks" | "editRenderedMessage" > { return { sendRenderedChunks: (chatId, chunks, replyToMessageId, options) => deps.sendRenderedChunks(chatId, chunks, { replyToMessageId, ...(options?.replyMarkup ? { replyMarkup: options.replyMarkup } : {}), }), editRenderedMessage: (chatId, messageId, chunks, options) => deps.editRenderedMessage(chatId, messageId, chunks, options), }; } export type TelegramPreviewControllerRuntimeDeps = Omit< TelegramPreviewControllerDeps, | "sendMessage" | "editMessageText" | "sendRenderedChunks" | "editRenderedMessage" > & TelegramPreviewMessageTransportDeps & TelegramPreviewRenderedChunkTransportDeps; export function createTelegramPreviewControllerRuntime( deps: TelegramPreviewControllerRuntimeDeps, ): TelegramPreviewController { return createTelegramPreviewController({ getDefaultReplyToMessageId: deps.getDefaultReplyToMessageId, maxMessageLength: deps.maxMessageLength, renderPreviewText: deps.renderPreviewText, initialDraftSupport: deps.initialDraftSupport, sendDraft: deps.sendDraft, ...createTelegramPreviewMessageTransport({ sendMessage: deps.sendMessage, editMessageText: deps.editMessageText, buildReplyParameters: deps.buildReplyParameters, }), renderTelegramMessage: deps.renderTelegramMessage, ...createTelegramPreviewRenderedChunkTransport({ sendRenderedChunks: deps.sendRenderedChunks, editRenderedMessage: deps.editRenderedMessage, }), throttleMs: deps.throttleMs, maxDraftId: deps.maxDraftId, setTimer: deps.setTimer, clearTimer: deps.clearTimer, }); } export interface TelegramAssistantPreviewRuntimeDeps< TMessage, > extends TelegramPreviewControllerRuntimeDeps { getActiveTurn: () => TelegramPreviewActiveTurn | undefined; isAssistantMessage: (message: TMessage) => boolean; getMessageText: (message: TMessage) => string; } export type TelegramAssistantPreviewRuntime = TelegramPreviewController & TelegramAssistantMessagePreviewHooks; export function createTelegramAssistantPreviewRuntime( deps: TelegramAssistantPreviewRuntimeDeps, ): TelegramAssistantPreviewRuntime { const controller = createTelegramPreviewControllerRuntime(deps); return { ...controller, ...createTelegramAssistantMessagePreviewHooks({ getActiveTurn: deps.getActiveTurn, isAssistantMessage: deps.isAssistantMessage, getState: controller.getState, setState: controller.setState, createPreviewState: controller.createState, finalizePreview: controller.finalize, finalizeMarkdownPreview: controller.finalizeMarkdown, getMessageText: deps.getMessageText, schedulePreviewFlush: controller.scheduleFlush, }), }; } export function createTelegramPreviewController( deps: TelegramPreviewControllerDeps, ): TelegramPreviewController { let state: TelegramPreviewRuntimeState | undefined; const clearTimer = deps.clearTimer ?? clearTimeout; const setTimer = deps.setTimer ?? ((callback: () => void, ms: number): ReturnType => setTimeout(callback, ms)); const throttleMs = deps.throttleMs ?? TELEGRAM_PREVIEW_THROTTLE_MS; const maxDraftId = deps.maxDraftId ?? TELEGRAM_DRAFT_ID_MAX; const maxMessageLength = deps.maxMessageLength ?? MAX_MESSAGE_LENGTH; const renderPreview = deps.renderPreviewText ?? renderMarkdownPreviewText; const renderMessage = deps.renderTelegramMessage ?? renderTelegramMessage; let draftSupport = deps.initialDraftSupport ?? "unknown"; let nextDraftId = 0; const getRuntimeDeps = ( replyToMessageId?: number, ): TelegramPreviewRuntimeDeps => ({ getState: () => state, setState: (nextState) => { state = nextState; }, clearScheduledFlush: (nextState) => { if (!nextState.flushTimer) return; clearTimer(nextState.flushTimer); nextState.flushTimer = undefined; }, maxMessageLength, renderPreviewText: renderPreview, getDraftSupport: () => draftSupport, setDraftSupport: (support) => { draftSupport = support; }, allocateDraftId: () => { nextDraftId = allocateTelegramDraftId(nextDraftId, maxDraftId); return nextDraftId; }, sendDraft: deps.sendDraft, sendMessage: (chatId, text, options) => deps.sendMessage( chatId, text, options, replyToMessageId ?? deps.getDefaultReplyToMessageId?.(), ), editMessageText: deps.editMessageText, renderTelegramMessage: renderMessage, sendRenderedChunks: (chatId, chunks, options) => deps.sendRenderedChunks( chatId, chunks, replyToMessageId ?? deps.getDefaultReplyToMessageId?.(), options, ), editRenderedMessage: deps.editRenderedMessage, }); return { getState: () => state, setState: (nextState) => { state = nextState; }, setPendingText: (text) => { if (state) state.pendingText = text; }, createState: () => createTelegramPreviewRuntimeState(draftSupport), resetState: () => { state = createTelegramPreviewRuntimeState(draftSupport); }, clear: (chatId) => clearTelegramPreview(chatId, getRuntimeDeps()), flush: (chatId) => flushTelegramPreview(chatId, getRuntimeDeps()), scheduleFlush: (chatId) => { if (!state || state.flushTimer) return; state.flushTimer = setTimer(() => { void flushTelegramPreview(chatId, getRuntimeDeps()); }, throttleMs); }, finalize: (chatId, replyToMessageId) => finalizeTelegramPreview(chatId, getRuntimeDeps(replyToMessageId)), finalizeMarkdown: (chatId, markdown, replyToMessageId, options) => finalizeTelegramMarkdownPreview( chatId, markdown, getRuntimeDeps(replyToMessageId), options, ), }; } export function createTelegramAssistantMessagePreviewHooks( deps: TelegramAssistantMessagePreviewHookDeps, ): TelegramAssistantMessagePreviewHooks { return { onMessageStart: async ( event: TelegramAssistantMessagePreviewHookEvent, ): Promise => { await handleTelegramAssistantMessagePreviewStart(event.message, deps); }, onMessageUpdate: async ( event: TelegramAssistantMessagePreviewHookEvent, ): Promise => { await handleTelegramAssistantMessagePreviewUpdate(event.message, deps); }, }; } export async function handleTelegramAssistantMessagePreviewStart( message: TMessage, deps: TelegramAssistantMessagePreviewStartDeps, ): Promise { const turn = deps.getActiveTurn(); if (!turn || !deps.isAssistantMessage(message)) return; const state = deps.getState(); if ( state && (state.pendingText.trim().length > 0 || state.lastSentText.trim().length > 0) ) { const previousText = state.pendingText.trim(); if (previousText.length > 0) { await deps.finalizeMarkdownPreview(turn.chatId, previousText); } else { await deps.finalizePreview(turn.chatId); } } deps.setState(deps.createPreviewState()); } export async function handleTelegramAssistantMessagePreviewUpdate( message: TMessage, deps: TelegramAssistantMessagePreviewUpdateDeps, ): Promise { const turn = deps.getActiveTurn(); if (!turn || !deps.isAssistantMessage(message)) return; let state = deps.getState(); if (!state) { state = deps.createPreviewState(); deps.setState(state); } state.pendingText = stripTelegramCommentMarkupForPreview( deps.getMessageText(message), ); deps.schedulePreviewFlush(turn.chatId); } export function buildTelegramPreviewFinalText( state: TelegramPreviewState, ): string | undefined { const finalText = state.pendingText.trim(); if (finalText) return finalText; if ( state.lastSentStrategy === "rich-stable-blocks" || state.lastSentParseMode === "HTML" ) { return undefined; } return state.lastSentText.trim() || undefined; } export function createTelegramPreviewRuntimeState( draftSupport: TelegramDraftSupport, ): TelegramPreviewRuntimeState { return { mode: draftSupport === "unsupported" ? "message" : "draft", pendingText: "", lastSentText: "", }; } export function allocateTelegramDraftId( currentDraftId: number, maxDraftId: number, ): number { return currentDraftId >= maxDraftId ? 1 : currentDraftId + 1; } export function shouldUseTelegramDraftPreview(options: { draftSupport: TelegramDraftSupport; snapshot?: TelegramPreviewSnapshot; }): boolean { return ( options.draftSupport !== "unsupported" && (options.snapshot === undefined || options.snapshot.strategy === "plain") ); } export async function clearTelegramPreview( chatId: number, deps: TelegramPreviewRuntimeDeps, ): Promise { void chatId; const state = deps.getState(); if (!state) return; deps.clearScheduledFlush(state); deps.setState(undefined); } async function performTelegramPreviewFlush( chatId: number, state: TelegramPreviewRuntimeState, deps: TelegramPreviewRuntimeDeps, ): Promise { const snapshot = buildTelegramPreviewSnapshot({ state, maxMessageLength: deps.maxMessageLength, renderPreviewText: deps.renderPreviewText, renderTelegramMessage: deps.renderTelegramMessage, }); if (!snapshot) return; if ( shouldUseTelegramDraftPreview({ draftSupport: deps.getDraftSupport(), snapshot, }) ) { const draftId = state.draftId ?? deps.allocateDraftId(); state.draftId = draftId; try { await deps.sendDraft(chatId, draftId, snapshot.text); deps.setDraftSupport("supported"); state.mode = "draft"; state.lastSentText = snapshot.text; state.lastSentParseMode = snapshot.parseMode; state.lastSentStrategy = snapshot.strategy; return; } catch { deps.setDraftSupport("unsupported"); } } if (state.messageId === undefined) { const sent = await deps.sendMessage(chatId, snapshot.text, { parseMode: snapshot.parseMode, }); state.messageId = sent.message_id; state.mode = "message"; state.lastSentText = snapshot.text; state.lastSentParseMode = snapshot.parseMode; state.lastSentStrategy = snapshot.strategy; return; } await deps.editMessageText(chatId, state.messageId, snapshot.text, { parseMode: snapshot.parseMode, }); state.mode = "message"; state.lastSentText = snapshot.text; state.lastSentParseMode = snapshot.parseMode; state.lastSentStrategy = snapshot.strategy; } export async function flushTelegramPreview( chatId: number, deps: TelegramPreviewRuntimeDeps, ): Promise { const state = deps.getState(); if (!state) return; if (state.flushPromise) { state.flushRequested = true; await state.flushPromise; return; } state.flushTimer = undefined; state.flushPromise = (async () => { do { state.flushRequested = false; await performTelegramPreviewFlush(chatId, state, deps); } while (deps.getState() === state && state.flushRequested); })(); try { await state.flushPromise; } finally { if (deps.getState() === state) { state.flushPromise = undefined; } } } export async function finalizeTelegramPreview( chatId: number, deps: TelegramPreviewRuntimeDeps, ): Promise { const state = deps.getState(); if (!state) return false; await flushTelegramPreview(chatId, deps); const finalText = buildTelegramPreviewFinalText(state); if (!finalText) { await clearTelegramPreview(chatId, deps); return false; } if (state.mode === "draft") { await deps.sendMessage(chatId, finalText); await clearTelegramPreview(chatId, deps); return true; } deps.setState(undefined); return state.messageId !== undefined; } export async function finalizeTelegramMarkdownPreview( chatId: number, markdown: string, deps: TelegramPreviewRuntimeDeps, options?: { replyMarkup?: TelegramPreviewReplyMarkup }, ): Promise { const state = deps.getState(); if (!state) return false; await flushTelegramPreview(chatId, deps); const chunks = deps.renderTelegramMessage(markdown, { mode: "markdown" }); if (chunks.length === 0) { await clearTelegramPreview(chatId, deps); return false; } if (state.mode === "draft") { await deps.sendRenderedChunks(chatId, chunks, options); await clearTelegramPreview(chatId, deps); return true; } if (state.messageId === undefined) return false; await deps.editRenderedMessage(chatId, state.messageId, chunks, options); deps.setState(undefined); return true; }