diff --git a/extensions/telegram/src/bot-message-dispatch.fallback-topic-media.test.ts b/extensions/telegram/src/bot-message-dispatch.fallback-topic-media.test.ts index 98261e182ee2..5d29e30adc90 100644 --- a/extensions/telegram/src/bot-message-dispatch.fallback-topic-media.test.ts +++ b/extensions/telegram/src/bot-message-dispatch.fallback-topic-media.test.ts @@ -1,3 +1,7 @@ +import { + createOutboundPayloadPlan, + projectOutboundPayloadPlanForDelivery, +} from "openclaw/plugin-sdk/channel-outbound"; import { describe, expect, it, vi } from "vitest"; import { describeTelegramDispatch, @@ -296,6 +300,45 @@ describeTelegramDispatch("dispatchTelegramMessage fallback-topic-media", () => { expect(finalDeliveryPayload().mediaUrls).toEqual([]); }); + it("does not restore block-sent legacy media when the final includes another attachment", async () => { + const sentMediaUrl = "/tmp/cat.jpg"; + const remainingMediaUrl = "/tmp/dog.jpg"; + deliverReplies.mockResolvedValue({ delivered: true }); + deliverInboundReplyWithMessageSendContext.mockResolvedValue({ + status: "handled_visible", + delivery: { messageIds: ["101"], visibleReplySent: true }, + }); + dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { + await dispatcherOptions.deliver({ mediaUrl: sentMediaUrl }, { kind: "block" }); + await dispatcherOptions.deliver( + { + text: "Here are the images", + mediaUrls: [remainingMediaUrl], + mediaUrl: sentMediaUrl, + }, + { kind: "final" }, + ); + return { queuedFinal: true }; + }); + + await dispatchWithContext({ + context: createContext(), + streamMode: "off", + telegramDeps: telegramDepsForTest, + }); + + const finalPayload = finalDeliveryPayload(); + expect(finalPayload).toMatchObject({ + text: "Here are the images", + mediaUrl: undefined, + mediaUrls: [remainingMediaUrl], + }); + expect( + projectOutboundPayloadPlanForDelivery(createOutboundPayloadPlan([finalPayload]))[0] + ?.mediaUrls, + ).toEqual([remainingMediaUrl]); + }); + it("preserves final media when block delivery reports no visible send", async () => { deliverReplies.mockResolvedValueOnce({ delivered: false }); deliverReplies.mockResolvedValue({ delivered: true }); diff --git a/extensions/telegram/src/bot-message-dispatch.media-dedup.test.ts b/extensions/telegram/src/bot-message-dispatch.media-dedup.test.ts index afd6d527cddd..322ad0e1bc0c 100644 --- a/extensions/telegram/src/bot-message-dispatch.media-dedup.test.ts +++ b/extensions/telegram/src/bot-message-dispatch.media-dedup.test.ts @@ -55,7 +55,7 @@ describe("deduplicateBlockSentMedia", () => { expect(result).toEqual({ text: "captioned", mediaUrl: undefined, mediaUrls: [] }); }); - it("preserves legacy mediaUrl when some mediaUrls remain", () => { + it("clears already-sent legacy mediaUrl when other mediaUrls remain", () => { const payload = { text: "hey", mediaUrl: "/tmp/a.jpg", @@ -63,6 +63,42 @@ describe("deduplicateBlockSentMedia", () => { }; const sent = new Set(["/tmp/a.jpg"]); const result = deduplicateBlockSentMedia(payload, sent); - expect(result).toEqual({ text: "hey", mediaUrl: "/tmp/a.jpg", mediaUrls: ["/tmp/b.jpg"] }); + expect(result).toEqual({ text: "hey", mediaUrl: undefined, mediaUrls: ["/tmp/b.jpg"] }); + }); + + it("preserves legacy mediaUrl when its attachment remains unsent", () => { + const payload = { + text: "hey", + mediaUrl: "/tmp/b.jpg", + mediaUrls: ["/tmp/a.jpg", "/tmp/b.jpg"], + }; + const sent = new Set(["/tmp/a.jpg"]); + const result = deduplicateBlockSentMedia(payload, sent); + expect(result).toEqual({ text: "hey", mediaUrl: "/tmp/b.jpg", mediaUrls: ["/tmp/b.jpg"] }); + }); + + it.each(["/tmp/dog.jpg", " /tmp/dog.jpg "])( + "preserves unsent legacy mediaUrl outside the remaining mediaUrls (%s)", + (mediaUrl) => { + const payload = { + text: "hey", + mediaUrl, + mediaUrls: ["/tmp/cat.jpg", "/tmp/bird.jpg"], + }; + const sent = new Set(["/tmp/cat.jpg"]); + const result = deduplicateBlockSentMedia(payload, sent); + expect(result).toEqual({ text: "hey", mediaUrl, mediaUrls: ["/tmp/bird.jpg"] }); + }, + ); + + it("clears whitespace-padded legacy mediaUrl after its normalized attachment was sent", () => { + const payload = { + text: "hey", + mediaUrl: " /tmp/a.jpg ", + mediaUrls: ["/tmp/a.jpg", "/tmp/b.jpg"], + }; + const sent = new Set(["/tmp/a.jpg"]); + const result = deduplicateBlockSentMedia(payload, sent); + expect(result).toEqual({ text: "hey", mediaUrl: undefined, mediaUrls: ["/tmp/b.jpg"] }); }); }); diff --git a/extensions/telegram/src/bot-message-dispatch.media-dedup.ts b/extensions/telegram/src/bot-message-dispatch.media-dedup.ts index 71bbdbe67a62..a4809c813c6a 100644 --- a/extensions/telegram/src/bot-message-dispatch.media-dedup.ts +++ b/extensions/telegram/src/bot-message-dispatch.media-dedup.ts @@ -1,4 +1,4 @@ -// Telegram plugin module implements bot message dispatch.media dedup behavior. +// Keep sent-block media out of both delivery fields so outbound planning cannot restore it. export function deduplicateBlockSentMedia< T extends { mediaUrl?: string; mediaUrls?: string[]; text?: string }, >(payload: T, sentBlockMediaUrls: ReadonlySet): T | undefined { @@ -15,6 +15,6 @@ export function deduplicateBlockSentMedia< return { ...payload, mediaUrls: remainingMedia, - mediaUrl: remainingMedia.length === 0 ? undefined : payload.mediaUrl, + mediaUrl: sentBlockMediaUrls.has(payload.mediaUrl?.trim() ?? "") ? undefined : payload.mediaUrl, }; }