diff --git a/CHANGELOG.md b/CHANGELOG.md index e0a763c1418..849b72ed9fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -91,6 +91,7 @@ Docs: https://docs.openclaw.ai - Commands/config writes: enforce `configWrites` against both the originating account and the targeted account scope for `/config` and config-backed `/allowlist` edits, blocking sibling-account mutations while preserving gateway `operator.admin` flows. Thanks @tdjackey for reporting. - Security/system.run: fail closed for approval-backed interpreter/runtime commands when OpenClaw cannot bind exactly one concrete local file operand, while extending best-effort direct-file binding to additional runtime forms. Thanks @tdjackey for reporting. - Gateway/session reset auth: split conversation `/new` and `/reset` handling away from the admin-only `sessions.reset` control-plane RPC so write-scoped gateway callers can no longer reach the privileged reset path through `agent`. Thanks @tdjackey for reporting. +- Telegram/final preview delivery followup: keep ambiguous first preview sends without a returned `message_id` instead of falling back to a second final send, so slow-provider Telegram replies stop duplicating on the first preview-final seam. (#41932) thanks @hougangdev. ## 2026.3.8 diff --git a/src/telegram/draft-stream.test.ts b/src/telegram/draft-stream.test.ts index fc65dd6b82a..58990c41abf 100644 --- a/src/telegram/draft-stream.test.ts +++ b/src/telegram/draft-stream.test.ts @@ -435,6 +435,46 @@ describe("createTelegramDraftStream", () => { expect(api.editMessageText).not.toHaveBeenCalledWith(123, 17, "Message B partial"); }); + it("marks sendMayHaveLanded after an ambiguous first preview send failure", async () => { + const api = createMockDraftApi(); + api.sendMessage.mockRejectedValueOnce(new Error("timeout after Telegram accepted send")); + const stream = createDraftStream(api); + + stream.update("Hello"); + await stream.flush(); + + expect(api.sendMessage).toHaveBeenCalledTimes(1); + expect(stream.sendMayHaveLanded?.()).toBe(true); + }); + + it("clears sendMayHaveLanded on pre-connect first preview send failures", async () => { + const api = createMockDraftApi(); + api.sendMessage.mockRejectedValueOnce( + Object.assign(new Error("connect ECONNREFUSED"), { code: "ECONNREFUSED" }), + ); + const stream = createDraftStream(api); + + stream.update("Hello"); + await stream.flush(); + + expect(api.sendMessage).toHaveBeenCalledTimes(1); + expect(stream.sendMayHaveLanded?.()).toBe(false); + }); + + it("clears sendMayHaveLanded on Telegram 4xx client rejections", async () => { + const api = createMockDraftApi(); + api.sendMessage.mockRejectedValueOnce( + Object.assign(new Error("403: Forbidden"), { error_code: 403 }), + ); + const stream = createDraftStream(api); + + stream.update("Hello"); + await stream.flush(); + + expect(api.sendMessage).toHaveBeenCalledTimes(1); + expect(stream.sendMayHaveLanded?.()).toBe(false); + }); + it("supports rendered previews with parse_mode", async () => { const api = createMockDraftApi(); const stream = createTelegramDraftStream({ diff --git a/src/telegram/lane-delivery-text-deliverer.ts b/src/telegram/lane-delivery-text-deliverer.ts index 8c4b4480a69..d5614057452 100644 --- a/src/telegram/lane-delivery-text-deliverer.ts +++ b/src/telegram/lane-delivery-text-deliverer.ts @@ -365,9 +365,13 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) { context, }); if (typeof previewTargetAfterStop.previewMessageId !== "number") { - // This is the first preview creation — no prior visible message exists. - // Even if sendMayHaveLanded, prefer fallback over silence: a duplicate - // is better than the user seeing nothing at all. + if (lane.stream?.sendMayHaveLanded?.()) { + params.log( + `telegram: ${laneName} first preview send may have landed despite missing message id; keeping to avoid duplicate`, + ); + params.markDelivered(); + return "retained"; + } return "fallback"; } return finalizePreview(previewTargetAfterStop.previewMessageId, true, false); diff --git a/src/telegram/lane-delivery.test.ts b/src/telegram/lane-delivery.test.ts index 52d333c1213..1243ae4a266 100644 --- a/src/telegram/lane-delivery.test.ts +++ b/src/telegram/lane-delivery.test.ts @@ -538,10 +538,9 @@ describe("createLaneTextDeliverer", () => { ); }); - it("falls back when sendMayHaveLanded is true but no prior visible preview exists", async () => { + it("retains when the first preview send may have landed without a message id", async () => { const stream = createTestDraftStream(); stream.sendMayHaveLanded.mockReturnValue(true); - // No messageId and no prior preview → nothing visible for user to see const harness = createHarness({ answerStream: stream }); const result = await harness.deliverLaneText({ @@ -551,10 +550,10 @@ describe("createLaneTextDeliverer", () => { infoKind: "final", }); - // Prefer fallback (possible duplicate) over silence (no message at all) - expect(result).toBe("sent"); - expect(harness.sendPayload).toHaveBeenCalledWith( - expect.objectContaining({ text: "Hello final" }), + expect(result).toBe("preview-retained"); + expect(harness.sendPayload).not.toHaveBeenCalled(); + expect(harness.log).toHaveBeenCalledWith( + expect.stringContaining("first preview send may have landed despite missing message id"), ); }); diff --git a/src/telegram/send.test.ts b/src/telegram/send.test.ts index a00d1b2e89e..2bd6556ee42 100644 --- a/src/telegram/send.test.ts +++ b/src/telegram/send.test.ts @@ -1734,6 +1734,22 @@ describe("editMessageTelegram", () => { expect(botApi.editMessageText).toHaveBeenCalledTimes(1); }); + it("retries editMessageTelegram on Telegram 5xx errors", async () => { + botApi.editMessageText + .mockRejectedValueOnce(Object.assign(new Error("502: Bad Gateway"), { error_code: 502 })) + .mockResolvedValueOnce({ message_id: 1, chat: { id: "123" } }); + + await expect( + editMessageTelegram("123", 1, "hi", { + token: "tok", + cfg: {}, + retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 }, + }), + ).resolves.toEqual({ ok: true, messageId: "1", chatId: "123" }); + + expect(botApi.editMessageText).toHaveBeenCalledTimes(2); + }); + it("disables link previews when linkPreview is false", async () => { botApi.editMessageText.mockResolvedValue({ message_id: 1, chat: { id: "123" } });