diff --git a/CHANGELOG.md b/CHANGELOG.md index b7331b9f6..1911a75bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -133,6 +133,7 @@ Docs: https://docs.openclaw.ai - Telegram/draft-stream boundary stability: materialize DM draft previews at assistant-message/tool boundaries, serialize lane-boundary callbacks before final delivery, and scope preview cleanup to the active preview so multi-step Telegram streams no longer lose, overwrite, or leave stale preview bubbles. (#33842) Thanks @ngutman. - Telegram/DM draft finalization reliability: require verified final-text draft emission before treating preview finalization as delivered, and fall back to normal payload send when final draft delivery is not confirmed (preventing missing final responses and preserving media/button delivery). (#32118) Thanks @OpenCils. - Telegram/DM draft final delivery: materialize text-only `sendMessageDraft` previews into one permanent final message and skip duplicate final payload sends, while preserving fallback behavior when materialization fails. (#34318) Thanks @Brotherinlaw-13. +- Telegram/DM draft duplicate display: clear stale DM draft previews after materializing the real final message, including threadless fallback when DM topic lookup fails, so partial streaming no longer briefly shows duplicate replies. (#36746) Thanks @joelnishanth. - Telegram/draft preview boundary + silent-token reliability: stabilize answer-lane message boundaries across late-partial/message-start races, preserve/reset finalized preview state at the correct boundaries, and suppress `NO_REPLY` lead-fragment leaks without broad heartbeat-prefix false positives. (#33169) Thanks @obviyus. - Discord/audit wildcard warnings: ignore "\*" wildcard keys when counting unresolved guild channels so doctor/status no longer warns on allow-all configs. (#33125) Thanks @thewilloftheshadow. - Discord/channel resolution: default bare numeric recipients to channels, harden allowlist numeric ID handling with safe fallbacks, and avoid inbound WS heartbeat stalls. (#33142) Thanks @thewilloftheshadow. diff --git a/src/telegram/draft-stream.test.ts b/src/telegram/draft-stream.test.ts index 07de41344..fc65dd6b8 100644 --- a/src/telegram/draft-stream.test.ts +++ b/src/telegram/draft-stream.test.ts @@ -239,6 +239,27 @@ describe("createTelegramDraftStream", () => { }); }); + it("clears draft after materializing to avoid duplicate display in DM", async () => { + const api = createMockDraftApi(); + const stream = createDraftStream(api, { + thread: { id: 42, scope: "dm" }, + previewTransport: "draft", + }); + + stream.update("Hello"); + await stream.flush(); + const materializedId = await stream.materialize?.(); + + expect(materializedId).toBe(17); + expect(api.sendMessage).toHaveBeenCalledWith(123, "Hello", { message_thread_id: 42 }); + // Draft should be cleared with empty string after real message is sent. + const draftCalls = api.sendMessageDraft.mock.calls; + const clearCall = draftCalls.find((call) => call[2] === ""); + expect(clearCall).toBeDefined(); + expect(clearCall?.[0]).toBe(123); + expect(clearCall?.[3]).toEqual({ message_thread_id: 42 }); + }); + it("retries materialize send without thread when dm thread lookup fails", async () => { const api = createMockDraftApi(); api.sendMessage @@ -258,6 +279,10 @@ describe("createTelegramDraftStream", () => { expect(materializedId).toBe(55); expect(api.sendMessage).toHaveBeenNthCalledWith(1, 123, "Hello", { message_thread_id: 42 }); expect(api.sendMessage).toHaveBeenNthCalledWith(2, 123, "Hello", undefined); + const draftCalls = api.sendMessageDraft.mock.calls; + const clearCall = draftCalls.find((call) => call[2] === ""); + expect(clearCall).toBeDefined(); + expect(clearCall?.[3]).toBeUndefined(); expect(warn).toHaveBeenCalledWith( "telegram stream preview materialize send failed with message_thread_id, retrying without thread", ); diff --git a/src/telegram/draft-stream.ts b/src/telegram/draft-stream.ts index cb64ba80a..f7f03db48 100644 --- a/src/telegram/draft-stream.ts +++ b/src/telegram/draft-stream.ts @@ -150,13 +150,16 @@ export function createTelegramDraftStream(params: { parse_mode: sendArgs.renderedParseMode, } : replyParams; + const usedThreadParams = + "message_thread_id" in (sendParams ?? {}) && + typeof (sendParams as { message_thread_id?: unknown }).message_thread_id === "number"; try { - return await params.api.sendMessage(chatId, sendArgs.renderedText, sendParams); + return { + sent: await params.api.sendMessage(chatId, sendArgs.renderedText, sendParams), + usedThreadParams, + }; } catch (err) { - const hasThreadParam = - "message_thread_id" in (sendParams ?? {}) && - typeof (sendParams as { message_thread_id?: unknown }).message_thread_id === "number"; - if (!hasThreadParam || !THREAD_NOT_FOUND_RE.test(String(err))) { + if (!usedThreadParams || !THREAD_NOT_FOUND_RE.test(String(err))) { throw err; } const threadlessParams = { @@ -164,11 +167,14 @@ export function createTelegramDraftStream(params: { }; delete threadlessParams.message_thread_id; params.warn?.(sendArgs.fallbackWarnMessage); - return await params.api.sendMessage( - chatId, - sendArgs.renderedText, - Object.keys(threadlessParams).length > 0 ? threadlessParams : undefined, - ); + return { + sent: await params.api.sendMessage( + chatId, + sendArgs.renderedText, + Object.keys(threadlessParams).length > 0 ? threadlessParams : undefined, + ), + usedThreadParams: false, + }; } }; const sendMessageTransportPreview = async ({ @@ -186,7 +192,7 @@ export function createTelegramDraftStream(params: { } return true; } - const sent = await sendRenderedMessageWithThreadFallback({ + const { sent } = await sendRenderedMessageWithThreadFallback({ renderedText, renderedParseMode, fallbackWarnMessage: @@ -369,7 +375,7 @@ export function createTelegramDraftStream(params: { } const renderedParseMode = lastSentText ? lastSentParseMode : undefined; try { - const sent = await sendRenderedMessageWithThreadFallback({ + const { sent, usedThreadParams } = await sendRenderedMessageWithThreadFallback({ renderedText, renderedParseMode, fallbackWarnMessage: @@ -378,6 +384,20 @@ export function createTelegramDraftStream(params: { const sentId = sent?.message_id; if (typeof sentId === "number" && Number.isFinite(sentId)) { streamMessageId = Math.trunc(sentId); + // Clear the draft so Telegram's input area doesn't briefly show a + // stale copy alongside the newly materialized real message. + if (resolvedDraftApi != null && streamDraftId != null) { + const clearDraftId = streamDraftId; + const clearThreadParams = + usedThreadParams && threadParams?.message_thread_id != null + ? { message_thread_id: threadParams.message_thread_id } + : undefined; + try { + await resolvedDraftApi(chatId, clearDraftId, "", clearThreadParams); + } catch { + // Best-effort cleanup; draft clear failure is cosmetic. + } + } return streamMessageId; } } catch (err) {