fix: preserve iMessage self-chat aliases (#61619) (thanks @neeravmakwana)

* fix(imessage): avoid DM self-chat false positives

* fix(imessage): treat blank destination caller id as missing

* fix(imessage): preserve alias self-chat

* fix: preserve iMessage self-chat aliases (#61619) (thanks @neeravmakwana)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
This commit is contained in:
Neerav Makwana
2026-04-09 07:43:22 -04:00
committed by GitHub
parent 5577e2d441
commit 9267c3f8f2
6 changed files with 117 additions and 9 deletions

View File

@@ -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.

View File

@@ -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: {

View File

@@ -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.

View File

@@ -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) ||

View File

@@ -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: "<22>\u0001corrupted stored text",
is_from_me: true,
is_group: false,
},
messageText: "<22>\u0001corrupted stored text",
bodyText: "<22>\u0001corrupted stored text",
echoCache,
selfChatCache,
}),
);
expect(decision).toEqual({ kind: "drop", reason: "from me" });
});

View File

@@ -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;