diff --git a/CHANGELOG.md b/CHANGELOG.md index 64a0571cb7a..083dfab122e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai - Mattermost replies: keep `root_id` pinned to the existing thread root when an agent replies inside a thread, while still using reply-target threading for top-level posts. (#27744) thanks @hnykda. - Agents/failover: detect Amazon Bedrock `Too many tokens per day` quota errors as rate limits across fallback, cron retry, and memory embeddings while keeping context-window `too many tokens per request` errors out of the rate-limit lane. (#39377) Thanks @gambletan. - Android/Play distribution: remove self-update, background location, `screen.record`, and background mic capture from the Android app, narrow the foreground service to `dataSync` only, and clean up the legacy `location.enabledMode=always` preference migration. (#39660) Thanks @obviyus. +- Telegram/DM partial streaming: keep DM preview lanes on real message edits instead of native draft materialization so final replies no longer flash a second duplicate copy before collapsing back to one. - macOS overlays: fix VoiceWake, Talk, and Notify overlay exclusivity crashes by removing shared `inout` visibility mutation from `OverlayPanelFactory.present`, and add a repeated Talk overlay smoke test. (#39275, #39321) Thanks @fellanH. - macOS Talk Mode: set the speech recognition request `taskHint` to `.dictation` for mic capture, and add regression coverage for the request defaults. (#38445) Thanks @dmiv. - macOS release packaging: default `scripts/package-mac-app.sh` to universal binaries for `BUILD_CONFIG=release`, and clarify that `scripts/package-mac-dist.sh` already produces the release zip + DMG. (#33891) Thanks @cgdusek. diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index e50590c8427..f49ea5fe3f7 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -232,10 +232,10 @@ curl "https://api.telegram.org/bot/getUpdates" ## Feature reference - + OpenClaw can stream partial replies in real time: - - direct chats: Telegram native draft streaming via `sendMessageDraft` + - direct chats: preview message + `editMessageText` - groups/topics: preview message + `editMessageText` Requirement: @@ -244,11 +244,9 @@ curl "https://api.telegram.org/bot/getUpdates" - `progress` maps to `partial` on Telegram (compat with cross-channel naming) - legacy `channels.telegram.streamMode` and boolean `streaming` values are auto-mapped - Telegram enabled `sendMessageDraft` for all bots in Bot API 9.5 (March 1, 2026). - For text-only replies: - - DM: OpenClaw updates the draft in place (no extra preview message) + - DM: OpenClaw keeps the same preview message and performs a final edit in place (no second message) - group/topic: OpenClaw keeps the same preview message and performs a final edit in place (no second message) For complex replies (for example media payloads), OpenClaw falls back to normal final delivery and then cleans up the preview message. @@ -872,7 +870,7 @@ Primary reference: - `channels.telegram.textChunkLimit`: outbound chunk size (chars). - `channels.telegram.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking. - `channels.telegram.linkPreview`: toggle link previews for outbound messages (default: true). -- `channels.telegram.streaming`: `off | partial | block | progress` (live stream preview; default: `partial`; `progress` maps to `partial`; `block` is legacy preview mode compatibility). In DMs, `partial` uses native `sendMessageDraft` when available. +- `channels.telegram.streaming`: `off | partial | block | progress` (live stream preview; default: `partial`; `progress` maps to `partial`; `block` is legacy preview mode compatibility). Telegram preview streaming uses a single preview message that is edited in place. - `channels.telegram.mediaMaxMb`: inbound/outbound Telegram media cap (MB, default: 100). - `channels.telegram.retry`: retry policy for Telegram send helpers (CLI/tools/actions) on recoverable outbound API errors (attempts, minDelayMs, maxDelayMs, jitter). - `channels.telegram.network.autoSelectFamily`: override Node autoSelectFamily (true=enable, false=disable). Defaults to enabled on Node 22+, with WSL2 defaulting to disabled. diff --git a/docs/concepts/streaming.md b/docs/concepts/streaming.md index 382dc730ccc..c31048cb268 100644 --- a/docs/concepts/streaming.md +++ b/docs/concepts/streaming.md @@ -138,7 +138,7 @@ Legacy key migration: Telegram: -- Uses Bot API `sendMessageDraft` in DMs when available, and `sendMessage` + `editMessageText` for group/topic preview updates. +- Uses `sendMessage` + `editMessageText` preview updates across DMs and group/topics. - Preview streaming is skipped when Telegram block streaming is explicitly enabled (to avoid double-streaming). - `/reasoning stream` can write reasoning to preview. diff --git a/src/telegram/bot-message-dispatch.test.ts b/src/telegram/bot-message-dispatch.test.ts index 1e8202bce67..8972532e139 100644 --- a/src/telegram/bot-message-dispatch.test.ts +++ b/src/telegram/bot-message-dispatch.test.ts @@ -1171,7 +1171,7 @@ describe("dispatchTelegramMessage draft streaming", () => { }, ); - it("uses message preview transport for DM reasoning lane when answer preview lane is active", async () => { + it("uses message preview transport for all DM lanes when streaming is active", async () => { setupDraftStreams({ answerMessageId: 999, reasoningMessageId: 111 }); dispatchReplyWithBufferedBlockDispatcher.mockImplementation( async ({ dispatcherOptions, replyOptions }) => { @@ -1190,7 +1190,7 @@ describe("dispatchTelegramMessage draft streaming", () => { expect(createTelegramDraftStream.mock.calls[0]?.[0]).toEqual( expect.objectContaining({ thread: { id: 777, scope: "dm" }, - previewTransport: "auto", + previewTransport: "message", }), ); expect(createTelegramDraftStream.mock.calls[1]?.[0]).toEqual( @@ -1201,9 +1201,8 @@ describe("dispatchTelegramMessage draft streaming", () => { ); }); - it("materializes DM answer draft final without sending a duplicate final message", async () => { - const answerDraftStream = createTestDraftStream({ previewMode: "draft" }); - answerDraftStream.materialize.mockResolvedValue(321); + it("finalizes DM answer preview in place without materializing or sending a duplicate", async () => { + const answerDraftStream = createDraftStream(321); const reasoningDraftStream = createDraftStream(111); createTelegramDraftStream .mockImplementationOnce(() => answerDraftStream) @@ -1222,12 +1221,17 @@ describe("dispatchTelegramMessage draft streaming", () => { expect(createTelegramDraftStream.mock.calls[0]?.[0]).toEqual( expect.objectContaining({ thread: { id: 777, scope: "dm" }, - previewTransport: "auto", + previewTransport: "message", }), ); - expect(answerDraftStream.materialize).toHaveBeenCalledTimes(1); + expect(answerDraftStream.materialize).not.toHaveBeenCalled(); expect(deliverReplies).not.toHaveBeenCalled(); - expect(editMessageTelegram).not.toHaveBeenCalled(); + expect(editMessageTelegram).toHaveBeenCalledWith( + 123, + 321, + "Checking the directory...", + expect.any(Object), + ); }); it("keeps reasoning and answer streaming in separate preview lanes", async () => { diff --git a/src/telegram/bot-message-dispatch.ts b/src/telegram/bot-message-dispatch.ts index 859a35688f6..63e7b6e8e8f 100644 --- a/src/telegram/bot-message-dispatch.ts +++ b/src/telegram/bot-message-dispatch.ts @@ -190,19 +190,21 @@ export const dispatchTelegramMessage = async ({ const draftReplyToMessageId = replyToMode !== "off" && typeof msg.message_id === "number" ? msg.message_id : undefined; const draftMinInitialChars = DRAFT_MIN_INITIAL_CHARS; + // Keep DM preview lanes on real message transport. Native draft previews still + // require a draft->message materialize hop, and that overlap keeps reintroducing + // a visible duplicate flash at finalize time. + const useMessagePreviewTransportForDm = threadSpec?.scope === "dm" && canStreamAnswerDraft; const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, route.agentId); const archivedAnswerPreviews: ArchivedPreview[] = []; const archivedReasoningPreviewIds: number[] = []; const createDraftLane = (laneName: LaneName, enabled: boolean): DraftLaneState => { - const useMessagePreviewTransportForDmReasoning = - laneName === "reasoning" && threadSpec?.scope === "dm" && canStreamAnswerDraft; const stream = enabled ? createTelegramDraftStream({ api: bot.api, chatId, maxChars: draftMaxChars, thread: threadSpec, - previewTransport: useMessagePreviewTransportForDmReasoning ? "message" : "auto", + previewTransport: useMessagePreviewTransportForDm ? "message" : "auto", replyToMessageId: draftReplyToMessageId, minInitialChars: draftMinInitialChars, renderText: renderDraftPreview,