From bb3fae834810cf466be70668b1bce2bd16e31227 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 15 Jul 2026 03:25:54 -0700 Subject: [PATCH] refactor(telegram): split message dispatch (#108228) * refactor(telegram): split message dispatch * fix(telegram): keep dispatch types internal * fix(telegram): satisfy dispatch lint --- config/max-lines-baseline.txt | 1 - .../src/bot-message-dispatch-context.ts | 292 ++ .../src/bot-message-dispatch-delivery.ts | 570 +++ .../src/bot-message-dispatch-draft.ts | 505 +++ .../src/bot-message-dispatch-fence.ts | 117 + .../src/bot-message-dispatch-progress.ts | 360 ++ .../src/bot-message-dispatch-reply.ts | 417 +++ .../src/bot-message-dispatch-session.ts | 155 + .../src/bot-message-dispatch-status.ts | 91 + .../telegram/src/bot-message-dispatch-turn.ts | 250 ++ .../telegram/src/bot-message-dispatch.test.ts | 65 +- .../telegram/src/bot-message-dispatch.ts | 3306 ++--------------- .../src/bot-message-dispatch.types.ts | 69 + .../turn/message-turn-guardrails.test.ts | 3 +- 14 files changed, 3280 insertions(+), 2921 deletions(-) create mode 100644 extensions/telegram/src/bot-message-dispatch-context.ts create mode 100644 extensions/telegram/src/bot-message-dispatch-delivery.ts create mode 100644 extensions/telegram/src/bot-message-dispatch-draft.ts create mode 100644 extensions/telegram/src/bot-message-dispatch-fence.ts create mode 100644 extensions/telegram/src/bot-message-dispatch-progress.ts create mode 100644 extensions/telegram/src/bot-message-dispatch-reply.ts create mode 100644 extensions/telegram/src/bot-message-dispatch-session.ts create mode 100644 extensions/telegram/src/bot-message-dispatch-status.ts create mode 100644 extensions/telegram/src/bot-message-dispatch-turn.ts create mode 100644 extensions/telegram/src/bot-message-dispatch.types.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 61b8e3f40f7d..c70c2817ccf6 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -305,7 +305,6 @@ extensions/telegram/src/action-runtime.test.ts extensions/telegram/src/action-runtime.ts extensions/telegram/src/bot-message-context.session.ts extensions/telegram/src/bot-message-dispatch.test.ts -extensions/telegram/src/bot-message-dispatch.ts extensions/telegram/src/bot-native-commands.session-meta.test.ts extensions/telegram/src/bot-native-commands.ts extensions/telegram/src/bot.create-telegram-bot.test.ts diff --git a/extensions/telegram/src/bot-message-dispatch-context.ts b/extensions/telegram/src/bot-message-dispatch-context.ts new file mode 100644 index 000000000000..a33b4ab6413f --- /dev/null +++ b/extensions/telegram/src/bot-message-dispatch-context.ts @@ -0,0 +1,292 @@ +// Telegram plugin module recovers dispatch routing and group-history context. +import { CURRENT_MESSAGE_MARKER } from "openclaw/plugin-sdk/channel-mention-gating"; +import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime"; +import { createChannelHistoryWindow } from "openclaw/plugin-sdk/reply-history"; +import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; +import { withTelegramApiErrorLogging } from "./api-logging.js"; +import type { TelegramMessageContext } from "./bot-message-context.js"; +import { + buildTelegramGroupFrom, + buildTelegramGroupPeerId, + buildTelegramInboundOriginTarget, + buildTypingThreadParams, + type TelegramThreadSpec, +} from "./bot/helpers.js"; +import { + isTelegramHistoryEntryAfterAmbientWatermark, + mergeTelegramGroupHistoryPromptContext, + retainTelegramGroupHistoryPromptContext, + selectTelegramGroupHistoryAfterLastSelf, +} from "./group-history-window.js"; + +const TELEGRAM_GENERAL_TOPIC_ID = 1; + +function normalizeTelegramThreadId(value: unknown): number | undefined { + return parseStrictPositiveInteger(value); +} + +function resolveTelegramForumThreadScopeFromSessionKey( + sessionKey: unknown, +): { chatId: string; threadId: number } | undefined { + if (typeof sessionKey !== "string") { + return undefined; + } + const match = /:telegram:group:(-?\d+):topic:(\d+)(?::|$)/.exec(sessionKey); + const threadId = normalizeTelegramThreadId(match?.[2]); + if (!match?.[1] || threadId == null) { + return undefined; + } + return { chatId: match[1], threadId }; +} + +function resolveDispatchTelegramThreadSpec(params: { + chatId: TelegramMessageContext["chatId"]; + ctxPayload: TelegramMessageContext["ctxPayload"]; + threadSpec: TelegramThreadSpec; +}): TelegramThreadSpec { + if ( + params.threadSpec.scope !== "forum" || + (params.threadSpec.id != null && params.threadSpec.id !== TELEGRAM_GENERAL_TOPIC_ID) + ) { + return params.threadSpec; + } + const scopedThread = resolveTelegramForumThreadScopeFromSessionKey(params.ctxPayload.SessionKey); + const scopedThreadId = + scopedThread?.chatId === String(params.chatId) ? scopedThread.threadId : undefined; + const payloadThreadId = + normalizeTelegramThreadId(params.ctxPayload.MessageThreadId) ?? + normalizeTelegramThreadId(params.ctxPayload.TransportThreadId); + // Missing forum IDs are normalized to General; topic-scoped turn facts are more specific. + const recoveredThreadId = scopedThreadId ?? payloadThreadId; + return recoveredThreadId == null || recoveredThreadId === params.threadSpec.id + ? params.threadSpec + : { ...params.threadSpec, id: recoveredThreadId }; +} + +function normalizeDispatchTelegramThreadPayload(params: { + context: TelegramMessageContext; + threadSpec: TelegramThreadSpec; +}): TelegramMessageContext { + if (params.threadSpec.scope !== "forum" || params.threadSpec.id == null) { + return params.context; + } + const messageThreadId = normalizeTelegramThreadId(params.context.ctxPayload.MessageThreadId); + const transportThreadId = normalizeTelegramThreadId(params.context.ctxPayload.TransportThreadId); + if (messageThreadId === params.threadSpec.id && transportThreadId === params.threadSpec.id) { + return params.context; + } + return { + ...params.context, + ctxPayload: { + ...params.context.ctxPayload, + MessageThreadId: params.threadSpec.id, + TransportThreadId: params.threadSpec.id, + }, + }; +} + +function extractCurrentTelegramBody(body: string | undefined): string { + if (!body) { + return ""; + } + const markerIndex = body.lastIndexOf(CURRENT_MESSAGE_MARKER); + if (markerIndex === -1) { + return body; + } + return body.slice(markerIndex + CURRENT_MESSAGE_MARKER.length).trimStart(); +} + +function buildRecoveredTelegramChatActionSender(params: { + context: TelegramMessageContext; + threadId?: number; + action: "typing" | "record_voice"; +}): () => Promise { + return async () => { + try { + await withTelegramApiErrorLogging({ + operation: "sendChatAction", + fn: () => + params.context.sendChatActionHandler.sendChatAction( + params.context.chatId, + params.action, + buildTypingThreadParams(params.threadId), + ), + }); + } catch (err) { + if (params.action !== "record_voice") { + throw err; + } + logVerbose( + `telegram record_voice cue failed for chat ${params.context.chatId}: ${String(err)}`, + ); + } + }; +} + +function migrateRecoveredTelegramGroupHistory(params: { + context: TelegramMessageContext; + recoveredHistoryKey?: string; +}) { + const originalHistoryKey = params.context.historyKey; + const recoveredHistoryKey = params.recoveredHistoryKey; + if ( + !params.context.isGroup || + !originalHistoryKey || + !recoveredHistoryKey || + originalHistoryKey === recoveredHistoryKey || + params.context.historyLimit <= 0 + ) { + return; + } + // Topic recovery mutates the raw in-memory buffer before any prompt is built; + // prompt readers apply the ambient transcript watermark after recovery. + const originalEntries = params.context.groupHistories.get(originalHistoryKey); + if (!originalEntries?.length) { + return; + } + const messageId = params.context.ctxPayload.MessageSid; + const rawBody = params.context.ctxPayload.RawBody; + const entryIndex = originalEntries.findLastIndex((entry) => { + if (messageId && entry.messageId === messageId) { + return true; + } + return !messageId && typeof rawBody === "string" && entry.body === rawBody; + }); + if (entryIndex === -1) { + return; + } + const [entry] = originalEntries.splice(entryIndex, 1); + if (!entry) { + return; + } + createChannelHistoryWindow({ historyMap: params.context.groupHistories }).record({ + historyKey: recoveredHistoryKey, + limit: params.context.historyLimit, + entry, + }); +} + +export function resolveDispatchTelegramContext(params: { + context: TelegramMessageContext; +}): TelegramMessageContext { + const threadSpec = resolveDispatchTelegramThreadSpec({ + chatId: params.context.chatId, + ctxPayload: params.context.ctxPayload, + threadSpec: params.context.threadSpec, + }); + if (threadSpec === params.context.threadSpec || threadSpec.scope !== "forum") { + return normalizeDispatchTelegramThreadPayload({ context: params.context, threadSpec }); + } + const recoveredRoutingTarget = buildTelegramInboundOriginTarget( + params.context.chatId, + threadSpec, + ); + const recoveredFrom = params.context.isGroup + ? buildTelegramGroupFrom(params.context.chatId, threadSpec.id) + : params.context.ctxPayload.From; + const recoveredUpdateLastRoute = + params.context.turn.record.updateLastRoute && threadSpec.id != null + ? { + ...params.context.turn.record.updateLastRoute, + to: `telegram:${params.context.chatId}:topic:${threadSpec.id}`, + threadId: String(threadSpec.id), + } + : params.context.turn.record.updateLastRoute; + const recoveredHistoryKey = params.context.isGroup + ? buildTelegramGroupPeerId(params.context.chatId, threadSpec.id) + : params.context.historyKey; + const recoveredHistoryEntries = + recoveredHistoryKey && params.context.historyLimit > 0 + ? (params.context.groupHistories.get(recoveredHistoryKey) ?? []) + .filter((entry) => + isTelegramHistoryEntryAfterAmbientWatermark( + entry, + params.context.ctxPayload.AmbientTranscriptPreviousMessageId + ? { + messageId: params.context.ctxPayload.AmbientTranscriptPreviousMessageId, + ...(params.context.ctxPayload.AmbientTranscriptPreviousTimestampMs !== undefined + ? { + timestampMs: + params.context.ctxPayload.AmbientTranscriptPreviousTimestampMs, + } + : {}), + } + : undefined, + ), + ) + .slice(-params.context.historyLimit) + : []; + const recoveredWatermarkedHistoryEntries = selectTelegramGroupHistoryAfterLastSelf( + recoveredHistoryEntries, + ).slice(-params.context.historyLimit); + const recoveredPromptHistoryEntries = + params.context.isGroup && recoveredHistoryKey && params.context.historyLimit > 0 + ? params.context.ctxPayload.InboundEventKind === "room_event" + ? recoveredHistoryEntries + : recoveredWatermarkedHistoryEntries + : []; + const recoveredInboundHistory = + params.context.isGroup && recoveredHistoryKey && params.context.historyLimit > 0 + ? recoveredPromptHistoryEntries.length > 0 + ? recoveredPromptHistoryEntries + : undefined + : params.context.ctxPayload.InboundHistory; + const recoveredBodyForAgent = extractCurrentTelegramBody( + params.context.ctxPayload.BodyForAgent ?? params.context.ctxPayload.Body, + ); + const recoveredPromptContextBase = retainTelegramGroupHistoryPromptContext({ + promptContext: params.context.ctxPayload.UntrustedStructuredContext ?? [], + entries: recoveredPromptHistoryEntries, + }); + const recoveredPromptContext = + recoveredPromptHistoryEntries.length > 0 + ? mergeTelegramGroupHistoryPromptContext({ + promptContext: recoveredPromptContextBase ?? [], + entries: recoveredPromptHistoryEntries, + }) + : recoveredPromptContextBase?.length + ? recoveredPromptContextBase + : undefined; + const recoveredSendTyping = buildRecoveredTelegramChatActionSender({ + context: params.context, + threadId: threadSpec.id, + action: "typing", + }); + const recoveredSendRecordVoice = buildRecoveredTelegramChatActionSender({ + context: params.context, + threadId: threadSpec.id, + action: "record_voice", + }); + migrateRecoveredTelegramGroupHistory({ context: params.context, recoveredHistoryKey }); + return { + ...params.context, + historyKey: recoveredHistoryKey, + threadSpec, + resolvedThreadId: threadSpec.id, + replyThreadId: threadSpec.id, + sendTyping: recoveredSendTyping, + sendRecordVoice: recoveredSendRecordVoice, + turn: { + ...params.context.turn, + record: { + ...params.context.turn.record, + updateLastRoute: recoveredUpdateLastRoute, + }, + }, + ctxPayload: + threadSpec.id == null + ? params.context.ctxPayload + : { + ...params.context.ctxPayload, + Body: recoveredBodyForAgent, + BodyForAgent: recoveredBodyForAgent, + From: recoveredFrom, + InboundHistory: recoveredInboundHistory, + MessageThreadId: threadSpec.id, + OriginatingTo: recoveredRoutingTarget, + To: recoveredRoutingTarget, + TransportThreadId: threadSpec.id, + UntrustedStructuredContext: recoveredPromptContext, + }, + }; +} diff --git a/extensions/telegram/src/bot-message-dispatch-delivery.ts b/extensions/telegram/src/bot-message-dispatch-delivery.ts new file mode 100644 index 000000000000..fbba6854717d --- /dev/null +++ b/extensions/telegram/src/bot-message-dispatch-delivery.ts @@ -0,0 +1,570 @@ +// Telegram plugin module owns final payload projection and Telegram delivery. +import type { Bot } from "grammy"; +import type { Message } from "grammy/types"; +import { + createOutboundPayloadPlan, + deriveDurableFinalDeliveryRequirements, + projectOutboundPayloadPlanForDelivery, + resolveTranscriptBackedChannelFinalText, +} from "openclaw/plugin-sdk/channel-outbound"; +import type { + OpenClawConfig, + ReplyToMode, + TelegramAccountConfig, +} from "openclaw/plugin-sdk/config-contracts"; +import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; +import type { ReplyPayload } from "openclaw/plugin-sdk/reply-payload"; +import { isSingleUseReplyToMode } from "openclaw/plugin-sdk/reply-reference"; +import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; +import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; +import type { TelegramBotDeps } from "./bot-deps.js"; +import type { TelegramMessageContext } from "./bot-message-context.js"; +import type { TelegramDraftController } from "./bot-message-dispatch-draft.js"; +import type { TelegramProgressController } from "./bot-message-dispatch-progress.js"; +import { + mirrorTelegramAssistantReplyToTranscript, + createCurrentTurnTranscriptFinalResolver, +} from "./bot-message-dispatch-session.js"; +import type { + CurrentTurnTranscriptFinal, + FreshTelegramSessionEntryLoader, + TelegramTranscriptMirrorPayload, +} from "./bot-message-dispatch.types.js"; +import type { TelegramBotOptions } from "./bot.types.js"; +import { deliverReplies, emitInternalMessageSentHook } from "./bot/delivery.js"; +import type { TelegramThreadSpec } from "./bot/helpers.js"; +import { resolveTelegramReplyId } from "./bot/helpers.js"; +import type { TelegramNativeQuoteCandidateByMessageId } from "./bot/native-quote.js"; +import type { TelegramInlineButtons } from "./button-types.js"; +import { canonicalizeTelegramPresentationPayload } from "./interactive-fallback.js"; +import { + createLaneDeliveryStateTracker, + createLaneTextDeliverer, + type LaneDeliveryResult, +} from "./lane-delivery.js"; +import { recordOutboundMessageForPromptContext } from "./outbound-message-context.js"; +import { + createTelegramPromptContextProjectionSequence, + resolveTelegramPromptContextDeliverySignature, + withTelegramPromptContextSource, + type TelegramPromptContextProjection, + type TelegramPromptContextProjectionSequence, + type TelegramPromptContextSource, +} from "./prompt-context-projection.js"; +import { editMessageTelegram } from "./send.js"; + +export function createTelegramDeliveryController(params: { + bot: Bot; + cfg: OpenClawConfig; + chunkMode: ReturnType; + context: TelegramMessageContext; + dispatchStartedAt: number; + draft: TelegramDraftController; + isDispatchSuperseded: () => boolean; + loadFreshSessionEntry: FreshTelegramSessionEntryLoader; + mediaLocalRoots: readonly string[]; + opts: Pick; + progress: TelegramProgressController; + draftReplyToMessageId?: number; + replyQuoteByMessageId: TelegramNativeQuoteCandidateByMessageId; + replyQuoteEntities?: Message["entities"]; + replyQuoteMessageId?: number; + replyQuotePosition?: number; + replyQuoteText?: string; + replyToMode: ReplyToMode; + runtime: RuntimeEnv; + streamMode: import("./bot/types.js").TelegramStreamMode; + tableMode: Parameters[0]["tableMode"]; + telegramCfg: TelegramAccountConfig; + telegramDeps: TelegramBotDeps; + textLimit: number; + threadSpec: TelegramThreadSpec; +}) { + const { context } = params; + const sessionKey = context.ctxPayload.SessionKey; + const deliveryState = createLaneDeliveryStateTracker(); + const resolveCurrentTurnTranscriptFinal = createCurrentTurnTranscriptFinalResolver({ + agentId: context.route.agentId, + dispatchStartedAt: params.dispatchStartedAt, + loadFreshSessionEntry: params.loadFreshSessionEntry, + sessionKey, + }); + let transcriptMirrorSequence = 0; + const transcriptMirrorTurnId = `${context.chatId}:${context.ctxPayload.MessageSid ?? context.msg.message_id ?? params.dispatchStartedAt}`; + const implicitQuoteReplyTargetId = + context.ctxPayload.ReplyToIsQuote && + !context.msg.reply_to_message?.from?.is_bot && + params.replyQuoteMessageId != null + ? String(params.replyQuoteMessageId) + : undefined; + const currentMessageIdForQuoteReply = + implicitQuoteReplyTargetId && context.ctxPayload.MessageSid + ? context.ctxPayload.MessageSid + : undefined; + + const projectPayloadForDelivery = (payload: ReplyPayload): ReplyPayload | undefined => + projectOutboundPayloadPlanForDelivery( + createOutboundPayloadPlan([payload], { + cfg: params.cfg, + sessionKey, + surface: "telegram", + }), + )[0]; + const promptContextDeliverySignature = (payload: ReplyPayload): string | undefined => { + const projected = projectPayloadForDelivery(payload); + return projected ? resolveTelegramPromptContextDeliverySignature(projected) : undefined; + }; + const resolvePromptContextSource = ( + final: CurrentTurnTranscriptFinal | undefined, + ...payloads: ReplyPayload[] + ): TelegramPromptContextSource | undefined => { + const finalSignature = final ? promptContextDeliverySignature({ text: final.text }) : undefined; + if (!final?.messageId || !finalSignature) { + return undefined; + } + return payloads.some((payload) => promptContextDeliverySignature(payload) === finalSignature) + ? { transcriptMessageId: final.messageId } + : undefined; + }; + const recordPromptContextMessage = (record: { + messageId: number; + message?: Message; + text?: string; + projection?: TelegramPromptContextProjection; + }): Promise => + ( + params.telegramDeps.recordOutboundMessageForPromptContext ?? + recordOutboundMessageForPromptContext + )({ + cfg: params.cfg, + account: { + accountId: context.route.accountId, + ...(params.telegramCfg.name !== undefined ? { name: params.telegramCfg.name } : {}), + ...(context.primaryCtx.me ? { bot: context.primaryCtx.me } : {}), + }, + ...(context.primaryCtx.me?.id !== undefined ? { botUserId: context.primaryCtx.me.id } : {}), + chatId: String(context.chatId), + message: record.message ?? { message_id: record.messageId }, + messageId: record.messageId, + ...(record.text ? { text: record.text } : {}), + ...(record.projection ? { promptContextProjection: record.projection } : {}), + ...(params.threadSpec.id !== undefined ? { messageThreadId: params.threadSpec.id } : {}), + }); + const createPromptContextSequence = (source?: TelegramPromptContextSource) => + createTelegramPromptContextProjectionSequence({ + ...(source ? { source } : {}), + record: recordPromptContextMessage, + }); + const transcriptMirror = sessionKey + ? async (payload: TelegramTranscriptMirrorPayload) => { + const idempotencyKey = `telegram-final:${sessionKey}:${transcriptMirrorTurnId}:${transcriptMirrorSequence++}`; + await mirrorTelegramAssistantReplyToTranscript({ + cfg: params.cfg, + idempotencyKey, + loadFreshSessionEntry: params.loadFreshSessionEntry, + route: context.route, + sessionKey, + payload, + }); + } + : undefined; + const deliveryBaseOptions = { + chatId: String(context.chatId), + accountId: context.route.accountId, + sessionKeyForInternalHooks: sessionKey, + mirrorIsGroup: context.isGroup, + mirrorGroupId: context.isGroup ? String(context.chatId) : undefined, + token: params.opts.token, + runtime: params.runtime, + bot: params.bot, + mediaLocalRoots: params.mediaLocalRoots, + mediaMaxBytes: (params.opts.mediaMaxMb ?? params.telegramCfg.mediaMaxMb ?? 100) * 1024 * 1024, + replyToMode: params.replyToMode, + textLimit: params.textLimit, + thread: params.threadSpec, + tableMode: params.tableMode, + chunkMode: params.chunkMode, + richMessages: params.telegramCfg.richMessages, + linkPreview: params.telegramCfg.linkPreview, + replyQuoteMessageId: params.replyQuoteMessageId, + replyQuoteText: params.replyQuoteText, + replyQuotePosition: params.replyQuotePosition, + replyQuoteEntities: params.replyQuoteEntities, + replyQuoteByMessageId: params.replyQuoteByMessageId, + transcriptMirror, + }; + + const applyTextToPayload = (payload: ReplyPayload, text: string): ReplyPayload => + payload.text === text ? payload : { ...payload, text }; + const applyQuoteReplyTarget = (payload: ReplyPayload): ReplyPayload => { + if ( + !implicitQuoteReplyTargetId || + !currentMessageIdForQuoteReply || + payload.replyToId !== currentMessageIdForQuoteReply || + payload.replyToTag || + payload.replyToCurrent + ) { + return payload; + } + return { ...payload, replyToId: implicitQuoteReplyTargetId }; + }; + const usesNativeTelegramQuote = (payload: ReplyPayload): boolean => + params.replyQuoteText != null || + (payload.replyToId != null && params.replyQuoteByMessageId[payload.replyToId] != null); + + const sendPayload = async ( + payload: ReplyPayload, + options?: { + afterAcceptedDraft?: boolean; + durable?: boolean; + silent?: boolean; + mirrorTranscript?: boolean; + promptContextSequence?: TelegramPromptContextProjectionSequence; + textMode?: "html"; + }, + ) => { + if (params.isDispatchSuperseded()) { + await options?.promptContextSequence?.fail(); + return false; + } + const targetedPayload = applyQuoteReplyTarget(payload); + const finalReplyTargetId = resolveTelegramReplyId(targetedPayload.replyToId); + const targetsDifferentMessage = + finalReplyTargetId != null && finalReplyTargetId !== params.draftReplyToMessageId; + const consumedSingleUseReply = + options?.afterAcceptedDraft === true && + isSingleUseReplyToMode(params.replyToMode) && + !targetsDifferentMessage; + const deliverablePayload = consumedSingleUseReply + ? (({ replyToId: _, replyToTag: _tag, replyToCurrent: _current, ...rest }) => rest)( + targetedPayload, + ) + : targetedPayload; + const effectiveReplyToMode = consumedSingleUseReply ? "off" : params.replyToMode; + const projectionSequence = + options?.promptContextSequence ?? + createPromptContextSequence( + options?.durable + ? resolvePromptContextSource( + await resolveCurrentTurnTranscriptFinal(), + deliverablePayload, + ) + : undefined, + ); + const effectivePayload = withTelegramPromptContextSource( + deliverablePayload, + projectionSequence.source, + ); + const silent = + options?.silent ?? + (params.telegramCfg.silentErrorReplies === true && payload.isError === true); + const durableDelivery = params.telegramDeps.deliverInboundReplyWithMessageSendContext; + if (options?.durable && durableDelivery && projectionSequence.isFresh()) { + const durable = await durableDelivery({ + cfg: params.cfg, + channel: "telegram", + to: String(context.chatId), + accountId: context.route.accountId, + agentId: context.route.agentId, + ctxPayload: context.ctxPayload, + payload: effectivePayload, + info: { kind: "final" }, + replyToMode: effectiveReplyToMode, + threadId: params.threadSpec.id, + formatting: { + textLimit: params.textLimit, + tableMode: params.tableMode, + chunkMode: params.chunkMode, + ...(options?.textMode === "html" ? { parseMode: "HTML" as const } : {}), + }, + silent, + requiredCapabilities: deriveDurableFinalDeliveryRequirements({ + payload: effectivePayload, + replyToId: effectivePayload.replyToId, + threadId: params.threadSpec.id, + silent, + payloadTransport: true, + extraCapabilities: { + nativeQuote: !consumedSingleUseReply && usesNativeTelegramQuote(effectivePayload), + }, + }), + }); + if (durable.status === "failed") { + await projectionSequence.fail(); + throw durable.error; + } + if (durable.status === "handled_visible") { + deliveryState.markDelivered(); + return true; + } + if (durable.status === "handled_no_send") { + await projectionSequence.fail(); + return false; + } + } + try { + const result = await (params.telegramDeps.deliverReplies ?? deliverReplies)({ + ...deliveryBaseOptions, + replyToMode: effectiveReplyToMode, + transcriptMirror: + options?.durable && options?.mirrorTranscript !== false ? transcriptMirror : undefined, + replies: [effectivePayload], + onVoiceRecording: context.sendRecordVoice, + silent, + mediaLoader: params.telegramDeps.loadWebMedia, + promptContextSequence: projectionSequence, + ...(options?.textMode ? { textMode: options.textMode } : {}), + }); + if (!result.delivered) { + await projectionSequence.fail(); + return false; + } + await projectionSequence.finish(); + deliveryState.markDelivered(); + return true; + } catch (error) { + await projectionSequence.fail(); + throw error; + } + }; + + const emitPreviewFinalizedHook = async (result: LaneDeliveryResult) => { + if (params.isDispatchSuperseded() || result.kind !== "preview-finalized") { + return; + } + (params.telegramDeps.emitInternalMessageSentHook ?? emitInternalMessageSentHook)({ + sessionKeyForInternalHooks: sessionKey, + chatId: String(context.chatId), + accountId: context.route.accountId, + content: result.delivery.content, + success: true, + messageId: result.delivery.messageId, + isGroup: context.isGroup, + groupId: context.isGroup ? String(context.chatId) : undefined, + }); + if (transcriptMirror && result.delivery.content) { + void transcriptMirror({ text: result.delivery.content }).catch((err: unknown) => { + logVerbose( + `telegram preview-finalized transcriptMirror failed: ${formatErrorMessage(err)}`, + ); + }); + } + }; + const deliverLaneText = createLaneTextDeliverer({ + lanes: params.draft.lanes, + applyTextToPayload, + sendPayload, + flushDraftLane: params.draft.flushLane, + stopDraftLane: async (lane) => await lane.stream?.stop(), + clearDraftLane: async (lane) => await lane.stream?.clear(), + editStreamMessage: async ({ messageId, text, textMode, buttons }) => { + if (!params.isDispatchSuperseded()) { + await (params.telegramDeps.editMessageTelegram ?? editMessageTelegram)( + context.chatId, + messageId, + text, + { + api: params.bot.api, + cfg: params.cfg, + accountId: context.route.accountId, + linkPreview: params.telegramCfg.linkPreview, + textMode, + buttons, + }, + ); + } + }, + createPromptContextSequence, + resolveFinalTextCandidate: async () => (await resolveCurrentTurnTranscriptFinal())?.text, + log: logVerbose, + markDelivered: deliveryState.markDelivered, + }); + + const materializeAnswerLaneBeforeRotation = async () => { + const block = params.draft.activeAnswerBlockDelivery(); + const lane = params.draft.answerLane; + if ( + !block || + !lane.stream || + !lane.hasStreamedMessage || + lane.finalized || + params.draft.isAnswerToolProgressOnly() + ) { + return; + } + const text = lane.lastPartialText || params.draft.lastAnswerPartialText() || block.text; + if (!text?.trim()) { + return; + } + const result = await deliverLaneText({ + laneName: "answer", + text, + payload: block.payload, + infoKind: "block", + buttons: block.buttons, + finalizePreview: true, + durable: false, + }); + params.draft.setActiveAnswerBlockDelivery(); + await emitPreviewFinalizedHook(result); + }; + params.draft.setMaterializeBeforeRotation(materializeAnswerLaneBeforeRotation); + + const postCosmeticSummaryBar = async (line: string) => { + try { + await sendPayload({ text: line }, { durable: true, mirrorTranscript: false }); + } catch (err) { + logVerbose(`telegram: collapse summary bar send failed: ${formatErrorMessage(err)}`); + } + }; + const deliverProgressCollapseSummary = async () => { + const line = params.progress.resolveCollapseSummaryLine(); + if (line) { + await postCosmeticSummaryBar(line); + } + }; + const deliverProgressModeFinalAnswer = async ( + payload: ReplyPayload, + text: string, + promptContextSequence: TelegramPromptContextProjectionSequence, + ): Promise => { + const afterAcceptedDraft = params.draft.answerLane.stream?.hasConsumedReplyTarget?.() === true; + if (payload.isError === true) { + params.progress.setSummaryDelivered(); + await params.progress.teardownWindow(); + const delivered = await sendPayload(applyTextToPayload(payload, text), { + afterAcceptedDraft, + durable: true, + promptContextSequence, + }); + if (!delivered) { + return { kind: "skipped" }; + } + params.draft.answerLane.finalized = true; + params.progress.markFinalDelivered(); + return { kind: "sent" }; + } + const barLine = params.progress.resolveCollapseSummaryLine(); + const delivered = await sendPayload(applyTextToPayload(payload, text), { + afterAcceptedDraft, + durable: true, + promptContextSequence, + }); + if (barLine) { + await params.progress.applyCollapseSummary(barLine, postCosmeticSummaryBar); + params.progress.resetAnswerLaneAfterCollapse(); + } else { + await params.progress.teardownWindow(); + } + if (!delivered) { + return { kind: "skipped" }; + } + params.draft.answerLane.finalized = true; + params.progress.markFinalDelivered(); + return { kind: "sent" }; + }; + const deliverFinalAnswerText = async ( + answerPayload: ReplyPayload, + text: string, + buttons?: TelegramInlineButtons, + ): Promise => { + const transcriptFinal = await resolveCurrentTurnTranscriptFinal(); + const finalText = await resolveTranscriptBackedChannelFinalText({ + finalText: text, + resolveCandidateText: async () => transcriptFinal?.text, + }); + const source = resolvePromptContextSource( + transcriptFinal, + answerPayload, + applyTextToPayload(answerPayload, finalText), + ); + const promptContextSequence = createPromptContextSequence(source); + const isFollowUp = params.progress.finalAnswerDelivered(); + let result: LaneDeliveryResult; + if (!isFollowUp && params.streamMode === "progress") { + result = await deliverProgressModeFinalAnswer( + answerPayload, + finalText, + promptContextSequence, + ); + } else { + if (isFollowUp) { + await params.draft.prepareAnswerLaneForText(); + } else if (!(await params.draft.rotateAnswerLaneAfterToolProgress())) { + await params.draft.rotateAnswerLaneAfterQueuedBlocksSettle(); + } + result = await deliverLaneText({ + laneName: "answer", + text: finalText, + payload: answerPayload, + infoKind: "final", + buttons, + allowStream: !usesNativeTelegramQuote(answerPayload), + promptContextSequence, + }); + if (!isFollowUp && result.kind !== "skipped") { + params.progress.markFinalDelivered(); + } + } + if (result.kind === "preview-finalized") { + await emitPreviewFinalizedHook(result); + } + return result; + }; + const finalizePendingAnswerBlockDraft = async (final: { + queuedFinal: boolean; + dispatchError?: unknown; + }) => { + const block = params.draft.activeAnswerBlockDelivery(); + if ( + !block || + final.queuedFinal || + final.dispatchError || + params.isDispatchSuperseded() || + params.draft.answerLane.finalized + ) { + return; + } + const content = block.text.trimEnd(); + if (!content) { + return; + } + params.progress.markFinalStarted(); + await deliverFinalAnswerText(block.payload, content, block.buttons); + params.draft.setActiveAnswerBlockDelivery(); + }; + + return { + applyTextToPayload, + createPromptContextSequence, + deliverFallback: async (replies: ReplyPayload[], silent: boolean) => + await (params.telegramDeps.deliverReplies ?? deliverReplies)({ + replies, + ...deliveryBaseOptions, + silent, + mediaLoader: params.telegramDeps.loadWebMedia, + }), + deliverFinalAnswerText, + deliverLaneText, + deliverProgressCollapseSummary, + emitPreviewFinalizedHook, + finalizePendingAnswerBlockDraft, + markDelivered: deliveryState.markDelivered, + markNonSilentFailure: deliveryState.markNonSilentFailure, + markNonSilentSkip: deliveryState.markNonSilentSkip, + normalizeDeliveryPayload: (payload: ReplyPayload): ReplyPayload | undefined => { + const keepReasoningLane = + payload.isReasoning === true && params.draft.durableReasoningPayloadsEnabled; + const payloadForPlan = keepReasoningLane ? { ...payload } : payload; + if (keepReasoningLane) { + delete payloadForPlan.isReasoning; + } + const normalized = projectPayloadForDelivery(payloadForPlan); + return normalized ? canonicalizeTelegramPresentationPayload(normalized) : undefined; + }, + sendPayload, + snapshot: deliveryState.snapshot, + }; +} + +export type TelegramDeliveryController = ReturnType; diff --git a/extensions/telegram/src/bot-message-dispatch-draft.ts b/extensions/telegram/src/bot-message-dispatch-draft.ts new file mode 100644 index 000000000000..c95d8b8dd3b7 --- /dev/null +++ b/extensions/telegram/src/bot-message-dispatch-draft.ts @@ -0,0 +1,505 @@ +// Telegram plugin module owns answer/reasoning draft lanes and rotation state. +import type { Bot } from "grammy"; +import { resolveChannelStreamingBlockEnabled } from "openclaw/plugin-sdk/channel-outbound"; +import type { + OpenClawConfig, + ReplyToMode, + TelegramAccountConfig, +} from "openclaw/plugin-sdk/config-contracts"; +import type { ReplyPayload } from "openclaw/plugin-sdk/reply-payload"; +import type { BlockReplyContext } from "openclaw/plugin-sdk/reply-runtime"; +import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; +import type { TelegramBotDeps } from "./bot-deps.js"; +import { resolveMarkdownTableMode } from "./bot-message-dispatch.runtime.js"; +import type { + TelegramReasoningLevel, + TelegramAnswerBlockDelivery, +} from "./bot-message-dispatch.types.js"; +import type { TelegramThreadSpec } from "./bot/helpers.js"; +import type { TelegramStreamMode } from "./bot/types.js"; +import { resolveTelegramDraftStreamingChunking } from "./draft-chunking.js"; +import { createTelegramDraftStream, type TelegramDraftPreview } from "./draft-stream.js"; +import { renderTelegramHtmlText } from "./format.js"; +import type { DraftLaneState, LaneName } from "./lane-delivery.js"; +import { TELEGRAM_TEXT_CHUNK_LIMIT } from "./outbound-adapter.js"; +import { splitTelegramReasoningText } from "./reasoning-lane-coordinator.js"; +import { buildTelegramRichMarkdown, TELEGRAM_RICH_TEXT_LIMIT } from "./rich-message.js"; + +const DRAFT_MIN_INITIAL_CHARS = 30; + +type DraftPartialTextUpdate = { + text: string; + delta?: string; + replace?: true; + isReasoningSnapshot?: boolean; +}; + +type SplitLaneSegment = { lane: LaneName; update: DraftPartialTextUpdate }; +type SplitLaneSegmentsResult = { + segments: SplitLaneSegment[]; + suppressedReasoningOnly: boolean; +}; + +type QueuedAnswerBlockRotation = { + assistantMessageIndex?: number; + text?: string; + shouldRotateBeforeDelivery: boolean; +}; + +function resolveDraftPartialText( + previous: string, + update: DraftPartialTextUpdate, +): string | undefined { + const nextText = + update.replace || update.isReasoningSnapshot || update.delta === undefined + ? update.text + : `${previous}${update.delta}`; + return nextText === previous ? undefined : nextText; +} + +export function createTelegramDraftController(params: { + accountId: string; + bot: Bot; + cfg: OpenClawConfig; + chatId: number; + draftReplyToMessageId?: number; + forceBlockStreamingForReasoning: boolean; + hasTelegramQuoteReply: boolean; + isDispatchSuperseded: () => boolean; + isRoomEvent: boolean; + replyToMode: ReplyToMode; + resolvedReasoningLevel: TelegramReasoningLevel; + streamMode: TelegramStreamMode; + tableMode: ReturnType; + telegramCfg: TelegramAccountConfig; + telegramDeps: TelegramBotDeps; + textLimit: number; + threadSpec: TelegramThreadSpec; +}) { + const streamDeliveryEnabled = !params.isRoomEvent && params.streamMode !== "off"; + const accountBlockStreamingEnabled = + resolveChannelStreamingBlockEnabled(params.telegramCfg) ?? + params.cfg.agents?.defaults?.blockStreamingDefault === "on"; + const canStreamAnswerDraft = + streamDeliveryEnabled && + !params.hasTelegramQuoteReply && + !accountBlockStreamingEnabled && + !params.forceBlockStreamingForReasoning; + const streamReasoningDraft = params.resolvedReasoningLevel === "stream"; + const streamReasoningInProgressDraft = + streamReasoningDraft && params.streamMode === "progress" && canStreamAnswerDraft; + const canStreamReasoningDraft = + !params.isRoomEvent && streamReasoningDraft && !streamReasoningInProgressDraft; + const draftMaxChars = + params.streamMode === "block" + ? Math.min( + resolveTelegramDraftStreamingChunking(params.cfg, params.accountId).maxChars, + params.textLimit, + ) + : Math.min( + params.textLimit, + params.telegramCfg.richMessages === true + ? TELEGRAM_RICH_TEXT_LIMIT + : TELEGRAM_TEXT_CHUNK_LIMIT, + ); + const renderStreamText = (text: string): TelegramDraftPreview => + params.telegramCfg.richMessages === true + ? { + text, + richMessage: buildTelegramRichMarkdown(text, { + tableMode: params.tableMode, + skipEntityDetection: params.telegramCfg.linkPreview === false, + }), + } + : { + text: renderTelegramHtmlText(text, { tableMode: params.tableMode }), + parseMode: "HTML", + markdownSource: { text, tableMode: params.tableMode }, + }; + + const createDraftLane = (laneName: LaneName, enabled: boolean): DraftLaneState => { + const stream = enabled + ? (params.telegramDeps.createTelegramDraftStream ?? createTelegramDraftStream)({ + api: params.bot.api, + chatId: params.chatId, + maxChars: draftMaxChars, + thread: params.threadSpec, + replyToMessageId: params.draftReplyToMessageId, + replyToMode: params.replyToMode, + richMessages: params.telegramCfg.richMessages, + minInitialChars: params.streamMode === "progress" ? 0 : DRAFT_MIN_INITIAL_CHARS, + renderText: renderStreamText, + onRetainedPage: (page) => { + lanes[laneName].retainedPromptContextPages.push({ + messageId: page.messageId, + text: page.textSnapshot, + }); + }, + log: logVerbose, + warn: logVerbose, + }) + : undefined; + return { + stream, + lastPartialText: "", + hasStreamedMessage: false, + finalized: false, + retainedPromptContextPages: [], + }; + }; + const lanes: Record = { + answer: createDraftLane("answer", canStreamAnswerDraft), + reasoning: createDraftLane("reasoning", canStreamReasoningDraft), + }; + const answerLane = lanes.answer; + const reasoningLane = lanes.reasoning; + let lastAnswerPartialText = ""; + let activeAnswerDraftIsToolProgressOnly = false; + let activeAnswerBlockAssistantMessageIndex: number | undefined; + let activeAnswerBlockDelivery: TelegramAnswerBlockDelivery | undefined; + let materializeAnswerLaneBeforeRotation: (() => Promise) | undefined; + const queuedAnswerBlockRotations: QueuedAnswerBlockRotation[] = []; + let queuedAnswerBlockAssistantMessageIndex: number | undefined; + let pendingAnswerBlockAssistantMessageIndex: number | undefined; + let rotateAnswerLaneWhenQueuedBlocksSettle = false; + let eventQueue = Promise.resolve(); + let resetProgress = () => {}; + let suppressProgress = () => {}; + let noteReasoningHint = () => {}; + let noteReasoningDelivered = () => {}; + + const resetAnswerToolProgressDraft = () => { + activeAnswerDraftIsToolProgressOnly = false; + }; + const resetLaneState = (lane: DraftLaneState) => { + lane.lastPartialText = ""; + if (lane === answerLane) { + lastAnswerPartialText = ""; + } + lane.hasStreamedMessage = false; + lane.finalized = false; + lane.retainedPromptContextPages = []; + if (lane === answerLane) { + resetAnswerToolProgressDraft(); + pendingAnswerBlockAssistantMessageIndex = undefined; + activeAnswerBlockDelivery = undefined; + } + }; + const repositionLaneForNewMessage = (lane: DraftLaneState) => { + // Reposition instead of delete-then-repost: the replacement must land + // before deferred cleanup or Telegram can jump and retain a stale preview. + lane.stream?.rotateToNewMessageDeferringDelete(); + resetLaneState(lane); + }; + const rotateLaneForNewMessage = async (lane: DraftLaneState) => { + if (!lane.hasStreamedMessage && typeof lane.stream?.messageId() !== "number") { + resetLaneState(lane); + return; + } + await lane.stream?.stop(); + lane.stream?.forceNewMessage(); + resetLaneState(lane); + }; + const rotateAnswerLaneForNewMessage = async () => { + await materializeAnswerLaneBeforeRotation?.(); + await rotateLaneForNewMessage(answerLane); + }; + const rotateAnswerLaneAfterToolProgress = async () => { + if (!activeAnswerDraftIsToolProgressOnly) { + return false; + } + repositionLaneForNewMessage(answerLane); + suppressProgress(); + rotateAnswerLaneWhenQueuedBlocksSettle = false; + return true; + }; + const rotateAnswerLaneAfterQueuedBlocksSettle = async () => { + if (!rotateAnswerLaneWhenQueuedBlocksSettle || queuedAnswerBlockRotations.length > 0) { + return false; + } + rotateAnswerLaneWhenQueuedBlocksSettle = false; + if (!answerLane.hasStreamedMessage || activeAnswerDraftIsToolProgressOnly) { + return false; + } + await rotateAnswerLaneForNewMessage(); + return true; + }; + const prepareAnswerLaneForText = async (): Promise => { + // Progress mode owns one stationary activity window; answer text never rotates it. + if (params.streamMode === "progress") { + return false; + } + if (await rotateAnswerLaneAfterToolProgress()) { + return true; + } + if (await rotateAnswerLaneAfterQueuedBlocksSettle()) { + return true; + } + if (!answerLane.finalized) { + return false; + } + answerLane.stream?.forceNewMessage(); + resetLaneState(answerLane); + rotateAnswerLaneWhenQueuedBlocksSettle = false; + return true; + }; + const prepareAnswerLaneForToolProgress = async () => { + if (answerLane.finalized) { + answerLane.stream?.forceNewMessage(); + resetLaneState(answerLane); + } + if (activeAnswerDraftIsToolProgressOnly) { + return; + } + if (params.streamMode !== "progress" && answerLane.hasStreamedMessage) { + await rotateAnswerLaneForNewMessage(); + } + activeAnswerDraftIsToolProgressOnly = true; + }; + + const splitTextIntoLaneSegments = ( + update: { text?: string; delta?: string; replace?: true; isReasoningSnapshot?: boolean }, + isReasoning?: boolean, + ): SplitLaneSegmentsResult => { + const split = splitTelegramReasoningText(update.text, isReasoning); + const splitSegments: Array<{ lane: LaneName; text: string }> = []; + const useDelta = + !update.replace && update.isReasoningSnapshot !== true && update.delta !== undefined; + const suppressReasoning = params.resolvedReasoningLevel === "off"; + if (split.reasoningText && !suppressReasoning) { + splitSegments.push({ lane: "reasoning", text: split.reasoningText }); + } + if (split.answerText) { + splitSegments.push({ lane: "answer", text: split.answerText }); + } + return { + segments: splitSegments.map((segment) => ({ + lane: segment.lane, + update: { + text: segment.text, + ...(!useDelta || splitSegments.length !== 1 ? {} : { delta: update.delta }), + ...(update.replace ? { replace: true as const } : {}), + ...(update.isReasoningSnapshot ? { isReasoningSnapshot: true } : {}), + }, + })), + suppressedReasoningOnly: + Boolean(split.reasoningText) && suppressReasoning && !split.answerText, + }; + }; + const updateDraftFromPartial = (lane: DraftLaneState, update: DraftPartialTextUpdate) => { + if (!lane.stream || !update.text) { + return; + } + const previousText = lane === answerLane ? lastAnswerPartialText : lane.lastPartialText; + const nextText = resolveDraftPartialText(previousText, update); + if (!nextText || (lane === answerLane && params.streamMode === "progress")) { + return; + } + if (lane === answerLane) { + resetAnswerToolProgressDraft(); + suppressProgress(); + lastAnswerPartialText = nextText; + } + lane.hasStreamedMessage = true; + lane.finalized = false; + lane.lastPartialText = nextText; + lane.stream.update(nextText); + }; + const ingestDraftLaneSegments = async ( + update: { text?: string; delta?: string; replace?: true; isReasoningSnapshot?: boolean }, + isReasoning?: boolean, + ) => { + const split = splitTextIntoLaneSegments(update, isReasoning); + for (const segment of split.segments) { + if (segment.lane === "answer") { + await prepareAnswerLaneForText(); + } + if (segment.lane === "reasoning") { + noteReasoningHint(); + noteReasoningDelivered(); + } + updateDraftFromPartial(lanes[segment.lane], segment.update); + } + }; + const enqueueEvent = (task: () => Promise): Promise => { + const next = eventQueue.then(async () => { + if (!params.isDispatchSuperseded()) { + await task(); + } + }); + eventQueue = next.catch((err: unknown) => { + logVerbose(`telegram: draft lane callback failed: ${String(err)}`); + }); + return eventQueue; + }; + + const recomputeQueuedAnswerBlockRotations = () => { + let previous = + activeAnswerBlockAssistantMessageIndex ?? pendingAnswerBlockAssistantMessageIndex; + queuedAnswerBlockAssistantMessageIndex = undefined; + for (const entry of queuedAnswerBlockRotations) { + if (entry.assistantMessageIndex === undefined) { + continue; + } + entry.shouldRotateBeforeDelivery = + previous !== undefined && entry.assistantMessageIndex !== previous; + previous = entry.assistantMessageIndex; + queuedAnswerBlockAssistantMessageIndex = entry.assistantMessageIndex; + } + }; + const rotationMatches = ( + entry: QueuedAnswerBlockRotation, + payload: ReplyPayload, + assistantMessageIndex?: number, + ) => + assistantMessageIndex !== undefined && entry.assistantMessageIndex !== undefined + ? assistantMessageIndex === entry.assistantMessageIndex + : entry.text !== undefined && payload.text !== undefined && entry.text === payload.text; + const prepareQueuedAnswerBlock = async ( + payload: ReplyPayload, + blockContext?: BlockReplyContext, + ) => { + if ( + !splitTextIntoLaneSegments({ text: payload.text }, payload.isReasoning).segments.some( + (segment) => segment.lane === "answer", + ) + ) { + return; + } + resetProgress(); + const assistantMessageIndex = blockContext?.assistantMessageIndex; + if (assistantMessageIndex === undefined) { + queuedAnswerBlockRotations.push({ text: payload.text, shouldRotateBeforeDelivery: false }); + return; + } + const previous = + queuedAnswerBlockAssistantMessageIndex ?? + activeAnswerBlockAssistantMessageIndex ?? + pendingAnswerBlockAssistantMessageIndex; + queuedAnswerBlockRotations.push({ + assistantMessageIndex, + text: payload.text, + shouldRotateBeforeDelivery: previous !== undefined && assistantMessageIndex !== previous, + }); + queuedAnswerBlockAssistantMessageIndex = assistantMessageIndex; + }; + const takeQueuedAnswerBlockRotation = (payload: ReplyPayload, index?: number): boolean => { + if (queuedAnswerBlockRotations.length === 0) { + return false; + } + const matchIndex = queuedAnswerBlockRotations.findIndex((entry) => + rotationMatches(entry, payload, index), + ); + const matched = queuedAnswerBlockRotations.splice(0, Math.max(matchIndex, 0) + 1).at(-1); + if (matched?.assistantMessageIndex !== undefined) { + activeAnswerBlockAssistantMessageIndex = matched.assistantMessageIndex; + pendingAnswerBlockAssistantMessageIndex = undefined; + } + recomputeQueuedAnswerBlockRotations(); + return matched?.shouldRotateBeforeDelivery ?? false; + }; + const dropQueuedAnswerBlockRotation = (payload: ReplyPayload, index?: number) => { + let matchIndex = queuedAnswerBlockRotations.findIndex((entry) => + rotationMatches(entry, payload, index), + ); + if (matchIndex < 0 && index === undefined) { + matchIndex = queuedAnswerBlockRotations.findIndex( + (entry) => entry.assistantMessageIndex === undefined, + ); + } + if (matchIndex < 0) { + return; + } + const [matched] = queuedAnswerBlockRotations.splice(matchIndex, 1); + if ( + matchIndex === 0 && + matched?.assistantMessageIndex !== undefined && + rotateAnswerLaneWhenQueuedBlocksSettle && + activeAnswerBlockAssistantMessageIndex === undefined && + answerLane.hasStreamedMessage + ) { + pendingAnswerBlockAssistantMessageIndex = matched.assistantMessageIndex; + } + recomputeQueuedAnswerBlockRotations(); + }; + + const resolvedBlockStreamingEnabled = resolveChannelStreamingBlockEnabled(params.telegramCfg); + const disableBlockStreaming = !streamDeliveryEnabled + ? true + : params.forceBlockStreamingForReasoning + ? false + : typeof resolvedBlockStreamingEnabled === "boolean" + ? !resolvedBlockStreamingEnabled + : canStreamAnswerDraft + ? true + : undefined; + + return { + answerLane, + reasoningLane, + lanes, + canPushAnswerDraft: () => Boolean(answerLane.stream), + cleanup: async (superseded: boolean) => { + for (const lane of [answerLane, reasoningLane]) { + const stream = lane.stream; + if (!stream) { + continue; + } + if (superseded) { + await (typeof stream.discard === "function" ? stream.discard() : stream.stop()); + } else if (lane.finalized) { + await stream.stop(); + } else { + await stream.clear(); + } + } + }, + disableBlockStreaming, + durableReasoningPayloadsEnabled: + params.resolvedReasoningLevel === "on" || Boolean(reasoningLane.stream), + enqueueEvent, + ingestDraftLaneSegments, + isAnswerToolProgressOnly: () => activeAnswerDraftIsToolProgressOnly, + isQueuedAnswerBlock: (payload: ReplyPayload, index?: number) => + queuedAnswerBlockRotations.some((entry) => rotationMatches(entry, payload, index)), + lastAnswerPartialText: () => lastAnswerPartialText, + prepareAnswerLaneForText, + prepareAnswerLaneForToolProgress, + prepareQueuedAnswerBlock, + dropQueuedAnswerBlockRotation, + takeQueuedAnswerBlockRotation, + renderStreamText, + repositionLaneForNewMessage, + resetAnswerToolProgressDraft, + resetLaneState, + rotateAnswerLaneAfterQueuedBlocksSettle, + rotateAnswerLaneAfterToolProgress, + rotateAnswerLaneForNewMessage, + rotateLaneForNewMessage, + setActiveAnswerBlockDelivery: (delivery?: TelegramAnswerBlockDelivery) => { + activeAnswerBlockDelivery = delivery; + }, + activeAnswerBlockDelivery: () => activeAnswerBlockDelivery, + setMaterializeBeforeRotation: (materialize: () => Promise) => { + materializeAnswerLaneBeforeRotation = materialize; + }, + setProgressLifecycle: (lifecycle: { reset: () => void; suppress: () => void }) => { + resetProgress = lifecycle.reset; + suppressProgress = lifecycle.suppress; + }, + setReasoningStepCallbacks: (callbacks: { noteHint: () => void; noteDelivered: () => void }) => { + noteReasoningHint = callbacks.noteHint; + noteReasoningDelivered = callbacks.noteDelivered; + }, + setRotateWhenQueuedBlocksSettle: (value: boolean) => { + rotateAnswerLaneWhenQueuedBlocksSettle = value; + }, + splitTextIntoLaneSegments, + streamDeliveryEnabled, + streamReasoningInProgressDraft, + waitForEvents: async () => await eventQueue, + flushLane: async (lane: DraftLaneState) => await lane.stream?.flush(), + }; +} + +export type TelegramDraftController = ReturnType; diff --git a/extensions/telegram/src/bot-message-dispatch-fence.ts b/extensions/telegram/src/bot-message-dispatch-fence.ts new file mode 100644 index 000000000000..4607337bb784 --- /dev/null +++ b/extensions/telegram/src/bot-message-dispatch-fence.ts @@ -0,0 +1,117 @@ +// Telegram plugin module owns pre-adoption reply-fence authority. +import type { TelegramMessageContext } from "./bot-message-context.js"; +import type { DispatchTelegramMessageParams } from "./bot-message-dispatch.types.js"; +import { getTelegramSequentialKey } from "./sequential-key.js"; +import { + beginTelegramReplyFence, + buildTelegramNonInterruptingReplyFenceKey, + buildTelegramReplyFenceLaneKey, + endTelegramReplyFence, + isTelegramReplyFenceSuperseded, + releaseTelegramReplyFenceAbortController, + resolveTelegramReplyFenceKey, + shouldSupersedeTelegramReplyFence, + supersedeTelegramReplyFence, +} from "./telegram-reply-fence.js"; + +type CreateTelegramReplyFenceParams = Pick< + DispatchTelegramMessageParams, + "onTurnAdopted" | "onTurnDeferred" | "onTurnAbandoned" | "turnAbortSignal" +> & { + context: TelegramMessageContext; +}; + +export function createTelegramReplyFenceController(params: CreateTelegramReplyFenceParams) { + const { context } = params; + const replyFenceKey = resolveTelegramReplyFenceKey({ + ctxPayload: context.ctxPayload, + chatId: context.chatId, + threadSpec: context.threadSpec, + }); + const sequentialKey = getTelegramSequentialKey({ + message: context.msg, + ...(context.primaryCtx.me ? { me: context.primaryCtx.me } : {}), + }); + const laneKey = buildTelegramReplyFenceLaneKey({ + accountId: context.route.accountId, + sequentialKey, + }); + const supersedes = shouldSupersedeTelegramReplyFence(context.ctxPayload); + const activeKey = supersedes + ? replyFenceKey.activeKey + : buildTelegramNonInterruptingReplyFenceKey({ + activeKey: replyFenceKey.activeKey, + laneKey, + }); + // Ambient room-event work uses a separate fence key. Any non-room-event + // inbound may cancel it without owning abort authority over adopted user turns. + if (context.ctxPayload.InboundEventKind !== "room_event") { + supersedeTelegramReplyFence(replyFenceKey.roomEventKey); + } + + const abortController = new AbortController(); + const abortSignal = params.turnAbortSignal + ? AbortSignal.any([abortController.signal, params.turnAbortSignal]) + : abortController.signal; + let generation: number | undefined = beginTelegramReplyFence({ + key: activeKey, + supersede: supersedes, + abortController, + laneKey, + }); + let abortControllerQueued = false; + let queuedTurnAdmitted = false; + + const isSuperseded = () => + abortController.signal.aborted || + (generation !== undefined && isTelegramReplyFenceSuperseded({ key: activeKey, generation })); + + const release = () => { + if (generation === undefined) { + return; + } + endTelegramReplyFence(activeKey, abortControllerQueued ? undefined : abortController); + generation = undefined; + }; + + const adoptTurn = async () => { + await params.onTurnAdopted?.(); + // Fence abort and supersession authority end after durable adoption. + // Core then owns all interruption of the adopted run. + release(); + releaseTelegramReplyFenceAbortController(activeKey, abortController); + }; + + return { + abortSignal, + adoptTurn, + generation: () => generation, + isSuperseded, + release, + queuedFollowupLifecycle: + context.ctxPayload.InboundEventKind === "room_event" || + params.onTurnAdopted || + params.onTurnDeferred || + params.onTurnAbandoned + ? { + onEnqueued: () => { + abortControllerQueued = true; + params.onTurnDeferred?.(); + }, + onAdmitted: async () => { + await adoptTurn(); + queuedTurnAdmitted = true; + }, + onComplete: () => { + abortControllerQueued = false; + releaseTelegramReplyFenceAbortController(activeKey, abortController); + if (!queuedTurnAdmitted) { + params.onTurnAbandoned?.(); + } + }, + } + : undefined, + }; +} + +export type TelegramReplyFenceController = ReturnType; diff --git a/extensions/telegram/src/bot-message-dispatch-progress.ts b/extensions/telegram/src/bot-message-dispatch-progress.ts new file mode 100644 index 000000000000..5c494067a70c --- /dev/null +++ b/extensions/telegram/src/bot-message-dispatch-progress.ts @@ -0,0 +1,360 @@ +// Telegram plugin module owns the ephemeral progress window and collapse summary. +import { + buildChannelProgressDraftLine, + buildChannelProgressDraftLineForEntry, + createChannelProgressDraftCompositor, + isChannelProgressDraftWorkToolName, + resolveChannelStreamingPreviewToolProgress, + type ChannelProgressDraftLine, +} from "openclaw/plugin-sdk/channel-outbound"; +import type { TelegramAccountConfig } from "openclaw/plugin-sdk/config-contracts"; +import type { TelegramBotDeps } from "./bot-deps.js"; +import type { TelegramMessageContext } from "./bot-message-context.js"; +import type { TelegramDraftController } from "./bot-message-dispatch-draft.js"; +import type { TelegramStreamMode } from "./bot/types.js"; +import { + formatTelegramProgressLine, + renderTelegramProgressDraftPreview, +} from "./progress-draft-preview.js"; +import { + createTelegramProgressSummaryTracker, + formatTelegramProgressSummaryLine, +} from "./progress-summary.js"; + +type BufferedDispatchParams = Parameters< + TelegramBotDeps["dispatchReplyWithBufferedBlockDispatcher"] +>[0]; +type ReplyOptions = NonNullable; +type CallbackPayload = + NonNullable extends (...args: infer Args) => unknown ? Args[0] : never; + +function buildTelegramThinkingProgressLine(progressTokens: number): ChannelProgressDraftLine { + const label = `Thinking… (~${Math.round(progressTokens)} tokens)`; + return { + id: "reasoning:token-progress", + kind: "item", + icon: "🧠", + label, + text: `🧠 ${label}`, + prefix: false, + }; +} + +export function createTelegramProgressController(params: { + accountId: string; + chatId: TelegramMessageContext["chatId"]; + draft: TelegramDraftController; + statusReactionController: TelegramMessageContext["statusReactionController"]; + streamMode: TelegramStreamMode; + streamReasoningInProgressDraft: boolean; + telegramCfg: TelegramAccountConfig; + threadId?: number; +}) { + const { answerLane } = params.draft; + const summaryStartedAt = Date.now(); + const summary = createTelegramProgressSummaryTracker(); + let summaryDelivered = false; + let draftEverRendered = false; + let finalAnswerDeliveryStarted = false; + let finalAnswerDelivered = false; + let sawProgressFinal = false; + let verboseProgressActive: () => boolean = () => false; + + const compositor = createChannelProgressDraftCompositor({ + entry: params.telegramCfg, + mode: params.streamMode, + active: Boolean(answerLane.stream), + seed: `${params.accountId}:${params.chatId}:${params.threadId ?? ""}`, + formatLine: (text) => (compositor.hasStatusHeadline ? text : formatTelegramProgressLine(text)), + reasoningGate: params.streamReasoningInProgressDraft, + reasoningLinePrefix: "🧠 ", + commentaryLinePrefix: "πŸ’¬ ", + commentaryItalics: false, + update: async (streamText, options) => { + draftEverRendered = true; + await params.draft.prepareAnswerLaneForToolProgress(); + answerLane.lastPartialText = streamText; + answerLane.hasStreamedMessage = true; + answerLane.finalized = false; + answerLane.stream?.updatePreview( + renderTelegramProgressDraftPreview( + streamText, + options?.lines ?? [], + params.telegramCfg.richMessages === true, + compositor.hasStatusHeadline, + ), + ); + if (options?.flush) { + await answerLane.stream?.flush(); + } + }, + }); + + params.draft.setProgressLifecycle({ + reset: () => compositor.reset(), + suppress: () => compositor.suppress(), + }); + + const canPushToolProgress = () => + Boolean( + answerLane.stream && + !verboseProgressActive() && + !answerLane.finalized && + !finalAnswerDeliveryStarted && + !finalAnswerDelivered, + ); + const pushToolProgress = async ( + line?: string | ChannelProgressDraftLine, + options?: { toolName?: string; startImmediately?: boolean }, + ) => { + if (!canPushToolProgress()) { + return false; + } + return await compositor.pushToolProgress(line, options); + }; + const pushReasoningProgress = async (payload: { + text?: string; + isReasoningSnapshot?: boolean; + }) => { + if (params.streamReasoningInProgressDraft && payload.text) { + summary.noteReasoningActivity(); + } + return await compositor.pushReasoningProgress(payload.text, { + snapshot: payload.isReasoningSnapshot === true, + }); + }; + const pushThinkingTokenProgress = async (progressTokens: number) => { + const rendered = await pushToolProgress(buildTelegramThinkingProgressLine(progressTokens), { + startImmediately: true, + }); + if (rendered) { + summary.noteReasoningActivity(); + } + return rendered; + }; + + const markFinalStarted = () => { + finalAnswerDeliveryStarted = true; + compositor.markFinalReplyStarted(); + }; + const markFinalDelivered = () => { + finalAnswerDelivered = true; + sawProgressFinal = true; + compositor.markFinalReplyDelivered(); + }; + const resolveCollapseSummaryLine = (): string | undefined => { + if (summaryDelivered) { + return undefined; + } + summaryDelivered = true; + if (!draftEverRendered) { + return undefined; + } + return ( + formatTelegramProgressSummaryLine(summary.counts(), Date.now() - summaryStartedAt) || + undefined + ); + }; + const applyCollapseSummary = async ( + line: string, + postCosmeticSummary: (line: string) => Promise, + ) => { + const messageId = await answerLane.stream?.finalizeToPreview( + params.draft.renderStreamText(line), + ); + if (typeof messageId !== "number") { + await postCosmeticSummary(line); + } + }; + const resetAnswerLaneAfterCollapse = () => { + if (params.draft.isAnswerToolProgressOnly()) { + params.draft.resetAnswerToolProgressDraft(); + compositor.suppress(); + params.draft.setRotateWhenQueuedBlocksSettle(false); + } + answerLane.stream?.forceNewMessage(); + params.draft.resetLaneState(answerLane); + }; + const teardownWindow = async () => { + if (params.draft.isAnswerToolProgressOnly()) { + await params.draft.rotateAnswerLaneAfterToolProgress(); + return; + } + await answerLane.stream?.clear(); + params.draft.resetLaneState(answerLane); + }; + + const handleToolStart = async (payload: CallbackPayload<"onToolStart">) => { + const toolName = payload.name?.trim(); + if (payload.phase === "start") { + const windowRendersTool = + canPushToolProgress() && + resolveChannelStreamingPreviewToolProgress(params.telegramCfg) && + isChannelProgressDraftWorkToolName(toolName); + if (windowRendersTool) { + summary.noteToolCall(); + } else { + summary.closeReasoningBurst(); + summary.closeCommentaryBurst(); + } + } + const progressPromise = pushToolProgress( + buildChannelProgressDraftLineForEntry( + params.telegramCfg, + { + event: "tool", + itemId: payload.itemId, + toolCallId: payload.toolCallId, + name: toolName, + phase: payload.phase, + args: payload.args, + }, + payload.detailMode ? { detailMode: payload.detailMode } : undefined, + ), + { toolName, startImmediately: true }, + ); + if (params.statusReactionController && toolName) { + await params.statusReactionController.setTool(toolName); + } + await progressPromise; + }; + const handleItemEvent = async (payload: CallbackPayload<"onItemEvent">) => { + if (payload.kind === "preamble") { + if (verboseProgressActive()) { + return; + } + if (params.streamMode === "progress") { + await compositor.pushPreambleHeadline(payload.progressText, { itemId: payload.itemId }); + } + if (params.streamMode === "progress" && compositor.commentaryProgressEnabled) { + const accepted = await compositor.pushCommentaryProgress(payload.progressText, { + itemId: payload.itemId, + }); + if (accepted) { + summary.noteCommentary(payload.itemId, payload.progressText); + } + } + return; + } + await pushToolProgress( + buildChannelProgressDraftLineForEntry(params.telegramCfg, { + event: "item", + itemId: payload.itemId, + toolCallId: payload.toolCallId, + itemKind: payload.kind, + title: payload.title, + name: payload.name, + phase: payload.phase, + status: payload.status, + summary: payload.summary, + progressText: payload.progressText, + meta: payload.meta, + }), + ); + }; + const handlePlanUpdate = async (payload: CallbackPayload<"onPlanUpdate">) => { + if (payload.phase === "update") { + await pushToolProgress( + buildChannelProgressDraftLine({ + event: "plan", + phase: payload.phase, + title: payload.title, + explanation: payload.explanation, + steps: payload.steps, + }), + ); + } + }; + const handleApprovalEvent = async (payload: CallbackPayload<"onApprovalEvent">) => { + if (payload.phase === "requested") { + await pushToolProgress( + buildChannelProgressDraftLine({ + event: "approval", + phase: payload.phase, + title: payload.title, + command: payload.command, + reason: payload.reason, + message: payload.message, + }), + ); + } + }; + const handleCommandOutput = async (payload: CallbackPayload<"onCommandOutput">) => { + if (payload.phase === "end") { + await pushToolProgress( + buildChannelProgressDraftLineForEntry(params.telegramCfg, { + event: "command-output", + itemId: payload.itemId, + toolCallId: payload.toolCallId, + phase: payload.phase, + title: payload.title, + name: payload.name, + status: payload.status, + exitCode: payload.exitCode, + }), + ); + } + }; + const handlePatchSummary = async (payload: CallbackPayload<"onPatchSummary">) => { + if (payload.phase === "end") { + await pushToolProgress( + buildChannelProgressDraftLine({ + event: "patch", + itemId: payload.itemId, + toolCallId: payload.toolCallId, + phase: payload.phase, + title: payload.title, + name: payload.name, + added: payload.added, + modified: payload.modified, + deleted: payload.deleted, + summary: payload.summary, + }), + ); + } + }; + + return { + applyCollapseSummary, + canPushToolProgress, + cancel: () => compositor.cancel(), + closeReasoningBurst: () => summary.closeReasoningBurst(), + commentaryProgressEnabled: compositor.commentaryProgressEnabled, + finalAnswerDelivered: () => finalAnswerDelivered, + finalAnswerDeliveryStarted: () => finalAnswerDeliveryStarted, + handleApprovalEvent, + handleCommandOutput, + handleItemEvent, + handlePatchSummary, + handlePlanUpdate, + handleToolStart, + markFinalDelivered, + markFinalStarted, + markSawFinal: () => { + sawProgressFinal = true; + }, + progressPreambleEnabled: + params.streamMode === "progress" && answerLane.stream ? true : undefined, + pushReasoningProgress, + pushThinkingTokenProgress, + pushToolProgress, + reset: () => compositor.reset(), + resetAnswerLaneAfterCollapse, + resolveCollapseSummaryLine, + sawProgressFinal: () => sawProgressFinal, + setFinalAnswerDelivered: (value: boolean) => { + finalAnswerDelivered = value; + }, + setSummaryDelivered: () => { + summaryDelivered = true; + }, + setVerboseProgressActive: (isActive: () => boolean) => { + verboseProgressActive = isActive; + }, + suppress: () => compositor.suppress(), + teardownWindow, + verboseProgressActive: () => verboseProgressActive(), + }; +} + +export type TelegramProgressController = ReturnType; diff --git a/extensions/telegram/src/bot-message-dispatch-reply.ts b/extensions/telegram/src/bot-message-dispatch-reply.ts new file mode 100644 index 000000000000..3ade3da55305 --- /dev/null +++ b/extensions/telegram/src/bot-message-dispatch-reply.ts @@ -0,0 +1,417 @@ +import type { OpenClawConfig, TelegramAccountConfig } from "openclaw/plugin-sdk/config-contracts"; +// Telegram plugin module owns buffered reply payload delivery decisions. +import { normalizeMessagePresentation } from "openclaw/plugin-sdk/interactive-runtime"; +import { + isFastModeAutoProgressPayload, + isReplyPayloadNonTerminalToolErrorWarning, + resolveSendableOutboundReplyParts, +} from "openclaw/plugin-sdk/reply-payload"; +import type { ReplyPayload } from "openclaw/plugin-sdk/reply-payload"; +import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; +import { danger } from "openclaw/plugin-sdk/runtime-env"; +import type { TelegramBotDeps } from "./bot-deps.js"; +import type { TelegramMessageContext } from "./bot-message-context.js"; +import type { TelegramDeliveryController } from "./bot-message-dispatch-delivery.js"; +import type { TelegramDraftController } from "./bot-message-dispatch-draft.js"; +import type { TelegramReplyFenceController } from "./bot-message-dispatch-fence.js"; +import type { TelegramProgressController } from "./bot-message-dispatch-progress.js"; +import { deduplicateBlockSentMedia } from "./bot-message-dispatch.media-dedup.js"; +import type { TelegramDispatchTurnState } from "./bot-message-dispatch.types.js"; +import type { TelegramStreamMode } from "./bot/types.js"; +import { resolveTelegramInlineButtons, type TelegramInlineButtons } from "./button-types.js"; +import { + buildTelegramErrorScopeKey, + isSilentErrorPolicy, + resolveTelegramErrorPolicy, + shouldSuppressTelegramError, +} from "./error-policy.js"; +import { shouldSuppressLocalTelegramExecApprovalPrompt } from "./exec-approvals.js"; +import { createTelegramReasoningStepState } from "./reasoning-lane-coordinator.js"; + +type BufferedDispatchParams = Parameters< + TelegramBotDeps["dispatchReplyWithBufferedBlockDispatcher"] +>[0]; +type DispatcherOptions = BufferedDispatchParams["dispatcherOptions"]; +type Deliver = DispatcherOptions["deliver"]; +type Skip = NonNullable; +type ErrorCallback = NonNullable; +type Cancel = NonNullable; + +function resolvePayloadTelegramInlineButtons( + payload: ReplyPayload, +): TelegramInlineButtons | undefined { + const telegramData = payload.channelData?.telegram as + | { buttons?: TelegramInlineButtons } + | undefined; + return resolveTelegramInlineButtons({ + buttons: telegramData?.buttons, + presentation: normalizeMessagePresentation(payload.presentation), + interactive: payload.interactive, + }); +} + +function hasExecApprovalPayload(payload: ReplyPayload): boolean { + return payload.channelData?.execApproval !== undefined; +} + +export function createTelegramReplyDelivery(params: { + cfg: OpenClawConfig; + context: TelegramMessageContext; + delivery: TelegramDeliveryController; + draft: TelegramDraftController; + fence: Pick; + progress: TelegramProgressController; + runtime: RuntimeEnv; + state: TelegramDispatchTurnState; + streamMode: TelegramStreamMode; + telegramCfg: TelegramAccountConfig; +}) { + const reasoningStepState = createTelegramReasoningStepState(); + const sentBlockMediaUrls = new Set(); + params.draft.setReasoningStepCallbacks({ + noteHint: () => reasoningStepState.noteReasoningHint(), + noteDelivered: () => reasoningStepState.noteReasoningDelivered(), + }); + + const flushBufferedFinalAnswer = async () => { + const buffered = reasoningStepState.takeBufferedFinalAnswer(params.fence.generation()); + if (!buffered) { + return; + } + await params.delivery.deliverFinalAnswerText( + buffered.payload, + buffered.text, + resolvePayloadTelegramInlineButtons(buffered.payload), + ); + reasoningStepState.resetForNextStep(); + }; + const trackBlockMedia = (delivered: boolean, kind: string, payload: ReplyPayload) => { + if (delivered && kind === "block" && payload.mediaUrls?.length) { + for (const url of payload.mediaUrls) { + sentBlockMediaUrls.add(url); + } + } + }; + + const deliver: Deliver = async (payload, info) => { + if (params.fence.isSuperseded()) { + return; + } + const normalizedPayload = params.delivery.normalizeDeliveryPayload(payload); + if (!normalizedPayload) { + return; + } + const deduped = + info.kind === "final" + ? deduplicateBlockSentMedia(normalizedPayload, sentBlockMediaUrls) + : normalizedPayload; + if (!deduped) { + return; + } + const effectivePayload = deduped; + if ( + shouldSuppressLocalTelegramExecApprovalPrompt({ + cfg: params.cfg, + accountId: params.context.route.accountId, + payload: effectivePayload, + }) + ) { + params.state.queuedFinal = true; + return; + } + const telegramButtons = resolvePayloadTelegramInlineButtons(effectivePayload); + const lanePayload = + info.kind === "block" && + typeof payload.text === "string" && + typeof effectivePayload.text === "string" && + payload.text !== effectivePayload.text && + payload.text.trimEnd() === effectivePayload.text && + !effectivePayload.mediaUrl && + !effectivePayload.mediaUrls?.length + ? { ...effectivePayload, text: payload.text } + : effectivePayload; + const split = params.draft.splitTextIntoLaneSegments( + { text: lanePayload.text }, + payload.isReasoning, + ); + const segments = split.segments; + const reply = resolveSendableOutboundReplyParts(effectivePayload); + if (info.kind === "final" && (reply.text.length > 0 || reply.hasMedia)) { + params.progress.markFinalStarted(); + } + if (info.kind === "final") { + await params.draft.enqueueEvent(async () => {}); + } + const isToolPayloadAfterFinal = + info.kind === "tool" && + (params.progress.finalAnswerDeliveryStarted() || params.progress.finalAnswerDelivered()); + const isNonTerminalWarningAfterDeliveredFinal = + isReplyPayloadNonTerminalToolErrorWarning(payload) && params.progress.finalAnswerDelivered(); + if ( + (isToolPayloadAfterFinal || isNonTerminalWarningAfterDeliveredFinal) && + !reply.hasMedia && + !hasExecApprovalPayload(effectivePayload) + ) { + return; + } + if (payload.isError === true) { + params.state.hadErrorReplyFailureOrSkip = true; + } + + let blockDelivered = false; + const hasAnswerSegment = segments.some((segment) => segment.lane === "answer"); + if (info.kind === "block" && !hasAnswerSegment) { + params.draft.dropQueuedAnswerBlockRotation(effectivePayload, info.assistantMessageIndex); + } + for (const segment of segments) { + if ( + segment.lane === "answer" && + info.kind === "final" && + reasoningStepState.shouldBufferFinalAnswer() + ) { + reasoningStepState.bufferFinalAnswer({ + payload: effectivePayload, + text: segment.update.text, + bufferedGeneration: params.fence.generation(), + }); + continue; + } + if (segment.lane === "reasoning") { + reasoningStepState.noteReasoningHint(); + } + if (segment.lane === "answer" && info.kind === "tool") { + if (params.progress.verboseProgressActive()) { + if ( + await params.delivery.sendPayload( + params.delivery.applyTextToPayload(effectivePayload, segment.update.text), + ) + ) { + blockDelivered = true; + } + continue; + } + const canRepresentAsTransientProgress = + !reply.hasMedia && + telegramButtons === undefined && + !hasExecApprovalPayload(effectivePayload); + const isFastModeProgressPayload = isFastModeAutoProgressPayload(effectivePayload); + if (params.streamMode === "progress") { + if ( + canRepresentAsTransientProgress && + params.draft.answerLane.stream && + !isFastModeProgressPayload + ) { + continue; + } + if ( + (canRepresentAsTransientProgress || isFastModeProgressPayload) && + (await params.progress.pushToolProgress(segment.update.text, { + startImmediately: true, + })) + ) { + blockDelivered = true; + continue; + } + } + await params.draft.prepareAnswerLaneForToolProgress(); + } + + const ownedByQueuedRotation = params.draft.isQueuedAnswerBlock( + lanePayload, + info.assistantMessageIndex, + ); + const skipTextOnlyBlock = + params.streamMode === "partial" && + info.kind === "block" && + segment.lane === "answer" && + !reply.hasMedia && + !hasExecApprovalPayload(effectivePayload) && + telegramButtons === undefined && + params.draft.answerLane.hasStreamedMessage && + !params.draft.isAnswerToolProgressOnly() && + !ownedByQueuedRotation && + segment.update.text.trimEnd() === params.draft.answerLane.lastPartialText.trimEnd(); + const suppressProgressAnswerBlock = + params.streamMode === "progress" && + info.kind === "block" && + segment.lane === "answer" && + !reply.hasMedia && + !hasExecApprovalPayload(effectivePayload) && + telegramButtons === undefined; + if (skipTextOnlyBlock || suppressProgressAnswerBlock) { + params.draft.setActiveAnswerBlockDelivery({ + payload: effectivePayload, + text: segment.update.text, + buttons: telegramButtons, + }); + params.draft.resetAnswerToolProgressDraft(); + params.progress.reset(); + blockDelivered = true; + continue; + } + + if (segment.lane === "answer" && info.kind === "block") { + const prepared = await params.draft.prepareAnswerLaneForText(); + const shouldRotate = params.draft.takeQueuedAnswerBlockRotation( + lanePayload, + info.assistantMessageIndex, + ); + if (params.streamMode !== "progress" && shouldRotate && !prepared) { + await params.draft.rotateAnswerLaneForNewMessage(); + params.draft.setRotateWhenQueuedBlocksSettle(false); + } + params.draft.resetAnswerToolProgressDraft(); + params.progress.reset(); + } + const result = + segment.lane === "answer" && info.kind === "final" + ? await params.delivery.deliverFinalAnswerText( + effectivePayload, + segment.update.text, + telegramButtons, + ) + : await params.delivery.deliverLaneText({ + laneName: segment.lane, + text: segment.update.text, + payload: lanePayload, + infoKind: info.kind, + buttons: telegramButtons, + }); + if ( + segment.lane === "answer" && + info.kind !== "final" && + result.kind === "preview-finalized" + ) { + await params.delivery.emitPreviewFinalizedHook(result); + } + if (segment.lane === "answer" && info.kind === "block" && result.kind === "preview-updated") { + params.draft.setActiveAnswerBlockDelivery({ + payload: lanePayload, + text: segment.update.text, + buttons: telegramButtons, + }); + } + blockDelivered ||= result.kind !== "skipped"; + if (segment.lane === "reasoning") { + if (result.kind !== "skipped") { + reasoningStepState.noteReasoningDelivered(); + await flushBufferedFinalAnswer(); + } + } else if (info.kind === "final") { + reasoningStepState.resetForNextStep(); + } + } + if (segments.length > 0) { + trackBlockMedia(blockDelivered, info.kind, effectivePayload); + return; + } + + if (split.suppressedReasoningOnly) { + let delivered = false; + if (reply.hasMedia) { + if (info.kind === "final") { + await params.draft.rotateAnswerLaneAfterToolProgress(); + await params.draft.answerLane.stream?.stop(); + await params.draft.reasoningLane.stream?.stop(); + reasoningStepState.resetForNextStep(); + } + const payloadWithoutReasoning = + typeof effectivePayload.text === "string" + ? { ...effectivePayload, text: "" } + : effectivePayload; + delivered = await params.delivery.sendPayload(payloadWithoutReasoning, { + durable: info.kind === "final", + }); + } + if (info.kind === "final" && delivered) { + params.progress.markFinalDelivered(); + } + if (info.kind === "final") { + await flushBufferedFinalAnswer(); + } + trackBlockMedia(delivered, info.kind, effectivePayload); + return; + } + + if (info.kind === "final") { + await params.draft.rotateAnswerLaneAfterToolProgress(); + await params.draft.answerLane.stream?.stop(); + await params.draft.reasoningLane.stream?.stop(); + reasoningStepState.resetForNextStep(); + } + if (!reply.hasMedia && reply.text.length === 0) { + if (info.kind === "final") { + await flushBufferedFinalAnswer(); + } + return; + } + const delivered = await params.delivery.sendPayload(effectivePayload, { + durable: info.kind === "final", + }); + if (info.kind === "final" && delivered) { + params.progress.markFinalDelivered(); + } + if (info.kind === "final") { + await flushBufferedFinalAnswer(); + } + trackBlockMedia(delivered, info.kind, effectivePayload); + }; + + const onSkip: Skip = (payload, info) => { + if (info.kind === "block") { + void params.draft.enqueueEvent(async () => { + params.draft.dropQueuedAnswerBlockRotation(payload, info.assistantMessageIndex); + }); + } + if (payload.isError === true) { + params.state.hadErrorReplyFailureOrSkip = true; + } + if (info.reason !== "silent") { + params.delivery.markNonSilentSkip(); + } + }; + + const onError: ErrorCallback = (err, info) => { + const errorPolicy = resolveTelegramErrorPolicy({ + accountConfig: params.telegramCfg, + groupConfig: params.context.groupConfig, + topicConfig: params.context.topicConfig, + }); + if (isSilentErrorPolicy(errorPolicy.policy)) { + return; + } + if ( + errorPolicy.policy === "once" && + shouldSuppressTelegramError({ + scopeKey: buildTelegramErrorScopeKey({ + accountId: params.context.route.accountId, + chatId: params.context.chatId, + threadId: params.context.threadSpec.id, + }), + cooldownMs: errorPolicy.cooldownMs, + errorMessage: String(err), + }) + ) { + return; + } + params.delivery.markNonSilentFailure(); + params.runtime.error?.(danger(`telegram ${info.kind} reply failed: ${String(err)}`)); + }; + + return { + deliver, + onBeforeDeliverCancelled: (payload: Parameters[0], info: Parameters[1]) => { + if (info.kind === "block") { + return params.draft.enqueueEvent(async () => { + params.draft.dropQueuedAnswerBlockRotation(payload, info.assistantMessageIndex); + }); + } + return undefined; + }, + onError, + onSkip, + reasoningStepState, + }; +} + +export type TelegramReplyDelivery = ReturnType; diff --git a/extensions/telegram/src/bot-message-dispatch-session.ts b/extensions/telegram/src/bot-message-dispatch-session.ts new file mode 100644 index 000000000000..6172e95cdc24 --- /dev/null +++ b/extensions/telegram/src/bot-message-dispatch-session.ts @@ -0,0 +1,155 @@ +// Telegram plugin module owns dispatch-time session and transcript access. +import path from "node:path"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; +import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; +import { + appendAssistantMirrorMessageByIdentity, + readLatestAssistantTextByIdentity, +} from "openclaw/plugin-sdk/session-transcript-runtime"; +import { resolveTelegramConfigReasoningDefault } from "./agent-config.js"; +import type { TelegramBotDeps } from "./bot-deps.js"; +import type { TelegramMessageContext } from "./bot-message-context.js"; +import { getSessionEntry } from "./bot-message-dispatch.runtime.js"; +import type { + CurrentTurnTranscriptFinal, + FreshTelegramSessionEntryLoader, + TelegramReasoningLevel, + TelegramScopedTranscriptSession, + TelegramTranscriptMirrorPayload, +} from "./bot-message-dispatch.types.js"; + +export function createFreshTelegramSessionEntryLoader(params: { + cfg: OpenClawConfig; + telegramDeps: TelegramBotDeps; +}): FreshTelegramSessionEntryLoader { + const entriesByPathAndKey = new Map>(); + const load = ((agentId: string, sessionKey: string) => { + const storePath = params.telegramDeps.resolveStorePath(params.cfg.session?.store, { agentId }); + const cacheKey = `${storePath}\0${sessionKey}`; + if (entriesByPathAndKey.has(cacheKey)) { + return { storePath, entry: entriesByPathAndKey.get(cacheKey) }; + } + const entry = (params.telegramDeps.getSessionEntry ?? getSessionEntry)({ + storePath, + sessionKey, + readConsistency: "latest", + }); + entriesByPathAndKey.set(cacheKey, entry); + return { storePath, entry }; + }) as FreshTelegramSessionEntryLoader; + load.clear = () => entriesByPathAndKey.clear(); + return load; +} + +export function resolveTelegramReasoningLevel(params: { + cfg: OpenClawConfig; + sessionKey?: string; + agentId: string; + loadFreshSessionEntry: FreshTelegramSessionEntryLoader; +}): TelegramReasoningLevel { + const configDefault = resolveTelegramConfigReasoningDefault(params.cfg, params.agentId); + if (!params.sessionKey) { + return configDefault; + } + try { + const { entry } = params.loadFreshSessionEntry(params.agentId, params.sessionKey); + const level = entry?.reasoningLevel; + return level === "on" || level === "stream" || level === "off" ? level : configDefault; + } catch { + return "off"; + } +} + +function resolveTelegramMirroredTranscriptText( + payload: TelegramTranscriptMirrorPayload, +): string | null { + const mediaUrls = payload.mediaUrls?.filter((url) => url.trim()) ?? []; + if (mediaUrls.length > 0) { + return mediaUrls + .map((url) => { + const pathname = url.split("#")[0]?.split("?")[0] ?? url; + const base = path.basename(pathname); + return base && base !== "." && base !== "/" ? base : "media"; + }) + .join(", "); + } + return payload.text?.trim() || null; +} + +function resolveTelegramScopedTranscriptSession(params: { + agentId: string; + loadFreshSessionEntry: FreshTelegramSessionEntryLoader; + sessionKey: string; +}): TelegramScopedTranscriptSession | undefined { + const { entry, storePath } = params.loadFreshSessionEntry(params.agentId, params.sessionKey); + const sessionId = entry?.sessionId?.trim(); + return sessionId ? { sessionId, storePath } : undefined; +} + +export async function mirrorTelegramAssistantReplyToTranscript(params: { + cfg: OpenClawConfig; + idempotencyKey: string; + loadFreshSessionEntry: FreshTelegramSessionEntryLoader; + route: TelegramMessageContext["route"]; + sessionKey: string; + payload: TelegramTranscriptMirrorPayload; +}) { + const text = resolveTelegramMirroredTranscriptText(params.payload); + if (!text) { + return; + } + const session = resolveTelegramScopedTranscriptSession({ + agentId: params.route.agentId, + loadFreshSessionEntry: params.loadFreshSessionEntry, + sessionKey: params.sessionKey, + }); + if (!session) { + return; + } + const appended = await appendAssistantMirrorMessageByIdentity({ + agentId: params.route.agentId, + config: params.cfg, + idempotencyKey: params.idempotencyKey, + deliveryMirror: { kind: "channel-final", sourceMessageId: params.idempotencyKey }, + sessionId: session.sessionId, + sessionKey: params.sessionKey, + storePath: session.storePath, + text, + }); + if (!appended.ok && appended.code !== "session-rebound") { + logVerbose(`telegram transcript mirror append failed: ${appended.reason}`); + } +} + +export function createCurrentTurnTranscriptFinalResolver(params: { + agentId: string; + dispatchStartedAt: number; + loadFreshSessionEntry: FreshTelegramSessionEntryLoader; + sessionKey?: string; +}): () => Promise { + return async () => { + if (!params.sessionKey) { + return undefined; + } + try { + const { entry, storePath } = params.loadFreshSessionEntry(params.agentId, params.sessionKey); + if (!entry?.sessionId) { + return undefined; + } + const latest = await readLatestAssistantTextByIdentity({ + agentId: params.agentId, + sessionId: entry.sessionId, + sessionKey: params.sessionKey, + storePath, + }); + if (!latest?.timestamp || latest.timestamp < params.dispatchStartedAt) { + return undefined; + } + return { ...(latest.id ? { messageId: latest.id } : {}), text: latest.text }; + } catch (err) { + logVerbose(`telegram transcript final candidate lookup failed: ${formatErrorMessage(err)}`); + return undefined; + } + }; +} diff --git a/extensions/telegram/src/bot-message-dispatch-status.ts b/extensions/telegram/src/bot-message-dispatch-status.ts new file mode 100644 index 000000000000..b14720f17dbf --- /dev/null +++ b/extensions/telegram/src/bot-message-dispatch-status.ts @@ -0,0 +1,91 @@ +// Telegram plugin module owns dispatch status-reaction finalization. +import { + DEFAULT_TIMING, + logAckFailure, + removeAckReactionAfterReply, +} from "openclaw/plugin-sdk/channel-feedback"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { logVerbose, sleepWithAbort } from "openclaw/plugin-sdk/runtime-env"; +import type { TelegramMessageContext } from "./bot-message-context.js"; + +export function createTelegramDispatchStatus(params: { + cfg: OpenClawConfig; + context: TelegramMessageContext; +}) { + const { context } = params; + const controller = + context.ctxPayload.InboundEventKind === "room_event" ? null : context.statusReactionController; + const timing = { ...DEFAULT_TIMING, ...params.cfg.messages?.statusReactions?.timing }; + + const clear = async () => { + if (!context.msg.message_id || !context.reactionApi) { + return; + } + await context.reactionApi(context.chatId, context.msg.message_id, []); + }; + + const finalize = async (final: { outcome: "done" | "error"; hasFinalResponse: boolean }) => { + if (!controller) { + return; + } + if (final.outcome === "done") { + await controller.setDone(); + if (context.removeAckAfterReply) { + await sleepWithAbort(timing.doneHoldMs); + await clear(); + } else { + await controller.restoreInitial(); + } + return; + } + await controller.setError(); + if (final.hasFinalResponse) { + if (context.removeAckAfterReply) { + await sleepWithAbort(timing.errorHoldMs); + await clear(); + } else { + await controller.restoreInitial(); + } + return; + } + if (context.removeAckAfterReply) { + await sleepWithAbort(timing.errorHoldMs); + } + await controller.restoreInitial(); + }; + + const removeAck = () => { + removeAckReactionAfterReply({ + removeAfterReply: context.removeAckAfterReply, + ackReactionPromise: context.ackReactionPromise, + ackReactionValue: context.ackReactionPromise ? "ack" : null, + remove: () => + ( + context.reactionApi?.(context.chatId, context.msg.message_id ?? 0, []) ?? + Promise.resolve() + ).then(() => {}), + onError: (err) => { + if (!context.msg.message_id) { + return; + } + logAckFailure({ + log: logVerbose, + channel: "telegram", + target: `${context.chatId}/${context.msg.message_id}`, + error: err, + }); + }, + }); + }; + + const finalizeInBackground = ( + final: { outcome: "done" | "error"; hasFinalResponse: boolean }, + label: string, + ) => { + void finalize(final).catch((err: unknown) => { + logVerbose(`telegram: status reaction ${label} failed: ${String(err)}`); + }); + }; + + return { controller, finalizeInBackground, removeAck }; +} diff --git a/extensions/telegram/src/bot-message-dispatch-turn.ts b/extensions/telegram/src/bot-message-dispatch-turn.ts new file mode 100644 index 000000000000..fab3240a8b31 --- /dev/null +++ b/extensions/telegram/src/bot-message-dispatch-turn.ts @@ -0,0 +1,250 @@ +import { logTypingFailure } from "openclaw/plugin-sdk/channel-feedback"; +import { runChannelInboundEvent } from "openclaw/plugin-sdk/channel-inbound"; +// Telegram plugin module wires inbound turn execution to Telegram delivery controllers. +import { + createChannelMessageReplyPipeline, + resolveChannelStreamingPreviewToolProgress, +} from "openclaw/plugin-sdk/channel-outbound"; +import type { OpenClawConfig, TelegramAccountConfig } from "openclaw/plugin-sdk/config-contracts"; +import { isFastModeAutoProgressPayload } from "openclaw/plugin-sdk/reply-payload"; +import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; +import type { TelegramBotDeps } from "./bot-deps.js"; +import type { TelegramMessageContext } from "./bot-message-context.js"; +import type { TelegramDeliveryController } from "./bot-message-dispatch-delivery.js"; +import type { TelegramDraftController } from "./bot-message-dispatch-draft.js"; +import type { TelegramReplyFenceController } from "./bot-message-dispatch-fence.js"; +import type { TelegramProgressController } from "./bot-message-dispatch-progress.js"; +import type { TelegramReplyDelivery } from "./bot-message-dispatch-reply.js"; +import type { TelegramDispatchTurnState } from "./bot-message-dispatch.types.js"; +import type { TelegramStreamMode } from "./bot/types.js"; +import { beginTelegramInboundEventDeliveryCorrelation } from "./inbound-event-delivery.js"; + +const TELEGRAM_MAX_CONSECUTIVE_TYPING_FAILURES = 5; + +export async function runTelegramDispatchTurn(params: { + cfg: OpenClawConfig; + context: TelegramMessageContext; + delivery: TelegramDeliveryController; + draft: TelegramDraftController; + fence: TelegramReplyFenceController; + progress: TelegramProgressController; + reply: TelegramReplyDelivery; + state: TelegramDispatchTurnState; + statusReactionController: TelegramMessageContext["statusReactionController"]; + streamMode: TelegramStreamMode; + telegramCfg: TelegramAccountConfig; + telegramDeps: TelegramBotDeps; +}) { + const { context } = params; + const isRoomEvent = context.ctxPayload.InboundEventKind === "room_event"; + const beginDeliveryCorrelation = () => + beginTelegramInboundEventDeliveryCorrelation( + context.ctxPayload.SessionKey, + { + outboundTo: context.historyKey || String(context.chatId), + outboundAccountId: context.route.accountId, + markInboundEventDelivered: params.delivery.markDelivered, + }, + { inboundEventKind: context.ctxPayload.InboundEventKind }, + ); + const endDeliveryCorrelation = beginDeliveryCorrelation(); + let splitReasoningOnNextStream = false; + + try { + const { onModelSelected, ...replyPipeline } = ( + params.telegramDeps.createChannelMessageReplyPipeline ?? createChannelMessageReplyPipeline + )({ + cfg: params.cfg, + agentId: context.route.agentId, + channel: "telegram", + accountId: context.route.accountId, + typing: { + start: context.sendTyping, + maxConsecutiveFailures: TELEGRAM_MAX_CONSECUTIVE_TYPING_FAILURES, + onStartError: (err) => { + logTypingFailure({ + log: logVerbose, + channel: "telegram", + target: String(context.chatId), + error: err, + }); + }, + }, + }); + const turnResult = await runChannelInboundEvent({ + channel: "telegram", + accountId: context.route.accountId, + raw: context, + adapter: { + ingest: () => ({ + id: context.ctxPayload.MessageSid ?? `${context.chatId}:${Date.now()}`, + timestamp: + typeof context.ctxPayload.Timestamp === "number" + ? context.ctxPayload.Timestamp + : undefined, + rawText: context.ctxPayload.RawBody ?? "", + textForAgent: context.ctxPayload.BodyForAgent, + textForCommands: context.ctxPayload.CommandBody, + raw: context, + }), + resolveTurn: () => ({ + channel: "telegram", + accountId: context.route.accountId, + routeSessionKey: context.route.sessionKey, + storePath: context.turn.storePath, + ctxPayload: context.ctxPayload, + recordInboundSession: context.turn.recordInboundSession, + record: context.turn.record, + runDispatch: () => + params.telegramDeps.dispatchReplyWithBufferedBlockDispatcher({ + ctx: context.ctxPayload, + cfg: params.cfg, + dispatcherOptions: { + ...replyPipeline, + beforeDeliver: async (payload) => payload, + onBeforeDeliverCancelled: params.reply.onBeforeDeliverCancelled, + deliver: params.reply.deliver, + onSkip: params.reply.onSkip, + onError: params.reply.onError, + }, + replyOptions: { + skillFilter: context.skillFilter, + disableBlockStreaming: params.draft.disableBlockStreaming, + abortSignal: params.fence.abortSignal, + onTurnAdopted: params.fence.adoptTurn, + sourceReplyDeliveryMode: isRoomEvent ? "message_tool_only" : undefined, + queuedDeliveryCorrelations: isRoomEvent + ? [{ begin: beginDeliveryCorrelation }] + : undefined, + queuedFollowupLifecycle: params.fence.queuedFollowupLifecycle, + suppressTyping: isRoomEvent, + onPartialReply: + params.draft.answerLane.stream || params.draft.reasoningLane.stream + ? (payload) => + params.draft.enqueueEvent(async () => { + await params.draft.ingestDraftLaneSegments(payload); + }) + : undefined, + onBlockReplyQueued: params.draft.answerLane.stream + ? (payload, blockContext) => + params.draft.enqueueEvent(async () => { + await params.draft.prepareQueuedAnswerBlock(payload, blockContext); + }) + : undefined, + onReasoningStream: params.draft.reasoningLane.stream + ? (payload) => + params.draft.enqueueEvent(async () => { + if (splitReasoningOnNextStream) { + params.draft.repositionLaneForNewMessage(params.draft.reasoningLane); + splitReasoningOnNextStream = false; + } + await params.draft.ingestDraftLaneSegments(payload, true); + }) + : params.draft.streamReasoningInProgressDraft + ? (payload) => + params.draft.enqueueEvent(async () => { + await params.progress.pushReasoningProgress(payload); + }) + : undefined, + onReasoningProgress: params.draft.answerLane.stream + ? (payload) => + params.draft.enqueueEvent(async () => { + await params.progress.pushThinkingTokenProgress(payload.progressTokens); + }) + : undefined, + onAssistantMessageStart: params.draft.answerLane.stream + ? () => + params.draft.enqueueEvent(async () => { + params.reply.reasoningStepState.resetForNextStep(); + params.progress.setFinalAnswerDelivered(false); + if (params.streamMode !== "progress") { + params.progress.reset(); + } + if (params.draft.answerLane.finalized) { + await params.draft.rotateLaneForNewMessage(params.draft.answerLane); + params.draft.setRotateWhenQueuedBlocksSettle(false); + } else if ( + params.draft.answerLane.hasStreamedMessage && + !params.draft.isAnswerToolProgressOnly() + ) { + params.draft.setRotateWhenQueuedBlocksSettle(true); + } + }) + : undefined, + onReasoningEnd: params.draft.reasoningLane.stream + ? () => + params.draft.enqueueEvent(async () => { + params.progress.closeReasoningBurst(); + splitReasoningOnNextStream = params.draft.reasoningLane.hasStreamedMessage; + params.progress.reset(); + }) + : () => params.progress.closeReasoningBurst(), + suppressDefaultToolProgressMessages: + !params.draft.streamDeliveryEnabled || Boolean(params.draft.answerLane.stream), + forceToolResultProgress: + params.streamMode === "progress" && + resolveChannelStreamingPreviewToolProgress(params.telegramCfg), + allowProgressCallbacksWhenSourceDeliverySuppressed: + !isRoomEvent && Boolean(params.draft.answerLane.stream), + onVerboseProgressVisibility: (isActive) => { + params.progress.setVerboseProgressActive(isActive); + }, + commentaryProgressEnabled: + params.streamMode === "progress" + ? params.progress.commentaryProgressEnabled + : undefined, + progressPreambleEnabled: params.progress.progressPreambleEnabled, + reasoningPayloadsEnabled: params.draft.durableReasoningPayloadsEnabled, + onToolStart: params.progress.handleToolStart, + onItemEvent: params.progress.handleItemEvent, + onPlanUpdate: params.progress.handlePlanUpdate, + onApprovalEvent: params.progress.handleApprovalEvent, + onToolResult: async (payload) => { + const text = payload.text?.trim(); + if (!text) { + return; + } + const updatedDraft = await params.progress.pushToolProgress(text, { + startImmediately: true, + }); + if ( + !updatedDraft && + isFastModeAutoProgressPayload(payload) && + !params.progress.canPushToolProgress() + ) { + await params.delivery.sendPayload(payload); + } + }, + onCommandOutput: params.progress.handleCommandOutput, + onPatchSummary: params.progress.handlePatchSummary, + onCompactionStart: params.statusReactionController + ? async () => { + await params.statusReactionController?.setCompacting(); + } + : undefined, + onCompactionEnd: params.statusReactionController + ? async () => { + params.statusReactionController?.cancelPending(); + await params.statusReactionController?.setThinking(); + } + : undefined, + onModelSelected, + }, + }), + }), + }, + }); + if (!turnResult.dispatched) { + return false; + } + params.state.queuedFinal = turnResult.dispatchResult.queuedFinal; + if ((turnResult.dispatchResult.counts?.final ?? 0) > 0) { + params.progress.markSawFinal(); + } + params.state.suppressSilentReplyFallback = + turnResult.dispatchResult.sourceReplyDeliveryMode === "message_tool_only"; + return true; + } finally { + endDeliveryCorrelation(); + } +} diff --git a/extensions/telegram/src/bot-message-dispatch.test.ts b/extensions/telegram/src/bot-message-dispatch.test.ts index 7e5285def7de..e5e656a0cd7c 100644 --- a/extensions/telegram/src/bot-message-dispatch.test.ts +++ b/extensions/telegram/src/bot-message-dispatch.test.ts @@ -563,13 +563,14 @@ describe("dispatchTelegramMessage draft streaming", () => { onTurnDeferred?: Parameters[0]["onTurnDeferred"]; onTurnAbandoned?: Parameters[0]["onTurnAbandoned"]; turnAbortSignal?: Parameters[0]["turnAbortSignal"]; + runtime?: Parameters[0]["runtime"]; }) { const bot = params.bot ?? createBot(); return await dispatchTelegramMessage({ context: params.context, bot, cfg: params.cfg ?? {}, - runtime: createRuntime(), + runtime: params.runtime ?? createRuntime(), replyToMode: params.replyToMode ?? "first", streamMode: params.streamMode ?? "partial", textLimit: params.textLimit ?? 4096, @@ -7056,6 +7057,68 @@ describe("dispatchTelegramMessage draft streaming", () => { await sidePromise; }); + it("does not acquire reply-fence ownership when draft initialization fails", async () => { + const sessionKey = "agent:main:telegram:direct:draft-init-failure"; + createTelegramDraftStream.mockImplementationOnce(() => { + throw new Error("draft initialization failed"); + }); + + await expect( + dispatchWithContext({ + context: createContext({ + ctxPayload: { + SessionKey: sessionKey, + ChatType: "direct", + } as TelegramMessageContext["ctxPayload"], + }), + }), + ).rejects.toThrow("draft initialization failed"); + + const { supersedeTelegramReplyFence } = await import("./telegram-reply-fence.js"); + expect(supersedeTelegramReplyFence(sessionKey)).toBe(false); + }); + + it("cleans delivery correlation when reply-pipeline initialization fails", async () => { + const sessionKey = "agent:main:telegram:direct:pipeline-init-failure"; + const statusReactionController = createStatusReactionController(); + const reactionApi = vi.fn(async () => undefined); + const runtime = createRuntime(); + runtime.error = vi.fn(() => { + notifyTelegramInboundEventOutboundSuccess({ + sessionKey, + to: "123", + accountId: "default", + }); + }); + createChannelMessageReplyPipeline.mockImplementationOnce(() => { + throw new Error("pipeline initialization failed"); + }); + + await dispatchWithContext({ + context: createContext({ + ctxPayload: { + SessionKey: sessionKey, + ChatType: "direct", + } as TelegramMessageContext["ctxPayload"], + statusReactionController: statusReactionController as never, + reactionApi, + removeAckAfterReply: true, + }), + cfg: { + messages: { + statusReactions: { + timing: { errorHoldMs: 0 }, + }, + }, + }, + runtime, + suppressFailureFallback: true, + }); + + await vi.waitFor(() => expect(statusReactionController.restoreInitial).toHaveBeenCalled()); + expect(reactionApi).not.toHaveBeenCalled(); + }); + it("releases fence abort authority at turn adoption", async () => { const historyKey = "telegram:group:-100123"; const groupHistories = new Map([[historyKey, []]]); diff --git a/extensions/telegram/src/bot-message-dispatch.ts b/extensions/telegram/src/bot-message-dispatch.ts index 716c8d639cb5..4d3f212d2f49 100644 --- a/extensions/telegram/src/bot-message-dispatch.ts +++ b/extensions/telegram/src/bot-message-dispatch.ts @@ -1,64 +1,22 @@ -// Telegram plugin module implements bot message dispatch behavior. -import path from "node:path"; -import type { Bot } from "grammy"; -import type { Message } from "grammy/types"; +// Telegram plugin module coordinates one inbound message dispatch lifecycle. import { - DEFAULT_TIMING, - logAckFailure, - logTypingFailure, - removeAckReactionAfterReply, -} from "openclaw/plugin-sdk/channel-feedback"; -import { runChannelInboundEvent } from "openclaw/plugin-sdk/channel-inbound"; -import { CURRENT_MESSAGE_MARKER } from "openclaw/plugin-sdk/channel-mention-gating"; -import { - createChannelMessageReplyPipeline, createOutboundPayloadPlan, - deriveDurableFinalDeliveryRequirements, projectOutboundPayloadPlanForDelivery, } from "openclaw/plugin-sdk/channel-outbound"; -import { - buildChannelProgressDraftLine, - buildChannelProgressDraftLineForEntry, - type ChannelProgressDraftLine, - createChannelProgressDraftCompositor, - isChannelProgressDraftWorkToolName, - resolveChannelStreamingBlockEnabled, - resolveChannelStreamingPreviewToolProgress, - resolveTranscriptBackedChannelFinalText, -} from "openclaw/plugin-sdk/channel-outbound"; -import type { - OpenClawConfig, - ReplyToMode, - TelegramAccountConfig, -} from "openclaw/plugin-sdk/config-contracts"; -import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; -import { normalizeMessagePresentation } from "openclaw/plugin-sdk/interactive-runtime"; -import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime"; -import { createChannelHistoryWindow } from "openclaw/plugin-sdk/reply-history"; -import { - isFastModeAutoProgressPayload, - isReplyPayloadNonTerminalToolErrorWarning, - resolveSendableOutboundReplyParts, -} from "openclaw/plugin-sdk/reply-payload"; -import type { ReplyPayload } from "openclaw/plugin-sdk/reply-payload"; -import { isSingleUseReplyToMode } from "openclaw/plugin-sdk/reply-reference"; -import type { BlockReplyContext } from "openclaw/plugin-sdk/reply-runtime"; -import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; -import { - createSubsystemLogger, - danger, - logVerbose, - sleepWithAbort, -} from "openclaw/plugin-sdk/runtime-env"; -import { - appendAssistantMirrorMessageByIdentity, - readLatestAssistantTextByIdentity, -} from "openclaw/plugin-sdk/session-transcript-runtime"; +import { createSubsystemLogger, danger, logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; -import { resolveTelegramConfigReasoningDefault } from "./agent-config.js"; -import { withTelegramApiErrorLogging } from "./api-logging.js"; -import type { TelegramBotDeps } from "./bot-deps.js"; -import type { TelegramMessageContext } from "./bot-message-context.js"; +import { resolveDispatchTelegramContext } from "./bot-message-dispatch-context.js"; +import { createTelegramDeliveryController } from "./bot-message-dispatch-delivery.js"; +import { createTelegramDraftController } from "./bot-message-dispatch-draft.js"; +import { createTelegramReplyFenceController } from "./bot-message-dispatch-fence.js"; +import { createTelegramProgressController } from "./bot-message-dispatch-progress.js"; +import { createTelegramReplyDelivery } from "./bot-message-dispatch-reply.js"; +import { + createFreshTelegramSessionEntryLoader, + resolveTelegramReasoningLevel, +} from "./bot-message-dispatch-session.js"; +import { createTelegramDispatchStatus } from "./bot-message-dispatch-status.js"; +import { runTelegramDispatchTurn } from "./bot-message-dispatch-turn.js"; import { findModelInCatalog, loadModelCatalog, @@ -66,153 +24,38 @@ import { resolveAgentDir, resolveDefaultModelForAgent, } from "./bot-message-dispatch.agent.runtime.js"; -import { deduplicateBlockSentMedia } from "./bot-message-dispatch.media-dedup.js"; import { generateTopicLabel, getAgentScopedMediaLocalRoots, - getSessionEntry, resolveAutoTopicLabelConfig, resolveChunkMode, resolveMarkdownTableMode, - type SessionEntry, } from "./bot-message-dispatch.runtime.js"; -import type { TelegramBotOptions } from "./bot.types.js"; -import { deliverReplies, emitInternalMessageSentHook } from "./bot/delivery.js"; -import { - buildTelegramGroupPeerId, - buildTelegramGroupFrom, - buildTelegramInboundOriginTarget, - buildTypingThreadParams, - getTelegramTextParts, - resolveTelegramReplyId, - type TelegramThreadSpec, -} from "./bot/helpers.js"; +import type { + DispatchTelegramMessageParams, + TelegramDispatchResult, + TelegramDispatchTurnState, +} from "./bot-message-dispatch.types.js"; +import { getTelegramTextParts, resolveTelegramReplyId } from "./bot/helpers.js"; import { addTelegramNativeQuoteCandidate, buildTelegramNativeQuoteCandidate, type TelegramNativeQuoteCandidateByMessageId, } from "./bot/native-quote.js"; -import type { TelegramStreamMode } from "./bot/types.js"; -import { resolveTelegramInlineButtons, type TelegramInlineButtons } from "./button-types.js"; -import { resolveTelegramDraftStreamingChunking } from "./draft-chunking.js"; -import { createTelegramDraftStream, type TelegramDraftPreview } from "./draft-stream.js"; -import { - buildTelegramErrorScopeKey, - isSilentErrorPolicy, - resolveTelegramErrorPolicy, - shouldSuppressTelegramError, -} from "./error-policy.js"; -import { shouldSuppressLocalTelegramExecApprovalPrompt } from "./exec-approvals.js"; -import { renderTelegramHtmlText } from "./format.js"; -import { - isTelegramHistoryEntryAfterAmbientWatermark, - mergeTelegramGroupHistoryPromptContext, - retainTelegramGroupHistoryPromptContext, - selectTelegramGroupHistoryAfterLastSelf, -} from "./group-history-window.js"; -import { beginTelegramInboundEventDeliveryCorrelation } from "./inbound-event-delivery.js"; -import { canonicalizeTelegramPresentationPayload } from "./interactive-fallback.js"; -import { - createLaneDeliveryStateTracker, - createLaneTextDeliverer, - type DraftLaneState, - type LaneDeliveryResult, - type LaneName, -} from "./lane-delivery.js"; -import { TELEGRAM_TEXT_CHUNK_LIMIT } from "./outbound-adapter.js"; -import { recordOutboundMessageForPromptContext } from "./outbound-message-context.js"; -import { - formatTelegramProgressLine, - renderTelegramProgressDraftPreview, -} from "./progress-draft-preview.js"; -import { - createTelegramProgressSummaryTracker, - formatTelegramProgressSummaryLine, -} from "./progress-summary.js"; -import { - createTelegramPromptContextProjectionSequence, - resolveTelegramPromptContextDeliverySignature, - withTelegramPromptContextSource, - type TelegramPromptContextProjection, - type TelegramPromptContextProjectionSequence, - type TelegramPromptContextSource, -} from "./prompt-context-projection.js"; -import { - createTelegramReasoningStepState, - splitTelegramReasoningText, -} from "./reasoning-lane-coordinator.js"; -import { buildTelegramRichMarkdown, TELEGRAM_RICH_TEXT_LIMIT } from "./rich-message.js"; -import { editMessageTelegram } from "./send.js"; -import { getTelegramSequentialKey } from "./sequential-key.js"; import { cacheSticker, describeStickerImage } from "./sticker-cache.js"; -import { - beginTelegramReplyFence, - buildTelegramNonInterruptingReplyFenceKey, - buildTelegramReplyFenceLaneKey, - endTelegramReplyFence, - isTelegramReplyFenceSuperseded, - releaseTelegramReplyFenceAbortController, - resolveTelegramReplyFenceKey, - shouldSupersedeTelegramReplyFence, - supersedeTelegramReplyFence, -} from "./telegram-reply-fence.js"; -// Telegram sendChatAction can fail transiently; keep the tolerance scoped to this transport. -const TELEGRAM_MAX_CONSECUTIVE_TYPING_FAILURES = 5; const EMPTY_RESPONSE_FALLBACK = "No response generated. Please try again."; const silentReplyDispatchLogger = createSubsystemLogger("telegram/silent-reply-dispatch"); -/** Minimum chars before sending first streaming message (improves push notification UX) */ -const DRAFT_MIN_INITIAL_CHARS = 30; - -type DraftPartialTextUpdate = { - text: string; - delta?: string; - replace?: true; - isReasoningSnapshot?: boolean; -}; - -function resolveDraftPartialText( - previous: string, - update: DraftPartialTextUpdate, -): string | undefined { - const nextText = - update.replace || update.isReasoningSnapshot || update.delta === undefined - ? update.text - : `${previous}${update.delta}`; - if (nextText === previous) { - return undefined; - } - return nextText; -} - -function resolvePayloadTelegramInlineButtons( - payload: ReplyPayload, -): TelegramInlineButtons | undefined { - const telegramData = payload.channelData?.telegram as - | { buttons?: TelegramInlineButtons } - | undefined; - const presentation = normalizeMessagePresentation(payload.presentation); - return resolveTelegramInlineButtons({ - buttons: telegramData?.buttons, - presentation, - interactive: payload.interactive, - }); -} - -function hasExecApprovalPayload(payload: ReplyPayload): boolean { - return payload.channelData?.execApproval !== undefined; -} - -async function resolveStickerVisionSupport(cfg: OpenClawConfig, agentId: string) { +async function resolveStickerVisionSupport( + cfg: DispatchTelegramMessageParams["cfg"], + agentId: string, +) { try { const catalog = await loadModelCatalog({ config: cfg }); const defaultModel = resolveDefaultModelForAgent({ cfg, agentId }); const entry = findModelInCatalog(catalog, defaultModel.provider, defaultModel.model); - if (!entry) { - return false; - } - return modelSupportsVision(entry); + return entry ? modelSupportsVision(entry) : false; } catch { return false; } @@ -234,448 +77,188 @@ function includeStickerDescription(body: string | undefined, formattedDescriptio return `${formattedDescription}\n${body}`; } -type DispatchTelegramMessageParams = { - context: TelegramMessageContext; - bot: Bot; - cfg: OpenClawConfig; - runtime: RuntimeEnv; - replyToMode: ReplyToMode; - streamMode: TelegramStreamMode; - textLimit: number; - telegramCfg: TelegramAccountConfig; - telegramDeps?: TelegramBotDeps; - opts: Pick; - retryDispatchErrors?: boolean; - suppressFailureFallback?: boolean; - /** Fires after recovery-relevant session/run state is durably persisted. */ - onTurnAdopted?: () => void | Promise; - /** Marks a queued follow-up whose adoption will happen at reply-lane admission. */ - onTurnDeferred?: () => void; - /** Releases a deferred turn that completed without ever owning the reply lane. */ - onTurnAbandoned?: () => void; - /** Cancels queued/model work when ingress ownership fails before adoption. */ - turnAbortSignal?: AbortSignal; -}; - -type TelegramDispatchResult = { kind: "completed" } | { kind: "failed-retryable"; error: unknown }; - -type TelegramReasoningLevel = "off" | "on" | "stream"; - -type TelegramTranscriptMirrorPayload = { text?: string; mediaUrls?: string[] }; -type CurrentTurnTranscriptFinal = { messageId?: string; text: string }; -type TelegramScopedTranscriptSession = { sessionId: string; storePath: string }; -type FreshTelegramSessionEntryLoader = (( - agentId: string, - sessionKey: string, -) => { - storePath: string; - entry?: SessionEntry; -}) & { - clear: () => void; -}; - -function createFreshTelegramSessionEntryLoader(params: { - cfg: OpenClawConfig; - telegramDeps: TelegramBotDeps; -}): FreshTelegramSessionEntryLoader { - const entriesByPathAndKey = new Map(); - const load = ((agentId: string, sessionKey: string) => { - const storePath = params.telegramDeps.resolveStorePath(params.cfg.session?.store, { agentId }); - const cacheKey = `${storePath}\0${sessionKey}`; - if (entriesByPathAndKey.has(cacheKey)) { - return { storePath, entry: entriesByPathAndKey.get(cacheKey) }; - } - const entry = (params.telegramDeps.getSessionEntry ?? getSessionEntry)({ - storePath, - sessionKey, - readConsistency: "latest", - }); - entriesByPathAndKey.set(cacheKey, entry); - return { storePath, entry }; - }) as FreshTelegramSessionEntryLoader; - load.clear = () => entriesByPathAndKey.clear(); - return load; -} - -function resolveTelegramReasoningLevel(params: { - cfg: OpenClawConfig; - sessionKey?: string; - agentId: string; - loadFreshSessionEntry: FreshTelegramSessionEntryLoader; -}): TelegramReasoningLevel { - const { cfg, sessionKey, agentId } = params; - const configDefault = resolveTelegramConfigReasoningDefault(cfg, agentId); - if (!sessionKey) { - return configDefault; - } - try { - const { entry } = params.loadFreshSessionEntry(agentId, sessionKey); - const level = entry?.reasoningLevel; - if (level === "on" || level === "stream" || level === "off") { - return level; - } - } catch { - return "off"; - } - return configDefault; -} - -function resolveTelegramMirroredTranscriptText( - payload: TelegramTranscriptMirrorPayload, -): string | null { - const mediaUrls = payload.mediaUrls?.filter((url) => url.trim()) ?? []; - if (mediaUrls.length > 0) { - return mediaUrls - .map((url) => { - const pathname = url.split("#")[0]?.split("?")[0] ?? url; - const base = path.basename(pathname); - return base && base !== "." && base !== "/" ? base : "media"; - }) - .join(", "); - } - - const text = payload.text?.trim(); - return text ? text : null; -} - -function resolveTelegramScopedTranscriptSession(params: { - agentId: string; - loadFreshSessionEntry: FreshTelegramSessionEntryLoader; - sessionKey: string; -}): TelegramScopedTranscriptSession | undefined { - const { entry, storePath } = params.loadFreshSessionEntry(params.agentId, params.sessionKey); - const sessionId = entry?.sessionId?.trim(); - return sessionId ? { sessionId, storePath } : undefined; -} - -async function mirrorTelegramAssistantReplyToTranscript(params: { - cfg: OpenClawConfig; - idempotencyKey: string; - loadFreshSessionEntry: FreshTelegramSessionEntryLoader; - route: TelegramMessageContext["route"]; - sessionKey: string; - payload: TelegramTranscriptMirrorPayload; +function resolveTelegramQuoteContext(params: { + context: ReturnType; + replyToMode: DispatchTelegramMessageParams["replyToMode"]; }) { - const text = resolveTelegramMirroredTranscriptText(params.payload); - if (!text) { - return; - } - const session = resolveTelegramScopedTranscriptSession({ - agentId: params.route.agentId, - loadFreshSessionEntry: params.loadFreshSessionEntry, - sessionKey: params.sessionKey, - }); - if (!session) { - return; - } - const appended = await appendAssistantMirrorMessageByIdentity({ - agentId: params.route.agentId, - config: params.cfg, - idempotencyKey: params.idempotencyKey, - deliveryMirror: { - kind: "channel-final", - sourceMessageId: params.idempotencyKey, - }, - sessionId: session.sessionId, - sessionKey: params.sessionKey, - storePath: session.storePath, - text, - }); - if (!appended.ok && appended.code !== "session-rebound") { - logVerbose(`telegram transcript mirror append failed: ${appended.reason}`); - } -} - -const TELEGRAM_GENERAL_TOPIC_ID = 1; - -function buildTelegramThinkingProgressLine(progressTokens: number): ChannelProgressDraftLine { - const label = `Thinking… (~${Math.round(progressTokens)} tokens)`; - const text = `🧠 ${label}`; - return { - id: "reasoning:token-progress", - kind: "item", - icon: "🧠", - label, - text, - prefix: false, - }; -} - -function normalizeTelegramThreadId(value: unknown): number | undefined { - return parseStrictPositiveInteger(value); -} - -function resolveTelegramForumThreadScopeFromSessionKey( - sessionKey: unknown, -): { chatId: string; threadId: number } | undefined { - if (typeof sessionKey !== "string") { - return undefined; - } - const match = /:telegram:group:(-?\d+):topic:(\d+)(?::|$)/.exec(sessionKey); - const threadId = normalizeTelegramThreadId(match?.[2]); - if (!match?.[1] || threadId == null) { - return undefined; - } - return { chatId: match[1], threadId }; -} - -function resolveDispatchTelegramThreadSpec(params: { - chatId: TelegramMessageContext["chatId"]; - ctxPayload: TelegramMessageContext["ctxPayload"]; - threadSpec: TelegramThreadSpec; -}): TelegramThreadSpec { - if ( - params.threadSpec.scope !== "forum" || - (params.threadSpec.id != null && params.threadSpec.id !== TELEGRAM_GENERAL_TOPIC_ID) - ) { - return params.threadSpec; - } - const scopedThread = resolveTelegramForumThreadScopeFromSessionKey(params.ctxPayload.SessionKey); - const scopedThreadId = - scopedThread?.chatId === String(params.chatId) ? scopedThread.threadId : undefined; - const payloadThreadId = - normalizeTelegramThreadId(params.ctxPayload.MessageThreadId) ?? - normalizeTelegramThreadId(params.ctxPayload.TransportThreadId); - // Missing forum IDs are normalized to General; topic-scoped turn facts are more specific. - const recoveredThreadId = scopedThreadId ?? payloadThreadId; - return recoveredThreadId == null || recoveredThreadId === params.threadSpec.id - ? params.threadSpec - : { ...params.threadSpec, id: recoveredThreadId }; -} - -function normalizeDispatchTelegramThreadPayload(params: { - context: TelegramMessageContext; - threadSpec: TelegramThreadSpec; -}): TelegramMessageContext { - if (params.threadSpec.scope !== "forum" || params.threadSpec.id == null) { - return params.context; - } - const messageThreadId = normalizeTelegramThreadId(params.context.ctxPayload.MessageThreadId); - const transportThreadId = normalizeTelegramThreadId(params.context.ctxPayload.TransportThreadId); - if (messageThreadId === params.threadSpec.id && transportThreadId === params.threadSpec.id) { - return params.context; - } - return { - ...params.context, - ctxPayload: { - ...params.context.ctxPayload, - MessageThreadId: params.threadSpec.id, - TransportThreadId: params.threadSpec.id, - }, - }; -} - -function extractCurrentTelegramBody(body: string | undefined): string { - if (!body) { - return ""; - } - const markerIndex = body.lastIndexOf(CURRENT_MESSAGE_MARKER); - if (markerIndex === -1) { - return body; - } - return body.slice(markerIndex + CURRENT_MESSAGE_MARKER.length).trimStart(); -} - -function buildRecoveredTelegramChatActionSender(params: { - context: TelegramMessageContext; - threadId?: number; - action: "typing" | "record_voice"; -}): () => Promise { - return async () => { - try { - await withTelegramApiErrorLogging({ - operation: "sendChatAction", - fn: () => - params.context.sendChatActionHandler.sendChatAction( - params.context.chatId, - params.action, - buildTypingThreadParams(params.threadId), - ), + const { context, replyToMode } = params; + const rawReplyQuoteText = + context.ctxPayload.ReplyToIsQuote && typeof context.ctxPayload.ReplyToQuoteText === "string" + ? context.ctxPayload.ReplyToQuoteText + : undefined; + const replyQuoteText = context.ctxPayload.ReplyToIsQuote + ? rawReplyQuoteText?.trim() + ? rawReplyQuoteText + : context.ctxPayload.ReplyToBody?.trim() || undefined + : undefined; + const replyQuoteMessageId = + replyQuoteText && !context.ctxPayload.ReplyToIsExternal + ? resolveTelegramReplyId(context.ctxPayload.ReplyToId) + : undefined; + const replyQuoteTargetsBotMessage = context.msg.reply_to_message?.from?.is_bot === true; + const replyQuoteByMessageId: TelegramNativeQuoteCandidateByMessageId = {}; + if (replyToMode !== "off") { + if (replyQuoteText && replyQuoteMessageId != null) { + addTelegramNativeQuoteCandidate(replyQuoteByMessageId, replyQuoteMessageId, { + text: replyQuoteText, + ...(typeof context.ctxPayload.ReplyToQuotePosition === "number" + ? { position: context.ctxPayload.ReplyToQuotePosition } + : {}), + ...(Array.isArray(context.ctxPayload.ReplyToQuoteEntities) + ? { entities: context.ctxPayload.ReplyToQuoteEntities } + : {}), }); - } catch (err) { - if (params.action !== "record_voice") { - throw err; - } - logVerbose( - `telegram record_voice cue failed for chat ${params.context.chatId}: ${String(err)}`, + } + addTelegramNativeQuoteCandidate( + replyQuoteByMessageId, + context.ctxPayload.MessageSid ?? context.msg.message_id, + buildTelegramNativeQuoteCandidate(getTelegramTextParts(context.msg)), + ); + if ( + !context.ctxPayload.ReplyToIsExternal && + typeof context.ctxPayload.ReplyToQuoteSourceText === "string" + ) { + addTelegramNativeQuoteCandidate( + replyQuoteByMessageId, + context.ctxPayload.ReplyToId, + buildTelegramNativeQuoteCandidate({ + text: context.ctxPayload.ReplyToQuoteSourceText, + entities: Array.isArray(context.ctxPayload.ReplyToQuoteSourceEntities) + ? context.ctxPayload.ReplyToQuoteSourceEntities + : undefined, + }), ); } - }; -} - -function migrateRecoveredTelegramGroupHistory(params: { - context: TelegramMessageContext; - recoveredHistoryKey?: string; -}) { - const originalHistoryKey = params.context.historyKey; - const recoveredHistoryKey = params.recoveredHistoryKey; - if ( - !params.context.isGroup || - !originalHistoryKey || - !recoveredHistoryKey || - originalHistoryKey === recoveredHistoryKey || - params.context.historyLimit <= 0 - ) { - return; } - // Topic recovery mutates the raw in-memory buffer before any prompt is built; - // prompt readers apply the ambient transcript watermark after recovery. - const originalEntries = params.context.groupHistories.get(originalHistoryKey); - if (!originalEntries?.length) { - return; - } - const messageId = params.context.ctxPayload.MessageSid; - const rawBody = params.context.ctxPayload.RawBody; - const entryIndex = originalEntries.findLastIndex((entry) => { - if (messageId && entry.messageId === messageId) { - return true; - } - return !messageId && typeof rawBody === "string" && entry.body === rawBody; - }); - if (entryIndex === -1) { - return; - } - const [entry] = originalEntries.splice(entryIndex, 1); - if (!entry) { - return; - } - createChannelHistoryWindow({ - historyMap: params.context.groupHistories, - }).record({ - historyKey: recoveredHistoryKey, - limit: params.context.historyLimit, - entry, - }); -} - -function resolveDispatchTelegramContext(params: { - context: TelegramMessageContext; -}): TelegramMessageContext { - const threadSpec = resolveDispatchTelegramThreadSpec({ - chatId: params.context.chatId, - ctxPayload: params.context.ctxPayload, - threadSpec: params.context.threadSpec, - }); - if (threadSpec === params.context.threadSpec || threadSpec.scope !== "forum") { - return normalizeDispatchTelegramThreadPayload({ context: params.context, threadSpec }); - } - const recoveredRoutingTarget = buildTelegramInboundOriginTarget( - params.context.chatId, - threadSpec, - ); - const recoveredFrom = params.context.isGroup - ? buildTelegramGroupFrom(params.context.chatId, threadSpec.id) - : params.context.ctxPayload.From; - const recoveredUpdateLastRoute = - params.context.turn.record.updateLastRoute && threadSpec.id != null - ? { - ...params.context.turn.record.updateLastRoute, - to: `telegram:${params.context.chatId}:topic:${threadSpec.id}`, - threadId: String(threadSpec.id), - } - : params.context.turn.record.updateLastRoute; - const recoveredHistoryKey = params.context.isGroup - ? buildTelegramGroupPeerId(params.context.chatId, threadSpec.id) - : params.context.historyKey; - const recoveredHistoryEntries = - recoveredHistoryKey && params.context.historyLimit > 0 - ? (params.context.groupHistories.get(recoveredHistoryKey) ?? []) - .filter((entry) => - isTelegramHistoryEntryAfterAmbientWatermark( - entry, - params.context.ctxPayload.AmbientTranscriptPreviousMessageId - ? { - messageId: params.context.ctxPayload.AmbientTranscriptPreviousMessageId, - ...(params.context.ctxPayload.AmbientTranscriptPreviousTimestampMs !== undefined - ? { - timestampMs: - params.context.ctxPayload.AmbientTranscriptPreviousTimestampMs, - } - : {}), - } - : undefined, - ), - ) - .slice(-params.context.historyLimit) - : []; - const recoveredWatermarkedHistoryEntries = selectTelegramGroupHistoryAfterLastSelf( - recoveredHistoryEntries, - ).slice(-params.context.historyLimit); - const recoveredPromptHistoryEntries = - params.context.isGroup && recoveredHistoryKey && params.context.historyLimit > 0 - ? params.context.ctxPayload.InboundEventKind === "room_event" - ? recoveredHistoryEntries - : recoveredWatermarkedHistoryEntries - : []; - const recoveredInboundHistory = - params.context.isGroup && recoveredHistoryKey && params.context.historyLimit > 0 - ? recoveredPromptHistoryEntries.length > 0 - ? recoveredPromptHistoryEntries - : undefined - : params.context.ctxPayload.InboundHistory; - const recoveredBodyForAgent = extractCurrentTelegramBody( - params.context.ctxPayload.BodyForAgent ?? params.context.ctxPayload.Body, - ); - const recoveredPromptContextBase = retainTelegramGroupHistoryPromptContext({ - promptContext: params.context.ctxPayload.UntrustedStructuredContext ?? [], - entries: recoveredPromptHistoryEntries, - }); - const recoveredPromptContext = - recoveredPromptHistoryEntries.length > 0 - ? mergeTelegramGroupHistoryPromptContext({ - promptContext: recoveredPromptContextBase ?? [], - entries: recoveredPromptHistoryEntries, - }) - : recoveredPromptContextBase?.length - ? recoveredPromptContextBase - : undefined; - const recoveredSendTyping = buildRecoveredTelegramChatActionSender({ - context: params.context, - threadId: threadSpec.id, - action: "typing", - }); - const recoveredSendRecordVoice = buildRecoveredTelegramChatActionSender({ - context: params.context, - threadId: threadSpec.id, - action: "record_voice", - }); - migrateRecoveredTelegramGroupHistory({ - context: params.context, - recoveredHistoryKey, - }); + const draftReplyToMessageId = + replyToMode !== "off" && typeof context.msg.message_id === "number" + ? replyQuoteTargetsBotMessage + ? context.msg.message_id + : (replyQuoteMessageId ?? context.msg.message_id) + : undefined; return { - ...params.context, - historyKey: recoveredHistoryKey, - threadSpec, - resolvedThreadId: threadSpec.id, - replyThreadId: threadSpec.id, - sendTyping: recoveredSendTyping, - sendRecordVoice: recoveredSendRecordVoice, - turn: { - ...params.context.turn, - record: { - ...params.context.turn.record, - updateLastRoute: recoveredUpdateLastRoute, - }, - }, - ctxPayload: - threadSpec.id == null - ? params.context.ctxPayload - : { - ...params.context.ctxPayload, - Body: recoveredBodyForAgent, - BodyForAgent: recoveredBodyForAgent, - From: recoveredFrom, - InboundHistory: recoveredInboundHistory, - MessageThreadId: threadSpec.id, - OriginatingTo: recoveredRoutingTarget, - To: recoveredRoutingTarget, - TransportThreadId: threadSpec.id, - UntrustedStructuredContext: recoveredPromptContext, - }, + draftReplyToMessageId, + hasTelegramQuoteReply: replyToMode !== "off" && replyQuoteText != null, + replyQuoteByMessageId, + replyQuoteEntities: Array.isArray(context.ctxPayload.ReplyToQuoteEntities) + ? context.ctxPayload.ReplyToQuoteEntities + : undefined, + replyQuoteMessageId, + replyQuotePosition: + typeof context.ctxPayload.ReplyToQuotePosition === "number" + ? context.ctxPayload.ReplyToQuotePosition + : undefined, + replyQuoteText, }; } +async function prepareTelegramSticker(params: { + cfg: DispatchTelegramMessageParams["cfg"]; + context: ReturnType; +}) { + const { context } = params; + const sticker = context.ctxPayload.Sticker; + if (!sticker?.fileId || !sticker.fileUniqueId || !context.ctxPayload.MediaPath) { + return; + } + const agentDir = resolveAgentDir(params.cfg, context.route.agentId); + const stickerSupportsVision = await resolveStickerVisionSupport( + params.cfg, + context.route.agentId, + ); + const description = + sticker.cachedDescription || + (await describeStickerImage({ + imagePath: context.ctxPayload.MediaPath, + cfg: params.cfg, + agentDir, + agentId: context.route.agentId, + })); + if (!description) { + return; + } + const stickerContext = [sticker.emoji, sticker.setName ? `from "${sticker.setName}"` : null] + .filter(Boolean) + .join(" "); + const formattedDescription = `[Sticker${stickerContext ? ` ${stickerContext}` : ""}] ${description}`; + sticker.cachedDescription = description; + if (!stickerSupportsVision) { + context.ctxPayload.Body = includeStickerDescription( + context.ctxPayload.Body, + formattedDescription, + ); + context.ctxPayload.BodyForAgent = includeStickerDescription( + context.ctxPayload.BodyForAgent, + formattedDescription, + ); + context.ctxPayload.SkipStickerMediaUnderstanding = true; + } + cacheSticker({ + fileId: sticker.fileId, + fileUniqueId: sticker.fileUniqueId, + emoji: sticker.emoji, + setName: sticker.setName, + description, + cachedAt: new Date().toISOString(), + receivedFrom: context.ctxPayload.From, + }); + logVerbose(`telegram: cached sticker description for ${sticker.fileUniqueId}`); +} + +function scheduleDmTopicLabel(params: { + bot: DispatchTelegramMessageParams["bot"]; + cfg: DispatchTelegramMessageParams["cfg"]; + context: ReturnType; + isFirstTurnInSession: boolean; + telegramCfg: DispatchTelegramMessageParams["telegramCfg"]; +}) { + const { context } = params; + const isDmTopic = + !context.isGroup && context.threadSpec.scope === "dm" && context.threadSpec.id != null; + if (!isDmTopic || !params.isFirstTurnInSession) { + return; + } + const userMessage = truncateUtf16Safe( + context.ctxPayload.RawBody ?? context.ctxPayload.Body ?? "", + 500, + ); + if (!userMessage.trim()) { + return; + } + const directAutoTopicLabel = + context.groupConfig && "autoTopicLabel" in context.groupConfig + ? context.groupConfig.autoTopicLabel + : undefined; + const autoTopicConfig = resolveAutoTopicLabelConfig( + directAutoTopicLabel, + params.telegramCfg.autoTopicLabel, + ); + if (!autoTopicConfig) { + return; + } + const topicThreadId = context.threadSpec.id!; + void (async () => { + try { + const label = await generateTopicLabel({ + userMessage, + prompt: autoTopicConfig.prompt, + cfg: params.cfg, + agentId: context.route.agentId, + agentDir: resolveAgentDir(params.cfg, context.route.agentId), + }); + if (!label) { + logVerbose("auto-topic-label: LLM returned empty label"); + return; + } + logVerbose(`auto-topic-label: generated label (len=${label.length})`); + await params.bot.api.editForumTopic(context.chatId, topicThreadId, { name: label }); + logVerbose(`auto-topic-label: renamed topic ${context.chatId}/${topicThreadId}`); + } catch (err) { + logVerbose(`auto-topic-label: failed: ${String(err)}`); + } + })(); +} + export const dispatchTelegramMessage = async ({ context, bot, @@ -699,2280 +282,223 @@ export const dispatchTelegramMessage = async ({ const telegramDeps = injectedTelegramDeps ?? (await import("./bot-deps.js")).defaultTelegramBotDeps; const loadFreshSessionEntry = createFreshTelegramSessionEntryLoader({ cfg, telegramDeps }); - const { - ctxPayload, - msg, - chatId, - isGroup, - groupConfig, - topicConfig, - threadSpec, - historyKey, - route, - skillFilter, - sendTyping, - sendRecordVoice, - ackReactionPromise, - reactionApi, - removeAckAfterReply, - statusReactionController: rawStatusReactionController, - } = dispatchContext; - const isRoomEvent = ctxPayload.InboundEventKind === "room_event"; - const statusReactionController = isRoomEvent ? null : rawStatusReactionController; - const statusReactionTiming = { - ...DEFAULT_TIMING, - ...cfg.messages?.statusReactions?.timing, - }; - const clearTelegramStatusReaction = async () => { - if (!msg.message_id || !reactionApi) { - return; - } - await reactionApi(chatId, msg.message_id, []); - }; - const finalizeTelegramStatusReaction = async (params: { - outcome: "done" | "error"; - hasFinalResponse: boolean; - }) => { - if (!statusReactionController) { - return; - } - if (params.outcome === "done") { - await statusReactionController.setDone(); - if (removeAckAfterReply) { - await sleepWithAbort(statusReactionTiming.doneHoldMs); - await clearTelegramStatusReaction(); - } else { - await statusReactionController.restoreInitial(); - } - return; - } - await statusReactionController.setError(); - if (params.hasFinalResponse) { - if (removeAckAfterReply) { - await sleepWithAbort(statusReactionTiming.errorHoldMs); - await clearTelegramStatusReaction(); - } else { - await statusReactionController.restoreInitial(); - } - return; - } - if (removeAckAfterReply) { - await sleepWithAbort(statusReactionTiming.errorHoldMs); - } - await statusReactionController.restoreInitial(); - }; - const replyFenceKey = resolveTelegramReplyFenceKey({ - ctxPayload, - chatId, - threadSpec, - }); - const replyFenceLaneKey = getTelegramSequentialKey({ - message: msg, - ...(context.primaryCtx.me ? { me: context.primaryCtx.me } : {}), - }); - const scopedReplyFenceLaneKey = buildTelegramReplyFenceLaneKey({ - accountId: route.accountId, - sequentialKey: replyFenceLaneKey, - }); - let activeReplyFenceKey = replyFenceKey.activeKey; - let replyFenceGeneration: number | undefined; - const replyAbortController = new AbortController(); - const replyAbortSignal = turnAbortSignal - ? AbortSignal.any([replyAbortController.signal, turnAbortSignal]) - : replyAbortController.signal; - let replyAbortControllerQueued = false; - let queuedTurnAdmitted = false; - let dispatchWasSuperseded; - // Queued source dispatches release their generation before admission but retain this controller. - // Its aborted bit preserves supersession across the later async adoption handoff. - const isDispatchSuperseded = () => - replyAbortController.signal.aborted || - (replyFenceGeneration !== undefined && - isTelegramReplyFenceSuperseded({ - key: activeReplyFenceKey, - generation: replyFenceGeneration, - })); - const releaseReplyFence = () => { - if (replyFenceGeneration === undefined) { - return; - } - endTelegramReplyFence( - activeReplyFenceKey, - replyAbortControllerQueued ? undefined : replyAbortController, - ); - replyFenceGeneration = undefined; - }; - const adoptReplyTurn = async () => { - await onTurnAdopted?.(); - // Fence abort and supersession authority end after durable adoption. - // Core then owns all interruption of the adopted run. - releaseReplyFence(); - releaseTelegramReplyFenceAbortController(activeReplyFenceKey, replyAbortController); - }; - // Block mode sizes preview rotation steps from streaming.preview.chunk (same - // contract as Discord's block chunker). Other modes keep one growing rich - // preview. The stream has no min-flush concept, so minChars/breakPreference - // do not apply here. - const draftMaxChars = - streamMode === "block" - ? Math.min(resolveTelegramDraftStreamingChunking(cfg, route.accountId).maxChars, textLimit) - : Math.min( - textLimit, - telegramCfg.richMessages === true ? TELEGRAM_RICH_TEXT_LIMIT : TELEGRAM_TEXT_CHUNK_LIMIT, - ); + const isRoomEvent = dispatchContext.ctxPayload.InboundEventKind === "room_event"; + const status = createTelegramDispatchStatus({ cfg, context: dispatchContext }); const tableMode = resolveMarkdownTableMode({ cfg, channel: "telegram", - accountId: route.accountId, + accountId: dispatchContext.route.accountId, supportsBlockTables: telegramCfg.richMessages === true, }); - const renderStreamText = (text: string): TelegramDraftPreview => - telegramCfg.richMessages === true - ? { - text, - richMessage: buildTelegramRichMarkdown(text, { - tableMode, - skipEntityDetection: telegramCfg.linkPreview === false, - }), - } - : { - text: renderTelegramHtmlText(text, { tableMode }), - parseMode: "HTML", - markdownSource: { text, tableMode }, - }; - const accountBlockStreamingEnabled = - resolveChannelStreamingBlockEnabled(telegramCfg) ?? - cfg.agents?.defaults?.blockStreamingDefault === "on"; const resolvedReasoningLevel = resolveTelegramReasoningLevel({ cfg, - sessionKey: ctxPayload.SessionKey, - agentId: route.agentId, + sessionKey: dispatchContext.ctxPayload.SessionKey, + agentId: dispatchContext.route.agentId, loadFreshSessionEntry, }); - // Progress mode's ephemeral working-lane window IS the streaming mechanism and - // is independent of reasoning persistence (Discord keeps its window alive - // regardless of /reasoning). Only non-progress modes upgrade reasoning-on to - // block streaming. Forcing block streaming in progress mode killed the whole - // window (no commentary/tool lanes, no collapse bar) and suppressed all - // streamed output for message_tool_only providers. const forceBlockStreamingForReasoning = resolvedReasoningLevel === "on" && streamMode !== "progress"; - const streamReasoningDraft = resolvedReasoningLevel === "stream"; - const streamDeliveryEnabled = !isRoomEvent && streamMode !== "off"; - const rawReplyQuoteText = - ctxPayload.ReplyToIsQuote && typeof ctxPayload.ReplyToQuoteText === "string" - ? ctxPayload.ReplyToQuoteText - : undefined; - const replyQuoteText = ctxPayload.ReplyToIsQuote - ? rawReplyQuoteText?.trim() - ? rawReplyQuoteText - : ctxPayload.ReplyToBody?.trim() || undefined - : undefined; - const replyQuoteMessageId = - replyQuoteText && !ctxPayload.ReplyToIsExternal - ? resolveTelegramReplyId(ctxPayload.ReplyToId) - : undefined; - const replyQuoteTargetsBotMessage = msg.reply_to_message?.from?.is_bot === true; - const replyQuoteByMessageId: TelegramNativeQuoteCandidateByMessageId = {}; - if (replyToMode !== "off") { - if (replyQuoteText && replyQuoteMessageId != null) { - addTelegramNativeQuoteCandidate(replyQuoteByMessageId, replyQuoteMessageId, { - text: replyQuoteText, - ...(typeof ctxPayload.ReplyToQuotePosition === "number" - ? { position: ctxPayload.ReplyToQuotePosition } - : {}), - ...(Array.isArray(ctxPayload.ReplyToQuoteEntities) - ? { entities: ctxPayload.ReplyToQuoteEntities } - : {}), - }); - } - - addTelegramNativeQuoteCandidate( - replyQuoteByMessageId, - ctxPayload.MessageSid ?? msg.message_id, - buildTelegramNativeQuoteCandidate(getTelegramTextParts(msg)), - ); - - if (!ctxPayload.ReplyToIsExternal && typeof ctxPayload.ReplyToQuoteSourceText === "string") { - addTelegramNativeQuoteCandidate( - replyQuoteByMessageId, - ctxPayload.ReplyToId, - buildTelegramNativeQuoteCandidate({ - text: ctxPayload.ReplyToQuoteSourceText, - entities: Array.isArray(ctxPayload.ReplyToQuoteSourceEntities) - ? ctxPayload.ReplyToQuoteSourceEntities - : undefined, - }), - ); - } - } - const hasTelegramQuoteReply = replyToMode !== "off" && replyQuoteText != null; - const canStreamAnswerDraft = - streamDeliveryEnabled && - !hasTelegramQuoteReply && - !accountBlockStreamingEnabled && - !forceBlockStreamingForReasoning; - const streamReasoningInProgressDraft = - streamReasoningDraft && streamMode === "progress" && canStreamAnswerDraft; - const canStreamReasoningDraft = - !isRoomEvent && streamReasoningDraft && !streamReasoningInProgressDraft; - const draftReplyToMessageId = - replyToMode !== "off" && typeof msg.message_id === "number" - ? replyQuoteTargetsBotMessage - ? msg.message_id - : (replyQuoteMessageId ?? msg.message_id) - : undefined; - const draftMinInitialChars = streamMode === "progress" ? 0 : DRAFT_MIN_INITIAL_CHARS; - const progressSeed = `${route.accountId}:${chatId}:${threadSpec.id ?? ""}`; - const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, route.agentId); - const createDraftLane = (laneName: LaneName, enabled: boolean): DraftLaneState => { - const stream = enabled - ? (telegramDeps.createTelegramDraftStream ?? createTelegramDraftStream)({ - api: bot.api, - chatId, - maxChars: draftMaxChars, - thread: threadSpec, - replyToMessageId: draftReplyToMessageId, - replyToMode, - richMessages: telegramCfg.richMessages, - minInitialChars: draftMinInitialChars, - renderText: renderStreamText, - onRetainedPage: (page) => { - lanes[laneName].retainedPromptContextPages.push({ - messageId: page.messageId, - text: page.textSnapshot, - }); - }, - log: logVerbose, - warn: logVerbose, - }) - : undefined; - return { - stream, - lastPartialText: "", - hasStreamedMessage: false, - finalized: false, - retainedPromptContextPages: [], - }; - }; - const lanes: Record = { - answer: createDraftLane("answer", canStreamAnswerDraft), - reasoning: createDraftLane("reasoning", canStreamReasoningDraft), - }; - const answerLane = lanes.answer; - const reasoningLane = lanes.reasoning; - const durableReasoningPayloadsEnabled = - resolvedReasoningLevel === "on" || Boolean(reasoningLane.stream); - const streamToolProgressEnabled = resolveChannelStreamingPreviewToolProgress(telegramCfg); - type AnswerBlockDelivery = { - payload: ReplyPayload; - text: string; - buttons: TelegramInlineButtons | undefined; - }; - let lastAnswerPartialText = ""; - let activeAnswerDraftIsToolProgressOnly = false; - let activeAnswerBlockAssistantMessageIndex: number | undefined; - let activeAnswerBlockDelivery: AnswerBlockDelivery | undefined; - let materializeAnswerLaneBeforeRotation: (() => Promise) | undefined; - type QueuedAnswerBlockRotation = { - assistantMessageIndex?: number; - text?: string; - shouldRotateBeforeDelivery: boolean; - }; - const queuedAnswerBlockRotations: QueuedAnswerBlockRotation[] = []; - let queuedAnswerBlockAssistantMessageIndex: number | undefined; - let pendingAnswerBlockAssistantMessageIndex: number | undefined; - let rotateAnswerLaneWhenQueuedBlocksSettle = false; - function resetAnswerToolProgressDraft() { - activeAnswerDraftIsToolProgressOnly = false; - } - async function prepareAnswerLaneForToolProgress() { - if (answerLane.finalized) { - answerLane.stream?.forceNewMessage(); - resetDraftLaneState(answerLane); - } - if (activeAnswerDraftIsToolProgressOnly) { - return; - } - // Progress mode keeps ONE stationary window: interim answer text never - // streams into it (updateDraftFromPartial returns early), so hasStreamedMessage - // is only ever set by tool progress on this same message β€” never rotate here. - // The rotate exists for block/partial, where answer text streams first and a - // following tool run needs its own message. - if (streamMode !== "progress" && answerLane.hasStreamedMessage) { - await rotateAnswerLaneForNewMessage(); - } - activeAnswerDraftIsToolProgressOnly = true; - } - // Tracks whether the ephemeral progress window ever actually rendered this - // turn (rv mode delivers everything durably and the window stays empty). The - // collapse summary must reflect what ACTUALLY streamed, so it is gated on - // this flag, not on the compositor gate having started (Bug 6). - let progressDraftEverRendered = false; - // Turn-activity tally for the post-turn collapse summary (Discord parity). - // Counters feed a one-line digest posted when the progress window collapses. - const progressSummaryStartedAt = Date.now(); - const progressSummary = createTelegramProgressSummaryTracker(); - let progressSummaryDelivered = false; - const progressDraft = createChannelProgressDraftCompositor({ - entry: telegramCfg, - mode: streamMode, - active: Boolean(answerLane.stream), - seed: progressSeed, - formatLine: (text) => { - // Status headlines carry model Markdown. Tool lines keep their compact - // code formatting when no headline owns the status slot. Read compositor - // state at render time so the first preamble renders as a headline. - return progressDraft.hasStatusHeadline ? text : formatTelegramProgressLine(text); - }, - reasoningGate: streamReasoningInProgressDraft, - // Distinguish the streamed lanes in the window the way Discord does: 🧠 - // reasoning (italic, default) vs πŸ’¬ commentary (plain). Without these the - // two lanes render identically and are indistinguishable. - reasoningLinePrefix: "🧠 ", - commentaryLinePrefix: "πŸ’¬ ", - commentaryItalics: false, - update: async (streamText, options) => { - progressDraftEverRendered = true; - await prepareAnswerLaneForToolProgress(); - answerLane.lastPartialText = streamText; - answerLane.hasStreamedMessage = true; - answerLane.finalized = false; - answerLane.stream?.updatePreview( - renderTelegramProgressDraftPreview( - streamText, - options?.lines ?? [], - telegramCfg.richMessages === true, - progressDraft.hasStatusHeadline, - ), - ); - if (options?.flush) { - await answerLane.stream?.flush(); - } - }, - }); - let finalAnswerDeliveryStarted = false; - let finalAnswerDelivered = false; - // While the durable verbose lane is active it owns EVERY progress surface - // (commentary, tool, plan, command output, patch summaries), posting each as - // its own persistent message. The ephemeral window must therefore render none - // of them, or each renders twice (invariant: persistent message XOR window). - let verboseProgressActive: () => boolean = () => false; - const canPushStreamToolProgress = () => - Boolean( - answerLane.stream && - !verboseProgressActive() && - !answerLane.finalized && - !finalAnswerDeliveryStarted && - !finalAnswerDelivered, - ); - const pushStreamToolProgress = async ( - line?: string | ChannelProgressDraftLine, - options?: { toolName?: string; startImmediately?: boolean }, - ) => { - if (!canPushStreamToolProgress()) { - return false; - } - return await progressDraft.pushToolProgress(line, options); - }; - const pushStreamReasoningProgress = async (payload: { - text?: string; - isReasoningSnapshot?: boolean; - }) => { - // Opens (or keeps open) the current window reasoning burst for the collapse - // summary whenever window-destined reasoning text arrives β€” independent of - // whether this particular push renders, so a short burst between renders is - // still counted at the summary flush (mirrors Discord's windowReasoningOpen). - // Gated on the window lane: durable reasoning (/reasoning on) must not feed - // the bar (Bug 6: the bar counts only what streamed to the window). - if (streamReasoningInProgressDraft && payload.text) { - progressSummary.noteReasoningActivity(); - } - return await progressDraft.pushReasoningProgress(payload.text, { - snapshot: payload.isReasoningSnapshot === true, - }); - }; - const pushStreamThinkingTokenProgress = async (progressTokens: number) => { - const rendered = await pushStreamToolProgress( - buildTelegramThinkingProgressLine(progressTokens), - { startImmediately: true }, - ); - if (rendered) { - progressSummary.noteReasoningActivity(); - } - return rendered; - }; - const markProgressFinalStarted = () => { - finalAnswerDeliveryStarted = true; - progressDraft.markFinalReplyStarted(); - }; - const markProgressFinalDelivered = () => { - finalAnswerDelivered = true; - sawProgressFinal = true; - progressDraft.markFinalReplyDelivered(); - }; - const resetProgressDraftState = () => { - progressDraft.reset(); - }; - const suppressProgressDraftState = () => { - progressDraft.suppress(); - }; - let splitReasoningOnNextStream = false; - let draftLaneEventQueue = Promise.resolve(); - const reasoningStepState = createTelegramReasoningStepState(); - const enqueueDraftLaneEvent = (task: () => Promise): Promise => { - const next = draftLaneEventQueue.then(async () => { - if (isDispatchSuperseded()) { - return; - } - await task(); - }); - draftLaneEventQueue = next.catch((err: unknown) => { - logVerbose(`telegram: draft lane callback failed: ${String(err)}`); - }); - return draftLaneEventQueue; - }; - type SplitLaneSegment = { lane: LaneName; update: DraftPartialTextUpdate }; - type SplitLaneSegmentsResult = { - segments: SplitLaneSegment[]; - suppressedReasoningOnly: boolean; - }; - const splitTextIntoLaneSegments = ( - update: { text?: string; delta?: string; replace?: true; isReasoningSnapshot?: boolean }, - isReasoning?: boolean, - ): SplitLaneSegmentsResult => { - const split = splitTelegramReasoningText(update.text, isReasoning); - const splitSegments: Array<{ lane: LaneName; text: string }> = []; - const useDelta = - !update.replace && update.isReasoningSnapshot !== true && update.delta !== undefined; - const segments: SplitLaneSegment[] = []; - const suppressReasoning = resolvedReasoningLevel === "off"; - if (split.reasoningText && !suppressReasoning) { - splitSegments.push({ lane: "reasoning", text: split.reasoningText }); - } - if (split.answerText) { - splitSegments.push({ lane: "answer", text: split.answerText }); - } - for (const segment of splitSegments) { - const canApplyDelta = useDelta && splitSegments.length === 1; - segments.push({ - lane: segment.lane, - update: { - text: segment.text, - ...(canApplyDelta ? { delta: update.delta } : {}), - ...(update.replace ? { replace: true } : {}), - ...(update.isReasoningSnapshot ? { isReasoningSnapshot: true } : {}), - }, - }); - } - return { - segments, - suppressedReasoningOnly: - Boolean(split.reasoningText) && suppressReasoning && !split.answerText, - }; - }; - const resetDraftLaneState = (lane: DraftLaneState) => { - lane.lastPartialText = ""; - if (lane === answerLane) { - lastAnswerPartialText = ""; - } - lane.hasStreamedMessage = false; - lane.finalized = false; - lane.retainedPromptContextPages = []; - if (lane === answerLane) { - resetAnswerToolProgressDraft(); - pendingAnswerBlockAssistantMessageIndex = undefined; - activeAnswerBlockDelivery = undefined; - } - }; - const repositionDraftLaneForNewMessage = (lane: DraftLaneState) => { - // Reposition instead of delete-then-repost: the replacement must land - // before deferred cleanup or Telegram can jump and retain a stale preview. - lane.stream?.rotateToNewMessageDeferringDelete(); - resetDraftLaneState(lane); - }; - const rotateLaneForNewMessage = async (lane: DraftLaneState) => { - if (!lane.hasStreamedMessage && typeof lane.stream?.messageId() !== "number") { - resetDraftLaneState(lane); - return; - } - await lane.stream?.stop(); - lane.stream?.forceNewMessage(); - resetDraftLaneState(lane); - }; - const rotateAnswerLaneForNewMessage = async () => { - if (materializeAnswerLaneBeforeRotation) { - await materializeAnswerLaneBeforeRotation(); - } - await rotateLaneForNewMessage(answerLane); - }; - const rotateAnswerLaneAfterToolProgress = async () => { - if (!activeAnswerDraftIsToolProgressOnly) { - return false; - } - repositionDraftLaneForNewMessage(answerLane); - suppressProgressDraftState(); - rotateAnswerLaneWhenQueuedBlocksSettle = false; - return true; - }; - const rotateAnswerLaneAfterQueuedBlocksSettle = async () => { - if (!rotateAnswerLaneWhenQueuedBlocksSettle || queuedAnswerBlockRotations.length > 0) { - return false; - } - rotateAnswerLaneWhenQueuedBlocksSettle = false; - if (!answerLane.hasStreamedMessage || activeAnswerDraftIsToolProgressOnly) { - return false; - } - await rotateAnswerLaneForNewMessage(); - return true; - }; - const prepareAnswerLaneForText = async (): Promise => { - // Single stationary window in progress mode: interim answer text never - // renders into the window (updateDraftFromPartial returns early for the - // answer lane), so it must NOT rotate/reposition the tool-progress window - // either. The one window message stays put through every lane handover and - // is edited into the summary bar at collapse (deliverProgressModeFinalAnswer); - // rotating here spawned a fresh bubble per interim answer chunk (churn). - if (streamMode === "progress") { - return false; - } - if (await rotateAnswerLaneAfterToolProgress()) { - return true; - } - if (await rotateAnswerLaneAfterQueuedBlocksSettle()) { - return true; - } - if (!answerLane.finalized) { - return false; - } - answerLane.stream?.forceNewMessage(); - resetDraftLaneState(answerLane); - rotateAnswerLaneWhenQueuedBlocksSettle = false; - return true; - }; - const prepareQueuedAnswerBlock = async ( - payload: ReplyPayload, - blockContext?: BlockReplyContext, - ) => { - const hasAnswerText = splitTextIntoLaneSegments( - { text: payload.text }, - payload.isReasoning, - ).segments.some((segment) => segment.lane === "answer"); - if (!hasAnswerText) { - return; - } - resetProgressDraftState(); - const assistantMessageIndex = blockContext?.assistantMessageIndex; - if (assistantMessageIndex === undefined) { - queuedAnswerBlockRotations.push({ - text: payload.text, - shouldRotateBeforeDelivery: false, - }); - return; - } - const previousAssistantMessageIndex = - queuedAnswerBlockAssistantMessageIndex ?? - activeAnswerBlockAssistantMessageIndex ?? - pendingAnswerBlockAssistantMessageIndex; - const shouldRotateBeforeDelivery = - previousAssistantMessageIndex !== undefined && - assistantMessageIndex !== previousAssistantMessageIndex; - queuedAnswerBlockRotations.push({ - assistantMessageIndex, - text: payload.text, - shouldRotateBeforeDelivery, - }); - queuedAnswerBlockAssistantMessageIndex = assistantMessageIndex; - }; - const recomputeQueuedAnswerBlockRotations = () => { - let previousAssistantMessageIndex = - activeAnswerBlockAssistantMessageIndex ?? pendingAnswerBlockAssistantMessageIndex; - queuedAnswerBlockAssistantMessageIndex = undefined; - for (const entry of queuedAnswerBlockRotations) { - if (entry.assistantMessageIndex === undefined) { - continue; - } - entry.shouldRotateBeforeDelivery = - previousAssistantMessageIndex !== undefined && - entry.assistantMessageIndex !== previousAssistantMessageIndex; - previousAssistantMessageIndex = entry.assistantMessageIndex; - queuedAnswerBlockAssistantMessageIndex = entry.assistantMessageIndex; - } - }; - const queuedAnswerBlockRotationTextMatchesPayload = ( - entry: QueuedAnswerBlockRotation, - payload: ReplyPayload, - ) => { - return entry.text !== undefined && payload.text !== undefined && entry.text === payload.text; - }; - const queuedAnswerBlockRotationMatchesDelivery = ( - entry: QueuedAnswerBlockRotation, - payload: ReplyPayload, - assistantMessageIndex?: number, - ) => { - if (assistantMessageIndex !== undefined && entry.assistantMessageIndex !== undefined) { - return assistantMessageIndex === entry.assistantMessageIndex; - } - return queuedAnswerBlockRotationTextMatchesPayload(entry, payload); - }; - const takeQueuedAnswerBlockRotation = ( - payload: ReplyPayload, - assistantMessageIndex?: number, - ): boolean => { - if (queuedAnswerBlockRotations.length === 0) { - return false; - } - const matchIndex = queuedAnswerBlockRotations.findIndex((entry) => - queuedAnswerBlockRotationMatchesDelivery(entry, payload, assistantMessageIndex), - ); - const consumeIndex = Math.max(matchIndex, 0); - const matchedEntries = queuedAnswerBlockRotations.splice(0, consumeIndex + 1); - const matchedEntry = matchedEntries.at(-1); - const shouldRotateBeforeDelivery = matchedEntry?.shouldRotateBeforeDelivery ?? false; - if (matchedEntry?.assistantMessageIndex !== undefined) { - activeAnswerBlockAssistantMessageIndex = matchedEntry.assistantMessageIndex; - pendingAnswerBlockAssistantMessageIndex = undefined; - } - recomputeQueuedAnswerBlockRotations(); - return shouldRotateBeforeDelivery; - }; - const dropQueuedAnswerBlockRotation = (payload: ReplyPayload, assistantMessageIndex?: number) => { - let matchIndex = queuedAnswerBlockRotations.findIndex((entry) => - queuedAnswerBlockRotationMatchesDelivery(entry, payload, assistantMessageIndex), - ); - if (matchIndex < 0 && assistantMessageIndex === undefined) { - matchIndex = queuedAnswerBlockRotations.findIndex( - (entry) => entry.assistantMessageIndex === undefined, - ); - } - if (matchIndex >= 0) { - const matchedEntry = queuedAnswerBlockRotations[matchIndex]; - queuedAnswerBlockRotations.splice(matchIndex, 1); - if ( - matchIndex === 0 && - matchedEntry?.assistantMessageIndex !== undefined && - rotateAnswerLaneWhenQueuedBlocksSettle && - activeAnswerBlockAssistantMessageIndex === undefined && - answerLane.hasStreamedMessage - ) { - pendingAnswerBlockAssistantMessageIndex = matchedEntry.assistantMessageIndex; - } - recomputeQueuedAnswerBlockRotations(); - } - }; - const updateDraftFromPartial = (lane: DraftLaneState, update: DraftPartialTextUpdate) => { - const laneStream = lane.stream; - if (!laneStream || !update.text) { - return; - } - const previousText = lane === answerLane ? lastAnswerPartialText : lane.lastPartialText; - const nextText = resolveDraftPartialText(previousText, update); - if (!nextText) { - return; - } - if (lane === answerLane) { - if (streamMode === "progress") { - return; - } - resetAnswerToolProgressDraft(); - suppressProgressDraftState(); - } - lane.hasStreamedMessage = true; - lane.finalized = false; - if (lane === answerLane) { - lastAnswerPartialText = nextText; - } - lane.lastPartialText = nextText; - laneStream.update(nextText); - }; - const ingestDraftLaneSegments = async ( - update: { text?: string; delta?: string; replace?: true; isReasoningSnapshot?: boolean }, - isReasoning?: boolean, - ) => { - const split = splitTextIntoLaneSegments(update, isReasoning); - for (const segment of split.segments) { - if (segment.lane === "answer") { - await prepareAnswerLaneForText(); - } - if (segment.lane === "reasoning") { - reasoningStepState.noteReasoningHint(); - reasoningStepState.noteReasoningDelivered(); - } - updateDraftFromPartial(lanes[segment.lane], segment.update); - } - }; - const flushDraftLane = async (lane: DraftLaneState) => { - if (!lane.stream) { - return; - } - await lane.stream.flush(); - }; - - const resolvedBlockStreamingEnabled = resolveChannelStreamingBlockEnabled(telegramCfg); - const disableBlockStreaming = !streamDeliveryEnabled - ? true - : forceBlockStreamingForReasoning - ? false - : typeof resolvedBlockStreamingEnabled === "boolean" - ? !resolvedBlockStreamingEnabled - : canStreamAnswerDraft - ? true - : undefined; - - const chunkMode = resolveChunkMode(cfg, "telegram", route.accountId); - - const supersedeReplyFence = shouldSupersedeTelegramReplyFence(ctxPayload); - activeReplyFenceKey = supersedeReplyFence - ? replyFenceKey.activeKey - : buildTelegramNonInterruptingReplyFenceKey({ - activeKey: replyFenceKey.activeKey, - laneKey: scopedReplyFenceLaneKey, - }); - // Ambient room-event work uses a separate fence key. Any non-room-event - // inbound may cancel it without owning abort authority over adopted user turns. - if (!isRoomEvent) { - supersedeTelegramReplyFence(replyFenceKey.roomEventKey); - } - replyFenceGeneration = beginTelegramReplyFence({ - key: activeReplyFenceKey, - supersede: supersedeReplyFence, - abortController: replyAbortController, - laneKey: scopedReplyFenceLaneKey, - }); - - const implicitQuoteReplyTargetId = - !replyQuoteTargetsBotMessage && replyQuoteMessageId != null - ? String(replyQuoteMessageId) - : undefined; - const currentMessageIdForQuoteReply = - implicitQuoteReplyTargetId && ctxPayload.MessageSid ? ctxPayload.MessageSid : undefined; - const replyQuotePosition = - typeof ctxPayload.ReplyToQuotePosition === "number" - ? ctxPayload.ReplyToQuotePosition - : undefined; - const replyQuoteEntities = Array.isArray(ctxPayload.ReplyToQuoteEntities) - ? ctxPayload.ReplyToQuoteEntities - : undefined; - const deliveryState = createLaneDeliveryStateTracker(); - const beginDeliveryCorrelation = () => - beginTelegramInboundEventDeliveryCorrelation( - ctxPayload.SessionKey, - { - outboundTo: historyKey || String(chatId), - outboundAccountId: route.accountId, - markInboundEventDelivered: () => { - deliveryState.markDelivered(); - }, - }, - { inboundEventKind: ctxPayload.InboundEventKind }, - ); - const endTelegramInboundEventDeliveryCorrelation = beginDeliveryCorrelation(); - const sessionKey = ctxPayload.SessionKey; - let transcriptMirrorSequence = 0; - const transcriptMirrorTurnId = `${chatId}:${ctxPayload.MessageSid ?? msg.message_id ?? dispatchStartedAt}`; - const resolveCurrentTurnTranscriptFinal = async (): Promise< - CurrentTurnTranscriptFinal | undefined - > => { - if (!sessionKey) { - return undefined; - } - try { - const { entry: sessionEntry, storePath } = loadFreshSessionEntry(route.agentId, sessionKey); - if (!sessionEntry?.sessionId) { - return undefined; - } - const latest = await readLatestAssistantTextByIdentity({ - agentId: route.agentId, - sessionId: sessionEntry.sessionId, - sessionKey, - storePath, - }); - if (!latest?.timestamp || latest.timestamp < dispatchStartedAt) { - return undefined; - } - return { - ...(latest.id ? { messageId: latest.id } : {}), - text: latest.text, - }; - } catch (err) { - logVerbose(`telegram transcript final candidate lookup failed: ${formatErrorMessage(err)}`); - return undefined; - } - }; - const projectPayloadForDelivery = (payload: ReplyPayload): ReplyPayload | undefined => - projectOutboundPayloadPlanForDelivery( - createOutboundPayloadPlan([payload], { - cfg, - sessionKey: ctxPayload.SessionKey, - surface: "telegram", - }), - )[0]; - const promptContextDeliverySignature = (payload: ReplyPayload): string | undefined => { - const projected = projectPayloadForDelivery(payload); - return projected ? resolveTelegramPromptContextDeliverySignature(projected) : undefined; - }; - const resolvePromptContextSource = ( - final: CurrentTurnTranscriptFinal | undefined, - ...payloads: ReplyPayload[] - ): TelegramPromptContextSource | undefined => { - const finalSignature = final ? promptContextDeliverySignature({ text: final.text }) : undefined; - if (!final?.messageId || !finalSignature) { - return undefined; - } - return payloads.some((payload) => promptContextDeliverySignature(payload) === finalSignature) - ? { transcriptMessageId: final.messageId } - : undefined; - }; - const deliveryBaseOptions = { - chatId: String(chatId), - accountId: route.accountId, - sessionKeyForInternalHooks: ctxPayload.SessionKey, - mirrorIsGroup: isGroup, - mirrorGroupId: isGroup ? String(chatId) : undefined, - token: opts.token, - runtime, + const quote = resolveTelegramQuoteContext({ context: dispatchContext, replyToMode }); + const isDispatchSuperseded = () => fence.isSuperseded(); + const draft = createTelegramDraftController({ + accountId: dispatchContext.route.accountId, bot, - mediaLocalRoots, - mediaMaxBytes: (opts.mediaMaxMb ?? telegramCfg.mediaMaxMb ?? 100) * 1024 * 1024, + cfg, + chatId: dispatchContext.chatId, + draftReplyToMessageId: quote.draftReplyToMessageId, + forceBlockStreamingForReasoning, + hasTelegramQuoteReply: quote.hasTelegramQuoteReply, + isDispatchSuperseded, + isRoomEvent, replyToMode, - textLimit, - thread: threadSpec, + resolvedReasoningLevel, + streamMode, tableMode, - chunkMode, - richMessages: telegramCfg.richMessages, - linkPreview: telegramCfg.linkPreview, - replyQuoteMessageId, - replyQuoteText, - replyQuotePosition, - replyQuoteEntities, - replyQuoteByMessageId, - transcriptMirror: sessionKey - ? async (payload: TelegramTranscriptMirrorPayload) => { - const idempotencyKey = `telegram-final:${sessionKey}:${transcriptMirrorTurnId}:${transcriptMirrorSequence++}`; - await mirrorTelegramAssistantReplyToTranscript({ - cfg, - idempotencyKey, - loadFreshSessionEntry, - route, - sessionKey, - payload, - }); - } - : undefined, + telegramCfg, + telegramDeps, + textLimit, + threadSpec: dispatchContext.threadSpec, + }); + const progress = createTelegramProgressController({ + accountId: dispatchContext.route.accountId, + chatId: dispatchContext.chatId, + draft, + statusReactionController: status.controller, + streamMode, + streamReasoningInProgressDraft: draft.streamReasoningInProgressDraft, + telegramCfg, + threadId: dispatchContext.threadSpec.id, + }); + const delivery = createTelegramDeliveryController({ + bot, + cfg, + chunkMode: resolveChunkMode(cfg, "telegram", dispatchContext.route.accountId), + context: dispatchContext, + dispatchStartedAt, + draft, + draftReplyToMessageId: quote.draftReplyToMessageId, + isDispatchSuperseded, + loadFreshSessionEntry, + mediaLocalRoots: getAgentScopedMediaLocalRoots(cfg, dispatchContext.route.agentId), + opts, + progress, + replyQuoteByMessageId: quote.replyQuoteByMessageId, + replyQuoteEntities: quote.replyQuoteEntities, + replyQuoteMessageId: quote.replyQuoteMessageId, + replyQuotePosition: quote.replyQuotePosition, + replyQuoteText: quote.replyQuoteText, + replyToMode, + runtime, + streamMode, + tableMode, + telegramCfg, + telegramDeps, + textLimit, + threadSpec: dispatchContext.threadSpec, + }); + const state: TelegramDispatchTurnState = { + queuedFinal: false, + suppressSilentReplyFallback: false, + hadErrorReplyFailureOrSkip: false, }; - const silentErrorReplies = telegramCfg.silentErrorReplies === true; - const isDmTopic = !isGroup && threadSpec.scope === "dm" && threadSpec.id != null; - let queuedFinal = false; - // A final answer was produced this turn (in-band or out-of-band). Out-of-band - // finals (message_tool_only / codex) never flow through - // deliverProgressModeFinalAnswer, so the collapse bar must be posted from the - // cleanup fallback instead β€” see the finally block. - let sawProgressFinal = false; - let suppressSilentReplyFallback = false; - let hadErrorReplyFailureOrSkip = false; + const reply = createTelegramReplyDelivery({ + cfg, + context: dispatchContext, + delivery, + draft, + fence: { + generation: () => fence.generation(), + isSuperseded: isDispatchSuperseded, + }, + progress, + runtime, + state, + streamMode, + telegramCfg, + }); + let isFirstTurnInSession = false; - let dispatchError: unknown; - + let dispatchWasSuperseded: boolean; + let turnDispatched: boolean | undefined; + const isDmTopic = + !dispatchContext.isGroup && + dispatchContext.threadSpec.scope === "dm" && + dispatchContext.threadSpec.id != null; + const fence = createTelegramReplyFenceController({ + context: dispatchContext, + onTurnAdopted, + onTurnDeferred, + onTurnAbandoned, + turnAbortSignal, + }); try { - const sticker = ctxPayload.Sticker; - if (sticker?.fileId && sticker.fileUniqueId && ctxPayload.MediaPath) { - const agentDir = resolveAgentDir(cfg, route.agentId); - const stickerSupportsVision = await resolveStickerVisionSupport(cfg, route.agentId); - let description = sticker.cachedDescription ?? null; - if (!description) { - description = await describeStickerImage({ - imagePath: ctxPayload.MediaPath, - cfg, - agentDir, - agentId: route.agentId, - }); - } - if (description) { - const stickerContext = [sticker.emoji, sticker.setName ? `from "${sticker.setName}"` : null] - .filter(Boolean) - .join(" "); - const formattedDesc = `[Sticker${stickerContext ? ` ${stickerContext}` : ""}] ${description}`; - - sticker.cachedDescription = description; - if (!stickerSupportsVision) { - ctxPayload.Body = includeStickerDescription(ctxPayload.Body, formattedDesc); - ctxPayload.BodyForAgent = includeStickerDescription( - ctxPayload.BodyForAgent, - formattedDesc, - ); - ctxPayload.SkipStickerMediaUnderstanding = true; - } - cacheSticker({ - fileId: sticker.fileId, - fileUniqueId: sticker.fileUniqueId, - emoji: sticker.emoji, - setName: sticker.setName, - description, - cachedAt: new Date().toISOString(), - receivedFrom: ctxPayload.From, - }); - logVerbose(`telegram: cached sticker description for ${sticker.fileUniqueId}`); - } - } - - const applyTextToPayload = (payload: ReplyPayload, text: string): ReplyPayload => { - if (payload.text === text) { - return payload; - } - return { ...payload, text }; - }; - const applyQuoteReplyTarget = (payload: ReplyPayload): ReplyPayload => { - if ( - !implicitQuoteReplyTargetId || - !currentMessageIdForQuoteReply || - payload.replyToId !== currentMessageIdForQuoteReply || - payload.replyToTag || - payload.replyToCurrent - ) { - return payload; - } - return { ...payload, replyToId: implicitQuoteReplyTargetId }; - }; - const normalizeDeliveryPayload = (payload: ReplyPayload): ReplyPayload | undefined => { - const keepReasoningLane = payload.isReasoning === true && durableReasoningPayloadsEnabled; - const payloadForPlan = keepReasoningLane ? { ...payload } : payload; - if (keepReasoningLane) { - delete payloadForPlan.isReasoning; - } - const normalized = projectPayloadForDelivery(payloadForPlan); - return normalized ? canonicalizeTelegramPresentationPayload(normalized) : undefined; - }; - const usesNativeTelegramQuote = (payload: ReplyPayload): boolean => { - if (replyQuoteText != null) { - return true; - } - return payload.replyToId != null && replyQuoteByMessageId[payload.replyToId] != null; - }; - const recordPromptContextMessage = (params: { - messageId: number; - message?: Message; - text?: string; - projection?: TelegramPromptContextProjection; - }): Promise => - (telegramDeps.recordOutboundMessageForPromptContext ?? recordOutboundMessageForPromptContext)( - { - cfg, - account: { - accountId: route.accountId, - ...(telegramCfg.name !== undefined ? { name: telegramCfg.name } : {}), - ...(context.primaryCtx.me ? { bot: context.primaryCtx.me } : {}), - }, - ...(context.primaryCtx.me?.id !== undefined - ? { botUserId: context.primaryCtx.me.id } - : {}), - chatId: deliveryBaseOptions.chatId, - message: params.message ?? { message_id: params.messageId }, - messageId: params.messageId, - ...(params.text ? { text: params.text } : {}), - ...(params.projection ? { promptContextProjection: params.projection } : {}), - ...(threadSpec.id !== undefined ? { messageThreadId: threadSpec.id } : {}), - }, - ); - const createPromptContextSequence = (source?: TelegramPromptContextSource) => - createTelegramPromptContextProjectionSequence({ - ...(source ? { source } : {}), - record: recordPromptContextMessage, - }); - const sendPayload = async ( - payload: ReplyPayload, - options?: { - afterAcceptedDraft?: boolean; - durable?: boolean; - silent?: boolean; - mirrorTranscript?: boolean; - promptContextSequence?: TelegramPromptContextProjectionSequence; - textMode?: "html"; - }, - ) => { - if (isDispatchSuperseded()) { - await options?.promptContextSequence?.fail(); - return false; - } - const targetedPayload = applyQuoteReplyTarget(payload); - const finalReplyTargetId = resolveTelegramReplyId(targetedPayload.replyToId); - const targetsDifferentMessage = - finalReplyTargetId != null && finalReplyTargetId !== draftReplyToMessageId; - const consumedSingleUseReply = - options?.afterAcceptedDraft === true && - isSingleUseReplyToMode(replyToMode) && - !targetsDifferentMessage; - const deliverablePayload = consumedSingleUseReply - ? (({ replyToId: _, replyToTag: _tag, replyToCurrent: _current, ...rest }) => rest)( - targetedPayload, - ) - : targetedPayload; - const effectiveReplyToMode = consumedSingleUseReply ? "off" : replyToMode; - const projectionSequence = - options?.promptContextSequence ?? - createPromptContextSequence( - options?.durable - ? resolvePromptContextSource( - await resolveCurrentTurnTranscriptFinal(), - deliverablePayload, - ) - : undefined, - ); - const effectivePayload = withTelegramPromptContextSource( - deliverablePayload, - projectionSequence.source, - ); - const silent = options?.silent ?? (silentErrorReplies && payload.isError === true); - const durableDelivery = telegramDeps.deliverInboundReplyWithMessageSendContext; - if (options?.durable && durableDelivery && projectionSequence.isFresh()) { - const durable = await durableDelivery({ - cfg, - channel: "telegram", - to: String(chatId), - accountId: route.accountId, - agentId: route.agentId, - ctxPayload, - payload: effectivePayload, - info: { kind: "final" }, - replyToMode: effectiveReplyToMode, - threadId: threadSpec.id, - formatting: { - textLimit, - tableMode, - chunkMode, - ...(options?.textMode === "html" ? { parseMode: "HTML" as const } : {}), - }, - silent, - requiredCapabilities: deriveDurableFinalDeliveryRequirements({ - payload: effectivePayload, - replyToId: effectivePayload.replyToId, - threadId: threadSpec.id, - silent, - payloadTransport: true, - extraCapabilities: { - nativeQuote: !consumedSingleUseReply && usesNativeTelegramQuote(effectivePayload), - }, - }), - }); - if (durable.status === "failed") { - await projectionSequence.fail(); - throw durable.error; - } - if (durable.status === "handled_visible") { - deliveryState.markDelivered(); - return true; - } - if (durable.status === "handled_no_send") { - await projectionSequence.fail(); - return false; - } - } - let result: Awaited>; - try { - result = await (telegramDeps.deliverReplies ?? deliverReplies)({ - ...deliveryBaseOptions, - replyToMode: effectiveReplyToMode, - // The collapse bar is a cosmetic activity digest, not an assistant - // message: pass mirrorTranscript:false so it never enters the session - // transcript (the model must not read it back as its own prior turn). - // Discord parity: its summary bar (reply-delivery.ts deliverDiscordReply) - // has no transcript-mirror seam either. Real finals keep the default. - transcriptMirror: - options?.durable && options?.mirrorTranscript !== false - ? deliveryBaseOptions.transcriptMirror - : undefined, - replies: [effectivePayload], - onVoiceRecording: sendRecordVoice, - silent, - mediaLoader: telegramDeps.loadWebMedia, - promptContextSequence: projectionSequence, - ...(options?.textMode ? { textMode: options.textMode } : {}), - }); - } catch (error) { - await projectionSequence.fail(); - throw error; - } - if (!result.delivered) { - await projectionSequence.fail(); - return false; - } - await projectionSequence.finish(); - deliveryState.markDelivered(); - return true; - }; - const emitPreviewFinalizedHook = async (result: LaneDeliveryResult) => { - if (isDispatchSuperseded() || result.kind !== "preview-finalized") { - return; - } - (telegramDeps.emitInternalMessageSentHook ?? emitInternalMessageSentHook)({ - sessionKeyForInternalHooks: deliveryBaseOptions.sessionKeyForInternalHooks, - chatId: deliveryBaseOptions.chatId, - accountId: deliveryBaseOptions.accountId, - content: result.delivery.content, - success: true, - messageId: result.delivery.messageId, - isGroup: deliveryBaseOptions.mirrorIsGroup, - groupId: deliveryBaseOptions.mirrorGroupId, - }); - if (deliveryBaseOptions.transcriptMirror && result.delivery.content) { - void deliveryBaseOptions - .transcriptMirror({ text: result.delivery.content }) - .catch((err: unknown) => { - logVerbose( - `telegram preview-finalized transcriptMirror failed: ${formatErrorMessage(err)}`, - ); - }); - } - }; - const finalizePendingAnswerBlockDraft = async () => { - if ( - !activeAnswerBlockDelivery || - queuedFinal || - dispatchError || - isDispatchSuperseded() || - answerLane.finalized - ) { - return; - } - const blockDelivery = activeAnswerBlockDelivery; - const content = blockDelivery?.text.trimEnd(); - if (!content || !blockDelivery) { - return; - } - // Some runtimes finish with a block callback and queuedFinal=false. At - // post-dispatch this is the terminal answer, not another markerless - // intermediate block; use the ordinary final/collapse path with its text. - markProgressFinalStarted(); - await deliverFinalAnswerText(blockDelivery.payload, content, blockDelivery.buttons); - activeAnswerBlockDelivery = undefined; - }; - const deliverLaneText = createLaneTextDeliverer({ - lanes, - applyTextToPayload, - sendPayload, - flushDraftLane, - stopDraftLane: async (lane) => { - await lane.stream?.stop(); - }, - clearDraftLane: async (lane) => { - await lane.stream?.clear(); - }, - editStreamMessage: async ({ messageId, text, textMode, buttons }) => { - if (isDispatchSuperseded()) { - return; - } - await (telegramDeps.editMessageTelegram ?? editMessageTelegram)(chatId, messageId, text, { - api: bot.api, - cfg, - accountId: route.accountId, - linkPreview: telegramCfg.linkPreview, - textMode, - buttons, - }); - }, - createPromptContextSequence, - resolveFinalTextCandidate: async () => (await resolveCurrentTurnTranscriptFinal())?.text, - log: logVerbose, - markDelivered: deliveryState.markDelivered, - }); - materializeAnswerLaneBeforeRotation = async () => { - if ( - !activeAnswerBlockDelivery || - !answerLane.stream || - !answerLane.hasStreamedMessage || - answerLane.finalized || - activeAnswerDraftIsToolProgressOnly - ) { - return; - } - const blockDelivery = activeAnswerBlockDelivery; - const text = answerLane.lastPartialText || lastAnswerPartialText || blockDelivery.text; - if (!text?.trim()) { - return; - } - // AgentSession persists after reply callbacks. A later identical assistant - // row may already be latest here, so intermediate blocks stay markerless. - const result = await deliverLaneText({ - laneName: "answer", - text, - payload: blockDelivery.payload, - infoKind: "block", - buttons: blockDelivery.buttons, - finalizePreview: true, - durable: false, - }); - activeAnswerBlockDelivery = undefined; - await emitPreviewFinalizedHook(result); - }; - // The one-line activity digest for the collapse bar, or undefined when the - // window never rendered (rv mode delivers everything durably β€” no bar) or - // the summary was already emitted this turn. - const resolveProgressCollapseSummaryLine = (): string | undefined => { - if (progressSummaryDelivered) { - return undefined; - } - progressSummaryDelivered = true; - if (!progressDraftEverRendered) { - return undefined; - } - const line = formatTelegramProgressSummaryLine( - progressSummary.counts(), - Date.now() - progressSummaryStartedAt, - ); - return line || undefined; - }; - // The collapse summary bar is cosmetic and always reaches the user AFTER the - // real final answer (edited in place, or posted below it). A flood-wait / - // network throw from its durable send must never fail an otherwise-complete - // turn. Shared by BOTH bar-post fallbacks (the cleanup path and the - // finalizeToPreview-miss path) so neither can propagate a cosmetic failure - // into turn delivery; sendPayload throws durable.error on delivery failure. - const postCosmeticSummaryBar = async (line: string) => { - try { - await sendPayload({ text: line }, { durable: true, mirrorTranscript: false }); - } catch (err) { - logVerbose(`telegram: collapse summary bar send failed: ${formatErrorMessage(err)}`); - } - }; - // Post-turn collapse summary (Discord parity) as a durable standalone - // message. Used when there is no live window to collapse in place β€” the - // final answer posts below so the timeline reads thoughts/tools β†’ summary β†’ - // answer. Emitted at most once per turn. - const deliverProgressCollapseSummary = async () => { - const line = resolveProgressCollapseSummaryLine(); - if (!line) { - return; - } - // Cleanup fallback bar (message_tool_only/codex turns): the once-guard - // already fired in resolveProgressCollapseSummaryLine, so no retry storm. - await postCosmeticSummaryBar(line); - }; - // Apply a pre-resolved bar line to the window: edit the live window message - // IN PLACE into the bar (no delete β€” deleting scroll-jumps the client), or - // post it durably when there is no live window message to edit. NOTHING is - // deleted. The line is snapshotted by the caller BEFORE the final answer is sent, - // so the final's own delivery cannot - // perturb the counts; the EDIT itself runs AFTER the final so shrinking the - // tall window bubble down to one line happens above the anchored viewport - // (the final already sits at the bottom) and never drops the final off - // screen (the edit-shrink anchor loss). finalizeToPreview settles pending - // previews so a still-pending tool-progress window is materialized and - // edited rather than missed. - const applyProgressCollapseSummary = async (line: string): Promise => { - const messageId = await answerLane.stream?.finalizeToPreview(renderStreamText(line)); - if (typeof messageId !== "number") { - // finalizeToPreview could not edit in place (no live window id, or a - // flood-wait/terminal edit): post the bar durably instead. This send is - // cosmetic and runs after the final answer, so a throw must not fail the - // turn β€” the shared guarded helper swallows and logs. - await postCosmeticSummaryBar(line); - } - }; - // Reset answer-lane bookkeeping after a bar was edited/posted in place, - // WITHOUT clear() β€” the window message stays (as the bar) and must not be - // deleted (no focus-jump). forceNewMessage only rewinds the stream so the - // next send starts a new message. - const resetAnswerLaneAfterCollapse = () => { - if (activeAnswerDraftIsToolProgressOnly) { - resetAnswerToolProgressDraft(); - suppressProgressDraftState(); - rotateAnswerLaneWhenQueuedBlocksSettle = false; - } - answerLane.stream?.forceNewMessage(); - resetDraftLaneState(answerLane); - }; - // Tear the window down (delete) β€” only when there is NO bar to keep it on - // screen for (error final, or a turn with nothing to summarize). A bar - // collapse never reaches here, so clear()/delete never runs when a bar - // exists (the on-off focus-jump). - const teardownProgressWindow = async () => { - if (activeAnswerDraftIsToolProgressOnly) { - await rotateAnswerLaneAfterToolProgress(); - } else { - await answerLane.stream?.clear(); - resetDraftLaneState(answerLane); - } - }; - const deliverProgressModeFinalAnswer = async ( - payload: ReplyPayload, - text: string, - promptContextSequence: TelegramPromptContextProjectionSequence, - ): Promise => { - const afterAcceptedDraft = answerLane.stream?.hasConsumedReplyTarget?.() === true; - if (payload.isError === true) { - // Error finals get no collapse summary (Discord parity); tear down, then - // deliver the error below. - progressSummaryDelivered = true; - await teardownProgressWindow(); - const delivered = await sendPayload(applyTextToPayload(payload, text), { - afterAcceptedDraft, - durable: true, - promptContextSequence, - }); - if (!delivered) { - return { kind: "skipped" }; - } - answerLane.finalized = true; - markProgressFinalDelivered(); - return { kind: "sent" }; - } - // Snapshot the bar line BEFORE the final send so the final's own delivery - // cannot perturb the counts/timer (and the once-guard fires exactly once). - const barLine = resolveProgressCollapseSummaryLine(); - // Send the final FIRST so it lands at the bottom of the anchored viewport; - // THEN collapse the window above it. Editing the tall window down to a - // one-line bar after the final is delivered keeps the shrink above the - // anchor, so the final never scrolls off screen (edit-shrink anchor loss). - const delivered = await sendPayload(applyTextToPayload(payload, text), { - afterAcceptedDraft, - durable: true, - promptContextSequence, - }); - // Collapse AFTER the final either way β€” don't leave a stale window even - // when the final skipped/failed. resetAnswerLaneAfterCollapse resets lane - // state (clearing `finalized`), so mark the final delivered LAST. - if (barLine) { - await applyProgressCollapseSummary(barLine); - resetAnswerLaneAfterCollapse(); - } else { - // Nothing to summarize (window never rendered / empty counts): tear the - // stale window down rather than leaving it above the final. - await teardownProgressWindow(); - } - if (!delivered) { - return { kind: "skipped" }; - } - answerLane.finalized = true; - markProgressFinalDelivered(); - return { kind: "sent" }; - }; - async function deliverFinalAnswerText( - answerPayload: ReplyPayload, - text: string, - buttons?: TelegramInlineButtons, - ): Promise { - const transcriptFinal = await resolveCurrentTurnTranscriptFinal(); - const finalText = await resolveTranscriptBackedChannelFinalText({ - finalText: text, - resolveCandidateText: async () => transcriptFinal?.text, - }); - const source = resolvePromptContextSource( - transcriptFinal, - answerPayload, - applyTextToPayload(answerPayload, finalText), - ); - const promptContextSequence = createPromptContextSequence(source); - const isFollowUp = finalAnswerDelivered; - let result: LaneDeliveryResult; - if (!isFollowUp && streamMode === "progress") { - result = await deliverProgressModeFinalAnswer( - answerPayload, - finalText, - promptContextSequence, - ); - } else { - if (isFollowUp) { - await prepareAnswerLaneForText(); - } else if (!(await rotateAnswerLaneAfterToolProgress())) { - await rotateAnswerLaneAfterQueuedBlocksSettle(); - } - result = await deliverLaneText({ - laneName: "answer", - text: finalText, - payload: answerPayload, - infoKind: "final", - buttons, - allowStream: !usesNativeTelegramQuote(answerPayload), - promptContextSequence, - }); - if (!isFollowUp && result.kind !== "skipped") { - markProgressFinalDelivered(); - } - } - if (result.kind === "preview-finalized") { - await emitPreviewFinalizedHook(result); - } - return result; - } - + await prepareTelegramSticker({ cfg, context: dispatchContext }); if (isDmTopic) { try { - const sessionKeyLocal = ctxPayload.SessionKey; - if (sessionKeyLocal) { - const { entry } = loadFreshSessionEntry(route.agentId, sessionKeyLocal); - isFirstTurnInSession = !entry?.systemSent; + const sessionKey = dispatchContext.ctxPayload.SessionKey; + if (sessionKey) { + isFirstTurnInSession = !loadFreshSessionEntry(dispatchContext.route.agentId, sessionKey) + .entry?.systemSent; } else { logVerbose("auto-topic-label: SessionKey is absent, skipping first-turn detection"); } } catch (err) { - logVerbose(`auto-topic-label: session store error: ${formatErrorMessage(err)}`); + logVerbose(`auto-topic-label: session store error: ${String(err)}`); } } loadFreshSessionEntry.clear(); - - if (statusReactionController && !isRoomEvent) { - void statusReactionController.setThinking(); + if (status.controller && !isRoomEvent) { + void status.controller.setThinking(); } - - const { onModelSelected, ...replyPipeline } = ( - telegramDeps.createChannelMessageReplyPipeline ?? createChannelMessageReplyPipeline - )({ - cfg, - agentId: route.agentId, - channel: "telegram", - accountId: route.accountId, - typing: { - start: sendTyping, - maxConsecutiveFailures: TELEGRAM_MAX_CONSECUTIVE_TYPING_FAILURES, - onStartError: (err) => { - logTypingFailure({ - log: logVerbose, - channel: "telegram", - target: String(chatId), - error: err, - }); - }, - }, - }); - try { - const turnResult = await runChannelInboundEvent({ - channel: "telegram", - accountId: route.accountId, - raw: dispatchContext, - adapter: { - ingest: () => ({ - id: ctxPayload.MessageSid ?? `${chatId}:${Date.now()}`, - timestamp: typeof ctxPayload.Timestamp === "number" ? ctxPayload.Timestamp : undefined, - rawText: ctxPayload.RawBody ?? "", - textForAgent: ctxPayload.BodyForAgent, - textForCommands: ctxPayload.CommandBody, - raw: dispatchContext, - }), - resolveTurn: () => ({ - channel: "telegram", - accountId: route.accountId, - routeSessionKey: route.sessionKey, - storePath: dispatchContext.turn.storePath, - ctxPayload, - recordInboundSession: dispatchContext.turn.recordInboundSession, - record: dispatchContext.turn.record, - runDispatch: () => { - const sentBlockMediaUrls = new Set(); - - return telegramDeps.dispatchReplyWithBufferedBlockDispatcher({ - ctx: ctxPayload, - cfg, - dispatcherOptions: { - ...replyPipeline, - beforeDeliver: async (payload) => payload, - onBeforeDeliverCancelled: (payload, info) => { - if (info.kind === "block") { - return enqueueDraftLaneEvent(async () => { - dropQueuedAnswerBlockRotation(payload, info.assistantMessageIndex); - }); - } - return undefined; - }, - deliver: async (payload, info) => { - if (isDispatchSuperseded()) { - return; - } - - const normalizedPayload = normalizeDeliveryPayload(payload); - if (!normalizedPayload) { - return; - } - const deduped = - info.kind === "final" - ? deduplicateBlockSentMedia(normalizedPayload, sentBlockMediaUrls) - : normalizedPayload; - if (deduped === undefined) { - return; - } - const effectivePayload = deduped; - - if ( - shouldSuppressLocalTelegramExecApprovalPrompt({ - cfg, - accountId: route.accountId, - payload: effectivePayload, - }) - ) { - queuedFinal = true; - return; - } - const telegramButtons = resolvePayloadTelegramInlineButtons(effectivePayload); - const lanePayload = - info.kind === "block" && - typeof payload.text === "string" && - typeof effectivePayload.text === "string" && - payload.text !== effectivePayload.text && - payload.text.trimEnd() === effectivePayload.text && - !effectivePayload.mediaUrl && - !effectivePayload.mediaUrls?.length - ? { ...effectivePayload, text: payload.text } - : effectivePayload; - const split = splitTextIntoLaneSegments( - { text: lanePayload.text }, - payload.isReasoning, - ); - const segments = split.segments; - const reply = resolveSendableOutboundReplyParts(effectivePayload); - if (info.kind === "final" && (reply.text.length > 0 || reply.hasMedia)) { - markProgressFinalStarted(); - } - if (info.kind === "final") { - await enqueueDraftLaneEvent(async () => {}); - } - // Hide handled post-answer probe failures while preserving final warnings. - // Agents may intentionally run searches/commands with no result, recover, - // and send a final answer; late text-only failures are non-actionable noise. - const isToolPayloadAfterFinal = - info.kind === "tool" && (finalAnswerDeliveryStarted || finalAnswerDelivered); - const isNonTerminalWarningAfterDeliveredFinal = - isReplyPayloadNonTerminalToolErrorWarning(payload) && finalAnswerDelivered; - if ( - (isToolPayloadAfterFinal || isNonTerminalWarningAfterDeliveredFinal) && - !reply.hasMedia && - !hasExecApprovalPayload(effectivePayload) - ) { - return; - } - if (payload.isError === true) { - hadErrorReplyFailureOrSkip = true; - } - - const flushBufferedFinalAnswer = async () => { - const buffered = - reasoningStepState.takeBufferedFinalAnswer(replyFenceGeneration); - if (!buffered) { - return; - } - const bufferedButtons = resolvePayloadTelegramInlineButtons(buffered.payload); - await deliverFinalAnswerText( - buffered.payload, - buffered.text, - bufferedButtons, - ); - reasoningStepState.resetForNextStep(); - }; - - let blockDelivered = false; - const hasAnswerSegment = segments.some((segment) => segment.lane === "answer"); - if (info.kind === "block" && !hasAnswerSegment) { - dropQueuedAnswerBlockRotation(effectivePayload, info.assistantMessageIndex); - } - for (const segment of segments) { - if ( - segment.lane === "answer" && - info.kind === "final" && - reasoningStepState.shouldBufferFinalAnswer() - ) { - reasoningStepState.bufferFinalAnswer({ - payload: effectivePayload, - text: segment.update.text, - bufferedGeneration: replyFenceGeneration, - }); - continue; - } - if (segment.lane === "reasoning") { - reasoningStepState.noteReasoningHint(); - } - if (segment.lane === "answer" && info.kind === "tool") { - if (verboseProgressActive()) { - // Durable lane owns tool payloads: send standalone instead - // of diverting into the draft, which is discarded at final. - if ( - await sendPayload( - applyTextToPayload(effectivePayload, segment.update.text), - ) - ) { - blockDelivered = true; - } - continue; - } - const canRepresentAsTransientProgress = - !reply.hasMedia && - telegramButtons === undefined && - !hasExecApprovalPayload(effectivePayload); - const isFastModeProgressPayload = - isFastModeAutoProgressPayload(effectivePayload); - if (streamMode === "progress") { - if ( - canRepresentAsTransientProgress && - answerLane.stream && - !isFastModeProgressPayload - ) { - // Progress-mode streams render tool status in the - // live draft. Do not also emit text-only tool output - // as answer text, or simple commands duplicate and - // restart the progress draft. - continue; - } - if ( - (canRepresentAsTransientProgress || isFastModeProgressPayload) && - (await pushStreamToolProgress(segment.update.text, { - startImmediately: true, - })) - ) { - blockDelivered = true; - continue; - } - } - await prepareAnswerLaneForToolProgress(); - } - - const ownedByQueuedAnswerBlockRotation = queuedAnswerBlockRotations.some( - (entry) => - queuedAnswerBlockRotationMatchesDelivery( - entry, - lanePayload, - info.assistantMessageIndex, - ), - ); - - const skipTextOnlyBlock = - streamMode === "partial" && - info.kind === "block" && - segment.lane === "answer" && - !reply.hasMedia && - !hasExecApprovalPayload(effectivePayload) && - telegramButtons === undefined && - answerLane.hasStreamedMessage && - !activeAnswerDraftIsToolProgressOnly && - !ownedByQueuedAnswerBlockRotation && - segment.update.text.trimEnd() === answerLane.lastPartialText.trimEnd(); - - // Progress mode: the window is a pure activity log β€” interim - // answer blocks (intermediate assistant messages before the - // final) never render into it (Discord parity). Buffer the - // block so it still feeds the final/collapse, and skip the - // draft stream. Media/approval/button blocks fall through to - // normal delivery (they are not plain interim prose). - const suppressProgressAnswerBlock = - streamMode === "progress" && - info.kind === "block" && - segment.lane === "answer" && - !reply.hasMedia && - !hasExecApprovalPayload(effectivePayload) && - telegramButtons === undefined; - - if (skipTextOnlyBlock || suppressProgressAnswerBlock) { - // Keep blocks available for later rotation/finalization. - activeAnswerBlockDelivery = { - payload: effectivePayload, - text: segment.update.text, - buttons: telegramButtons, - }; - resetAnswerToolProgressDraft(); - resetProgressDraftState(); - blockDelivered = true; - continue; - } - - if (segment.lane === "answer" && info.kind === "block") { - const preparedAnswerLane = await prepareAnswerLaneForText(); - const shouldRotateQueuedBlock = takeQueuedAnswerBlockRotation( - lanePayload, - info.assistantMessageIndex, - ); - // Single stationary window in progress mode: plain interim - // answer blocks are already suppressed above, so only - // media/approval/button blocks reach here in progress β€” they - // still must not rotate the window to a fresh bubble. - if ( - streamMode !== "progress" && - shouldRotateQueuedBlock && - !preparedAnswerLane - ) { - await rotateAnswerLaneForNewMessage(); - rotateAnswerLaneWhenQueuedBlocksSettle = false; - } - resetAnswerToolProgressDraft(); - resetProgressDraftState(); - } - const result = - segment.lane === "answer" && info.kind === "final" - ? await deliverFinalAnswerText( - effectivePayload, - segment.update.text, - telegramButtons, - ) - : await deliverLaneText({ - laneName: segment.lane, - text: segment.update.text, - payload: lanePayload, - infoKind: info.kind, - buttons: telegramButtons, - }); - if ( - segment.lane === "answer" && - info.kind !== "final" && - result.kind === "preview-finalized" - ) { - await emitPreviewFinalizedHook(result); - } - if ( - segment.lane === "answer" && - info.kind === "block" && - result.kind === "preview-updated" - ) { - activeAnswerBlockDelivery = { - payload: lanePayload, - text: segment.update.text, - buttons: telegramButtons, - }; - } - blockDelivered = blockDelivered || result.kind !== "skipped"; - if (segment.lane === "reasoning") { - if (result.kind !== "skipped") { - reasoningStepState.noteReasoningDelivered(); - await flushBufferedFinalAnswer(); - } - continue; - } - if (info.kind === "final") { - reasoningStepState.resetForNextStep(); - } - } - const trackBlockMedia = (delivered: boolean) => { - if ( - delivered && - info.kind === "block" && - effectivePayload.mediaUrls?.length - ) { - for (const url of effectivePayload.mediaUrls) { - sentBlockMediaUrls.add(url); - } - } - }; - - if (segments.length > 0) { - trackBlockMedia(blockDelivered); - return; - } - if (split.suppressedReasoningOnly) { - let delivered = false; - if (reply.hasMedia) { - if (info.kind === "final") { - await rotateAnswerLaneAfterToolProgress(); - await answerLane.stream?.stop(); - await reasoningLane.stream?.stop(); - reasoningStepState.resetForNextStep(); - } - const payloadWithoutSuppressedReasoning = - typeof effectivePayload.text === "string" - ? { ...effectivePayload, text: "" } - : effectivePayload; - delivered = await sendPayload(payloadWithoutSuppressedReasoning, { - durable: info.kind === "final", - }); - } - if (info.kind === "final" && delivered) { - markProgressFinalDelivered(); - } - if (info.kind === "final") { - await flushBufferedFinalAnswer(); - } - trackBlockMedia(delivered); - return; - } - - if (info.kind === "final") { - await rotateAnswerLaneAfterToolProgress(); - await answerLane.stream?.stop(); - await reasoningLane.stream?.stop(); - reasoningStepState.resetForNextStep(); - } - const canSendAsIs = reply.hasMedia || reply.text.length > 0; - if (!canSendAsIs) { - if (info.kind === "final") { - await flushBufferedFinalAnswer(); - } - return; - } - const delivered = await sendPayload(effectivePayload, { - durable: info.kind === "final", - }); - if (info.kind === "final" && delivered) { - markProgressFinalDelivered(); - } - if (info.kind === "final") { - await flushBufferedFinalAnswer(); - } - trackBlockMedia(delivered); - }, - onSkip: (payload, info) => { - if (info.kind === "block") { - void enqueueDraftLaneEvent(async () => { - dropQueuedAnswerBlockRotation(payload, info.assistantMessageIndex); - }); - } - if (payload.isError === true) { - hadErrorReplyFailureOrSkip = true; - } - if (info.reason !== "silent") { - deliveryState.markNonSilentSkip(); - } - }, - onError: (err, info) => { - const errorPolicy = resolveTelegramErrorPolicy({ - accountConfig: telegramCfg, - groupConfig, - topicConfig, - }); - if (isSilentErrorPolicy(errorPolicy.policy)) { - return; - } - if ( - errorPolicy.policy === "once" && - shouldSuppressTelegramError({ - scopeKey: buildTelegramErrorScopeKey({ - accountId: route.accountId, - chatId, - threadId: threadSpec.id, - }), - cooldownMs: errorPolicy.cooldownMs, - errorMessage: String(err), - }) - ) { - return; - } - deliveryState.markNonSilentFailure(); - runtime.error?.(danger(`telegram ${info.kind} reply failed: ${String(err)}`)); - }, - }, - replyOptions: { - skillFilter, - disableBlockStreaming, - abortSignal: replyAbortSignal, - onTurnAdopted: adoptReplyTurn, - sourceReplyDeliveryMode: isRoomEvent ? "message_tool_only" : undefined, - queuedDeliveryCorrelations: isRoomEvent - ? [{ begin: beginDeliveryCorrelation }] - : undefined, - queuedFollowupLifecycle: - isRoomEvent || onTurnAdopted || onTurnDeferred || onTurnAbandoned - ? { - onEnqueued: () => { - replyAbortControllerQueued = true; - onTurnDeferred?.(); - }, - onAdmitted: async () => { - await adoptReplyTurn(); - queuedTurnAdmitted = true; - }, - onComplete: () => { - replyAbortControllerQueued = false; - releaseTelegramReplyFenceAbortController( - activeReplyFenceKey, - replyAbortController, - ); - if (!queuedTurnAdmitted) { - onTurnAbandoned?.(); - } - }, - } - : undefined, - suppressTyping: isRoomEvent, - onPartialReply: - answerLane.stream || reasoningLane.stream - ? (payload) => - enqueueDraftLaneEvent(async () => { - await ingestDraftLaneSegments(payload); - }) - : undefined, - onBlockReplyQueued: answerLane.stream - ? (payload, blockContext) => - enqueueDraftLaneEvent(async () => { - await prepareQueuedAnswerBlock(payload, blockContext); - }) - : undefined, - onReasoningStream: reasoningLane.stream - ? (payload) => - enqueueDraftLaneEvent(async () => { - if (splitReasoningOnNextStream) { - repositionDraftLaneForNewMessage(reasoningLane); - splitReasoningOnNextStream = false; - } - await ingestDraftLaneSegments(payload, true); - }) - : streamReasoningInProgressDraft - ? (payload) => - enqueueDraftLaneEvent(async () => { - await pushStreamReasoningProgress(payload); - }) - : undefined, - onReasoningProgress: answerLane.stream - ? (payload) => - enqueueDraftLaneEvent(async () => { - await pushStreamThinkingTokenProgress(payload.progressTokens); - }) - : undefined, - onAssistantMessageStart: answerLane.stream - ? () => - enqueueDraftLaneEvent(async () => { - reasoningStepState.resetForNextStep(); - finalAnswerDelivered = false; - if (streamMode !== "progress") { - resetProgressDraftState(); - } - if (answerLane.finalized) { - await rotateLaneForNewMessage(answerLane); - rotateAnswerLaneWhenQueuedBlocksSettle = false; - } else if ( - answerLane.hasStreamedMessage && - !activeAnswerDraftIsToolProgressOnly - ) { - rotateAnswerLaneWhenQueuedBlocksSettle = true; - } - }) - : undefined, - onReasoningEnd: reasoningLane.stream - ? () => - enqueueDraftLaneEvent(async () => { - progressSummary.closeReasoningBurst(); - splitReasoningOnNextStream = reasoningLane.hasStreamedMessage; - resetProgressDraftState(); - }) - : () => { - // Window-reasoning turns have no separate reasoning lane; - // reasoning-end is still a burst boundary for the collapse - // summary (some models never fire it β€” the tracker also - // closes at the next tool call or the summary flush). - progressSummary.closeReasoningBurst(); - }, - suppressDefaultToolProgressMessages: - !streamDeliveryEnabled || Boolean(answerLane.stream), - forceToolResultProgress: streamMode === "progress" && streamToolProgressEnabled, - allowProgressCallbacksWhenSourceDeliverySuppressed: - !isRoomEvent && Boolean(answerLane.stream), - onVerboseProgressVisibility: (isActive) => { - verboseProgressActive = isActive; - }, - commentaryProgressEnabled: - streamMode === "progress" ? progressDraft.commentaryProgressEnabled : undefined, - progressPreambleEnabled: - streamMode === "progress" && answerLane.stream ? true : undefined, - reasoningPayloadsEnabled: durableReasoningPayloadsEnabled, - onToolStart: async (payload) => { - const toolName = payload.name?.trim(); - // Only the "start" phase is a boundary (later phases of the same - // call must not inflate the tally). The tool closes the preceding - // reasoning AND commentary bursts, counting each per-burst β€” so a - // turn's notes sharing the turn-local id "commentary-0" tally as - // N, not 1 (D3). The tool itself is counted only when it renders - // to the window: under verbose, tool summaries persist as their - // own durable messages and must NOT also feed the bar (invariant: - // persistent message XOR bar count β€” D2). - if (payload.phase === "start") { - // Count a tool only when the WINDOW actually renders it, so the - // bar's πŸ› οΈ tally matches what streamed. The compositor renders - // a tool line only for work tools (isChannelProgressDraftWorkToolName - // rejects message/reply/react/typing/etc.) and only when - // toolProgress is on; a start-phase message tool (codex/ - // message_tool_only) otherwise inflated the count with no tool - // line. canPushStreamToolProgress() is false under verbose (the - // durable lane owns the tool message: persistent XOR window). In - // every non-counting case the tool start is still a burst - // boundary, so close reasoning/commentary without counting it. - const windowRendersTool = - canPushStreamToolProgress() && - streamToolProgressEnabled && - isChannelProgressDraftWorkToolName(toolName); - if (windowRendersTool) { - progressSummary.noteToolCall(); - } else { - progressSummary.closeReasoningBurst(); - progressSummary.closeCommentaryBurst(); - } - } - const progressPromise = pushStreamToolProgress( - buildChannelProgressDraftLineForEntry( - telegramCfg, - { - event: "tool", - itemId: payload.itemId, - toolCallId: payload.toolCallId, - name: toolName, - phase: payload.phase, - args: payload.args, - }, - payload.detailMode ? { detailMode: payload.detailMode } : undefined, - ), - { toolName, startImmediately: true }, - ); - if (statusReactionController && toolName) { - await statusReactionController.setTool(toolName); - } - await progressPromise; - }, - onItemEvent: async (payload) => { - if (payload.kind === "preamble") { - if (verboseProgressActive()) { - // Durable verbose lane owns commentary; not counted toward - // the collapse summary β€” it did not stream to the window. - return; - } - if (streamMode === "progress") { - await progressDraft.pushPreambleHeadline(payload.progressText, { - itemId: payload.itemId, - }); - } - if (streamMode === "progress" && progressDraft.commentaryProgressEnabled) { - const accepted = await progressDraft.pushCommentaryProgress( - payload.progressText, - { - itemId: payload.itemId, - }, - ); - // Only accepted opt-in commentary contributes receipt notes; - // the always-on headline is display state, not a second lane. - if (accepted) { - progressSummary.noteCommentary(payload.itemId, payload.progressText); - } - } - return; - } - await pushStreamToolProgress( - buildChannelProgressDraftLineForEntry(telegramCfg, { - event: "item", - itemId: payload.itemId, - toolCallId: payload.toolCallId, - itemKind: payload.kind, - title: payload.title, - name: payload.name, - phase: payload.phase, - status: payload.status, - summary: payload.summary, - progressText: payload.progressText, - meta: payload.meta, - }), - ); - }, - onPlanUpdate: async (payload) => { - if (payload.phase !== "update") { - return; - } - await pushStreamToolProgress( - buildChannelProgressDraftLine({ - event: "plan", - phase: payload.phase, - title: payload.title, - explanation: payload.explanation, - steps: payload.steps, - }), - ); - }, - onApprovalEvent: async (payload) => { - if (payload.phase !== "requested") { - return; - } - await pushStreamToolProgress( - buildChannelProgressDraftLine({ - event: "approval", - phase: payload.phase, - title: payload.title, - command: payload.command, - reason: payload.reason, - message: payload.message, - }), - ); - }, - onToolResult: async (payload) => { - const text = payload.text?.trim(); - if (!text) { - return; - } - const updatedDraft = await pushStreamToolProgress(text, { - startImmediately: true, - }); - if ( - !updatedDraft && - isFastModeAutoProgressPayload(payload) && - !canPushStreamToolProgress() - ) { - await sendPayload(payload); - } - }, - onCommandOutput: async (payload) => { - if (payload.phase !== "end") { - return; - } - await pushStreamToolProgress( - buildChannelProgressDraftLineForEntry(telegramCfg, { - event: "command-output", - itemId: payload.itemId, - toolCallId: payload.toolCallId, - phase: payload.phase, - title: payload.title, - name: payload.name, - status: payload.status, - exitCode: payload.exitCode, - }), - ); - }, - onPatchSummary: async (payload) => { - if (payload.phase !== "end") { - return; - } - await pushStreamToolProgress( - buildChannelProgressDraftLine({ - event: "patch", - itemId: payload.itemId, - toolCallId: payload.toolCallId, - phase: payload.phase, - title: payload.title, - name: payload.name, - added: payload.added, - modified: payload.modified, - deleted: payload.deleted, - summary: payload.summary, - }), - ); - }, - onCompactionStart: statusReactionController - ? async () => { - await statusReactionController.setCompacting(); - } - : undefined, - onCompactionEnd: statusReactionController - ? async () => { - statusReactionController.cancelPending(); - await statusReactionController.setThinking(); - } - : undefined, - onModelSelected, - }, - }); - }, - }), - }, + turnDispatched = await runTelegramDispatchTurn({ + cfg, + context: dispatchContext, + delivery, + draft, + fence, + progress, + reply, + state, + statusReactionController: status.controller, + streamMode, + telegramCfg, + telegramDeps, }); - if (!turnResult.dispatched) { - return { kind: "completed" }; - } - ({ queuedFinal } = turnResult.dispatchResult); - // Out-of-band finals (message_tool_only) never run the in-band final-delivery - // path, so record the final from the dispatch counts for the cleanup-time - // collapse-bar fallback. - if ((turnResult.dispatchResult.counts?.final ?? 0) > 0) { - sawProgressFinal = true; - } - suppressSilentReplyFallback = - turnResult.dispatchResult.sourceReplyDeliveryMode === "message_tool_only"; } catch (err) { - dispatchError = err; + state.dispatchError = err; runtime.error?.(danger(`telegram dispatch failed: ${String(err)}`)); } finally { - progressDraft.cancel(); - await draftLaneEventQueue; + progress.cancel(); + await draft.waitForEvents(); try { - await finalizePendingAnswerBlockDraft(); + await delivery.finalizePendingAnswerBlockDraft(state); } catch (err) { - dispatchError ??= err; + state.dispatchError ??= err; runtime.error?.(danger(`telegram terminal block delivery failed: ${String(err)}`)); } - const lanesToCleanup: Array<{ laneName: LaneName; lane: DraftLaneState }> = [ - { laneName: "answer", lane: answerLane }, - { laneName: "reasoning", lane: reasoningLane }, - ]; - for (const { lane } of lanesToCleanup) { - const stream = lane.stream; - if (!stream) { - continue; - } - if (isDispatchSuperseded()) { - await (typeof stream.discard === "function" ? stream.discard() : stream.stop()); - continue; - } - if (lane.finalized) { - await stream.stop(); - } else { - await stream.clear(); - } - } - // Fallback collapse summary (Discord parity): finals that bypass - // deliverProgressModeFinalAnswer β€” notably message_tool_only/codex turns - // whose final is delivered out-of-band β€” still collapse here. The internal - // once-guard and progressDraftEverRendered check keep this from - // double-posting or firing when the window never rendered. + await draft.cleanup(fence.isSuperseded()); if ( streamMode === "progress" && - sawProgressFinal && - !dispatchError && - !hadErrorReplyFailureOrSkip && - !isDispatchSuperseded() + progress.sawProgressFinal() && + !state.dispatchError && + !state.hadErrorReplyFailureOrSkip && + !fence.isSuperseded() ) { - await deliverProgressCollapseSummary(); + await delivery.deliverProgressCollapseSummary(); } } } finally { - dispatchWasSuperseded = isDispatchSuperseded(); - releaseReplyFence(); - endTelegramInboundEventDeliveryCorrelation(); + dispatchWasSuperseded = fence.isSuperseded(); + fence.release(); + } + + if (turnDispatched === false) { + return { kind: "completed" }; } if (dispatchWasSuperseded) { - if (statusReactionController) { - void finalizeTelegramStatusReaction({ outcome: "done", hasFinalResponse: true }).catch( - (err: unknown) => { - logVerbose(`telegram: status reaction finalize failed: ${String(err)}`); - }, - ); + if (status.controller) { + status.finalizeInBackground({ outcome: "done", hasFinalResponse: true }, "finalize"); } else { - removeAckReactionAfterReply({ - removeAfterReply: removeAckAfterReply, - ackReactionPromise, - ackReactionValue: ackReactionPromise ? "ack" : null, - remove: () => - (reactionApi?.(chatId, msg.message_id ?? 0, []) ?? Promise.resolve()).then(() => {}), - onError: (err) => { - if (!msg.message_id) { - return; - } - logAckFailure({ - log: logVerbose, - channel: "telegram", - target: `${chatId}/${msg.message_id}`, - error: err, - }); - }, - }); + status.removeAck(); } return { kind: "completed" }; } + + const deliverySummary = delivery.snapshot(); let sentFallback = false; - const deliverySummary = deliveryState.snapshot(); const shouldSendFailureFallback = !isRoomEvent && !suppressFailureFallback && - !finalAnswerDelivered && - (dispatchError || deliverySummary.skippedNonSilent > 0 || deliverySummary.failedNonSilent > 0); + !progress.finalAnswerDelivered() && + (state.dispatchError || + deliverySummary.skippedNonSilent > 0 || + deliverySummary.failedNonSilent > 0); if (shouldSendFailureFallback) { - const fallbackText = dispatchError + const fallbackText = state.dispatchError ? "Something went wrong while processing your request. Please try again." : EMPTY_RESPONSE_FALLBACK; - const result = await (telegramDeps.deliverReplies ?? deliverReplies)({ - replies: [{ text: fallbackText }], - ...deliveryBaseOptions, - silent: silentErrorReplies && (dispatchError != null || hadErrorReplyFailureOrSkip), - mediaLoader: telegramDeps.loadWebMedia, - }); + const result = await delivery.deliverFallback( + [{ text: fallbackText }], + telegramCfg.silentErrorReplies === true && + (state.dispatchError != null || state.hadErrorReplyFailureOrSkip), + ); sentFallback = result.delivered; } if ( !sentFallback && - !dispatchError && + !state.dispatchError && !deliverySummary.delivered && - !suppressSilentReplyFallback && - !queuedFinal && - isGroup + !state.suppressSilentReplyFallback && + !state.queuedFinal && + dispatchContext.isGroup ) { const policySessionKey = - ctxPayload.CommandSource === "native" - ? (ctxPayload.CommandTargetSessionKey ?? ctxPayload.SessionKey) - : ctxPayload.SessionKey; + dispatchContext.ctxPayload.CommandSource === "native" + ? (dispatchContext.ctxPayload.CommandTargetSessionKey ?? + dispatchContext.ctxPayload.SessionKey) + : dispatchContext.ctxPayload.SessionKey; const silentReplyFallback = projectOutboundPayloadPlanForDelivery( createOutboundPayloadPlan([{ text: "NO_REPLY" }], { cfg, @@ -2981,127 +507,71 @@ export const dispatchTelegramMessage = async ({ }), ); if (silentReplyFallback.length > 0) { - const result = await (telegramDeps.deliverReplies ?? deliverReplies)({ - replies: silentReplyFallback, - ...deliveryBaseOptions, - silent: false, - mediaLoader: telegramDeps.loadWebMedia, - }); - sentFallback = result.delivered; + sentFallback = (await delivery.deliverFallback(silentReplyFallback, false)).delivered; } silentReplyDispatchLogger.debug("telegram turn ended without visible final response", { hasSessionKey: Boolean(policySessionKey), - hasChatId: chatId != null, - queuedFinal, + hasChatId: dispatchContext.chatId != null, + queuedFinal: state.queuedFinal, sentFallback, }); } const hasFinalResponse = - finalAnswerDelivered || sentFallback || suppressSilentReplyFallback || queuedFinal; + progress.finalAnswerDelivered() || + sentFallback || + state.suppressSilentReplyFallback || + state.queuedFinal; const hasVisibleResponse = - deliverySummary.delivered || sentFallback || suppressSilentReplyFallback || queuedFinal; + deliverySummary.delivered || + sentFallback || + state.suppressSilentReplyFallback || + state.queuedFinal; const deliveryFailureWithoutFinalResponse = - !finalAnswerDelivered && + !progress.finalAnswerDelivered() && (deliverySummary.skippedNonSilent > 0 || deliverySummary.failedNonSilent > 0); const retryableDispatchFailure = - dispatchError ?? + state.dispatchError ?? (deliveryFailureWithoutFinalResponse ? new Error( `Telegram reply delivery failed without a final response (failed=${deliverySummary.failedNonSilent}, skipped=${deliverySummary.skippedNonSilent})`, ) : null); - if (statusReactionController && !hasVisibleResponse) { - void finalizeTelegramStatusReaction({ outcome: "error", hasFinalResponse: false }).catch( - (err: unknown) => { - logVerbose(`telegram: status reaction error finalize failed: ${String(err)}`); - }, - ); + if (status.controller && !hasVisibleResponse) { + status.finalizeInBackground({ outcome: "error", hasFinalResponse: false }, "error finalize"); } - const shouldReturnRetryableDispatchFailure = retryDispatchErrors && - ((dispatchError != null && !hasFinalResponse) || - (dispatchError == null && deliveryFailureWithoutFinalResponse && !hasVisibleResponse)); - + ((state.dispatchError != null && !hasFinalResponse) || + (state.dispatchError == null && deliveryFailureWithoutFinalResponse && !hasVisibleResponse)); if (retryableDispatchFailure && shouldReturnRetryableDispatchFailure) { return { kind: "failed-retryable", error: retryableDispatchFailure }; } - if (!hasVisibleResponse) { return { kind: "completed" }; } - // Fire-and-forget: auto-rename DM topic on first message. - if (isDmTopic && isFirstTurnInSession) { - const userMessage = truncateUtf16Safe(ctxPayload.RawBody ?? ctxPayload.Body ?? "", 500); - if (userMessage.trim()) { - const agentDir = resolveAgentDir(cfg, route.agentId); - const directAutoTopicLabel = - !isGroup && groupConfig && "autoTopicLabel" in groupConfig - ? groupConfig.autoTopicLabel - : undefined; - const accountAutoTopicLabel = telegramCfg?.autoTopicLabel; - const autoTopicConfig = resolveAutoTopicLabelConfig( - directAutoTopicLabel, - accountAutoTopicLabel, - ); - if (autoTopicConfig) { - const topicThreadId = threadSpec.id!; - void (async () => { - try { - const label = await generateTopicLabel({ - userMessage, - prompt: autoTopicConfig.prompt, - cfg, - agentId: route.agentId, - agentDir, - }); - if (!label) { - logVerbose("auto-topic-label: LLM returned empty label"); - return; - } - logVerbose(`auto-topic-label: generated label (len=${label.length})`); - await bot.api.editForumTopic(chatId, topicThreadId, { name: label }); - logVerbose(`auto-topic-label: renamed topic ${chatId}/${topicThreadId}`); - } catch (err) { - logVerbose(`auto-topic-label: failed: ${formatErrorMessage(err)}`); - } - })(); - } - } - } - - if (statusReactionController) { - const statusReactionOutcome = - !finalAnswerDelivered && (dispatchError != null || sentFallback) ? "error" : "done"; - void finalizeTelegramStatusReaction({ - outcome: statusReactionOutcome, - hasFinalResponse: true, - }).catch((err: unknown) => { - logVerbose(`telegram: status reaction finalize failed: ${String(err)}`); - }); - } else { - removeAckReactionAfterReply({ - removeAfterReply: removeAckAfterReply, - ackReactionPromise, - ackReactionValue: ackReactionPromise ? "ack" : null, - remove: () => - (reactionApi?.(chatId, msg.message_id ?? 0, []) ?? Promise.resolve()).then(() => {}), - onError: (err) => { - if (!msg.message_id) { - return; - } - logAckFailure({ - log: logVerbose, - channel: "telegram", - target: `${chatId}/${msg.message_id}`, - error: err, - }); + scheduleDmTopicLabel({ + bot, + cfg, + context: dispatchContext, + isFirstTurnInSession, + telegramCfg, + }); + if (status.controller) { + status.finalizeInBackground( + { + outcome: + !progress.finalAnswerDelivered() && (state.dispatchError != null || sentFallback) + ? "error" + : "done", + hasFinalResponse: true, }, - }); + "finalize", + ); + } else { + status.removeAck(); } return { kind: "completed" }; }; -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/extensions/telegram/src/bot-message-dispatch.types.ts b/extensions/telegram/src/bot-message-dispatch.types.ts new file mode 100644 index 000000000000..3ab531a3987d --- /dev/null +++ b/extensions/telegram/src/bot-message-dispatch.types.ts @@ -0,0 +1,69 @@ +// Telegram plugin module defines message-dispatch contracts. +import type { Bot } from "grammy"; +import type { + OpenClawConfig, + ReplyToMode, + TelegramAccountConfig, +} from "openclaw/plugin-sdk/config-contracts"; +import type { ReplyPayload } from "openclaw/plugin-sdk/reply-payload"; +import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; +import type { TelegramBotDeps } from "./bot-deps.js"; +import type { TelegramMessageContext } from "./bot-message-context.js"; +import type { SessionEntry } from "./bot-message-dispatch.runtime.js"; +import type { TelegramBotOptions } from "./bot.types.js"; +import type { TelegramStreamMode } from "./bot/types.js"; + +export type DispatchTelegramMessageParams = { + context: TelegramMessageContext; + bot: Bot; + cfg: OpenClawConfig; + runtime: RuntimeEnv; + replyToMode: ReplyToMode; + streamMode: TelegramStreamMode; + textLimit: number; + telegramCfg: TelegramAccountConfig; + telegramDeps?: TelegramBotDeps; + opts: Pick; + retryDispatchErrors?: boolean; + suppressFailureFallback?: boolean; + /** Fires after recovery-relevant session/run state is durably persisted. */ + onTurnAdopted?: () => void | Promise; + /** Marks a queued follow-up whose adoption will happen at reply-lane admission. */ + onTurnDeferred?: () => void; + /** Releases a deferred turn that completed without ever owning the reply lane. */ + onTurnAbandoned?: () => void; + /** Cancels queued/model work when ingress ownership fails before adoption. */ + turnAbortSignal?: AbortSignal; +}; + +export type TelegramDispatchResult = + | { kind: "completed" } + | { kind: "failed-retryable"; error: unknown }; + +export type TelegramReasoningLevel = "off" | "on" | "stream"; +export type TelegramTranscriptMirrorPayload = { text?: string; mediaUrls?: string[] }; +export type CurrentTurnTranscriptFinal = { messageId?: string; text: string }; +export type TelegramScopedTranscriptSession = { sessionId: string; storePath: string }; + +export type FreshTelegramSessionEntryLoader = (( + agentId: string, + sessionKey: string, +) => { + storePath: string; + entry?: SessionEntry; +}) & { + clear: () => void; +}; + +export type TelegramAnswerBlockDelivery = { + payload: ReplyPayload; + text: string; + buttons: import("./button-types.js").TelegramInlineButtons | undefined; +}; + +export type TelegramDispatchTurnState = { + queuedFinal: boolean; + suppressSilentReplyFallback: boolean; + hadErrorReplyFailureOrSkip: boolean; + dispatchError?: unknown; +}; diff --git a/src/channels/turn/message-turn-guardrails.test.ts b/src/channels/turn/message-turn-guardrails.test.ts index 5b190c22e7b3..b6f9de9ea339 100644 --- a/src/channels/turn/message-turn-guardrails.test.ts +++ b/src/channels/turn/message-turn-guardrails.test.ts @@ -21,6 +21,7 @@ const migratedMessageTurnFiles = [ "extensions/slack/src/monitor/message-handler/prepare.ts", "extensions/telegram/src/bot-message-context.body.ts", "extensions/telegram/src/bot-message-context.session.ts", + "extensions/telegram/src/bot-message-dispatch-context.ts", "extensions/telegram/src/bot-message-dispatch.ts", "extensions/whatsapp/src/auto-reply/monitor/group-gating.ts", "extensions/zalouser/src/monitor.ts", @@ -37,7 +38,7 @@ const historyWindowFiles = [ "extensions/qqbot/src/bridge/sdk-adapter.ts", "extensions/signal/src/monitor/event-handler.ts", "extensions/slack/src/monitor/message-handler/prepare.ts", - "extensions/telegram/src/bot-message-dispatch.ts", + "extensions/telegram/src/bot-message-dispatch-context.ts", "extensions/telegram/src/group-history-window.ts", "extensions/whatsapp/src/auto-reply/monitor/group-gating.ts", "extensions/zalouser/src/monitor.ts",