
























@@ -87,6 +87,25 @@ vi.mock("./monitor/abort-handler.js", () => ({
8787describe("iMessage monitor last-route updates", () => {
8888const tempDirs: string[] = [];
898990+async function createMessagesDbWithMaxRowid(maxRowid: number, dbPath?: string): Promise<string> {
91+let resolvedDbPath = dbPath;
92+if (!resolvedDbPath) {
93+const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-imsg-watch-watermark-"));
94+tempDirs.push(stateDir);
95+resolvedDbPath = path.join(stateDir, "chat.db");
96+}
97+fs.mkdirSync(path.dirname(resolvedDbPath), { recursive: true });
98+const { DatabaseSync } = await import("node:sqlite");
99+const database = new DatabaseSync(resolvedDbPath);
100+try {
101+database.exec("CREATE TABLE message (text TEXT);");
102+database.prepare("INSERT INTO message(rowid, text) VALUES (?, ?)").run(maxRowid, "watermark");
103+} finally {
104+database.close();
105+}
106+return resolvedDbPath;
107+}
108+90109beforeEach(() => {
91110waitForTransportReadyMock.mockReset().mockResolvedValue(undefined);
92111createIMessageRpcClientMock.mockReset();
@@ -120,7 +139,7 @@ describe("iMessage monitor last-route updates", () => {
120139is_from_me: false,
121140text: "hello from imessage",
122141is_group: false,
123-date: 1_714_000_000_000,
142+created_at: new Date().toISOString(),
124143},
125144},
126145});
@@ -172,6 +191,199 @@ describe("iMessage monitor last-route updates", () => {
172191expect(recordParams?.updateLastRoute?.mainDmOwnerPin).toBeUndefined();
173192});
174193194+it("drops historical watch notifications on startup when catchup is disabled", async () => {
195+vi.useFakeTimers();
196+vi.setSystemTime(new Date("2026-05-30T05:23:18.000Z"));
197+const dbPath = await createMessagesDbWithMaxRowid(3000);
198+199+let onNotification: ((message: { method: string; params: unknown }) => void) | undefined;
200+const client = {
201+request: vi.fn(async () => ({ subscription: 1 })),
202+waitForClose: vi.fn(async () => {
203+onNotification?.({
204+method: "message",
205+params: {
206+message: {
207+id: 2023,
208+guid: "OLD-GUID-2023",
209+chat_id: 123,
210+sender: "+15550001111",
211+is_from_me: false,
212+text: "old row from another account",
213+is_group: false,
214+created_at: "2023-08-09T03:45:59.000Z",
215+},
216+},
217+});
218+await Promise.resolve();
219+}),
220+stop: vi.fn(async () => {}),
221+};
222+createIMessageRpcClientMock.mockImplementation(async (params) => {
223+if (!params?.onNotification) {
224+throw new Error("expected iMessage notification handler");
225+}
226+onNotification = params.onNotification;
227+return client as never;
228+});
229+230+await monitorIMessageProvider({
231+config: {
232+channels: { imessage: { dbPath, dmPolicy: "allowlist", allowFrom: ["+15550001111"] } },
233+messages: { inbound: { debounceMs: 0 } },
234+session: { mainKey: "main" },
235+} as never,
236+runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() },
237+});
238+239+expect(client.request).toHaveBeenCalledWith(
240+"watch.subscribe",
241+{ attachments: false, include_reactions: true, since_rowid: 3000 },
242+{ timeoutMs: 10_000 },
243+);
244+expect(recordInboundSessionMock).not.toHaveBeenCalled();
245+expect(dispatchInboundMessageMock).not.toHaveBeenCalled();
246+});
247+248+it("uses the default local chat.db path for the startup watermark", async () => {
249+const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-imsg-default-home-"));
250+tempDirs.push(homeDir);
251+vi.stubEnv("HOME", homeDir);
252+await createMessagesDbWithMaxRowid(4000, path.join(homeDir, "Library", "Messages", "chat.db"));
253+const client = {
254+request: vi.fn(async () => ({ subscription: 1 })),
255+waitForClose: vi.fn(async () => {}),
256+stop: vi.fn(async () => {}),
257+};
258+createIMessageRpcClientMock.mockImplementation(async () => client as never);
259+260+await monitorIMessageProvider({
261+config: {
262+channels: { imessage: { dmPolicy: "allowlist", allowFrom: ["+15550001111"] } },
263+messages: { inbound: { debounceMs: 0 } },
264+session: { mainKey: "main" },
265+} as never,
266+runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() },
267+});
268+269+expect(client.request).toHaveBeenCalledWith(
270+"watch.subscribe",
271+{ attachments: false, include_reactions: true, since_rowid: 4000 },
272+{ timeoutMs: 10_000 },
273+);
274+});
275+276+it("accepts live watch notifications after startup when catchup is disabled", async () => {
277+vi.useFakeTimers();
278+vi.setSystemTime(new Date("2026-05-30T05:23:18.000Z"));
279+const dbPath = await createMessagesDbWithMaxRowid(3000);
280+281+let onNotification: ((message: { method: string; params: unknown }) => void) | undefined;
282+const client = {
283+request: vi.fn(async () => ({ subscription: 1 })),
284+waitForClose: vi.fn(async () => {
285+onNotification?.({
286+method: "message",
287+params: {
288+message: {
289+id: 3001,
290+guid: "LIVE-GUID-2026",
291+chat_id: 123,
292+sender: "+15550001111",
293+is_from_me: false,
294+text: "current row",
295+is_group: false,
296+created_at: "2023-08-09T03:45:59.000Z",
297+},
298+},
299+});
300+await Promise.resolve();
301+await Promise.resolve();
302+}),
303+stop: vi.fn(async () => {}),
304+};
305+createIMessageRpcClientMock.mockImplementation(async (params) => {
306+if (!params?.onNotification) {
307+throw new Error("expected iMessage notification handler");
308+}
309+onNotification = params.onNotification;
310+return client as never;
311+});
312+313+await monitorIMessageProvider({
314+config: {
315+channels: { imessage: { dbPath, dmPolicy: "allowlist", allowFrom: ["+15550001111"] } },
316+messages: { inbound: { debounceMs: 0 } },
317+session: { mainKey: "main" },
318+} as never,
319+runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() },
320+});
321+322+await vi.waitFor(() => {
323+expect(recordInboundSessionMock).toHaveBeenCalledTimes(1);
324+expect(dispatchInboundMessageMock).toHaveBeenCalledTimes(1);
325+});
326+});
327+328+it("subscribes without a startup watermark when the configured dbPath is not readable", async () => {
329+const dbPath = path.join(os.tmpdir(), `openclaw-missing-chat-${Date.now()}.db`);
330+const client = {
331+request: vi.fn(async () => ({ subscription: 1 })),
332+waitForClose: vi.fn(async () => {}),
333+stop: vi.fn(async () => {}),
334+};
335+createIMessageRpcClientMock.mockImplementation(async () => client as never);
336+337+await monitorIMessageProvider({
338+config: {
339+channels: { imessage: { dbPath, dmPolicy: "allowlist", allowFrom: ["+15550001111"] } },
340+messages: { inbound: { debounceMs: 0 } },
341+session: { mainKey: "main" },
342+} as never,
343+runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() },
344+});
345+346+expect(client.request).toHaveBeenCalledWith(
347+"watch.subscribe",
348+{ attachments: false, include_reactions: true },
349+{ timeoutMs: 10_000 },
350+);
351+});
352+353+it("subscribes without a startup watermark when node sqlite is unavailable", async () => {
354+vi.doMock("node:sqlite", () => {
355+throw new Error("node:sqlite unavailable");
356+});
357+vi.resetModules();
358+try {
359+const { monitorIMessageProvider: monitorWithoutSqlite } = await import("./monitor.js");
360+const client = {
361+request: vi.fn(async () => ({ subscription: 1 })),
362+waitForClose: vi.fn(async () => {}),
363+stop: vi.fn(async () => {}),
364+};
365+createIMessageRpcClientMock.mockImplementation(async () => client as never);
366+367+await monitorWithoutSqlite({
368+config: {
369+channels: { imessage: { dmPolicy: "allowlist", allowFrom: ["+15550001111"] } },
370+messages: { inbound: { debounceMs: 0 } },
371+session: { mainKey: "main" },
372+} as never,
373+runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() },
374+});
375+376+expect(client.request).toHaveBeenCalledWith(
377+"watch.subscribe",
378+{ attachments: false, include_reactions: true },
379+{ timeoutMs: 10_000 },
380+);
381+} finally {
382+vi.doUnmock("node:sqlite");
383+vi.resetModules();
384+}
385+});
386+175387it("advances the catchup cursor after startup catchup succeeds and a live row is handled", async () => {
176388const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-imsg-live-cursor-"));
177389tempDirs.push(stateDir);
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。