diff --git a/CHANGELOG.md b/CHANGELOG.md index 44912314c32..d239beae08d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- iMessage/self-chat: distinguish normal DM outbound rows from true self-chat using `destination_caller_id` plus chat participants, while preserving multi-handle self-chat aliases so outbound DM replies stop looping back as inbound messages. (#61619) Thanks @neeravmakwana. - fix(browser): auto-generate browser control auth token for none/trusted-proxy modes [AI]. (#63280) Thanks @pgondhi987. - fix(exec): replace TOCTOU check-then-read with atomic pinned-fd open in script preflight [AI]. (#62333) Thanks @pgondhi987. - WhatsApp/auto-reply: keep inbound reply, media, and composing sends on the current socket across reconnects, wait through reconnect gaps, and retry timeout-only send failures without dropping the active socket ref. (#62892) Thanks @mcaxtr. diff --git a/extensions/imessage/src/monitor.gating.test.ts b/extensions/imessage/src/monitor.gating.test.ts index 41b5ac9b1c0..dbb4ba6b5fc 100644 --- a/extensions/imessage/src/monitor.gating.test.ts +++ b/extensions/imessage/src/monitor.gating.test.ts @@ -104,6 +104,22 @@ describe("imessage monitor gating + envelope builders", () => { ).toBeNull(); }); + it("parseIMessageNotification preserves destination_caller_id metadata", () => { + expect( + parseIMessageNotification({ + message: { + id: 1, + sender: "+15550001111", + destination_caller_id: "+15550002222", + is_from_me: true, + text: "hello", + }, + }), + ).toMatchObject({ + destination_caller_id: "+15550002222", + }); + }); + it("drops group messages without mention by default", () => { const decision = resolve({ message: { diff --git a/extensions/imessage/src/monitor/inbound-processing.ts b/extensions/imessage/src/monitor/inbound-processing.ts index 2458e3b221d..7d41bb37534 100644 --- a/extensions/imessage/src/monitor/inbound-processing.ts +++ b/extensions/imessage/src/monitor/inbound-processing.ts @@ -170,6 +170,7 @@ export function resolveIMessageInboundDecision(params: { const chatId = params.message.chat_id ?? undefined; const chatGuid = params.message.chat_guid ?? undefined; const chatIdentifier = params.message.chat_identifier ?? undefined; + const destinationCallerId = params.message.destination_caller_id ?? undefined; const createdAt = params.message.created_at ? Date.parse(params.message.created_at) : undefined; const messageText = params.messageText.trim(); const bodyText = params.bodyText.trim(); @@ -203,14 +204,23 @@ export function resolveIMessageInboundDecision(params: { text: bodyText, createdAt, }; - // Self-chat detection: in self-chat, sender == chat_identifier (both are the - // user's own handle). When is_from_me=true in self-chat, the message could be - // either: (a) a real user message typed by the user, or (b) an agent reply - // echo reflected back by iMessage. We must distinguish them. + const chatIdentifierNormalized = normalizeIMessageHandle(chatIdentifier ?? "") || undefined; + const destinationCallerIdNormalized = + normalizeIMessageHandle(destinationCallerId ?? "") || undefined; + const chatParticipantHandles = new Set( + (params.message.participants ?? []) + .map((participant) => normalizeIMessageHandle(participant)) + .filter((participant): participant is string => participant.length > 0), + ); + const matchesSelfChatDestination = + destinationCallerIdNormalized == null || + destinationCallerIdNormalized === senderNormalized || + chatParticipantHandles.has(destinationCallerIdNormalized); const isSelfChat = !isGroup && - chatIdentifier != null && - normalizeIMessageHandle(sender) === normalizeIMessageHandle(chatIdentifier); + chatIdentifierNormalized != null && + senderNormalized === chatIdentifierNormalized && + matchesSelfChatDestination; // Track whether we already processed the is_from_me=true self-chat path. // When true, the selfChatCache.has() check below must be skipped — we just // called remember() and would immediately match our own entry. diff --git a/extensions/imessage/src/monitor/parse-notification.ts b/extensions/imessage/src/monitor/parse-notification.ts index e9ea5f0b03b..61e1798d5d1 100644 --- a/extensions/imessage/src/monitor/parse-notification.ts +++ b/extensions/imessage/src/monitor/parse-notification.ts @@ -61,6 +61,7 @@ export function parseIMessageNotification(raw: unknown): IMessagePayload | null !isOptionalString(message.guid) || !isOptionalNumber(message.chat_id) || !isOptionalString(message.sender) || + !isOptionalString(message.destination_caller_id) || !isOptionalBoolean(message.is_from_me) || !isOptionalString(message.text) || !isOptionalStringOrNumber(message.reply_to_id) || diff --git a/extensions/imessage/src/monitor/self-chat-dedupe.test.ts b/extensions/imessage/src/monitor/self-chat-dedupe.test.ts index 54bf5b4bb41..679c38b42c0 100644 --- a/extensions/imessage/src/monitor/self-chat-dedupe.test.ts +++ b/extensions/imessage/src/monitor/self-chat-dedupe.test.ts @@ -344,7 +344,6 @@ describe("self-chat is_from_me=true handling (Bruce Phase 2 fix)", () => { }); it("processes real user self-chat message (is_from_me=true, no echo cache match)", () => { - // User sends "Hello" to themselves — is_from_me=true, sender==chat_identifier const echoCache = createSentMessageCache(); const selfChatCache = createSelfChatCache(); @@ -354,6 +353,7 @@ describe("self-chat is_from_me=true handling (Bruce Phase 2 fix)", () => { id: 123703, sender: "+15551234567", chat_identifier: "+15551234567", + destination_caller_id: "+15551234567", text: "Hello this is a test message", is_from_me: true, is_group: false, @@ -365,7 +365,57 @@ describe("self-chat is_from_me=true handling (Bruce Phase 2 fix)", () => { }), ); - // Real user message — should be dispatched, not dropped + expect(decision.kind).toBe("dispatch"); + }); + + it("treats blank destination_caller_id as missing for real self-chat", () => { + const echoCache = createSentMessageCache(); + const selfChatCache = createSelfChatCache(); + + const decision = resolveIMessageInboundDecision( + createParams({ + message: { + id: 123704, + sender: "+15551234567", + chat_identifier: "+15551234567", + destination_caller_id: "", + text: "Hello this is a test message", + is_from_me: true, + is_group: false, + }, + messageText: "Hello this is a test message", + bodyText: "Hello this is a test message", + echoCache, + selfChatCache, + }), + ); + + expect(decision.kind).toBe("dispatch"); + }); + + it("preserves self-chat when destination_caller_id is another local handle", () => { + const echoCache = createSentMessageCache(); + const selfChatCache = createSelfChatCache(); + + const decision = resolveIMessageInboundDecision( + createParams({ + message: { + id: 123705, + sender: "+15551234567", + chat_identifier: "+15551234567", + destination_caller_id: "me@icloud.com", + participants: ["+15551234567", "me@icloud.com"], + text: "Hello from my other local handle", + is_from_me: true, + is_group: false, + }, + messageText: "Hello from my other local handle", + bodyText: "Hello from my other local handle", + echoCache, + selfChatCache, + }), + ); + expect(decision.kind).toBe("dispatch"); }); @@ -575,7 +625,36 @@ describe("self-chat is_from_me=true handling (Bruce Phase 2 fix)", () => { }), ); - // sender != chat_identifier → not self-chat → dropped as "from me" + expect(decision).toEqual({ kind: "drop", reason: "from me" }); + }); + + it("uses destination_caller_id to avoid DM self-chat false positives", () => { + const echoCache = createSentMessageCache(); + const selfChatCache = createSelfChatCache(); + + echoCache.remember("default:imessage:+15551234567", { + text: "Clean outbound text", + messageId: "p:0/GUID-outbound", + }); + + const decision = resolveIMessageInboundDecision( + createParams({ + message: { + id: 10001, + sender: "+15551234567", + chat_identifier: "+15551234567", + destination_caller_id: "+15550001111", + text: "�\u0001corrupted stored text", + is_from_me: true, + is_group: false, + }, + messageText: "�\u0001corrupted stored text", + bodyText: "�\u0001corrupted stored text", + echoCache, + selfChatCache, + }), + ); + expect(decision).toEqual({ kind: "drop", reason: "from me" }); }); diff --git a/extensions/imessage/src/monitor/types.ts b/extensions/imessage/src/monitor/types.ts index db357f56b60..78c56285536 100644 --- a/extensions/imessage/src/monitor/types.ts +++ b/extensions/imessage/src/monitor/types.ts @@ -12,6 +12,7 @@ export type IMessagePayload = { guid?: string | null; chat_id?: number | null; sender?: string | null; + destination_caller_id?: string | null; is_from_me?: boolean | null; text?: string | null; reply_to_id?: number | string | null;