import type { OpenClawConfig, PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk/zalouser"; import { beforeEach, describe, expect, it, vi } from "vitest"; import "./monitor.send-mocks.js"; import { __testing } from "./monitor.js"; import { sendDeliveredZalouserMock, sendMessageZalouserMock, sendSeenZalouserMock, sendTypingZalouserMock, } from "./monitor.send-mocks.js"; import { setZalouserRuntime } from "./runtime.js"; import type { ResolvedZalouserAccount, ZaloInboundMessage } from "./types.js"; function createAccount(): ResolvedZalouserAccount { return { accountId: "default", enabled: true, profile: "default", authenticated: true, config: { groupPolicy: "open", groups: { "*": { requireMention: true }, }, }, }; } function createConfig(): OpenClawConfig { return { channels: { zalouser: { enabled: true, groups: { "*": { requireMention: true }, }, }, }, }; } function createRuntimeEnv(): RuntimeEnv { return { log: vi.fn(), error: vi.fn(), exit: ((code: number): never => { throw new Error(`exit ${code}`); }) as RuntimeEnv["exit"], }; } function installRuntime(params: { commandAuthorized?: boolean; replyPayload?: { text?: string; mediaUrl?: string; mediaUrls?: string[] }; resolveCommandAuthorizedFromAuthorizers?: (params: { useAccessGroups: boolean; authorizers: Array<{ configured: boolean; allowed: boolean }>; }) => boolean; }) { const dispatchReplyWithBufferedBlockDispatcher = vi.fn(async ({ dispatcherOptions, ctx }) => { await dispatcherOptions.typingCallbacks?.onReplyStart?.(); if (params.replyPayload) { await dispatcherOptions.deliver(params.replyPayload); } return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 }, ctx }; }); const resolveCommandAuthorizedFromAuthorizers = vi.fn( (input: { useAccessGroups: boolean; authorizers: Array<{ configured: boolean; allowed: boolean }>; }) => { if (params.resolveCommandAuthorizedFromAuthorizers) { return params.resolveCommandAuthorizedFromAuthorizers(input); } return params.commandAuthorized ?? false; }, ); const resolveAgentRoute = vi.fn((input: { peer?: { kind?: string; id?: string } }) => { const peerKind = input.peer?.kind === "direct" ? "direct" : "group"; const peerId = input.peer?.id ?? "1"; return { agentId: "main", sessionKey: peerKind === "direct" ? "agent:main:main" : `agent:main:zalouser:${peerKind}:${peerId}`, accountId: "default", mainSessionKey: "agent:main:main", }; }); const readAllowFromStore = vi.fn(async () => []); const readSessionUpdatedAt = vi.fn( (_params?: { storePath: string; sessionKey: string }): number | undefined => undefined, ); const buildAgentSessionKey = vi.fn( (input: { agentId: string; channel: string; accountId?: string; peer?: { kind?: string; id?: string }; dmScope?: string; }) => { const peerKind = input.peer?.kind === "direct" ? "direct" : "group"; const peerId = input.peer?.id ?? "1"; if (peerKind === "direct") { if (input.dmScope === "per-account-channel-peer") { return `agent:${input.agentId}:${input.channel}:${input.accountId ?? "default"}:direct:${peerId}`; } if (input.dmScope === "per-peer") { return `agent:${input.agentId}:direct:${peerId}`; } if (input.dmScope === "main" || !input.dmScope) { return "agent:main:main"; } } return `agent:${input.agentId}:${input.channel}:${peerKind}:${peerId}`; }, ); setZalouserRuntime({ logging: { shouldLogVerbose: () => false, }, channel: { pairing: { readAllowFromStore, upsertPairingRequest: vi.fn(async () => ({ code: "PAIR", created: true })), buildPairingReply: vi.fn(() => "pair"), }, commands: { shouldComputeCommandAuthorized: vi.fn((body: string) => body.trim().startsWith("/")), resolveCommandAuthorizedFromAuthorizers, isControlCommandMessage: vi.fn((body: string) => body.trim().startsWith("/")), shouldHandleTextCommands: vi.fn(() => true), }, mentions: { buildMentionRegexes: vi.fn(() => []), matchesMentionWithExplicit: vi.fn( (input) => input.explicit?.isExplicitlyMentioned === true, ), }, groups: { resolveRequireMention: vi.fn((input) => { const cfg = input.cfg as OpenClawConfig; const groupCfg = cfg.channels?.zalouser?.groups ?? {}; const groupEntry = input.groupId ? groupCfg[input.groupId] : undefined; const defaultEntry = groupCfg["*"]; if (typeof groupEntry?.requireMention === "boolean") { return groupEntry.requireMention; } if (typeof defaultEntry?.requireMention === "boolean") { return defaultEntry.requireMention; } return true; }), }, routing: { buildAgentSessionKey, resolveAgentRoute, }, session: { resolveStorePath: vi.fn(() => "/tmp"), readSessionUpdatedAt, recordInboundSession: vi.fn(async () => {}), }, reply: { resolveEnvelopeFormatOptions: vi.fn(() => undefined), formatAgentEnvelope: vi.fn(({ body }) => body), finalizeInboundContext: vi.fn((ctx) => ctx), dispatchReplyWithBufferedBlockDispatcher, }, text: { resolveMarkdownTableMode: vi.fn(() => "code"), convertMarkdownTables: vi.fn((text: string) => text), resolveChunkMode: vi.fn(() => "length"), resolveTextChunkLimit: vi.fn(() => 1200), chunkMarkdownTextWithMode: vi.fn((text: string) => [text]), }, }, } as unknown as PluginRuntime); return { dispatchReplyWithBufferedBlockDispatcher, resolveAgentRoute, resolveCommandAuthorizedFromAuthorizers, readAllowFromStore, readSessionUpdatedAt, buildAgentSessionKey, }; } function installGroupCommandAuthRuntime() { return installRuntime({ resolveCommandAuthorizedFromAuthorizers: ({ useAccessGroups, authorizers }) => useAccessGroups && authorizers.some((entry) => entry.configured && entry.allowed), }); } async function processGroupControlCommand(params: { account: ResolvedZalouserAccount; content?: string; commandContent?: string; }) { await __testing.processMessage({ message: createGroupMessage({ content: params.content ?? "/new", commandContent: params.commandContent ?? "/new", hasAnyMention: true, wasExplicitlyMentioned: true, }), account: params.account, config: createConfig(), runtime: createRuntimeEnv(), }); } function createGroupMessage(overrides: Partial = {}): ZaloInboundMessage { return { threadId: "g-1", isGroup: true, senderId: "123", senderName: "Alice", groupName: "Team", content: "hello", timestampMs: Date.now(), msgId: "m-1", hasAnyMention: false, wasExplicitlyMentioned: false, canResolveExplicitMention: true, implicitMention: false, raw: { source: "test" }, ...overrides, }; } function createDmMessage(overrides: Partial = {}): ZaloInboundMessage { return { threadId: "u-1", isGroup: false, senderId: "321", senderName: "Bob", groupName: undefined, content: "hello", timestampMs: Date.now(), msgId: "dm-1", raw: { source: "test" }, ...overrides, }; } describe("zalouser monitor group mention gating", () => { beforeEach(() => { sendMessageZalouserMock.mockClear(); sendTypingZalouserMock.mockClear(); sendDeliveredZalouserMock.mockClear(); sendSeenZalouserMock.mockClear(); }); it("skips unmentioned group messages when requireMention=true", async () => { const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({ commandAuthorized: false, }); await __testing.processMessage({ message: createGroupMessage(), account: createAccount(), config: createConfig(), runtime: createRuntimeEnv(), }); expect(dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); expect(sendTypingZalouserMock).not.toHaveBeenCalled(); }); it("fails closed when requireMention=true but mention detection is unavailable", async () => { const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({ commandAuthorized: false, }); await __testing.processMessage({ message: createGroupMessage({ canResolveExplicitMention: false, hasAnyMention: false, wasExplicitlyMentioned: false, }), account: createAccount(), config: createConfig(), runtime: createRuntimeEnv(), }); expect(dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); expect(sendTypingZalouserMock).not.toHaveBeenCalled(); }); it("dispatches explicitly-mentioned group messages and marks WasMentioned", async () => { const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({ commandAuthorized: false, }); await __testing.processMessage({ message: createGroupMessage({ hasAnyMention: true, wasExplicitlyMentioned: true, content: "ping @bot", }), account: createAccount(), config: createConfig(), runtime: createRuntimeEnv(), }); expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1); const callArg = dispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0]; expect(callArg?.ctx?.WasMentioned).toBe(true); expect(callArg?.ctx?.To).toBe("zalouser:group:g-1"); expect(callArg?.ctx?.OriginatingTo).toBe("zalouser:group:g-1"); expect(sendTypingZalouserMock).toHaveBeenCalledWith("g-1", { profile: "default", isGroup: true, }); }); it("allows authorized control commands to bypass mention gating", async () => { const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({ commandAuthorized: true, }); await __testing.processMessage({ message: createGroupMessage({ content: "/status", hasAnyMention: false, wasExplicitlyMentioned: false, }), account: createAccount(), config: createConfig(), runtime: createRuntimeEnv(), }); expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1); const callArg = dispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0]; expect(callArg?.ctx?.WasMentioned).toBe(true); }); it("passes long markdown replies through once so formatting happens before chunking", async () => { const replyText = `**${"a".repeat(2501)}**`; installRuntime({ commandAuthorized: false, replyPayload: { text: replyText }, }); await __testing.processMessage({ message: createDmMessage({ content: "hello", }), account: { ...createAccount(), config: { ...createAccount().config, dmPolicy: "open", }, }, config: createConfig(), runtime: createRuntimeEnv(), }); expect(sendMessageZalouserMock).toHaveBeenCalledTimes(1); expect(sendMessageZalouserMock).toHaveBeenCalledWith( "u-1", replyText, expect.objectContaining({ isGroup: false, profile: "default", textMode: "markdown", textChunkMode: "length", textChunkLimit: 1200, }), ); }); it("uses commandContent for mention-prefixed control commands", async () => { const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({ commandAuthorized: true, }); await __testing.processMessage({ message: createGroupMessage({ content: "@Bot /new", commandContent: "/new", hasAnyMention: true, wasExplicitlyMentioned: true, }), account: createAccount(), config: createConfig(), runtime: createRuntimeEnv(), }); expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1); const callArg = dispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0]; expect(callArg?.ctx?.CommandBody).toBe("/new"); expect(callArg?.ctx?.BodyForCommands).toBe("/new"); }); it("allows group control commands when only allowFrom is configured", async () => { const { dispatchReplyWithBufferedBlockDispatcher, resolveCommandAuthorizedFromAuthorizers } = installGroupCommandAuthRuntime(); await processGroupControlCommand({ account: { ...createAccount(), config: { ...createAccount().config, allowFrom: ["123"], }, }, }); expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1); const authCall = resolveCommandAuthorizedFromAuthorizers.mock.calls[0]?.[0]; expect(authCall?.authorizers).toEqual([ { configured: true, allowed: true }, { configured: true, allowed: true }, ]); }); it("blocks group messages when sender is not in groupAllowFrom/allowFrom", async () => { const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({ commandAuthorized: false, }); await __testing.processMessage({ message: createGroupMessage({ content: "ping @bot", hasAnyMention: true, wasExplicitlyMentioned: true, }), account: { ...createAccount(), config: { ...createAccount().config, groupPolicy: "allowlist", allowFrom: ["999"], }, }, config: createConfig(), runtime: createRuntimeEnv(), }); expect(dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); }); it("does not accept a different group id by matching only the mutable group name by default", async () => { const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({ commandAuthorized: false, }); await __testing.processMessage({ message: createGroupMessage({ threadId: "g-attacker-001", groupName: "Trusted Team", senderId: "666", hasAnyMention: true, wasExplicitlyMentioned: true, content: "ping @bot", }), account: { ...createAccount(), config: { ...createAccount().config, groupPolicy: "allowlist", groupAllowFrom: ["*"], groups: { "group:g-trusted-001": { allow: true }, "Trusted Team": { allow: true }, }, }, }, config: createConfig(), runtime: createRuntimeEnv(), }); expect(dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); }); it("accepts mutable group-name matches only when dangerouslyAllowNameMatching is enabled", async () => { const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({ commandAuthorized: false, }); await __testing.processMessage({ message: createGroupMessage({ threadId: "g-attacker-001", groupName: "Trusted Team", senderId: "666", hasAnyMention: true, wasExplicitlyMentioned: true, content: "ping @bot", }), account: { ...createAccount(), config: { ...createAccount().config, dangerouslyAllowNameMatching: true, groupPolicy: "allowlist", groupAllowFrom: ["*"], groups: { "group:g-trusted-001": { allow: true }, "Trusted Team": { allow: true }, }, }, }, config: createConfig(), runtime: createRuntimeEnv(), }); expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1); const callArg = dispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0]; expect(callArg?.ctx?.To).toBe("zalouser:group:g-attacker-001"); }); it("allows group control commands when sender is in groupAllowFrom", async () => { const { dispatchReplyWithBufferedBlockDispatcher, resolveCommandAuthorizedFromAuthorizers } = installGroupCommandAuthRuntime(); await processGroupControlCommand({ account: { ...createAccount(), config: { ...createAccount().config, allowFrom: ["999"], groupAllowFrom: ["123"], }, }, }); expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1); const authCall = resolveCommandAuthorizedFromAuthorizers.mock.calls[0]?.[0]; expect(authCall?.authorizers).toEqual([ { configured: true, allowed: false }, { configured: true, allowed: true }, ]); }); it("routes DM messages with direct peer kind", async () => { const { dispatchReplyWithBufferedBlockDispatcher, resolveAgentRoute, buildAgentSessionKey } = installRuntime({ commandAuthorized: false, }); const account = createAccount(); await __testing.processMessage({ message: createDmMessage(), account: { ...account, config: { ...account.config, dmPolicy: "open", }, }, config: createConfig(), runtime: createRuntimeEnv(), }); expect(resolveAgentRoute).toHaveBeenCalledWith( expect.objectContaining({ peer: { kind: "direct", id: "321" }, }), ); expect(buildAgentSessionKey).toHaveBeenCalledWith( expect.objectContaining({ peer: { kind: "direct", id: "321" }, dmScope: "per-channel-peer", }), ); const callArg = dispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0]; expect(callArg?.ctx?.SessionKey).toBe("agent:main:zalouser:direct:321"); }); it("reuses the legacy DM session key when only the old group-shaped session exists", async () => { const { dispatchReplyWithBufferedBlockDispatcher, readSessionUpdatedAt } = installRuntime({ commandAuthorized: false, }); readSessionUpdatedAt.mockImplementation((input?: { storePath: string; sessionKey: string }) => input?.sessionKey === "agent:main:zalouser:group:321" ? 123 : undefined, ); const account = createAccount(); await __testing.processMessage({ message: createDmMessage(), account: { ...account, config: { ...account.config, dmPolicy: "open", }, }, config: createConfig(), runtime: createRuntimeEnv(), }); const callArg = dispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0]; expect(callArg?.ctx?.SessionKey).toBe("agent:main:zalouser:group:321"); }); it("reads pairing store for open DM control commands", async () => { const { readAllowFromStore } = installRuntime({ commandAuthorized: false, }); const account = createAccount(); await __testing.processMessage({ message: createDmMessage({ content: "/new", commandContent: "/new" }), account: { ...account, config: { ...account.config, dmPolicy: "open", }, }, config: createConfig(), runtime: createRuntimeEnv(), }); expect(readAllowFromStore).toHaveBeenCalledTimes(1); }); it("skips pairing store read for open DM non-command messages", async () => { const { readAllowFromStore } = installRuntime({ commandAuthorized: false, }); const account = createAccount(); await __testing.processMessage({ message: createDmMessage({ content: "hello there" }), account: { ...account, config: { ...account.config, dmPolicy: "open", }, }, config: createConfig(), runtime: createRuntimeEnv(), }); expect(readAllowFromStore).not.toHaveBeenCalled(); }); it("includes skipped group messages as InboundHistory on the next processed message", async () => { const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({ commandAuthorized: false, }); const historyState = { historyLimit: 5, groupHistories: new Map< string, Array<{ sender: string; body: string; timestamp?: number; messageId?: string }> >(), }; const account = createAccount(); const config = createConfig(); await __testing.processMessage({ message: createGroupMessage({ content: "first unmentioned line", hasAnyMention: false, wasExplicitlyMentioned: false, }), account, config, runtime: createRuntimeEnv(), historyState, }); expect(dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); await __testing.processMessage({ message: createGroupMessage({ content: "second line @bot", hasAnyMention: true, wasExplicitlyMentioned: true, }), account, config, runtime: createRuntimeEnv(), historyState, }); expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1); const firstDispatch = dispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0]; expect(firstDispatch?.ctx?.InboundHistory).toEqual([ expect.objectContaining({ sender: "Alice", body: "first unmentioned line" }), ]); expect(String(firstDispatch?.ctx?.Body ?? "")).toContain("first unmentioned line"); await __testing.processMessage({ message: createGroupMessage({ content: "third line @bot", hasAnyMention: true, wasExplicitlyMentioned: true, }), account, config, runtime: createRuntimeEnv(), historyState, }); expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(2); const secondDispatch = dispatchReplyWithBufferedBlockDispatcher.mock.calls[1]?.[0]; expect(secondDispatch?.ctx?.InboundHistory).toEqual([]); }); });