From 18652d181b47732c7389391da56e7536656e5cf4 Mon Sep 17 00:00:00 2001 From: Iranb <49674669+Iranb@users.noreply.github.com> Date: Wed, 4 Feb 2026 19:31:35 +0800 Subject: [PATCH] fix(imessage): detect self-chat echoes to prevent infinite loops (#8680) --- src/imessage/monitor/deliver.ts | 14 +++++- src/imessage/monitor/monitor-provider.ts | 58 ++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/src/imessage/monitor/deliver.ts b/src/imessage/monitor/deliver.ts index eb5b72dd3..b39d68a6b 100644 --- a/src/imessage/monitor/deliver.ts +++ b/src/imessage/monitor/deliver.ts @@ -7,6 +7,10 @@ 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; +}; + export async function deliverReplies(params: { replies: ReplyPayload[]; target: string; @@ -15,8 +19,11 @@ export async function deliverReplies(params: { runtime: RuntimeEnv; maxBytes: number; textLimit: number; + sentMessageCache?: SentMessageCache; }) { - const { replies, target, client, runtime, maxBytes, textLimit, accountId } = params; + const { replies, target, client, runtime, maxBytes, textLimit, accountId, sentMessageCache } = + params; + const scope = `${accountId ?? ""}:${target}`; const cfg = loadConfig(); const tableMode = resolveMarkdownTableMode({ cfg, @@ -32,12 +39,14 @@ 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, { maxBytes, client, accountId, }); + sentMessageCache?.remember(scope, chunk); } } else { let first = true; @@ -50,6 +59,9 @@ export async function deliverReplies(params: { client, accountId, }); + if (caption) { + sentMessageCache?.remember(scope, caption); + } } } runtime.log?.(`imessage: delivered reply to ${target}`); diff --git a/src/imessage/monitor/monitor-provider.ts b/src/imessage/monitor/monitor-provider.ts index 9044f221b..bb2123e0c 100644 --- a/src/imessage/monitor/monitor-provider.ts +++ b/src/imessage/monitor/monitor-provider.ts @@ -111,6 +111,51 @@ 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(); @@ -126,6 +171,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P DEFAULT_GROUP_HISTORY_LIMIT, ); const groupHistories = new Map(); + const sentMessageCache = new SentMessageCache(); const textLimit = resolveTextChunkLimit(cfg, "imessage", accountInfo.accountId); const allowFrom = normalizeAllowList(opts.allowFrom ?? imessageCfg.allowFrom); const groupAllowFrom = normalizeAllowList( @@ -347,6 +393,17 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P }); const mentionRegexes = buildMentionRegexes(cfg, route.agentId); 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)) { + logVerbose( + `imessage: skipping echo message (matches recently sent text within 5s): "${truncateUtf16Safe(messageText, 50)}"`, + ); + return; + } + const attachments = includeAttachments ? (message.attachments ?? []) : []; // Filter to valid attachments with paths const validAttachments = attachments.filter((entry) => entry?.original_path && !entry?.missing); @@ -568,6 +625,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P runtime, maxBytes: mediaMaxBytes, textLimit, + sentMessageCache, }); }, onError: (err, info) => {