diff --git a/.agents/maintainer-notes/telegram.md b/.agents/maintainer-notes/telegram.md index f80b21461b98..ab8ca29dba42 100644 --- a/.agents/maintainer-notes/telegram.md +++ b/.agents/maintainer-notes/telegram.md @@ -7,7 +7,6 @@ Verified against Telegram Bot API 10.0, May 8 2026. ## Streaming - Do not reintroduce `sendMessageDraft` for answer streaming. Telegram drafts are ephemeral 30-second previews in private chats; final delivery still requires a separate `sendMessage`. OpenClaw uses `sendMessage` plus `editMessageText`, then finalizes in place so the user sees one persistent answer. -- `sendMessageDraft` is allowed only for explicitly enabled transient private-chat tool progress/Thinking previews. Keep it default-off/canary-gated. Never route answer text, reasoning text, or final delivery through native drafts. - Streaming owns one visible preview message. Edit it forward. Do not send an extra final bubble unless the final edit genuinely failed. - Keep the first-preview debounce. If a provider sends token-sized deltas, coalesce them into cumulative preview text instead of removing the debounce. - Respect Telegram limits in the Telegram layer. Text over 4096 chars chains into continuation messages. Polls keep the current Bot API 12-option cap. diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index ed01e5a0b37c..4387106fe5fd 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -307,7 +307,7 @@ curl "https://api.telegram.org/bot/getUpdates" - direct chats: preview message + `editMessageText` - groups/topics: preview message + `editMessageText` - - direct-chat tool progress: optional native ephemeral `sendMessageDraft` Thinking/status preview when explicitly enabled and the Bot API supports it + - direct-chat tool progress: optional native `sendMessageDraft` status preview when enabled and supported Requirement: @@ -319,7 +319,7 @@ curl "https://api.telegram.org/bot/getUpdates" Tool-progress preview updates are the short status lines shown while tools run, for example command execution, file reads, planning updates, patch summaries, or Codex preamble/commentary text in Codex app-server mode. Telegram keeps these enabled by default to match released OpenClaw behavior from `v2026.4.22` and later. - In direct chats, supported Telegram Bot API clients can use native ephemeral drafts for these tool-progress lines. This shows an immediate Telegram-native Thinking/status preview without persisting tool chatter into the chat history. As soon as assistant answer text starts, OpenClaw stops updating the native draft and continues with the normal persistent answer preview/final delivery path. If `sendMessageDraft` is unavailable or rejected, OpenClaw silently falls back to the edited preview behavior. This native draft lane is off by default; enable it only for trusted DM canaries first: + Direct chats can use native Telegram drafts for these tool-progress lines without persisting tool chatter into chat history. Native drafts stop before answer text starts; final answers stay on the normal persistent delivery path. This lane is off by default and should be gated to trusted DM IDs first: ```json { diff --git a/extensions/telegram/src/bot-message-dispatch.test.ts b/extensions/telegram/src/bot-message-dispatch.test.ts index 3c21280c7977..d650c871654a 100644 --- a/extensions/telegram/src/bot-message-dispatch.test.ts +++ b/extensions/telegram/src/bot-message-dispatch.test.ts @@ -1605,6 +1605,28 @@ describe("dispatchTelegramMessage draft streaming", () => { expect(editMessageTelegram).not.toHaveBeenCalled(); }); + it("does not restart progress drafts after final answer delivery", async () => { + const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 }); + dispatchReplyWithBufferedBlockDispatcher.mockImplementation( + async ({ dispatcherOptions, replyOptions }) => { + await replyOptions?.onToolStart?.({ name: "exec", phase: "start" }); + await dispatcherOptions.deliver({ text: "Branch is up to date" }, { kind: "final" }); + await replyOptions?.onToolStart?.({ name: "exec", phase: "start" }); + return { queuedFinal: true }; + }, + ); + + await dispatchWithContext({ + context: createContext(), + streamMode: "progress", + telegramCfg: { streaming: { mode: "progress", progress: { label: "Shelling" } } }, + }); + + expect(answerDraftStream.update).toHaveBeenCalledTimes(1); + expect(answerDraftStream.update).toHaveBeenCalledWith("Shelling\n`🛠️ Exec`"); + expectDeliveredReply(0, { text: "Branch is up to date" }); + }); + it("uses the transcript final when progress-mode final text is truncated", async () => { setupDraftStreams({ answerMessageId: 2001 }); const fullAnswer = @@ -1727,6 +1749,31 @@ describe("dispatchTelegramMessage draft streaming", () => { expect(draftStream.flush).toHaveBeenCalled(); }); + it("keeps the progress draft label when tool progress lines are hidden", async () => { + const draftStream = createSequencedDraftStream(2001); + createTelegramDraftStream.mockReturnValue(draftStream); + dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ replyOptions }) => { + await replyOptions?.onReplyStart?.(); + await replyOptions?.onAssistantMessageStart?.(); + await replyOptions?.onToolStart?.({ name: "exec", phase: "start" }); + return { queuedFinal: false }; + }); + + await dispatchWithContext({ + context: createContext(), + streamMode: "progress", + telegramCfg: { + streaming: { + mode: "progress", + progress: { label: "Shelling", toolProgress: false }, + }, + }, + }); + + expect(draftStream.update).toHaveBeenCalledWith("Shelling"); + expect(draftStream.flush).toHaveBeenCalled(); + }); + it("renders Telegram progress drafts before slow status reactions resolve", async () => { const draftStream = createSequencedDraftStream(2001); createTelegramDraftStream.mockReturnValue(draftStream); diff --git a/extensions/telegram/src/bot-message-dispatch.ts b/extensions/telegram/src/bot-message-dispatch.ts index ad4203d64717..dc75628cb54a 100644 --- a/extensions/telegram/src/bot-message-dispatch.ts +++ b/extensions/telegram/src/bot-message-dispatch.ts @@ -671,15 +671,28 @@ export const dispatchTelegramMessage = async ({ } const rawText = typeof line === "string" ? line : line?.text; const normalized = sanitizeProgressMarkdownText(rawText?.replace(/\s+/g, " ").trim() ?? ""); + if (streamToolProgressSuppressed) { + return false; + } + if (streamMode !== "progress" && !streamToolProgressEnabled) { + return false; + } + const shouldUpdateProgressLines = + streamToolProgressEnabled && !streamToolProgressSuppressed && Boolean(normalized); + if (!shouldUpdateProgressLines && streamMode !== "progress") { + return false; + } const progressLine = typeof line === "object" && line !== undefined ? { ...line, text: normalized } : normalized; - if (nativeToolProgressDraft && !streamToolProgressSuppressed && normalized) { - const nextLines = mergeChannelProgressDraftLine(streamToolProgressLines, progressLine, { - maxLines: resolveChannelProgressDraftMaxLines(telegramCfg), - }); - if (nextLines === streamToolProgressLines) { - return false; - } + const nextLines = shouldUpdateProgressLines + ? mergeChannelProgressDraftLine(streamToolProgressLines, progressLine, { + maxLines: resolveChannelProgressDraftMaxLines(telegramCfg), + }) + : streamToolProgressLines; + if (shouldUpdateProgressLines && nextLines === streamToolProgressLines) { + return false; + } + if (nativeToolProgressDraft && shouldUpdateProgressLines) { const streamText = formatChannelProgressDraftText({ entry: telegramCfg, lines: nextLines, @@ -691,15 +704,6 @@ export const dispatchTelegramMessage = async ({ } } if (streamMode !== "progress") { - if (!streamToolProgressEnabled || streamToolProgressSuppressed || !normalized) { - return false; - } - const nextLines = mergeChannelProgressDraftLine(streamToolProgressLines, progressLine, { - maxLines: resolveChannelProgressDraftMaxLines(telegramCfg), - }); - if (nextLines === streamToolProgressLines) { - return false; - } streamToolProgressLines = nextLines; const streamText = formatChannelProgressDraftText({ entry: telegramCfg, @@ -714,21 +718,8 @@ export const dispatchTelegramMessage = async ({ answerLane.stream.update(streamText); return true; } - if (streamToolProgressEnabled && !streamToolProgressSuppressed && normalized) { - streamToolProgressLines = mergeChannelProgressDraftLine( - streamToolProgressLines, - progressLine, - { - maxLines: resolveChannelProgressDraftMaxLines(telegramCfg), - }, - ); - } - if ( - options?.startImmediately && - streamToolProgressEnabled && - !streamToolProgressSuppressed && - normalized - ) { + streamToolProgressLines = nextLines; + if (options?.startImmediately) { const alreadyStarted = progressDraftGate.hasStarted; await progressDraftGate.startNow(); if (alreadyStarted && progressDraftGate.hasStarted) { diff --git a/extensions/telegram/src/native-tool-progress-draft.test.ts b/extensions/telegram/src/native-tool-progress-draft.test.ts index 8ea517236780..4cf54d472af5 100644 --- a/extensions/telegram/src/native-tool-progress-draft.test.ts +++ b/extensions/telegram/src/native-tool-progress-draft.test.ts @@ -30,14 +30,12 @@ describe("createNativeTelegramToolProgressDraft", () => { } as never); expect(draft).toBeDefined(); - await draft?.update(""); await draft?.update("Running command"); - expect(sendMessageDraft).toHaveBeenCalledTimes(2); + expect(sendMessageDraft).toHaveBeenCalledTimes(1); const firstDraftId = sendMessageDraft.mock.calls[0]?.[1]; expect(firstDraftId).toEqual(expect.any(Number)); expect(firstDraftId).not.toBe(0); - expect(sendMessageDraft.mock.calls[1]?.[1]).toBe(firstDraftId); expect(sendMessageDraft).toHaveBeenLastCalledWith(123, firstDraftId, "Running command", { message_thread_id: 456, }); diff --git a/extensions/telegram/src/native-tool-progress-draft.ts b/extensions/telegram/src/native-tool-progress-draft.ts index 630ffb482e74..967fef662e89 100644 --- a/extensions/telegram/src/native-tool-progress-draft.ts +++ b/extensions/telegram/src/native-tool-progress-draft.ts @@ -8,7 +8,7 @@ const TELEGRAM_DRAFT_ID_STATE_KEY = Symbol.for("openclaw.telegramNativeDraftIdSt type TelegramSendMessageDraft = ( chatId: Parameters[0], draftId: number, - text?: string, + text: string, params?: { message_thread_id?: number; parse_mode?: "HTML"; @@ -69,6 +69,9 @@ export function createNativeTelegramToolProgressDraft(params: { return false; } const normalizedText = normalizeDraftText(text); + if (!normalizedText) { + return false; + } if (normalizedText === lastSentText) { return true; } diff --git a/src/config/types.telegram.ts b/src/config/types.telegram.ts index 93cb20a97115..4939f6284bca 100644 --- a/src/config/types.telegram.ts +++ b/src/config/types.telegram.ts @@ -64,12 +64,9 @@ export type TelegramStreamingMode = "off" | "partial" | "block" | "progress"; export type TelegramExecApprovalTarget = "dm" | "channel" | "both"; export type TelegramStreamingPreviewConfig = ChannelStreamingPreviewConfig & { - /** - * Use Telegram-native ephemeral draft UI for DM preview tool progress. - * Default: false. - */ + /** Use Telegram-native ephemeral draft UI for DM preview tool progress. */ nativeToolProgress?: boolean; - /** Optional Telegram sender/user ID allowlist for native DM preview tool progress. */ + /** Telegram sender/user IDs allowed to use native DM preview tool progress. */ nativeToolProgressAllowFrom?: Array; };