From b782ae104d9e3ccd6bc2bd5debe0304d4fd0988b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 4 Feb 2026 03:31:08 -0800 Subject: [PATCH] fix: imessage echo detection ids (#8680) (thanks @Iranb) --- CHANGELOG.md | 1 + ...essages-without-mention-by-default.test.ts | 41 ++++++++++ src/imessage/monitor/deliver.ts | 18 ++--- src/imessage/monitor/echo-cache.ts | 78 +++++++++++++++++++ src/imessage/monitor/monitor-provider.ts | 65 ++++------------ 5 files changed, 142 insertions(+), 61 deletions(-) create mode 100644 src/imessage/monitor/echo-cache.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 669c7984be..2d6da5383c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Telegram: honor session model overrides in inline model selection. (#8193) Thanks @gildo. +- iMessage: skip echo replies using recent outbound IDs before falling back to text matching. (#8680) Thanks @Iranb. - Web UI: resolve header logo path when `gateway.controlUi.basePath` is set. (#7178) Thanks @Yeom-JinHo. - Web UI: apply button styling to the new-messages indicator. - Security: keep untrusted channel metadata out of system prompts (Slack/Discord). Thanks @KonstantinMirin. diff --git a/src/imessage/monitor.skips-group-messages-without-mention-by-default.test.ts b/src/imessage/monitor.skips-group-messages-without-mention-by-default.test.ts index 099e8508da..e61cb7335a 100644 --- a/src/imessage/monitor.skips-group-messages-without-mention-by-default.test.ts +++ b/src/imessage/monitor.skips-group-messages-without-mention-by-default.test.ts @@ -101,6 +101,47 @@ beforeEach(() => { }); describe("monitorIMessageProvider", () => { + it("skips echo messages that match recent outbound ids", async () => { + sendMock.mockResolvedValue({ messageId: "123" }); + const run = monitorIMessageProvider(); + await waitForSubscribe(); + + notificationHandler?.({ + method: "message", + params: { + message: { + id: 1, + sender: "+15550001111", + is_from_me: false, + text: "ping", + is_group: false, + }, + }, + }); + + await flush(); + + notificationHandler?.({ + method: "message", + params: { + message: { + id: 123, + sender: "+15550001111", + is_from_me: false, + text: "ok", + is_group: false, + }, + }, + }); + + await flush(); + closeResolve?.(); + await run; + + expect(replyMock).toHaveBeenCalledTimes(1); + expect(sendMock).toHaveBeenCalledTimes(1); + }); + it("skips group messages without a mention by default", async () => { const run = monitorIMessageProvider(); await waitForSubscribe(); diff --git a/src/imessage/monitor/deliver.ts b/src/imessage/monitor/deliver.ts index b39d68a6be..7668049ac0 100644 --- a/src/imessage/monitor/deliver.ts +++ b/src/imessage/monitor/deliver.ts @@ -6,10 +6,7 @@ import { loadConfig } from "../../config/config.js"; import { resolveMarkdownTableMode } from "../../config/markdown-tables.js"; import { convertMarkdownTables } from "../../markdown/tables.js"; import { sendMessageIMessage } from "../send.js"; - -type SentMessageCache = { - remember: (scope: string, text: string) => void; -}; +import { buildIMessageEchoScope, type SentMessageCache } from "./echo-cache.js"; export async function deliverReplies(params: { replies: ReplyPayload[]; @@ -23,7 +20,7 @@ export async function deliverReplies(params: { }) { const { replies, target, client, runtime, maxBytes, textLimit, accountId, sentMessageCache } = params; - const scope = `${accountId ?? ""}:${target}`; + const scope = buildIMessageEchoScope({ accountId, target }); const cfg = loadConfig(); const tableMode = resolveMarkdownTableMode({ cfg, @@ -39,29 +36,30 @@ export async function deliverReplies(params: { continue; } if (mediaList.length === 0) { - sentMessageCache?.remember(scope, text); for (const chunk of chunkTextWithMode(text, textLimit, chunkMode)) { - await sendMessageIMessage(target, chunk, { + const result = await sendMessageIMessage(target, chunk, { maxBytes, client, accountId, }); - sentMessageCache?.remember(scope, chunk); + sentMessageCache?.rememberText(scope, chunk); + sentMessageCache?.rememberId(scope, result.messageId); } } else { let first = true; for (const url of mediaList) { const caption = first ? text : ""; first = false; - await sendMessageIMessage(target, caption, { + const result = await sendMessageIMessage(target, caption, { mediaUrl: url, maxBytes, client, accountId, }); if (caption) { - sentMessageCache?.remember(scope, caption); + sentMessageCache?.rememberText(scope, caption); } + sentMessageCache?.rememberId(scope, result.messageId); } } runtime.log?.(`imessage: delivered reply to ${target}`); diff --git a/src/imessage/monitor/echo-cache.ts b/src/imessage/monitor/echo-cache.ts new file mode 100644 index 0000000000..3c18b7953b --- /dev/null +++ b/src/imessage/monitor/echo-cache.ts @@ -0,0 +1,78 @@ +export function buildIMessageEchoScope(params: { + accountId?: string | null; + target: string; +}): string { + return `${params.accountId ?? ""}:${params.target}`; +} + +type CacheEntry = Map; + +export class SentMessageCache { + private readonly ttlMs: number; + private readonly textCache: CacheEntry = new Map(); + private readonly idCache: CacheEntry = new Map(); + + constructor(opts: { ttlMs?: number } = {}) { + this.ttlMs = opts.ttlMs ?? 5000; + } + + rememberText(scope: string, text: string): void { + const trimmed = text?.trim?.() ?? ""; + if (!trimmed) { + return; + } + this.textCache.set(this.buildKey(scope, trimmed), Date.now()); + this.cleanup(this.textCache); + } + + rememberId(scope: string, id: string | number): void { + const normalized = String(id ?? "").trim(); + if (!normalized || normalized === "ok" || normalized === "unknown") { + return; + } + this.idCache.set(this.buildKey(scope, normalized), Date.now()); + this.cleanup(this.idCache); + } + + hasText(scope: string, text: string): boolean { + const trimmed = text?.trim?.() ?? ""; + if (!trimmed) { + return false; + } + return this.has(scope, trimmed, this.textCache); + } + + hasId(scope: string, id: string | number): boolean { + const normalized = String(id ?? "").trim(); + if (!normalized) { + return false; + } + return this.has(scope, normalized, this.idCache); + } + + private has(scope: string, value: string, cache: CacheEntry): boolean { + const key = this.buildKey(scope, value); + const timestamp = cache.get(key); + if (!timestamp) { + return false; + } + if (Date.now() - timestamp > this.ttlMs) { + cache.delete(key); + return false; + } + return true; + } + + private buildKey(scope: string, value: string): string { + return `${scope}:${value}`; + } + + private cleanup(cache: CacheEntry): void { + const now = Date.now(); + for (const [key, timestamp] of cache.entries()) { + if (now - timestamp > this.ttlMs) { + cache.delete(key); + } + } + } +} diff --git a/src/imessage/monitor/monitor-provider.ts b/src/imessage/monitor/monitor-provider.ts index 2bd7481f2f..2c33138807 100644 --- a/src/imessage/monitor/monitor-provider.ts +++ b/src/imessage/monitor/monitor-provider.ts @@ -53,6 +53,7 @@ import { normalizeIMessageHandle, } from "../targets.js"; import { deliverReplies } from "./deliver.js"; +import { buildIMessageEchoScope, SentMessageCache } from "./echo-cache.js"; import { normalizeAllowList, resolveRuntime } from "./runtime.js"; /** @@ -110,51 +111,6 @@ function describeReplyContext(message: IMessagePayload): IMessageReplyContext | return { body, id, sender }; } -/** - * Cache for recently sent messages, used for echo detection. - * Keys are scoped by conversation (accountId:target) so the same text in different chats is not conflated. - * Entries expire after 5 seconds; we do not forget on match so multiple echo deliveries are all filtered. - */ -class SentMessageCache { - private cache = new Map(); - private readonly ttlMs = 5000; // 5 seconds - - remember(scope: string, text: string): void { - if (!text?.trim()) { - return; - } - const key = `${scope}:${text.trim()}`; - this.cache.set(key, Date.now()); - this.cleanup(); - } - - has(scope: string, text: string): boolean { - if (!text?.trim()) { - return false; - } - const key = `${scope}:${text.trim()}`; - const timestamp = this.cache.get(key); - if (!timestamp) { - return false; - } - const age = Date.now() - timestamp; - if (age > this.ttlMs) { - this.cache.delete(key); - return false; - } - return true; - } - - private cleanup(): void { - const now = Date.now(); - for (const [text, timestamp] of this.cache.entries()) { - if (now - timestamp > this.ttlMs) { - this.cache.delete(text); - } - } - } -} - export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): Promise { const runtime = resolveRuntime(opts); const cfg = opts.config ?? loadConfig(); @@ -390,12 +346,20 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P }, }); const mentionRegexes = buildMentionRegexes(cfg, route.agentId); + const chatTarget = isGroup ? formatIMessageChatTarget(chatId) : undefined; const messageText = (message.text ?? "").trim(); - - // Echo detection: check if the received message matches a recently sent message (within 5 seconds). - // Scope by conversation so same text in different chats is not conflated. - const echoScope = `${accountInfo.accountId}:${isGroup ? formatIMessageChatTarget(chatId) : `imessage:${sender}`}`; - if (messageText && sentMessageCache.has(echoScope, messageText)) { + const messageId = message.id ?? undefined; + const echoScope = buildIMessageEchoScope({ + accountId: accountInfo.accountId, + target: chatTarget ?? `imessage:${sender}`, + }); + if (messageId !== undefined && sentMessageCache.hasId(echoScope, messageId)) { + logVerbose( + `imessage: skipping echo message (matches recently sent id within 5s): ${String(messageId)}`, + ); + return; + } + if (messageText && sentMessageCache.hasText(echoScope, messageText)) { logVerbose( `imessage: skipping echo message (matches recently sent text within 5s): "${truncateUtf16Safe(messageText, 50)}"`, ); @@ -494,7 +458,6 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P return; } - const chatTarget = formatIMessageChatTarget(chatId); const fromLabel = formatInboundFromLabel({ isGroup, groupLabel: message.chat_name ?? undefined,