import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import type { IMessagePayload } from "./monitor/types.js"; import { buildIMessageInboundContext, resolveIMessageInboundDecision, } from "./monitor/inbound-processing.js"; import { parseIMessageNotification } from "./monitor/parse-notification.js"; function baseCfg(): OpenClawConfig { return { channels: { imessage: { dmPolicy: "open", allowFrom: ["*"], groupPolicy: "open", groups: { "*": { requireMention: true } }, }, }, session: { mainKey: "main" }, messages: { groupChat: { mentionPatterns: ["@openclaw"] }, }, } as unknown as OpenClawConfig; } function resolve(params: { cfg?: OpenClawConfig; message: IMessagePayload; storeAllowFrom?: string[]; }) { const cfg = params.cfg ?? baseCfg(); const groupHistories = new Map(); return resolveIMessageInboundDecision({ cfg, accountId: "default", message: params.message, opts: {}, messageText: (params.message.text ?? "").trim(), bodyText: (params.message.text ?? "").trim(), allowFrom: ["*"], groupAllowFrom: [], groupPolicy: cfg.channels?.imessage?.groupPolicy ?? "open", dmPolicy: cfg.channels?.imessage?.dmPolicy ?? "pairing", storeAllowFrom: params.storeAllowFrom ?? [], historyLimit: 0, groupHistories, }); } function buildDispatchContextPayload(params: { cfg: OpenClawConfig; message: IMessagePayload }) { const { cfg, message } = params; const groupHistories = new Map(); const decision = resolveIMessageInboundDecision({ cfg, accountId: "default", message, opts: {}, messageText: message.text, bodyText: message.text, allowFrom: ["*"], groupAllowFrom: [], groupPolicy: "open", dmPolicy: "open", storeAllowFrom: [], historyLimit: 0, groupHistories, }); expect(decision.kind).toBe("dispatch"); const { ctxPayload } = buildIMessageInboundContext({ cfg, decision, message, historyLimit: 0, groupHistories, }); return ctxPayload; } describe("imessage monitor gating + envelope builders", () => { it("parseIMessageNotification rejects malformed payloads", () => { expect( parseIMessageNotification({ message: { chat_id: 1, sender: { nested: "nope" } }, }), ).toBeNull(); }); it("drops group messages without mention by default", () => { const decision = resolve({ message: { id: 1, chat_id: 99, sender: "+15550001111", is_from_me: false, text: "hello group", is_group: true, }, }); expect(decision.kind).toBe("drop"); if (decision.kind !== "drop") { throw new Error("expected drop decision"); } expect(decision.reason).toBe("no mention"); }); it("dispatches group messages with mention and builds a group envelope", () => { const cfg = baseCfg(); const message: IMessagePayload = { id: 3, chat_id: 42, sender: "+15550002222", is_from_me: false, text: "@openclaw ping", is_group: true, chat_name: "Lobster Squad", participants: ["+1555", "+1556"], }; const ctxPayload = buildDispatchContextPayload({ cfg, message }); expect(ctxPayload.ChatType).toBe("group"); expect(ctxPayload.SessionKey).toBe("agent:main:imessage:group:42"); expect(String(ctxPayload.Body ?? "")).toContain("+15550002222:"); expect(String(ctxPayload.Body ?? "")).not.toContain("[from:"); expect(ctxPayload.To).toBe("chat_id:42"); }); it("includes reply-to context fields + suffix", () => { const cfg = baseCfg(); const message: IMessagePayload = { id: 5, chat_id: 55, sender: "+15550001111", is_from_me: false, text: "replying now", is_group: false, reply_to_id: 9001, reply_to_text: "original message", reply_to_sender: "+15559998888", }; const ctxPayload = buildDispatchContextPayload({ cfg, message }); expect(ctxPayload.ReplyToId).toBe("9001"); expect(ctxPayload.ReplyToBody).toBe("original message"); expect(ctxPayload.ReplyToSender).toBe("+15559998888"); expect(String(ctxPayload.Body ?? "")).toContain("[Replying to +15559998888 id:9001]"); expect(String(ctxPayload.Body ?? "")).toContain("original message"); }); it("treats configured chat_id as a group session even when is_group is false", () => { const cfg = baseCfg(); cfg.channels ??= {}; cfg.channels.imessage ??= {}; cfg.channels.imessage.groups = { "2": { requireMention: false } }; const groupHistories = new Map(); const message: IMessagePayload = { id: 14, chat_id: 2, sender: "+15550001111", is_from_me: false, text: "hello", is_group: false, }; const decision = resolveIMessageInboundDecision({ cfg, accountId: "default", message, opts: {}, messageText: message.text, bodyText: message.text, allowFrom: ["*"], groupAllowFrom: [], groupPolicy: "open", dmPolicy: "open", storeAllowFrom: [], historyLimit: 0, groupHistories, }); expect(decision.kind).toBe("dispatch"); expect(decision.isGroup).toBe(true); expect(decision.route.sessionKey).toBe("agent:main:imessage:group:2"); }); it("allows group messages when requireMention is true but no mentionPatterns exist", () => { const cfg = baseCfg(); cfg.messages ??= {}; cfg.messages.groupChat ??= {}; cfg.messages.groupChat.mentionPatterns = []; const groupHistories = new Map(); const decision = resolveIMessageInboundDecision({ cfg, accountId: "default", message: { id: 12, chat_id: 777, sender: "+15550001111", is_from_me: false, text: "hello group", is_group: true, }, opts: {}, messageText: "hello group", bodyText: "hello group", allowFrom: ["*"], groupAllowFrom: [], groupPolicy: "open", dmPolicy: "open", storeAllowFrom: [], historyLimit: 0, groupHistories, }); expect(decision.kind).toBe("dispatch"); }); it("blocks group messages when imessage.groups is set without a wildcard", () => { const cfg = baseCfg(); cfg.channels ??= {}; cfg.channels.imessage ??= {}; cfg.channels.imessage.groups = { "99": { requireMention: false } }; const groupHistories = new Map(); const decision = resolveIMessageInboundDecision({ cfg, accountId: "default", message: { id: 13, chat_id: 123, sender: "+15550001111", is_from_me: false, text: "@openclaw hello", is_group: true, }, opts: {}, messageText: "@openclaw hello", bodyText: "@openclaw hello", allowFrom: ["*"], groupAllowFrom: [], groupPolicy: "open", dmPolicy: "open", storeAllowFrom: [], historyLimit: 0, groupHistories, }); expect(decision.kind).toBe("drop"); }); it("honors group allowlist and ignores pairing-store senders in groups", () => { const cfg = baseCfg(); cfg.channels ??= {}; cfg.channels.imessage ??= {}; cfg.channels.imessage.groupPolicy = "allowlist"; const groupHistories = new Map(); const denied = resolveIMessageInboundDecision({ cfg, accountId: "default", message: { id: 3, chat_id: 202, sender: "+15550003333", is_from_me: false, text: "@openclaw hi", is_group: true, }, opts: {}, messageText: "@openclaw hi", bodyText: "@openclaw hi", allowFrom: ["*"], groupAllowFrom: ["chat_id:101"], groupPolicy: "allowlist", dmPolicy: "pairing", storeAllowFrom: ["+15550003333"], historyLimit: 0, groupHistories, }); expect(denied.kind).toBe("drop"); const allowed = resolveIMessageInboundDecision({ cfg, accountId: "default", message: { id: 33, chat_id: 101, sender: "+15550003333", is_from_me: false, text: "@openclaw ok", is_group: true, }, opts: {}, messageText: "@openclaw ok", bodyText: "@openclaw ok", allowFrom: ["*"], groupAllowFrom: ["chat_id:101"], groupPolicy: "allowlist", dmPolicy: "pairing", storeAllowFrom: ["+15550003333"], historyLimit: 0, groupHistories, }); expect(allowed.kind).toBe("dispatch"); }); it("blocks group messages when groupPolicy is disabled", () => { const cfg = baseCfg(); cfg.channels ??= {}; cfg.channels.imessage ??= {}; cfg.channels.imessage.groupPolicy = "disabled"; const groupHistories = new Map(); const decision = resolveIMessageInboundDecision({ cfg, accountId: "default", message: { id: 10, chat_id: 303, sender: "+15550003333", is_from_me: false, text: "@openclaw hi", is_group: true, }, opts: {}, messageText: "@openclaw hi", bodyText: "@openclaw hi", allowFrom: ["*"], groupAllowFrom: [], groupPolicy: "disabled", dmPolicy: "open", storeAllowFrom: [], historyLimit: 0, groupHistories, }); expect(decision.kind).toBe("drop"); }); });