Skip to content

Commit ff1d9da

Browse files
committed
fix: repair anchorless iMessage watch payloads
1 parent e4332f7 commit ff1d9da

5 files changed

Lines changed: 440 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai
88

99
### Fixes
1010

11+
- Channels/iMessage: recover malformed anchorless group watch payloads by GUID before debounce/routing, and drop unrecoverable payloads instead of replying to the sender DM. Fixes #84470. Refs #84503. Thanks @zhangguiping-xydt and @zqchris.
1112
- Channels/iMessage: advance the startup catchup cursor from live-handled rows after a completed catchup pass, including rows received while catchup is still running, so restarts do not replay them. (#85475) Thanks @TurboTheTurtle.
1213
- Tests: mount the shared Windows command helper into bare Docker E2E harness containers so published upgrade-survivor config walks can start on Linux.
1314
- Tests: let the generic plugin install E2E assertions use a configurable temp root and Windows home-relative install paths.

extensions/imessage/src/monitor.last-route.test.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,101 @@ describe("iMessage monitor last-route updates", () => {
324324
});
325325
});
326326

327+
it("repairs anchorless group watch payloads before routing or cursor updates", async () => {
328+
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-imsg-anchor-repair-"));
329+
tempDirs.push(stateDir);
330+
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
331+
332+
let onNotification: ((message: { method: string; params: unknown }) => void) | undefined;
333+
const client = {
334+
request: vi.fn(async (method: string, params?: Record<string, unknown>) => {
335+
if (method === "watch.subscribe") {
336+
return { subscription: 1 };
337+
}
338+
if (method === "chats.list") {
339+
return { chats: [{ id: 349 }] };
340+
}
341+
if (method === "messages.history") {
342+
expect(params?.chat_id).toBe(349);
343+
return {
344+
messages: [
345+
{
346+
id: 9500,
347+
guid: "ANCHORLESS-GROUP-GUID",
348+
chat_id: 349,
349+
chat_guid: "iMessage;+;chat349",
350+
chat_identifier: "chat349",
351+
chat_name: "Project group",
352+
participants: ["+15550001111", "+15550002222"],
353+
is_group: true,
354+
},
355+
],
356+
};
357+
}
358+
throw new Error(`unexpected imsg method ${method}`);
359+
}),
360+
waitForClose: vi.fn(async () => {
361+
onNotification?.({
362+
method: "message",
363+
params: {
364+
message: {
365+
id: 9500,
366+
guid: "ANCHORLESS-GROUP-GUID",
367+
chat_id: 0,
368+
sender: "+15550001111",
369+
is_from_me: false,
370+
text: "@openclaw check this https://example.com",
371+
is_group: false,
372+
chat_guid: "",
373+
chat_identifier: "",
374+
chat_name: "",
375+
participants: null,
376+
created_at: "2026-05-22T15:30:00.000Z",
377+
},
378+
},
379+
});
380+
await Promise.resolve();
381+
await Promise.resolve();
382+
}),
383+
stop: vi.fn(async () => {}),
384+
};
385+
createIMessageRpcClientMock.mockImplementation(async (params) => {
386+
if (!params?.onNotification) {
387+
throw new Error("expected iMessage notification handler");
388+
}
389+
onNotification = params.onNotification;
390+
return client as never;
391+
});
392+
393+
await monitorIMessageProvider({
394+
config: {
395+
channels: {
396+
imessage: {
397+
catchup: { enabled: true },
398+
groupPolicy: "open",
399+
groups: { "*": { requireMention: true } },
400+
},
401+
},
402+
messages: {
403+
groupChat: { mentionPatterns: ["@openclaw"] },
404+
inbound: { debounceMs: 0 },
405+
},
406+
session: { mainKey: "main" },
407+
} as never,
408+
runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() },
409+
});
410+
411+
await vi.waitFor(() => {
412+
expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(1);
413+
});
414+
const dispatchParams = dispatchInboundMessageMock.mock.calls.at(0)?.[0];
415+
expect(dispatchParams?.ctx.To).toBe("chat_id:349");
416+
expect(dispatchParams?.ctx.From).toBe("imessage:group:349");
417+
expect(dispatchParams?.ctx.ChatType).toBe("group");
418+
expect(dispatchParams?.ctx.SessionKey).toBe("agent:main:imessage:group:349");
419+
expect(dispatchParams?.ctx.To).not.toBe("imessage:+15550001111");
420+
});
421+
327422
it("does not advance the live cursor after partial startup catchup", async () => {
328423
vi.useFakeTimers();
329424
vi.setSystemTime(new Date("2026-05-22T15:31:00.000Z"));
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import { isIMessageAnchorless, repairIMessageConversationAnchor } from "./conversation-repair.js";
3+
import type { IMessagePayload } from "./types.js";
4+
5+
function anchorlessMessage(overrides: Partial<IMessagePayload> = {}): IMessagePayload {
6+
return {
7+
id: 9500,
8+
guid: "ANCHORLESS-GUID-1",
9+
chat_id: 0,
10+
sender: "+15550001111",
11+
is_from_me: false,
12+
text: "https://example.com",
13+
chat_guid: "",
14+
chat_identifier: "",
15+
chat_name: "",
16+
participants: null,
17+
is_group: false,
18+
...overrides,
19+
};
20+
}
21+
22+
function mockClient(chats: Array<{ id: number; messages: Record<string, unknown>[] }>) {
23+
const request = vi.fn(async (method: string, params?: Record<string, unknown>) => {
24+
if (method === "chats.list") {
25+
return { chats: chats.map((chat) => ({ id: chat.id })) };
26+
}
27+
if (method === "messages.history") {
28+
return {
29+
messages: chats.find((chat) => chat.id === params?.chat_id)?.messages ?? [],
30+
};
31+
}
32+
throw new Error(`unexpected method ${method}`);
33+
});
34+
return { request };
35+
}
36+
37+
describe("isIMessageAnchorless", () => {
38+
it("detects explicit broken conversation anchors", () => {
39+
expect(isIMessageAnchorless(anchorlessMessage())).toBe(true);
40+
expect(isIMessageAnchorless(anchorlessMessage({ chat_guid: undefined }))).toBe(true);
41+
expect(isIMessageAnchorless(anchorlessMessage({ chat_identifier: undefined }))).toBe(true);
42+
expect(
43+
isIMessageAnchorless(
44+
anchorlessMessage({ chat_id: undefined, chat_guid: "", chat_identifier: "" }),
45+
),
46+
).toBe(true);
47+
});
48+
49+
it("does not classify sender-only direct messages as anchorless", () => {
50+
expect(
51+
isIMessageAnchorless({
52+
guid: "DM-GUID",
53+
sender: "+15550001111",
54+
is_from_me: false,
55+
text: "hello",
56+
}),
57+
).toBe(false);
58+
});
59+
60+
it("does not classify messages with any usable conversation anchor", () => {
61+
expect(isIMessageAnchorless(anchorlessMessage({ chat_id: 349 }))).toBe(false);
62+
expect(isIMessageAnchorless(anchorlessMessage({ chat_guid: "iMessage;+;chat349" }))).toBe(
63+
false,
64+
);
65+
expect(isIMessageAnchorless(anchorlessMessage({ chat_identifier: "chat349" }))).toBe(false);
66+
});
67+
});
68+
69+
describe("repairIMessageConversationAnchor", () => {
70+
it("passes through non-anchorless messages without recovery RPCs", async () => {
71+
const message = anchorlessMessage({ chat_id: 349, is_group: true });
72+
const client = mockClient([]);
73+
74+
await expect(
75+
repairIMessageConversationAnchor({ client: client as never, message }),
76+
).resolves.toBe(message);
77+
expect(client.request).not.toHaveBeenCalled();
78+
});
79+
80+
it("recovers the conversation from recent history by GUID", async () => {
81+
const message = anchorlessMessage();
82+
const client = mockClient([
83+
{ id: 100, messages: [{ guid: "OTHER-GUID", chat_id: 100, is_group: true }] },
84+
{
85+
id: 349,
86+
messages: [
87+
{
88+
guid: "ANCHORLESS-GUID-1",
89+
chat_id: 349,
90+
chat_guid: "iMessage;+;chat349",
91+
chat_identifier: "chat349",
92+
chat_name: "Project group",
93+
participants: ["+15550001111", "+15550002222"],
94+
is_group: true,
95+
},
96+
],
97+
},
98+
]);
99+
100+
const repaired = await repairIMessageConversationAnchor({
101+
client: client as never,
102+
message,
103+
});
104+
105+
expect(repaired).toMatchObject({
106+
chat_id: 349,
107+
chat_guid: "iMessage;+;chat349",
108+
chat_identifier: "chat349",
109+
chat_name: "Project group",
110+
participants: ["+15550001111", "+15550002222"],
111+
is_group: true,
112+
});
113+
});
114+
115+
it("drops fail-closed when the GUID cannot be matched", async () => {
116+
const runtime = { error: vi.fn() };
117+
const client = mockClient([{ id: 349, messages: [{ guid: "OTHER-GUID", chat_id: 349 }] }]);
118+
119+
await expect(
120+
repairIMessageConversationAnchor({
121+
client: client as never,
122+
message: anchorlessMessage(),
123+
runtime,
124+
}),
125+
).resolves.toBeNull();
126+
expect(runtime.error.mock.calls.at(-1)?.[0]).toContain("no recent chat matched");
127+
});
128+
129+
it("drops fail-closed when history finds the GUID but no usable anchor", async () => {
130+
const runtime = { error: vi.fn() };
131+
const client = mockClient([
132+
{
133+
id: 349,
134+
messages: [
135+
{
136+
guid: "ANCHORLESS-GUID-1",
137+
chat_id: 0,
138+
chat_guid: "",
139+
chat_identifier: "",
140+
is_group: false,
141+
},
142+
],
143+
},
144+
]);
145+
146+
await expect(
147+
repairIMessageConversationAnchor({
148+
client: client as never,
149+
message: anchorlessMessage(),
150+
runtime,
151+
}),
152+
).resolves.toBeNull();
153+
expect(runtime.error.mock.calls.at(-1)?.[0]).toContain("no usable conversation anchor");
154+
});
155+
});

0 commit comments

Comments
 (0)