refactor(imessage): extract RPC notification parsing

This commit is contained in:
Peter Steinberger
2026-02-14 20:57:01 +01:00
parent d9d321f94b
commit 63aa155ade
3 changed files with 147 additions and 141 deletions
@@ -23,6 +23,7 @@ beforeAll(async () => {
const replyMock = getReplyMock(); const replyMock = getReplyMock();
const sendMock = getSendMock(); const sendMock = getSendMock();
const readAllowFromStoreMock = getReadAllowFromStoreMock();
const upsertPairingRequestMock = getUpsertPairingRequestMock(); const upsertPairingRequestMock = getUpsertPairingRequestMock();
type TestConfig = { type TestConfig = {
@@ -36,24 +37,42 @@ function getConfig(): TestConfig {
return getConfigMock() as unknown as 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", () => { describe("monitorIMessageProvider", () => {
it("ignores malformed rpc message payloads", async () => { it("ignores malformed rpc message payloads", async () => {
const run = monitorIMessageProvider(); const run = startMonitor();
await waitForSubscribe(); await waitForSubscribe();
getNotificationHandler()?.({ notifyMessage({
method: "message", id: 1,
params: { sender: { nested: "not-a-string" },
message: { text: "hello",
id: 1,
sender: { nested: "not-a-string" },
text: "hello",
},
},
}); });
await flush(); await flush();
getCloseResolve()?.(); await closeMonitor();
await run; await run;
expect(replyMock).not.toHaveBeenCalled(); expect(replyMock).not.toHaveBeenCalled();
@@ -403,7 +422,7 @@ describe("monitorIMessageProvider", () => {
channels: { channels: {
...config.channels, ...config.channels,
imessage: { imessage: {
...config.channels?.imessage, ...config.channels.imessage,
dmPolicy: "pairing", dmPolicy: "pairing",
allowFrom: [], allowFrom: [],
groupPolicy: "allowlist", groupPolicy: "allowlist",
@@ -411,26 +430,21 @@ describe("monitorIMessageProvider", () => {
}, },
}, },
}); });
getReadAllowFromStoreMock().mockResolvedValue(["+15550003333"]); readAllowFromStoreMock.mockResolvedValue(["+15550003333"]);
const run = monitorIMessageProvider(); const run = startMonitor();
await waitForSubscribe(); await waitForSubscribe();
getNotificationHandler()?.({ notifyMessage({
method: "message", id: 30,
params: { chat_id: 909,
message: { sender: "+15550003333",
id: 30, is_from_me: false,
chat_id: 909, text: "@openclaw hi from paired sender",
sender: "+15550003333", is_group: true,
is_from_me: false,
text: "@openclaw hi from paired sender",
is_group: true,
},
},
}); });
await flush(); await flush();
getCloseResolve()?.(); await closeMonitor();
await run; await run;
expect(replyMock).not.toHaveBeenCalled(); expect(replyMock).not.toHaveBeenCalled();
@@ -444,7 +458,7 @@ describe("monitorIMessageProvider", () => {
channels: { channels: {
...config.channels, ...config.channels,
imessage: { imessage: {
...config.channels?.imessage, ...config.channels.imessage,
dmPolicy: "pairing", dmPolicy: "pairing",
allowFrom: [], allowFrom: [],
groupPolicy: "allowlist", groupPolicy: "allowlist",
@@ -452,26 +466,21 @@ describe("monitorIMessageProvider", () => {
}, },
}, },
}); });
getReadAllowFromStoreMock().mockResolvedValue(["+15550003333"]); readAllowFromStoreMock.mockResolvedValue(["+15550003333"]);
const run = monitorIMessageProvider(); const run = startMonitor();
await waitForSubscribe(); await waitForSubscribe();
getNotificationHandler()?.({ notifyMessage({
method: "message", id: 31,
params: { chat_id: 202,
message: { sender: "+15550003333",
id: 31, is_from_me: false,
chat_id: 202, text: "@openclaw hi from paired sender",
sender: "+15550003333", is_group: true,
is_from_me: false,
text: "@openclaw hi from paired sender",
is_group: true,
},
},
}); });
await flush(); await flush();
getCloseResolve()?.(); await closeMonitor();
await run; await run;
expect(replyMock).not.toHaveBeenCalled(); expect(replyMock).not.toHaveBeenCalled();
@@ -485,7 +494,7 @@ describe("monitorIMessageProvider", () => {
channels: { channels: {
...config.channels, ...config.channels,
imessage: { imessage: {
...config.channels?.imessage, ...config.channels.imessage,
dmPolicy: "pairing", dmPolicy: "pairing",
allowFrom: [], allowFrom: [],
groupPolicy: "allowlist", groupPolicy: "allowlist",
@@ -493,26 +502,21 @@ describe("monitorIMessageProvider", () => {
}, },
}, },
}); });
getReadAllowFromStoreMock().mockResolvedValue(["+15550003333"]); readAllowFromStoreMock.mockResolvedValue(["+15550003333"]);
const run = monitorIMessageProvider(); const run = startMonitor();
await waitForSubscribe(); await waitForSubscribe();
getNotificationHandler()?.({ notifyMessage({
method: "message", id: 32,
params: { chat_id: 202,
message: { sender: "+15550003333",
id: 32, is_from_me: false,
chat_id: 202, text: "/status",
sender: "+15550003333", is_group: true,
is_from_me: false,
text: "/status",
is_group: true,
},
},
}); });
await flush(); await flush();
getCloseResolve()?.(); await closeMonitor();
await run; await run;
expect(replyMock).not.toHaveBeenCalled(); expect(replyMock).not.toHaveBeenCalled();
+1 -82
View File
@@ -54,6 +54,7 @@ import {
normalizeIMessageHandle, normalizeIMessageHandle,
} from "../targets.js"; } from "../targets.js";
import { deliverReplies } from "./deliver.js"; import { deliverReplies } from "./deliver.js";
import { parseIMessageNotification } from "./parse-notification.js";
import { normalizeAllowList, resolveRuntime } from "./runtime.js"; import { normalizeAllowList, resolveRuntime } from "./runtime.js";
/** /**
@@ -111,88 +112,6 @@ function describeReplyContext(message: IMessagePayload): IMessageReplyContext |
return { body, id, sender }; return { body, id, sender };
} }
function isRecord(value: unknown): value is Record<string, unknown> {
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. * 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. * Keys are scoped by conversation (accountId:target) so the same text in different chats is not conflated.
@@ -0,0 +1,83 @@
import type { IMessagePayload } from "./types.js";
function isRecord(value: unknown): value is Record<string, unknown> {
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;
}