fix(telegram): preserve unsent media while deduplicating streamed replies (#121141)

* fix(telegram): preserve unsent media while deduplicating streamed replies

## What Problem This Solves
A Telegram final reply could resend an attachment already delivered in a streamed block when the legacy mediaUrl still referenced that sent attachment while another mediaUrls item remained. Conversely, unsent legacy-only attachments must not be discarded.

## Why This Change Was Made
The Telegram media-deduplication owner now clears mediaUrl only when its normalized attachment was actually sent, preserving independently unsent legacy attachments and preventing downstream outbound planning from restoring delivered media.

## User Impact
Telegram replies retain every unsent attachment exactly once and no longer duplicate already streamed images in mixed final-message payloads.

## Context
Seven zero-dependency scenarios passed against the actual media owner and outbound planner, including mixed legacy/vector attachments, whitespace normalization, visible-send handling, and remaining-media preservation. The exact frozen campaign baseline passed 844 tests. Existing oxfmt formatting and staged whitespace checks passed; focused Vitest CI remains pending.

* refactor(telegram): inline legacy media dedup check
This commit is contained in:
Peter Steinberger
2026-08-09 22:57:21 -07:00
committed by GitHub
parent 7d2031986c
commit b8f861364c
3 changed files with 83 additions and 4 deletions
@@ -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 });
@@ -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"] });
});
});
@@ -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<string>): 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,
};
}