diff --git a/src/imessage/monitor.skips-group-messages-without-mention-by-default.test.ts b/src/imessage/monitor.skips-group-messages-without-mention-by-default.test.ts index 3a367023705..5698f41b71e 100644 --- a/src/imessage/monitor.skips-group-messages-without-mention-by-default.test.ts +++ b/src/imessage/monitor.skips-group-messages-without-mention-by-default.test.ts @@ -23,6 +23,7 @@ beforeAll(async () => { const replyMock = getReplyMock(); const sendMock = getSendMock(); +const readAllowFromStoreMock = getReadAllowFromStoreMock(); const upsertPairingRequestMock = getUpsertPairingRequestMock(); type TestConfig = { @@ -36,24 +37,42 @@ function getConfig(): TestConfig { return getConfigMock() as unknown as TestConfig; } +function notifyMessage(message: unknown) { + getNotificationHandler()?.({ + method: "message", + params: { message }, + }); +} + +async function closeMonitor() { + for (let i = 0; i < 20; i += 1) { + const close = getCloseResolve(); + if (close) { + close(); + return; + } + await flush(); + } + throw new Error("imessage test harness: closeResolve not set"); +} + +function startMonitor() { + return monitorIMessageProvider(); +} + describe("monitorIMessageProvider", () => { it("ignores malformed rpc message payloads", async () => { - const run = monitorIMessageProvider(); + const run = startMonitor(); await waitForSubscribe(); - getNotificationHandler()?.({ - method: "message", - params: { - message: { - id: 1, - sender: { nested: "not-a-string" }, - text: "hello", - }, - }, + notifyMessage({ + id: 1, + sender: { nested: "not-a-string" }, + text: "hello", }); await flush(); - getCloseResolve()?.(); + await closeMonitor(); await run; expect(replyMock).not.toHaveBeenCalled(); @@ -403,7 +422,7 @@ describe("monitorIMessageProvider", () => { channels: { ...config.channels, imessage: { - ...config.channels?.imessage, + ...config.channels.imessage, dmPolicy: "pairing", allowFrom: [], groupPolicy: "allowlist", @@ -411,26 +430,21 @@ describe("monitorIMessageProvider", () => { }, }, }); - getReadAllowFromStoreMock().mockResolvedValue(["+15550003333"]); - const run = monitorIMessageProvider(); + readAllowFromStoreMock.mockResolvedValue(["+15550003333"]); + const run = startMonitor(); await waitForSubscribe(); - getNotificationHandler()?.({ - method: "message", - params: { - message: { - id: 30, - chat_id: 909, - sender: "+15550003333", - is_from_me: false, - text: "@openclaw hi from paired sender", - is_group: true, - }, - }, + notifyMessage({ + id: 30, + chat_id: 909, + sender: "+15550003333", + is_from_me: false, + text: "@openclaw hi from paired sender", + is_group: true, }); await flush(); - getCloseResolve()?.(); + await closeMonitor(); await run; expect(replyMock).not.toHaveBeenCalled(); @@ -444,7 +458,7 @@ describe("monitorIMessageProvider", () => { channels: { ...config.channels, imessage: { - ...config.channels?.imessage, + ...config.channels.imessage, dmPolicy: "pairing", allowFrom: [], groupPolicy: "allowlist", @@ -452,26 +466,21 @@ describe("monitorIMessageProvider", () => { }, }, }); - getReadAllowFromStoreMock().mockResolvedValue(["+15550003333"]); - const run = monitorIMessageProvider(); + readAllowFromStoreMock.mockResolvedValue(["+15550003333"]); + const run = startMonitor(); await waitForSubscribe(); - getNotificationHandler()?.({ - method: "message", - params: { - message: { - id: 31, - chat_id: 202, - sender: "+15550003333", - is_from_me: false, - text: "@openclaw hi from paired sender", - is_group: true, - }, - }, + notifyMessage({ + id: 31, + chat_id: 202, + sender: "+15550003333", + is_from_me: false, + text: "@openclaw hi from paired sender", + is_group: true, }); await flush(); - getCloseResolve()?.(); + await closeMonitor(); await run; expect(replyMock).not.toHaveBeenCalled(); @@ -485,7 +494,7 @@ describe("monitorIMessageProvider", () => { channels: { ...config.channels, imessage: { - ...config.channels?.imessage, + ...config.channels.imessage, dmPolicy: "pairing", allowFrom: [], groupPolicy: "allowlist", @@ -493,26 +502,21 @@ describe("monitorIMessageProvider", () => { }, }, }); - getReadAllowFromStoreMock().mockResolvedValue(["+15550003333"]); - const run = monitorIMessageProvider(); + readAllowFromStoreMock.mockResolvedValue(["+15550003333"]); + const run = startMonitor(); await waitForSubscribe(); - getNotificationHandler()?.({ - method: "message", - params: { - message: { - id: 32, - chat_id: 202, - sender: "+15550003333", - is_from_me: false, - text: "/status", - is_group: true, - }, - }, + notifyMessage({ + id: 32, + chat_id: 202, + sender: "+15550003333", + is_from_me: false, + text: "/status", + is_group: true, }); await flush(); - getCloseResolve()?.(); + await closeMonitor(); await run; expect(replyMock).not.toHaveBeenCalled(); diff --git a/src/imessage/monitor/monitor-provider.ts b/src/imessage/monitor/monitor-provider.ts index 894a52961a0..f918bf6a170 100644 --- a/src/imessage/monitor/monitor-provider.ts +++ b/src/imessage/monitor/monitor-provider.ts @@ -54,6 +54,7 @@ import { normalizeIMessageHandle, } from "../targets.js"; import { deliverReplies } from "./deliver.js"; +import { parseIMessageNotification } from "./parse-notification.js"; import { normalizeAllowList, resolveRuntime } from "./runtime.js"; /** @@ -111,88 +112,6 @@ function describeReplyContext(message: IMessagePayload): IMessageReplyContext | return { body, id, sender }; } -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - -function isOptionalString(value: unknown): value is string | null | undefined { - return value === undefined || value === null || typeof value === "string"; -} - -function isOptionalStringOrNumber(value: unknown): value is string | number | null | undefined { - return ( - value === undefined || value === null || typeof value === "string" || typeof value === "number" - ); -} - -function isOptionalNumber(value: unknown): value is number | null | undefined { - return value === undefined || value === null || typeof value === "number"; -} - -function isOptionalBoolean(value: unknown): value is boolean | null | undefined { - return value === undefined || value === null || typeof value === "boolean"; -} - -function isOptionalStringArray(value: unknown): value is string[] | null | undefined { - return ( - value === undefined || - value === null || - (Array.isArray(value) && value.every((entry) => typeof entry === "string")) - ); -} - -function isOptionalAttachments(value: unknown): value is IMessagePayload["attachments"] { - if (value === undefined || value === null) { - return true; - } - if (!Array.isArray(value)) { - return false; - } - return value.every((attachment) => { - if (!isRecord(attachment)) { - return false; - } - return ( - isOptionalString(attachment.original_path) && - isOptionalString(attachment.mime_type) && - isOptionalBoolean(attachment.missing) - ); - }); -} - -function parseIMessageNotification(raw: unknown): IMessagePayload | null { - if (!isRecord(raw)) { - return null; - } - const maybeMessage = raw.message; - if (!isRecord(maybeMessage)) { - return null; - } - - const message: IMessagePayload = maybeMessage; - if ( - !isOptionalNumber(message.id) || - !isOptionalNumber(message.chat_id) || - !isOptionalString(message.sender) || - !isOptionalBoolean(message.is_from_me) || - !isOptionalString(message.text) || - !isOptionalStringOrNumber(message.reply_to_id) || - !isOptionalString(message.reply_to_text) || - !isOptionalString(message.reply_to_sender) || - !isOptionalString(message.created_at) || - !isOptionalAttachments(message.attachments) || - !isOptionalString(message.chat_identifier) || - !isOptionalString(message.chat_guid) || - !isOptionalString(message.chat_name) || - !isOptionalStringArray(message.participants) || - !isOptionalBoolean(message.is_group) - ) { - return null; - } - - return message; -} - /** * 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. diff --git a/src/imessage/monitor/parse-notification.ts b/src/imessage/monitor/parse-notification.ts new file mode 100644 index 00000000000..98ad941665c --- /dev/null +++ b/src/imessage/monitor/parse-notification.ts @@ -0,0 +1,83 @@ +import type { IMessagePayload } from "./types.js"; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function isOptionalString(value: unknown): value is string | null | undefined { + return value === undefined || value === null || typeof value === "string"; +} + +function isOptionalStringOrNumber(value: unknown): value is string | number | null | undefined { + return ( + value === undefined || value === null || typeof value === "string" || typeof value === "number" + ); +} + +function isOptionalNumber(value: unknown): value is number | null | undefined { + return value === undefined || value === null || typeof value === "number"; +} + +function isOptionalBoolean(value: unknown): value is boolean | null | undefined { + return value === undefined || value === null || typeof value === "boolean"; +} + +function isOptionalStringArray(value: unknown): value is string[] | null | undefined { + return ( + value === undefined || + value === null || + (Array.isArray(value) && value.every((entry) => typeof entry === "string")) + ); +} + +function isOptionalAttachments(value: unknown): value is IMessagePayload["attachments"] { + if (value === undefined || value === null) { + return true; + } + if (!Array.isArray(value)) { + return false; + } + return value.every((attachment) => { + if (!isRecord(attachment)) { + return false; + } + return ( + isOptionalString(attachment.original_path) && + isOptionalString(attachment.mime_type) && + isOptionalBoolean(attachment.missing) + ); + }); +} + +export function parseIMessageNotification(raw: unknown): IMessagePayload | null { + if (!isRecord(raw)) { + return null; + } + const maybeMessage = raw.message; + if (!isRecord(maybeMessage)) { + return null; + } + + const message: IMessagePayload = maybeMessage; + if ( + !isOptionalNumber(message.id) || + !isOptionalNumber(message.chat_id) || + !isOptionalString(message.sender) || + !isOptionalBoolean(message.is_from_me) || + !isOptionalString(message.text) || + !isOptionalStringOrNumber(message.reply_to_id) || + !isOptionalString(message.reply_to_text) || + !isOptionalString(message.reply_to_sender) || + !isOptionalString(message.created_at) || + !isOptionalAttachments(message.attachments) || + !isOptionalString(message.chat_identifier) || + !isOptionalString(message.chat_guid) || + !isOptionalString(message.chat_name) || + !isOptionalStringArray(message.participants) || + !isOptionalBoolean(message.is_group) + ) { + return null; + } + + return message; +}