diff --git a/CHANGELOG.md b/CHANGELOG.md index 04248fe0d4a..38a1a2b5014 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -320,6 +320,7 @@ Docs: https://docs.openclaw.ai - Exec approvals/config fallback: inherit `ask` from `exec-approvals.json` when `tools.exec.ask` is unset, so local full/off defaults no longer fall back to `on-miss` for exec tool and `nodes run`. Landed from contributor PR #29187 by @Bartok9. Thanks @Bartok9. - Exec approvals/allow-always shell scripts: persist and match script paths for wrapper invocations like `bash scripts/foo.sh` while still blocking `-c`/`-s` wrapper bypasses. Landed from contributor PR #35137 by @yuweuii. Thanks @yuweuii. - Queue/followup dedupe across drain restarts: dedupe queued redelivery `message_id` values after queue recreation so busy-session followups no longer duplicate on replayed inbound events. Landed from contributor PR #33168 by @rylena. Thanks @rylena. +- Telegram/preview-final edit idempotence: treat `message is not modified` errors during preview finalization as delivered so partial-stream final replies do not fall back to duplicate sends. Landed from contributor PR #34983 by @HOYALIM. Thanks @HOYALIM. ## 2026.3.2 diff --git a/src/telegram/lane-delivery.test.ts b/src/telegram/lane-delivery.test.ts index 5259a99f6c7..1cd1d36cf4c 100644 --- a/src/telegram/lane-delivery.test.ts +++ b/src/telegram/lane-delivery.test.ts @@ -146,6 +146,30 @@ describe("createLaneTextDeliverer", () => { expect(harness.log).toHaveBeenCalledWith(expect.stringContaining("treating as delivered")); }); + it("treats 'message is not modified' preview edit errors as delivered", async () => { + const harness = createHarness({ answerMessageId: 999 }); + harness.editPreview.mockRejectedValue( + new Error( + "400: Bad Request: message is not modified: specified new message content and reply markup are exactly the same as a current content and reply markup of the message", + ), + ); + + const result = await harness.deliverLaneText({ + laneName: "answer", + text: "Hello final", + payload: { text: "Hello final" }, + infoKind: "final", + }); + + expect(result).toBe("preview-finalized"); + expect(harness.editPreview).toHaveBeenCalledTimes(1); + expect(harness.sendPayload).not.toHaveBeenCalled(); + expect(harness.markDelivered).toHaveBeenCalledTimes(1); + expect(harness.log).toHaveBeenCalledWith( + expect.stringContaining('edit returned "message is not modified"; treating as delivered'), + ); + }); + it("falls back to normal delivery when editing an existing preview fails", async () => { const harness = createHarness({ answerMessageId: 999 }); harness.editPreview.mockRejectedValue(new Error("500: preview edit failed")); diff --git a/src/telegram/lane-delivery.ts b/src/telegram/lane-delivery.ts index b02837d90b0..d46fd66cf57 100644 --- a/src/telegram/lane-delivery.ts +++ b/src/telegram/lane-delivery.ts @@ -2,6 +2,23 @@ import type { ReplyPayload } from "../auto-reply/types.js"; import type { TelegramInlineButtons } from "./button-types.js"; import type { TelegramDraftStream } from "./draft-stream.js"; +const MESSAGE_NOT_MODIFIED_RE = + /400:\s*Bad Request:\s*message is not modified|MESSAGE_NOT_MODIFIED/i; + +function isMessageNotModifiedError(err: unknown): boolean { + const text = + typeof err === "string" + ? err + : err instanceof Error + ? err.message + : typeof err === "object" && err && "description" in err + ? typeof err.description === "string" + ? err.description + : "" + : ""; + return MESSAGE_NOT_MODIFIED_RE.test(text); +} + export type LaneName = "answer" | "reasoning"; export type DraftLaneState = { @@ -216,6 +233,13 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) { params.markDelivered(); return true; } catch (err) { + if (isMessageNotModifiedError(err)) { + params.log( + `telegram: ${args.laneName} preview ${args.context} edit returned "message is not modified"; treating as delivered`, + ); + params.markDelivered(); + return true; + } if (args.treatEditFailureAsDelivered) { params.log( `telegram: ${args.laneName} preview ${args.context} edit failed after stop-created flush; treating as delivered (${String(err)})`,