refactor(telegram): split message dispatch (#108228)

* refactor(telegram): split message dispatch

* fix(telegram): keep dispatch types internal

* fix(telegram): satisfy dispatch lint
This commit is contained in:
Peter Steinberger
2026-07-15 03:25:54 -07:00
committed by GitHub
parent b4d82f4729
commit bb3fae8348
14 changed files with 3280 additions and 2921 deletions
-1
View File
@@ -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
@@ -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<void> {
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,
},
};
}
@@ -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<typeof import("./bot-message-dispatch.runtime.js").resolveChunkMode>;
context: TelegramMessageContext;
dispatchStartedAt: number;
draft: TelegramDraftController;
isDispatchSuperseded: () => boolean;
loadFreshSessionEntry: FreshTelegramSessionEntryLoader;
mediaLocalRoots: readonly string[];
opts: Pick<TelegramBotOptions, "token" | "mediaMaxMb">;
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<typeof deliverReplies>[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<boolean> =>
(
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<LaneDeliveryResult> => {
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<LaneDeliveryResult> => {
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<typeof createTelegramDeliveryController>;
@@ -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<typeof resolveMarkdownTableMode>;
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<LaneName, DraftLaneState> = {
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<void>) | 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<boolean> => {
// 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<void>): Promise<void> => {
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<void>) => {
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<typeof createTelegramDraftController>;
@@ -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<typeof createTelegramReplyFenceController>;
@@ -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<BufferedDispatchParams["replyOptions"]>;
type CallbackPayload<K extends keyof ReplyOptions> =
NonNullable<ReplyOptions[K]> 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<void>,
) => {
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<typeof createTelegramProgressController>;
@@ -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<DispatcherOptions["onSkip"]>;
type ErrorCallback = NonNullable<DispatcherOptions["onError"]>;
type Cancel = NonNullable<DispatcherOptions["onBeforeDeliverCancelled"]>;
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<TelegramReplyFenceController, "generation" | "isSuperseded">;
progress: TelegramProgressController;
runtime: RuntimeEnv;
state: TelegramDispatchTurnState;
streamMode: TelegramStreamMode;
telegramCfg: TelegramAccountConfig;
}) {
const reasoningStepState = createTelegramReasoningStepState();
const sentBlockMediaUrls = new Set<string>();
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<Cancel>[0], info: Parameters<Cancel>[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<typeof createTelegramReplyDelivery>;
@@ -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<string, ReturnType<typeof getSessionEntry>>();
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<CurrentTurnTranscriptFinal | undefined> {
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;
}
};
}
@@ -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 };
}
@@ -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();
}
}
@@ -563,13 +563,14 @@ describe("dispatchTelegramMessage draft streaming", () => {
onTurnDeferred?: Parameters<typeof dispatchTelegramMessage>[0]["onTurnDeferred"];
onTurnAbandoned?: Parameters<typeof dispatchTelegramMessage>[0]["onTurnAbandoned"];
turnAbortSignal?: Parameters<typeof dispatchTelegramMessage>[0]["turnAbortSignal"];
runtime?: Parameters<typeof dispatchTelegramMessage>[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, []]]);
File diff suppressed because it is too large Load Diff
@@ -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<TelegramBotOptions, "token" | "mediaMaxMb">;
retryDispatchErrors?: boolean;
suppressFailureFallback?: boolean;
/** Fires after recovery-relevant session/run state is durably persisted. */
onTurnAdopted?: () => void | Promise<void>;
/** 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;
};
@@ -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",