From 95d7b0bbe15c61dba47383c0883cd89df2bc06b2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Feb 2026 19:33:10 +0100 Subject: [PATCH] fix(replies): normalize media path variants for dedupe Co-authored-by: Ho Lim --- CHANGELOG.md | 1 + src/auto-reply/reply/reply-payloads.test.ts | 16 +++++++++++++ src/auto-reply/reply/reply-payloads.ts | 25 ++++++++++++++++++--- 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e9b557e5..6d8db061f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -96,6 +96,7 @@ Docs: https://docs.openclaw.ai - Telegram/Forward bursts: coalesce forwarded text+media updates through a dedicated forward lane debounce window that works with default inbound debounce config, while keeping forwarded control commands immediate. (#19476) thanks @napetrov. - Telegram/Streaming: preserve archived draft preview mapping after flush and clean superseded reasoning preview bubbles so multi-message preview finals no longer cross-edit or orphan stale messages under send/rotation races. (#23202) Thanks @obviyus. - Telegram/Replies: scope messaging-tool text/media dedupe to same-target sends only, so cross-target tool sends can no longer silently suppress Telegram final replies. +- Telegram/Replies: normalize `file://` and local-path media variants during messaging dedupe so equivalent media paths do not produce duplicate Telegram replies. - Telegram/Replies: extract forwarded-origin context from unified reply targets (`reply_to_message` and `external_reply`) so forward+comment metadata is preserved across partial reply shapes. (#9720) thanks @mcaxtr. - Telegram/Polling: persist a safe update-offset watermark bounded by pending updates so crash/restart cannot skip queued lower `update_id` updates after out-of-order completion. (#23284) thanks @frankekn. - Telegram/Polling: force-restart stuck runner instances when recoverable unhandled network rejections escape the polling task path, so polling resumes instead of silently stalling. (#19721) Thanks @jg-noncelogic. diff --git a/src/auto-reply/reply/reply-payloads.test.ts b/src/auto-reply/reply/reply-payloads.test.ts index 160eed93a..0c52903a9 100644 --- a/src/auto-reply/reply/reply-payloads.test.ts +++ b/src/auto-reply/reply/reply-payloads.test.ts @@ -58,4 +58,20 @@ describe("filterMessagingToolMediaDuplicates", () => { }); expect(result).toBe(payloads); }); + + it("dedupes equivalent file and local path variants", () => { + const result = filterMessagingToolMediaDuplicates({ + payloads: [{ text: "hello", mediaUrl: "/tmp/photo.jpg" }], + sentMediaUrls: ["file:///tmp/photo.jpg"], + }); + expect(result).toEqual([{ text: "hello", mediaUrl: undefined, mediaUrls: undefined }]); + }); + + it("dedupes encoded file:// paths against local paths", () => { + const result = filterMessagingToolMediaDuplicates({ + payloads: [{ text: "hello", mediaUrl: "/tmp/photo one.jpg" }], + sentMediaUrls: ["file:///tmp/photo%20one.jpg"], + }); + expect(result).toEqual([{ text: "hello", mediaUrl: undefined, mediaUrls: undefined }]); + }); }); diff --git a/src/auto-reply/reply/reply-payloads.ts b/src/auto-reply/reply/reply-payloads.ts index 5c320d502..41906f122 100644 --- a/src/auto-reply/reply/reply-payloads.ts +++ b/src/auto-reply/reply/reply-payloads.ts @@ -100,16 +100,35 @@ export function filterMessagingToolMediaDuplicates(params: { payloads: ReplyPayload[]; sentMediaUrls: string[]; }): ReplyPayload[] { + const normalizeMediaForDedupe = (value: string): string => { + const trimmed = value.trim(); + if (!trimmed) { + return ""; + } + if (!trimmed.toLowerCase().startsWith("file://")) { + return trimmed; + } + try { + const parsed = new URL(trimmed); + if (parsed.protocol === "file:") { + return decodeURIComponent(parsed.pathname || ""); + } + } catch { + // Keep fallback below for non-URL-like inputs. + } + return trimmed.replace(/^file:\/\//i, ""); + }; + const { payloads, sentMediaUrls } = params; if (sentMediaUrls.length === 0) { return payloads; } - const sentSet = new Set(sentMediaUrls); + const sentSet = new Set(sentMediaUrls.map(normalizeMediaForDedupe).filter(Boolean)); return payloads.map((payload) => { const mediaUrl = payload.mediaUrl; const mediaUrls = payload.mediaUrls; - const stripSingle = mediaUrl && sentSet.has(mediaUrl); - const filteredUrls = mediaUrls?.filter((u) => !sentSet.has(u)); + const stripSingle = mediaUrl && sentSet.has(normalizeMediaForDedupe(mediaUrl)); + const filteredUrls = mediaUrls?.filter((u) => !sentSet.has(normalizeMediaForDedupe(u))); if (!stripSingle && (!mediaUrls || filteredUrls?.length === mediaUrls.length)) { return payload; // No change }