diff --git a/src/auto-reply/reply/get-reply-inline-actions.skip-when-config-empty.test.ts b/src/auto-reply/reply/get-reply-inline-actions.skip-when-config-empty.test.ts new file mode 100644 index 00000000000..df833f6da11 --- /dev/null +++ b/src/auto-reply/reply/get-reply-inline-actions.skip-when-config-empty.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it, vi } from "vitest"; +import type { TemplateContext } from "../templating.js"; +import type { TypingController } from "./typing.js"; +import { clearInlineDirectives } from "./get-reply-directives-utils.js"; +import { buildTestCtx } from "./test-ctx.js"; + +const handleCommandsMock = vi.fn(); + +vi.mock("./commands.js", () => ({ + handleCommands: (...args: unknown[]) => handleCommandsMock(...args), + buildStatusReply: vi.fn(), + buildCommandContext: vi.fn(), +})); + +// Import after mocks. +const { handleInlineActions } = await import("./get-reply-inline-actions.js"); + +describe("handleInlineActions", () => { + it("skips whatsapp replies when config is empty and From !== To", async () => { + handleCommandsMock.mockReset(); + + const typing: TypingController = { + onReplyStart: async () => {}, + startTypingLoop: async () => {}, + startTypingOnText: async () => {}, + refreshTypingTtl: () => {}, + isActive: () => false, + markRunComplete: () => {}, + markDispatchIdle: () => {}, + cleanup: vi.fn(), + }; + + const ctx = buildTestCtx({ + From: "whatsapp:+999", + To: "whatsapp:+123", + Body: "hi", + }); + + const result = await handleInlineActions({ + ctx, + sessionCtx: ctx as unknown as TemplateContext, + cfg: {}, + agentId: "main", + sessionKey: "s:main", + workspaceDir: "/tmp", + isGroup: false, + typing, + allowTextCommands: false, + inlineStatusRequested: false, + command: { + surface: "whatsapp", + channel: "whatsapp", + channelId: "whatsapp", + ownerList: [], + senderIsOwner: false, + isAuthorizedSender: false, + senderId: undefined, + abortKey: "whatsapp:+999", + rawBodyNormalized: "hi", + commandBodyNormalized: "hi", + from: "whatsapp:+999", + to: "whatsapp:+123", + }, + directives: clearInlineDirectives("hi"), + cleanedBody: "hi", + elevatedEnabled: false, + elevatedAllowed: false, + elevatedFailures: [], + defaultActivation: () => ({ enabled: true, message: "" }), + resolvedThinkLevel: undefined, + resolvedVerboseLevel: undefined, + resolvedReasoningLevel: "off", + resolvedElevatedLevel: "off", + resolveDefaultThinkingLevel: () => "off", + provider: "openai", + model: "gpt-4o-mini", + contextTokens: 0, + abortedLastRun: false, + sessionScope: "per-sender", + }); + + expect(result).toEqual({ kind: "reply", reply: undefined }); + expect(typing.cleanup).toHaveBeenCalled(); + expect(handleCommandsMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/web/auto-reply.web-auto-reply.group-gating-and-activation.test.ts b/src/web/auto-reply.web-auto-reply.group-gating-and-activation.test.ts deleted file mode 100644 index c1174e509c8..00000000000 --- a/src/web/auto-reply.web-auto-reply.group-gating-and-activation.test.ts +++ /dev/null @@ -1,939 +0,0 @@ -import crypto from "node:crypto"; -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; -import { expectInboundContextContract } from "../../test/helpers/inbound-contract.js"; -import { setLoggerOverride } from "../logging.js"; -import { - installWebAutoReplyTestHomeHooks, - installWebAutoReplyUnitTestHooks, - makeSessionStore, - resetLoadConfigMock, - rmDirWithRetries, - setLoadConfigMock, -} from "./auto-reply.test-harness.js"; - -installWebAutoReplyTestHomeHooks(); - -let monitorWebChannel: typeof import("./auto-reply.js").monitorWebChannel; -let SILENT_REPLY_TOKEN: typeof import("./auto-reply.js").SILENT_REPLY_TOKEN; -let lastRouteSpy: { mockRestore: () => void } | undefined; - -beforeAll(async () => { - ({ monitorWebChannel, SILENT_REPLY_TOKEN } = await import("./auto-reply.js")); - const lastRoute = await import("./auto-reply/monitor/last-route.js"); - lastRouteSpy = vi - .spyOn(lastRoute, "updateLastRouteInBackground") - .mockImplementation(() => undefined); -}); - -afterAll(() => { - lastRouteSpy?.mockRestore(); - lastRouteSpy = undefined; -}); - -describe("web auto-reply", () => { - installWebAutoReplyUnitTestHooks(); - - it("requires mention in group chats and injects history when replying", async () => { - const sendMedia = vi.fn(); - const reply = vi.fn().mockResolvedValue(undefined); - const sendComposing = vi.fn(); - const resolver = vi.fn().mockResolvedValue({ text: "ok" }); - - let capturedOnMessage: - | ((msg: import("./inbound.js").WebInboundMessage) => Promise) - | undefined; - const listenerFactory = async (opts: { - onMessage: (msg: import("./inbound.js").WebInboundMessage) => Promise; - }) => { - capturedOnMessage = opts.onMessage; - return { close: vi.fn() }; - }; - - await monitorWebChannel(false, listenerFactory, false, resolver); - expect(capturedOnMessage).toBeDefined(); - - await capturedOnMessage?.({ - body: "hello group", - from: "123@g.us", - conversationId: "123@g.us", - chatId: "123@g.us", - chatType: "group", - to: "+2", - id: "g1", - senderE164: "+111", - senderName: "Alice", - selfE164: "+999", - sendComposing, - reply, - sendMedia, - }); - - expect(resolver).not.toHaveBeenCalled(); - - await capturedOnMessage?.({ - body: "@bot ping", - from: "123@g.us", - conversationId: "123@g.us", - chatId: "123@g.us", - chatType: "group", - to: "+2", - id: "g2", - senderE164: "+222", - senderName: "Bob", - mentionedJids: ["999@s.whatsapp.net"], - selfE164: "+999", - selfJid: "999@s.whatsapp.net", - sendComposing, - reply, - sendMedia, - }); - - expect(resolver).toHaveBeenCalledTimes(1); - const payload = resolver.mock.calls[0][0]; - expect(payload.Body).toContain("Chat messages since your last reply"); - expect(payload.Body).toContain("Alice (+111): hello group"); - // Message id hints are not included in prompts anymore. - expect(payload.Body).not.toContain("[message_id:"); - expect(payload.Body).toContain("@bot ping"); - expect(payload.SenderName).toBe("Bob"); - expect(payload.SenderE164).toBe("+222"); - expect(payload.SenderId).toBe("+222"); - }); - - it("bypasses mention gating for owner /new in group chats", async () => { - const sendMedia = vi.fn(); - const reply = vi.fn().mockResolvedValue(undefined); - const sendComposing = vi.fn(); - const resolver = vi.fn().mockResolvedValue({ text: "ok" }); - - let capturedOnMessage: - | ((msg: import("./inbound.js").WebInboundMessage) => Promise) - | undefined; - const listenerFactory = async (opts: { - onMessage: (msg: import("./inbound.js").WebInboundMessage) => Promise; - }) => { - capturedOnMessage = opts.onMessage; - return { close: vi.fn() }; - }; - - setLoadConfigMock(() => ({ - channels: { - whatsapp: { - allowFrom: ["+111"], - }, - }, - })); - - await monitorWebChannel(false, listenerFactory, false, resolver); - expect(capturedOnMessage).toBeDefined(); - - await capturedOnMessage?.({ - body: "/new", - from: "123@g.us", - conversationId: "123@g.us", - chatId: "123@g.us", - chatType: "group", - to: "+2", - id: "g-new", - senderE164: "+111", - senderName: "Owner", - selfE164: "+999", - sendComposing, - reply, - sendMedia, - }); - - expect(resolver).toHaveBeenCalledTimes(1); - }); - - it("does not bypass mention gating for non-owner /new in group chats", async () => { - const sendMedia = vi.fn(); - const reply = vi.fn().mockResolvedValue(undefined); - const sendComposing = vi.fn(); - const resolver = vi.fn().mockResolvedValue({ text: "ok" }); - - let capturedOnMessage: - | ((msg: import("./inbound.js").WebInboundMessage) => Promise) - | undefined; - const listenerFactory = async (opts: { - onMessage: (msg: import("./inbound.js").WebInboundMessage) => Promise; - }) => { - capturedOnMessage = opts.onMessage; - return { close: vi.fn() }; - }; - - setLoadConfigMock(() => ({ - channels: { - whatsapp: { - allowFrom: ["+999"], - }, - }, - })); - - await monitorWebChannel(false, listenerFactory, false, resolver); - expect(capturedOnMessage).toBeDefined(); - - await capturedOnMessage?.({ - body: "/new", - from: "123@g.us", - conversationId: "123@g.us", - chatId: "123@g.us", - chatType: "group", - to: "+2", - id: "g-new-unauth", - senderE164: "+111", - senderName: "NotOwner", - selfE164: "+999", - sendComposing, - reply, - sendMedia, - }); - - expect(resolver).not.toHaveBeenCalled(); - }); - - it("bypasses mention gating for owner /status in group chats", async () => { - const sendMedia = vi.fn(); - const reply = vi.fn().mockResolvedValue(undefined); - const sendComposing = vi.fn(); - const resolver = vi.fn().mockResolvedValue({ text: "ok" }); - - let capturedOnMessage: - | ((msg: import("./inbound.js").WebInboundMessage) => Promise) - | undefined; - const listenerFactory = async (opts: { - onMessage: (msg: import("./inbound.js").WebInboundMessage) => Promise; - }) => { - capturedOnMessage = opts.onMessage; - return { close: vi.fn() }; - }; - - setLoadConfigMock(() => ({ - channels: { - whatsapp: { - allowFrom: ["+111"], - }, - }, - })); - - await monitorWebChannel(false, listenerFactory, false, resolver); - expect(capturedOnMessage).toBeDefined(); - - await capturedOnMessage?.({ - body: "/status", - from: "123@g.us", - conversationId: "123@g.us", - chatId: "123@g.us", - chatType: "group", - to: "+2", - id: "g-status", - senderE164: "+111", - senderName: "Owner", - selfE164: "+999", - sendComposing, - reply, - sendMedia, - }); - - expect(resolver).toHaveBeenCalledTimes(1); - }); - - it("uses per-agent mention patterns for group gating", async () => { - const sendMedia = vi.fn(); - const reply = vi.fn().mockResolvedValue(undefined); - const sendComposing = vi.fn(); - const resolver = vi.fn().mockResolvedValue({ text: "ok" }); - - setLoadConfigMock(() => ({ - channels: { - whatsapp: { - allowFrom: ["*"], - groups: { "*": { requireMention: true } }, - }, - }, - messages: { - groupChat: { mentionPatterns: ["@global"] }, - }, - agents: { - list: [ - { - id: "work", - groupChat: { mentionPatterns: ["@workbot"] }, - }, - ], - }, - bindings: [ - { - agentId: "work", - match: { - provider: "whatsapp", - peer: { kind: "group", id: "123@g.us" }, - }, - }, - ], - })); - - let capturedOnMessage: - | ((msg: import("./inbound.js").WebInboundMessage) => Promise) - | undefined; - const listenerFactory = async (opts: { - onMessage: (msg: import("./inbound.js").WebInboundMessage) => Promise; - }) => { - capturedOnMessage = opts.onMessage; - return { close: vi.fn() }; - }; - - await monitorWebChannel(false, listenerFactory, false, resolver); - expect(capturedOnMessage).toBeDefined(); - - await capturedOnMessage?.({ - body: "@global ping", - from: "123@g.us", - conversationId: "123@g.us", - chatId: "123@g.us", - chatType: "group", - to: "+2", - id: "g1", - senderE164: "+111", - senderName: "Alice", - selfE164: "+999", - sendComposing, - reply, - sendMedia, - }); - expect(resolver).not.toHaveBeenCalled(); - - await capturedOnMessage?.({ - body: "@workbot ping", - from: "123@g.us", - conversationId: "123@g.us", - chatId: "123@g.us", - chatType: "group", - to: "+2", - id: "g2", - senderE164: "+222", - senderName: "Bob", - selfE164: "+999", - sendComposing, - reply, - sendMedia, - }); - expect(resolver).toHaveBeenCalledTimes(1); - }); - - it("allows group messages when whatsapp groups default disables mention gating", async () => { - const sendMedia = vi.fn(); - const reply = vi.fn().mockResolvedValue(undefined); - const sendComposing = vi.fn(); - const resolver = vi.fn().mockResolvedValue({ text: "ok" }); - - setLoadConfigMock(() => ({ - channels: { - whatsapp: { - allowFrom: ["*"], - groups: { "*": { requireMention: false } }, - }, - }, - messages: { groupChat: { mentionPatterns: ["@openclaw"] } }, - })); - - let capturedOnMessage: - | ((msg: import("./inbound.js").WebInboundMessage) => Promise) - | undefined; - const listenerFactory = async (opts: { - onMessage: (msg: import("./inbound.js").WebInboundMessage) => Promise; - }) => { - capturedOnMessage = opts.onMessage; - return { close: vi.fn() }; - }; - - await monitorWebChannel(false, listenerFactory, false, resolver); - expect(capturedOnMessage).toBeDefined(); - - await capturedOnMessage?.({ - body: "hello group", - from: "123@g.us", - conversationId: "123@g.us", - chatId: "123@g.us", - chatType: "group", - to: "+2", - id: "g-default-off", - senderE164: "+111", - senderName: "Alice", - selfE164: "+999", - sendComposing, - reply, - sendMedia, - }); - - expect(resolver).toHaveBeenCalledTimes(1); - resetLoadConfigMock(); - }); - - it("blocks group messages when whatsapp groups is set without a wildcard", async () => { - const sendMedia = vi.fn(); - const reply = vi.fn().mockResolvedValue(undefined); - const sendComposing = vi.fn(); - const resolver = vi.fn().mockResolvedValue({ text: "ok" }); - - setLoadConfigMock(() => ({ - channels: { - whatsapp: { - allowFrom: ["*"], - groups: { "999@g.us": { requireMention: false } }, - }, - }, - messages: { groupChat: { mentionPatterns: ["@openclaw"] } }, - })); - - let capturedOnMessage: - | ((msg: import("./inbound.js").WebInboundMessage) => Promise) - | undefined; - const listenerFactory = async (opts: { - onMessage: (msg: import("./inbound.js").WebInboundMessage) => Promise; - }) => { - capturedOnMessage = opts.onMessage; - return { close: vi.fn() }; - }; - - await monitorWebChannel(false, listenerFactory, false, resolver); - expect(capturedOnMessage).toBeDefined(); - - await capturedOnMessage?.({ - body: "@openclaw hello", - from: "123@g.us", - conversationId: "123@g.us", - chatId: "123@g.us", - chatType: "group", - to: "+2", - id: "g-allowlist-block", - senderE164: "+111", - senderName: "Alice", - mentionedJids: ["999@s.whatsapp.net"], - selfE164: "+999", - selfJid: "999@s.whatsapp.net", - sendComposing, - reply, - sendMedia, - }); - - expect(resolver).not.toHaveBeenCalled(); - resetLoadConfigMock(); - }); - - it("honors per-group mention overrides when conversationId uses session key", async () => { - const sendMedia = vi.fn(); - const reply = vi.fn().mockResolvedValue(undefined); - const sendComposing = vi.fn(); - const resolver = vi.fn().mockResolvedValue({ text: "ok" }); - - setLoadConfigMock(() => ({ - channels: { - whatsapp: { - allowFrom: ["*"], - groups: { - "*": { requireMention: true }, - "123@g.us": { requireMention: false }, - }, - }, - }, - messages: { groupChat: { mentionPatterns: ["@openclaw"] } }, - })); - - let capturedOnMessage: - | ((msg: import("./inbound.js").WebInboundMessage) => Promise) - | undefined; - const listenerFactory = async (opts: { - onMessage: (msg: import("./inbound.js").WebInboundMessage) => Promise; - }) => { - capturedOnMessage = opts.onMessage; - return { close: vi.fn() }; - }; - - await monitorWebChannel(false, listenerFactory, false, resolver); - expect(capturedOnMessage).toBeDefined(); - - await capturedOnMessage?.({ - body: "hello group", - from: "whatsapp:group:123@g.us", - conversationId: "whatsapp:group:123@g.us", - chatId: "123@g.us", - chatType: "group", - to: "+2", - id: "g-per-group-session-key", - senderE164: "+111", - senderName: "Alice", - selfE164: "+999", - sendComposing, - reply, - sendMedia, - }); - - expect(resolver).toHaveBeenCalledTimes(1); - resetLoadConfigMock(); - }); - - it("passes conversation id through as From for group replies", async () => { - const sendMedia = vi.fn(); - const reply = vi.fn().mockResolvedValue(undefined); - const sendComposing = vi.fn(); - const resolver = vi.fn().mockResolvedValue({ text: "ok" }); - - let capturedOnMessage: - | ((msg: import("./inbound.js").WebInboundMessage) => Promise) - | undefined; - const listenerFactory = async (opts: { - onMessage: (msg: import("./inbound.js").WebInboundMessage) => Promise; - }) => { - capturedOnMessage = opts.onMessage; - return { close: vi.fn() }; - }; - - await monitorWebChannel(false, listenerFactory, false, resolver); - expect(capturedOnMessage).toBeDefined(); - - await capturedOnMessage?.({ - body: "@bot ping", - from: "123@g.us", - conversationId: "123@g.us", - chatId: "123@g.us", - chatType: "group", - to: "+2", - id: "g1", - senderE164: "+222", - senderName: "Bob", - mentionedJids: ["999@s.whatsapp.net"], - selfE164: "+999", - selfJid: "999@s.whatsapp.net", - sendComposing, - reply, - sendMedia, - }); - - const payload = resolver.mock.calls[0]?.[0] as { From?: string; To?: string }; - expect(payload.From).toBe("123@g.us"); - expect(payload.To).toBe("+2"); - }); - - it("detects LID mentions using authDir mapping", async () => { - const sendMedia = vi.fn(); - const reply = vi.fn().mockResolvedValue(undefined); - const sendComposing = vi.fn(); - const resolver = vi.fn().mockResolvedValue({ text: "ok" }); - - let capturedOnMessage: - | ((msg: import("./inbound.js").WebInboundMessage) => Promise) - | undefined; - const listenerFactory = async (opts: { - onMessage: (msg: import("./inbound.js").WebInboundMessage) => Promise; - }) => { - capturedOnMessage = opts.onMessage; - return { close: vi.fn() }; - }; - - const authDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-wa-auth-")); - - try { - await fs.writeFile( - path.join(authDir, "lid-mapping-555_reverse.json"), - JSON.stringify("15551234"), - ); - - setLoadConfigMock(() => ({ - channels: { - whatsapp: { - allowFrom: ["*"], - accounts: { - default: { authDir }, - }, - }, - }, - })); - - await monitorWebChannel(false, listenerFactory, false, resolver); - expect(capturedOnMessage).toBeDefined(); - - await capturedOnMessage?.({ - body: "hello group", - from: "123@g.us", - conversationId: "123@g.us", - chatId: "123@g.us", - chatType: "group", - to: "+2", - id: "g1", - senderE164: "+111", - senderName: "Alice", - selfE164: "+15551234", - sendComposing, - reply, - sendMedia, - }); - - await capturedOnMessage?.({ - body: "@bot ping", - from: "123@g.us", - conversationId: "123@g.us", - chatId: "123@g.us", - chatType: "group", - to: "+2", - id: "g2", - senderE164: "+222", - senderName: "Bob", - mentionedJids: ["555@lid"], - selfE164: "+15551234", - selfJid: "15551234@s.whatsapp.net", - sendComposing, - reply, - sendMedia, - }); - - expect(resolver).toHaveBeenCalledTimes(1); - } finally { - resetLoadConfigMock(); - await rmDirWithRetries(authDir); - } - }); - - it("derives self E.164 from LID selfJid for mention gating", async () => { - const sendMedia = vi.fn(); - const reply = vi.fn().mockResolvedValue(undefined); - const sendComposing = vi.fn(); - const resolver = vi.fn().mockResolvedValue({ text: "ok" }); - - let capturedOnMessage: - | ((msg: import("./inbound.js").WebInboundMessage) => Promise) - | undefined; - const listenerFactory = async (opts: { - onMessage: (msg: import("./inbound.js").WebInboundMessage) => Promise; - }) => { - capturedOnMessage = opts.onMessage; - return { close: vi.fn() }; - }; - - const authDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-wa-auth-")); - - try { - await fs.writeFile( - path.join(authDir, "lid-mapping-777_reverse.json"), - JSON.stringify("15550077"), - ); - - setLoadConfigMock(() => ({ - channels: { - whatsapp: { - allowFrom: ["*"], - accounts: { - default: { authDir }, - }, - }, - }, - })); - - await monitorWebChannel(false, listenerFactory, false, resolver); - expect(capturedOnMessage).toBeDefined(); - - await capturedOnMessage?.({ - body: "@bot ping", - from: "123@g.us", - conversationId: "123@g.us", - chatId: "123@g.us", - chatType: "group", - to: "+2", - id: "g3", - senderE164: "+333", - senderName: "Cara", - mentionedJids: ["777@lid"], - selfJid: "777@lid", - sendComposing, - reply, - sendMedia, - }); - - expect(resolver).toHaveBeenCalledTimes(1); - } finally { - resetLoadConfigMock(); - await rmDirWithRetries(authDir); - } - }); - - it("sets OriginatingTo to the sender for queued routing", async () => { - const sendMedia = vi.fn(); - const reply = vi.fn().mockResolvedValue(undefined); - const sendComposing = vi.fn(); - const resolver = vi.fn().mockResolvedValue({ text: "ok" }); - - let capturedOnMessage: - | ((msg: import("./inbound.js").WebInboundMessage) => Promise) - | undefined; - const listenerFactory = async (opts: { - onMessage: (msg: import("./inbound.js").WebInboundMessage) => Promise; - }) => { - capturedOnMessage = opts.onMessage; - return { close: vi.fn() }; - }; - - await monitorWebChannel(false, listenerFactory, false, resolver); - expect(capturedOnMessage).toBeDefined(); - - await capturedOnMessage?.({ - body: "hello", - from: "+15551234567", - to: "+19998887777", - id: "m-originating", - sendComposing, - reply, - sendMedia, - }); - - expect(resolver).toHaveBeenCalledTimes(1); - const payload = resolver.mock.calls[0][0]; - expect(payload.OriginatingChannel).toBe("whatsapp"); - expect(payload.OriginatingTo).toBe("+15551234567"); - expect(payload.To).toBe("+19998887777"); - expect(payload.OriginatingTo).not.toBe(payload.To); - }); - - it("supports always-on group activation with silent token and clears pending history", async () => { - const sendMedia = vi.fn(); - const reply = vi.fn().mockResolvedValue(undefined); - const sendComposing = vi.fn(); - const resolver = vi - .fn() - .mockResolvedValueOnce({ text: SILENT_REPLY_TOKEN }) - .mockResolvedValueOnce({ text: "ok" }); - - const { storePath, cleanup } = await makeSessionStore({ - "agent:main:whatsapp:group:123@g.us": { - sessionId: "g-1", - updatedAt: Date.now(), - groupActivation: "always", - }, - }); - - setLoadConfigMock(() => ({ - messages: { - groupChat: { mentionPatterns: ["@openclaw"] }, - }, - session: { store: storePath }, - })); - - let capturedOnMessage: - | ((msg: import("./inbound.js").WebInboundMessage) => Promise) - | undefined; - const listenerFactory = async (opts: { - onMessage: (msg: import("./inbound.js").WebInboundMessage) => Promise; - }) => { - capturedOnMessage = opts.onMessage; - return { close: vi.fn() }; - }; - - await monitorWebChannel(false, listenerFactory, false, resolver); - expect(capturedOnMessage).toBeDefined(); - - await capturedOnMessage?.({ - body: "first", - from: "123@g.us", - conversationId: "123@g.us", - chatId: "123@g.us", - chatType: "group", - to: "+2", - id: "g-always-1", - senderE164: "+111", - senderName: "Alice", - selfE164: "+999", - sendComposing, - reply, - sendMedia, - }); - - expect(resolver).toHaveBeenCalledTimes(1); - expect(reply).not.toHaveBeenCalled(); - - await capturedOnMessage?.({ - body: "second", - from: "123@g.us", - conversationId: "123@g.us", - chatId: "123@g.us", - chatType: "group", - to: "+2", - id: "g-always-2", - senderE164: "+222", - senderName: "Bob", - selfE164: "+999", - sendComposing, - reply, - sendMedia, - }); - - expect(resolver).toHaveBeenCalledTimes(2); - const payload = resolver.mock.calls[1][0]; - expect(payload.Body).not.toContain("Chat messages since your last reply"); - expect(payload.Body).not.toContain("Alice (+111): first"); - expect(payload.Body).not.toContain("[message_id: g-always-1]"); - expect(payload.Body).toContain("second"); - expectInboundContextContract(payload); - expect(payload.SenderName).toBe("Bob"); - expect(payload.SenderE164).toBe("+222"); - expect(reply).toHaveBeenCalledTimes(1); - - await cleanup(); - resetLoadConfigMock(); - }); - - it("ignores JID mentions in self-chat mode (group chats)", async () => { - const sendMedia = vi.fn(); - const reply = vi.fn().mockResolvedValue(undefined); - const sendComposing = vi.fn(); - const resolver = vi.fn().mockResolvedValue({ text: "ok" }); - - setLoadConfigMock(() => ({ - channels: { - whatsapp: { - // Self-chat heuristic: allowFrom includes selfE164. - allowFrom: ["+999"], - groups: { "*": { requireMention: true } }, - }, - }, - messages: { - groupChat: { - mentionPatterns: ["\\bopenclaw\\b"], - }, - }, - })); - - let capturedOnMessage: - | ((msg: import("./inbound.js").WebInboundMessage) => Promise) - | undefined; - const listenerFactory = async (opts: { - onMessage: (msg: import("./inbound.js").WebInboundMessage) => Promise; - }) => { - capturedOnMessage = opts.onMessage; - return { close: vi.fn() }; - }; - - await monitorWebChannel(false, listenerFactory, false, resolver); - expect(capturedOnMessage).toBeDefined(); - - // WhatsApp @mention of the owner should NOT trigger the bot in self-chat mode. - await capturedOnMessage?.({ - body: "@owner ping", - from: "123@g.us", - conversationId: "123@g.us", - chatId: "123@g.us", - chatType: "group", - to: "+2", - id: "g-self-1", - senderE164: "+111", - senderName: "Alice", - mentionedJids: ["999@s.whatsapp.net"], - selfE164: "+999", - selfJid: "999@s.whatsapp.net", - sendComposing, - reply, - sendMedia, - }); - - expect(resolver).not.toHaveBeenCalled(); - - // Text-based mentionPatterns still work (user can type "openclaw" explicitly). - await capturedOnMessage?.({ - body: "openclaw ping", - from: "123@g.us", - conversationId: "123@g.us", - chatId: "123@g.us", - chatType: "group", - to: "+2", - id: "g-self-2", - senderE164: "+222", - senderName: "Bob", - selfE164: "+999", - selfJid: "999@s.whatsapp.net", - sendComposing, - reply, - sendMedia, - }); - - expect(resolver).toHaveBeenCalledTimes(1); - - resetLoadConfigMock(); - }); - - it("emits heartbeat logs with connection metadata", async () => { - vi.useFakeTimers(); - const logPath = `/tmp/openclaw-heartbeat-${crypto.randomUUID()}.log`; - setLoggerOverride({ level: "trace", file: logPath }); - - const runtime = { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn(), - }; - - const controller = new AbortController(); - const listenerFactory = vi.fn(async () => { - const onClose = new Promise(() => { - // never resolves; abort will short-circuit - }); - return { close: vi.fn(), onClose }; - }); - - const run = monitorWebChannel( - false, - listenerFactory, - true, - async () => ({ text: "ok" }), - runtime as never, - controller.signal, - { - heartbeatSeconds: 1, - reconnect: { initialMs: 5, maxMs: 5, maxAttempts: 1, factor: 1.1 }, - }, - ); - - await vi.advanceTimersByTimeAsync(1_000); - controller.abort(); - await vi.runAllTimersAsync(); - await run.catch(() => {}); - - const content = await fs.readFile(logPath, "utf-8"); - expect(content).toMatch(/web-heartbeat/); - expect(content).toMatch(/connectionId/); - expect(content).toMatch(/messagesHandled/); - }); - - it("logs outbound replies to file", async () => { - const logPath = `/tmp/openclaw-log-test-${crypto.randomUUID()}.log`; - setLoggerOverride({ level: "trace", file: logPath }); - - let capturedOnMessage: - | ((msg: import("./inbound.js").WebInboundMessage) => Promise) - | undefined; - const listenerFactory = async (opts: { - onMessage: (msg: import("./inbound.js").WebInboundMessage) => Promise; - }) => { - capturedOnMessage = opts.onMessage; - return { close: vi.fn() }; - }; - - const resolver = vi.fn().mockResolvedValue({ text: "auto" }); - await monitorWebChannel(false, listenerFactory, false, resolver); - expect(capturedOnMessage).toBeDefined(); - - await capturedOnMessage?.({ - body: "hello", - from: "+1", - to: "+2", - id: "msg1", - sendComposing: vi.fn(), - reply: vi.fn(), - sendMedia: vi.fn(), - }); - - const content = await fs.readFile(logPath, "utf-8"); - expect(content).toMatch(/web-auto-reply/); - expect(content).toMatch(/auto/); - }); -}); diff --git a/src/web/auto-reply.web-auto-reply.monitor-logging.test.ts b/src/web/auto-reply.web-auto-reply.monitor-logging.test.ts new file mode 100644 index 00000000000..0838f6c73b7 --- /dev/null +++ b/src/web/auto-reply.web-auto-reply.monitor-logging.test.ts @@ -0,0 +1,91 @@ +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import { describe, expect, it, vi } from "vitest"; +import { setLoggerOverride } from "../logging.js"; +import { + installWebAutoReplyTestHomeHooks, + installWebAutoReplyUnitTestHooks, +} from "./auto-reply.test-harness.js"; +import { monitorWebChannel } from "./auto-reply/monitor.js"; + +installWebAutoReplyTestHomeHooks(); + +describe("web auto-reply monitor logging", () => { + installWebAutoReplyUnitTestHooks(); + + it("emits heartbeat logs with connection metadata", async () => { + vi.useFakeTimers(); + const logPath = `/tmp/openclaw-heartbeat-${crypto.randomUUID()}.log`; + setLoggerOverride({ level: "trace", file: logPath }); + + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + const controller = new AbortController(); + const listenerFactory = vi.fn(async () => { + const onClose = new Promise(() => { + // never resolves; abort will short-circuit + }); + return { close: vi.fn(), onClose }; + }); + + const run = monitorWebChannel( + false, + listenerFactory as never, + true, + async () => ({ text: "ok" }), + runtime as never, + controller.signal, + { + heartbeatSeconds: 1, + reconnect: { initialMs: 5, maxMs: 5, maxAttempts: 1, factor: 1.1 }, + }, + ); + + await vi.advanceTimersByTimeAsync(1_000); + controller.abort(); + await vi.runAllTimersAsync(); + await run.catch(() => {}); + + const content = await fs.readFile(logPath, "utf-8"); + expect(content).toMatch(/web-heartbeat/); + expect(content).toMatch(/connectionId/); + expect(content).toMatch(/messagesHandled/); + }); + + it("logs outbound replies to file", async () => { + const logPath = `/tmp/openclaw-log-test-${crypto.randomUUID()}.log`; + setLoggerOverride({ level: "trace", file: logPath }); + + let capturedOnMessage: + | ((msg: import("./inbound.js").WebInboundMessage) => Promise) + | undefined; + const listenerFactory = async (opts: { + onMessage: (msg: import("./inbound.js").WebInboundMessage) => Promise; + }) => { + capturedOnMessage = opts.onMessage; + return { close: vi.fn() }; + }; + + const resolver = vi.fn().mockResolvedValue({ text: "auto" }); + await monitorWebChannel(false, listenerFactory as never, false, resolver as never); + expect(capturedOnMessage).toBeDefined(); + + await capturedOnMessage?.({ + body: "hello", + from: "+1", + to: "+2", + id: "msg1", + sendComposing: vi.fn(), + reply: vi.fn(), + sendMedia: vi.fn(), + }); + + const content = await fs.readFile(logPath, "utf-8"); + expect(content).toMatch(/web-auto-reply/); + expect(content).toMatch(/auto/); + }); +}); diff --git a/src/web/auto-reply.web-auto-reply.prefixes-and-partial-gating.test.ts b/src/web/auto-reply.web-auto-reply.prefixes-and-partial-gating.test.ts deleted file mode 100644 index a60271e3fb3..00000000000 --- a/src/web/auto-reply.web-auto-reply.prefixes-and-partial-gating.test.ts +++ /dev/null @@ -1,556 +0,0 @@ -import "./test-helpers.js"; -import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../config/config.js"; -import { - installWebAutoReplyTestHomeHooks, - installWebAutoReplyUnitTestHooks, - resetLoadConfigMock, - setLoadConfigMock, -} from "./auto-reply.test-harness.js"; - -installWebAutoReplyTestHomeHooks(); - -let monitorWebChannel: typeof import("./auto-reply.js").monitorWebChannel; -let HEARTBEAT_TOKEN: typeof import("./auto-reply.js").HEARTBEAT_TOKEN; -let getReplyFromConfig: typeof import("../auto-reply/reply.js").getReplyFromConfig; -let runEmbeddedPiAgent: typeof import("../agents/pi-embedded.js").runEmbeddedPiAgent; -let lastRouteSpy: { mockRestore: () => void } | undefined; - -beforeAll(async () => { - ({ monitorWebChannel, HEARTBEAT_TOKEN } = await import("./auto-reply.js")); - ({ getReplyFromConfig } = await import("../auto-reply/reply.js")); - ({ runEmbeddedPiAgent } = await import("../agents/pi-embedded.js")); - const lastRoute = await import("./auto-reply/monitor/last-route.js"); - lastRouteSpy = vi - .spyOn(lastRoute, "updateLastRouteInBackground") - .mockImplementation(() => undefined); -}); - -afterAll(() => { - lastRouteSpy?.mockRestore(); - lastRouteSpy = undefined; -}); - -function createCapturedListener() { - let capturedOnMessage: - | ((msg: import("./inbound.js").WebInboundMessage) => Promise) - | undefined; - const listenerFactory = async (opts: { - onMessage: (msg: import("./inbound.js").WebInboundMessage) => Promise; - }) => { - capturedOnMessage = opts.onMessage; - return { close: vi.fn() }; - }; - return { listenerFactory, getCapturedOnMessage: () => capturedOnMessage }; -} - -describe("web auto-reply", () => { - installWebAutoReplyUnitTestHooks(); - - it("prefixes body with same-phone marker when from === to", async () => { - setLoadConfigMock(() => ({ - channels: { whatsapp: { allowFrom: ["*"] } }, - messages: { - messagePrefix: "[same-phone]", - responsePrefix: undefined, - }, - })); - - const { listenerFactory, getCapturedOnMessage } = createCapturedListener(); - const resolver = vi.fn().mockResolvedValue({ text: "reply" }); - - await monitorWebChannel(false, listenerFactory, false, resolver); - expect(getCapturedOnMessage()).toBeDefined(); - - await getCapturedOnMessage()?.({ - body: "hello", - from: "+1555", - to: "+1555", - id: "msg1", - sendComposing: vi.fn(), - reply: vi.fn(), - sendMedia: vi.fn(), - }); - - const callArg = resolver.mock.calls[0]?.[0] as { Body?: string }; - expect(callArg?.Body).toBeDefined(); - expect(callArg?.Body).toContain("[WhatsApp +1555"); - expect(callArg?.Body).toContain("[same-phone] hello"); - resetLoadConfigMock(); - }); - - it("does not prefix body when from !== to", async () => { - const { listenerFactory, getCapturedOnMessage } = createCapturedListener(); - const resolver = vi.fn().mockResolvedValue({ text: "reply" }); - - await monitorWebChannel(false, listenerFactory, false, resolver); - expect(getCapturedOnMessage()).toBeDefined(); - - await getCapturedOnMessage()?.({ - body: "hello", - from: "+1555", - to: "+2666", - id: "msg1", - sendComposing: vi.fn(), - reply: vi.fn(), - sendMedia: vi.fn(), - }); - - const callArg = resolver.mock.calls[0]?.[0] as { Body?: string }; - expect(callArg?.Body).toContain("[WhatsApp +1555"); - expect(callArg?.Body).toContain("hello"); - }); - - it("forwards reply-to context to resolver", async () => { - const { listenerFactory, getCapturedOnMessage } = createCapturedListener(); - const resolver = vi.fn().mockResolvedValue({ text: "reply" }); - - await monitorWebChannel(false, listenerFactory, false, resolver); - expect(getCapturedOnMessage()).toBeDefined(); - - await getCapturedOnMessage()?.({ - body: "hello", - from: "+1555", - to: "+2666", - id: "msg1", - replyToId: "q1", - replyToBody: "original", - replyToSender: "+1999", - sendComposing: vi.fn(), - reply: vi.fn(), - sendMedia: vi.fn(), - }); - - const callArg = resolver.mock.calls[0]?.[0] as { - ReplyToId?: string; - ReplyToBody?: string; - ReplyToSender?: string; - Body?: string; - }; - expect(callArg.ReplyToId).toBe("q1"); - expect(callArg.ReplyToBody).toBe("original"); - expect(callArg.ReplyToSender).toBe("+1999"); - expect(callArg.Body).toContain("[Replying to +1999 id:q1]"); - expect(callArg.Body).toContain("original"); - }); - - it("applies responsePrefix to regular replies", async () => { - setLoadConfigMock(() => ({ - channels: { whatsapp: { allowFrom: ["*"] } }, - messages: { - messagePrefix: undefined, - responsePrefix: "🦞", - }, - })); - - const { listenerFactory, getCapturedOnMessage } = createCapturedListener(); - const reply = vi.fn(); - const resolver = vi.fn().mockResolvedValue({ text: "hello there" }); - - await monitorWebChannel(false, listenerFactory, false, resolver); - expect(getCapturedOnMessage()).toBeDefined(); - - await getCapturedOnMessage()?.({ - body: "hi", - from: "+1555", - to: "+2666", - id: "msg1", - sendComposing: vi.fn(), - reply, - sendMedia: vi.fn(), - }); - - expect(reply).toHaveBeenCalledWith("🦞 hello there"); - resetLoadConfigMock(); - }); - - it("applies channel responsePrefix override to replies", async () => { - setLoadConfigMock(() => ({ - channels: { whatsapp: { allowFrom: ["*"], responsePrefix: "[WA]" } }, - messages: { - messagePrefix: undefined, - responsePrefix: "[Global]", - }, - })); - - const { listenerFactory, getCapturedOnMessage } = createCapturedListener(); - const reply = vi.fn(); - const resolver = vi.fn().mockResolvedValue({ text: "hello there" }); - - await monitorWebChannel(false, listenerFactory, false, resolver); - expect(getCapturedOnMessage()).toBeDefined(); - - await getCapturedOnMessage()?.({ - body: "hi", - from: "+1555", - to: "+2666", - id: "msg1", - sendComposing: vi.fn(), - reply, - sendMedia: vi.fn(), - }); - - expect(reply).toHaveBeenCalledWith("[WA] hello there"); - resetLoadConfigMock(); - }); - - it("defaults responsePrefix for self-chat replies when unset", async () => { - setLoadConfigMock(() => ({ - agents: { - list: [ - { - id: "main", - default: true, - identity: { name: "Mainbot", emoji: "🦞", theme: "space lobster" }, - }, - ], - }, - channels: { whatsapp: { allowFrom: ["+1555"] } }, - messages: { - messagePrefix: undefined, - responsePrefix: undefined, - }, - })); - - const { listenerFactory, getCapturedOnMessage } = createCapturedListener(); - const reply = vi.fn(); - const resolver = vi.fn().mockResolvedValue({ text: "hello there" }); - - await monitorWebChannel(false, listenerFactory, false, resolver); - expect(getCapturedOnMessage()).toBeDefined(); - - await getCapturedOnMessage()?.({ - body: "hi", - from: "+1555", - to: "+1555", - selfE164: "+1555", - chatType: "direct", - id: "msg1", - sendComposing: vi.fn(), - reply, - sendMedia: vi.fn(), - }); - - expect(reply).toHaveBeenCalledWith("[Mainbot] hello there"); - resetLoadConfigMock(); - }); - - it("does not deliver HEARTBEAT_OK responses", async () => { - setLoadConfigMock(() => ({ - channels: { whatsapp: { allowFrom: ["*"] } }, - messages: { - messagePrefix: undefined, - responsePrefix: "🦞", - }, - })); - - const { listenerFactory, getCapturedOnMessage } = createCapturedListener(); - const reply = vi.fn(); - const resolver = vi.fn().mockResolvedValue({ text: HEARTBEAT_TOKEN }); - - await monitorWebChannel(false, listenerFactory, false, resolver); - expect(getCapturedOnMessage()).toBeDefined(); - - await getCapturedOnMessage()?.({ - body: "test", - from: "+1555", - to: "+2666", - id: "msg1", - sendComposing: vi.fn(), - reply, - sendMedia: vi.fn(), - }); - - expect(reply).not.toHaveBeenCalled(); - resetLoadConfigMock(); - }); - - it("does not double-prefix if responsePrefix already present", async () => { - setLoadConfigMock(() => ({ - channels: { whatsapp: { allowFrom: ["*"] } }, - messages: { - messagePrefix: undefined, - responsePrefix: "🦞", - }, - })); - - const { listenerFactory, getCapturedOnMessage } = createCapturedListener(); - const reply = vi.fn(); - const resolver = vi.fn().mockResolvedValue({ text: "🦞 already prefixed" }); - - await monitorWebChannel(false, listenerFactory, false, resolver); - expect(getCapturedOnMessage()).toBeDefined(); - - await getCapturedOnMessage()?.({ - body: "test", - from: "+1555", - to: "+2666", - id: "msg1", - sendComposing: vi.fn(), - reply, - sendMedia: vi.fn(), - }); - - expect(reply).toHaveBeenCalledWith("🦞 already prefixed"); - resetLoadConfigMock(); - }); - - it("skips tool summaries and sends final reply with responsePrefix", async () => { - setLoadConfigMock(() => ({ - channels: { whatsapp: { allowFrom: ["*"] } }, - messages: { - messagePrefix: undefined, - responsePrefix: "🦞", - }, - })); - - const { listenerFactory, getCapturedOnMessage } = createCapturedListener(); - const reply = vi.fn(); - const resolver = vi.fn().mockResolvedValue({ text: "final" }); - - await monitorWebChannel(false, listenerFactory, false, resolver); - expect(getCapturedOnMessage()).toBeDefined(); - - await getCapturedOnMessage()?.({ - body: "hi", - from: "+1555", - to: "+2666", - id: "msg1", - sendComposing: vi.fn(), - reply, - sendMedia: vi.fn(), - }); - - const replies = reply.mock.calls.map((call) => call[0]); - expect(replies).toEqual(["🦞 final"]); - resetLoadConfigMock(); - }); - - it("uses identity.name for messagePrefix when set", async () => { - setLoadConfigMock(() => ({ - agents: { - list: [ - { - id: "main", - default: true, - identity: { name: "Mainbot", emoji: "🦞", theme: "space lobster" }, - }, - { - id: "rich", - identity: { name: "Richbot", emoji: "🦁", theme: "lion bot" }, - }, - ], - }, - bindings: [ - { - agentId: "rich", - match: { - channel: "whatsapp", - peer: { kind: "direct", id: "+1555" }, - }, - }, - ], - })); - - const { listenerFactory, getCapturedOnMessage } = createCapturedListener(); - const reply = vi.fn(); - const resolver = vi.fn().mockResolvedValue({ text: "hello" }); - - await monitorWebChannel(false, listenerFactory, false, resolver); - expect(getCapturedOnMessage()).toBeDefined(); - - await getCapturedOnMessage()?.({ - body: "hi", - from: "+1555", - to: "+2666", - id: "msg1", - sendComposing: vi.fn(), - reply, - sendMedia: vi.fn(), - }); - - expect(resolver).toHaveBeenCalled(); - const resolverArg = resolver.mock.calls[0][0]; - expect(resolverArg.Body).toContain("[Richbot]"); - expect(resolverArg.Body).not.toContain("[openclaw]"); - resetLoadConfigMock(); - }); - - it("does not derive responsePrefix from identity.name when unset", async () => { - setLoadConfigMock(() => ({ - agents: { - list: [ - { - id: "main", - default: true, - identity: { name: "Mainbot", emoji: "🦞", theme: "space lobster" }, - }, - { - id: "rich", - identity: { name: "Richbot", emoji: "🦁", theme: "lion bot" }, - }, - ], - }, - bindings: [ - { - agentId: "rich", - match: { - channel: "whatsapp", - peer: { kind: "direct", id: "+1555" }, - }, - }, - ], - })); - - const { listenerFactory, getCapturedOnMessage } = createCapturedListener(); - const reply = vi.fn(); - const resolver = vi.fn().mockResolvedValue({ text: "hello there" }); - - await monitorWebChannel(false, listenerFactory, false, resolver); - expect(getCapturedOnMessage()).toBeDefined(); - - await getCapturedOnMessage()?.({ - body: "hi", - from: "+1555", - to: "+2666", - id: "msg1", - sendComposing: vi.fn(), - reply, - sendMedia: vi.fn(), - }); - - expect(reply).toHaveBeenCalledWith("hello there"); - resetLoadConfigMock(); - }); -}); - -describe("partial reply gating", () => { - installWebAutoReplyUnitTestHooks(); - - it("does not send partial replies for WhatsApp provider", async () => { - const reply = vi.fn().mockResolvedValue(undefined); - const sendComposing = vi.fn().mockResolvedValue(undefined); - const sendMedia = vi.fn().mockResolvedValue(undefined); - - const replyResolver = vi.fn().mockResolvedValue({ text: "final reply" }); - - const mockConfig: OpenClawConfig = { - channels: { whatsapp: { allowFrom: ["*"] } }, - }; - - setLoadConfigMock(mockConfig); - - await monitorWebChannel( - false, - async ({ onMessage }) => { - await onMessage({ - id: "m1", - from: "+1000", - conversationId: "+1000", - to: "+2000", - body: "hello", - timestamp: Date.now(), - chatType: "direct", - chatId: "direct:+1000", - sendComposing, - reply, - sendMedia, - }); - return { close: vi.fn().mockResolvedValue(undefined) }; - }, - false, - replyResolver, - ); - - resetLoadConfigMock(); - - expect(replyResolver).toHaveBeenCalledTimes(1); - const resolverOptions = replyResolver.mock.calls[0]?.[1] ?? {}; - expect("onPartialReply" in resolverOptions).toBe(false); - expect(reply).toHaveBeenCalledTimes(1); - expect(reply).toHaveBeenCalledWith("final reply"); - }); - - it("falls back from empty senderJid to senderE164 for SenderId", async () => { - const reply = vi.fn().mockResolvedValue(undefined); - const sendComposing = vi.fn().mockResolvedValue(undefined); - const sendMedia = vi.fn().mockResolvedValue(undefined); - - const replyResolver = vi.fn().mockResolvedValue({ text: "final reply" }); - - const mockConfig: OpenClawConfig = { - channels: { - whatsapp: { - allowFrom: ["*"], - }, - }, - }; - - setLoadConfigMock(mockConfig); - - await monitorWebChannel( - false, - async ({ onMessage }) => { - await onMessage({ - id: "m1", - from: "+1000", - conversationId: "+1000", - to: "+2000", - body: "hello", - timestamp: Date.now(), - chatType: "direct", - chatId: "direct:+1000", - senderJid: "", - senderE164: "+1000", - sendComposing, - reply, - sendMedia, - }); - return { close: vi.fn().mockResolvedValue(undefined) }; - }, - false, - replyResolver, - ); - - resetLoadConfigMock(); - - expect(replyResolver).toHaveBeenCalledTimes(1); - const ctx = replyResolver.mock.calls[0]?.[0] ?? {}; - expect(ctx.SenderE164).toBe("+1000"); - expect(ctx.SenderId).toBe("+1000"); - }); - - it("defaults to self-only when no config is present", async () => { - vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ - payloads: [{ text: "ok" }], - meta: { - durationMs: 1, - agentMeta: { sessionId: "s", provider: "p", model: "m" }, - }, - }); - - const blocked = await getReplyFromConfig( - { - Body: "hi", - From: "whatsapp:+999", - To: "whatsapp:+123", - }, - undefined, - {}, - ); - expect(blocked).toBeUndefined(); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); - - const allowed = await getReplyFromConfig( - { - Body: "hi", - From: "whatsapp:+123", - To: "whatsapp:+123", - }, - undefined, - {}, - ); - expect(allowed).toMatchObject({ text: "ok", audioAsVoice: false }); - expect(runEmbeddedPiAgent).toHaveBeenCalledOnce(); - }); -}); diff --git a/src/web/auto-reply/mentions.test.ts b/src/web/auto-reply/mentions.test.ts index f7ea598b49b..27e4f426bc3 100644 --- a/src/web/auto-reply/mentions.test.ts +++ b/src/web/auto-reply/mentions.test.ts @@ -1,3 +1,6 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import { describe, expect, it } from "vitest"; import type { WebInboundMsg } from "./types.js"; import { isBotMentionedFromTargets, resolveMentionTargets } from "./mentions.js"; @@ -52,4 +55,64 @@ describe("isBotMentionedFromTargets", () => { const targets = resolveMentionTargets(msg); expect(isBotMentionedFromTargets(msg, mentionCfg, targets)).toBe(true); }); + + it("ignores JID mentions in self-chat mode", () => { + const cfg = { mentionRegexes: [/\bopenclaw\b/i], allowFrom: ["+999"] }; + const msg = makeMsg({ + body: "@owner ping", + mentionedJids: ["999@s.whatsapp.net"], + selfE164: "+999", + selfJid: "999@s.whatsapp.net", + }); + const targets = resolveMentionTargets(msg); + expect(isBotMentionedFromTargets(msg, cfg, targets)).toBe(false); + + const msgTextMention = makeMsg({ + body: "openclaw ping", + selfE164: "+999", + selfJid: "999@s.whatsapp.net", + }); + const targetsText = resolveMentionTargets(msgTextMention); + expect(isBotMentionedFromTargets(msgTextMention, cfg, targetsText)).toBe(true); + }); +}); + +describe("resolveMentionTargets with @lid mapping", () => { + it("resolves mentionedJids via lid reverse mapping in authDir", async () => { + const authDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lid-mapping-")); + try { + await fs.writeFile( + path.join(authDir, "lid-mapping-777_reverse.json"), + JSON.stringify("+1777"), + ); + const msg = makeMsg({ + body: "ping", + mentionedJids: ["777@lid"], + selfE164: "+15551234567", + selfJid: "15551234567@s.whatsapp.net", + }); + const targets = resolveMentionTargets(msg, authDir); + expect(targets.normalizedMentions).toContain("+1777"); + } finally { + await fs.rm(authDir, { recursive: true, force: true }); + } + }); + + it("derives selfE164 from selfJid when selfJid is @lid and mapping exists", async () => { + const authDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lid-mapping-")); + try { + await fs.writeFile( + path.join(authDir, "lid-mapping-777_reverse.json"), + JSON.stringify("+1777"), + ); + const msg = makeMsg({ + body: "ping", + selfJid: "777@lid", + }); + const targets = resolveMentionTargets(msg, authDir); + expect(targets.selfE164).toBe("+1777"); + } finally { + await fs.rm(authDir, { recursive: true, force: true }); + } + }); }); diff --git a/src/web/auto-reply/monitor/group-gating.test.ts b/src/web/auto-reply/monitor/group-gating.test.ts index 6eb1e2e2be7..74bce52197a 100644 --- a/src/web/auto-reply/monitor/group-gating.test.ts +++ b/src/web/auto-reply/monitor/group-gating.test.ts @@ -1,23 +1,73 @@ -import { describe, expect, it } from "vitest"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { resolveAgentRoute } from "../../../routing/resolve-route.js"; +import { buildMentionConfig } from "../mentions.js"; import { applyGroupGating } from "./group-gating.js"; -const baseConfig = { - channels: { - whatsapp: { - groupPolicy: "open", - groups: { "*": { requireMention: true } }, +let sessionDir: string | undefined; +let sessionStorePath: string; + +beforeEach(async () => { + sessionDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-group-gating-")); + sessionStorePath = path.join(sessionDir, "sessions.json"); + await fs.writeFile(sessionStorePath, "{}"); +}); + +afterEach(async () => { + if (sessionDir) { + await fs.rm(sessionDir, { recursive: true, force: true }); + sessionDir = undefined; + } +}); + +const makeConfig = (overrides: Record) => + ({ + channels: { + whatsapp: { + groupPolicy: "open", + groups: { "*": { requireMention: true } }, + }, }, - }, - session: { store: "/tmp/openclaw-sessions.json" }, -} as const; + session: { store: sessionStorePath }, + ...overrides, + }) as unknown as ReturnType; + +function runGroupGating(params: { + cfg: ReturnType; + msg: Record; + conversationId?: string; + agentId?: string; +}) { + const groupHistories = new Map(); + const conversationId = params.conversationId ?? "123@g.us"; + const agentId = params.agentId ?? "main"; + const sessionKey = `agent:${agentId}:whatsapp:group:${conversationId}`; + const baseMentionConfig = buildMentionConfig(params.cfg, undefined); + const result = applyGroupGating({ + cfg: params.cfg, + // oxlint-disable-next-line typescript/no-explicit-any + msg: params.msg as any, + conversationId, + groupHistoryKey: `whatsapp:default:group:${conversationId}`, + agentId, + sessionKey, + baseMentionConfig, + groupHistories, + groupHistoryLimit: 10, + groupMemberNames: new Map(), + logVerbose: () => {}, + replyLogger: { debug: () => {} }, + }); + return { result, groupHistories }; +} describe("applyGroupGating", () => { it("treats reply-to-bot as implicit mention", () => { - const groupHistories = new Map(); - const result = applyGroupGating({ - cfg: baseConfig as unknown as ReturnType< - typeof import("../../../config/config.js").loadConfig - >, + const cfg = makeConfig({}); + const { result } = runGroupGating({ + cfg, msg: { id: "m1", from: "123@g.us", @@ -39,18 +89,254 @@ describe("applyGroupGating", () => { reply: async () => {}, sendMedia: async () => {}, }, - conversationId: "123@g.us", - groupHistoryKey: "whatsapp:default:group:123@g.us", - agentId: "main", - sessionKey: "agent:main:whatsapp:group:123@g.us", - baseMentionConfig: { mentionRegexes: [] }, - groupHistories, - groupHistoryLimit: 10, - groupMemberNames: new Map(), - logVerbose: () => {}, - replyLogger: { debug: () => {} }, }); expect(result.shouldProcess).toBe(true); }); + + it("bypasses mention gating for owner /new in group chats", () => { + const cfg = makeConfig({ + channels: { + whatsapp: { + allowFrom: ["+111"], + groups: { "*": { requireMention: true } }, + }, + }, + }); + + const { result } = runGroupGating({ + cfg, + msg: { + id: "g-new", + from: "123@g.us", + conversationId: "123@g.us", + chatId: "123@g.us", + chatType: "group", + to: "+2", + body: "/new", + senderE164: "+111", + senderName: "Owner", + selfE164: "+999", + sendComposing: async () => {}, + reply: async () => {}, + sendMedia: async () => {}, + }, + }); + + expect(result.shouldProcess).toBe(true); + }); + + it("does not bypass mention gating for non-owner /new in group chats", () => { + const cfg = makeConfig({ + channels: { + whatsapp: { + allowFrom: ["+999"], + groups: { "*": { requireMention: true } }, + }, + }, + }); + + const { result, groupHistories } = runGroupGating({ + cfg, + msg: { + id: "g-new-unauth", + from: "123@g.us", + conversationId: "123@g.us", + chatId: "123@g.us", + chatType: "group", + to: "+2", + body: "/new", + senderE164: "+111", + senderName: "NotOwner", + selfE164: "+999", + sendComposing: async () => {}, + reply: async () => {}, + sendMedia: async () => {}, + }, + }); + + expect(result.shouldProcess).toBe(false); + expect(groupHistories.get("whatsapp:default:group:123@g.us")?.length).toBe(1); + }); + + it("bypasses mention gating for owner /status in group chats", () => { + const cfg = makeConfig({ + channels: { + whatsapp: { + allowFrom: ["+111"], + groups: { "*": { requireMention: true } }, + }, + }, + }); + + const { result } = runGroupGating({ + cfg, + msg: { + id: "g-status", + from: "123@g.us", + conversationId: "123@g.us", + chatId: "123@g.us", + chatType: "group", + to: "+2", + body: "/status", + senderE164: "+111", + senderName: "Owner", + selfE164: "+999", + sendComposing: async () => {}, + reply: async () => {}, + sendMedia: async () => {}, + }, + }); + + expect(result.shouldProcess).toBe(true); + }); + + it("uses per-agent mention patterns for group gating (routing + mentionPatterns)", () => { + const cfg = makeConfig({ + channels: { + whatsapp: { + allowFrom: ["*"], + groups: { "*": { requireMention: true } }, + }, + }, + messages: { + groupChat: { mentionPatterns: ["@global"] }, + }, + agents: { + list: [ + { + id: "work", + groupChat: { mentionPatterns: ["@workbot"] }, + }, + ], + }, + bindings: [ + { + agentId: "work", + match: { + provider: "whatsapp", + peer: { kind: "group", id: "123@g.us" }, + }, + }, + ], + }); + + const route = resolveAgentRoute({ + cfg, + channel: "whatsapp", + peer: { kind: "group", id: "123@g.us" }, + }); + expect(route.agentId).toBe("work"); + + const { result: globalMention } = runGroupGating({ + cfg, + agentId: route.agentId, + msg: { + id: "g1", + from: "123@g.us", + conversationId: "123@g.us", + chatId: "123@g.us", + chatType: "group", + to: "+2", + body: "@global ping", + senderE164: "+111", + senderName: "Alice", + selfE164: "+999", + sendComposing: async () => {}, + reply: async () => {}, + sendMedia: async () => {}, + }, + }); + expect(globalMention.shouldProcess).toBe(false); + + const { result: workMention } = runGroupGating({ + cfg, + agentId: route.agentId, + msg: { + id: "g2", + from: "123@g.us", + conversationId: "123@g.us", + chatId: "123@g.us", + chatType: "group", + to: "+2", + body: "@workbot ping", + senderE164: "+222", + senderName: "Bob", + selfE164: "+999", + sendComposing: async () => {}, + reply: async () => {}, + sendMedia: async () => {}, + }, + }); + expect(workMention.shouldProcess).toBe(true); + }); + + it("allows group messages when whatsapp groups default disables mention gating", () => { + const cfg = makeConfig({ + channels: { + whatsapp: { + allowFrom: ["*"], + groups: { "*": { requireMention: false } }, + }, + }, + messages: { groupChat: { mentionPatterns: ["@openclaw"] } }, + }); + + const { result } = runGroupGating({ + cfg, + msg: { + id: "g1", + from: "123@g.us", + conversationId: "123@g.us", + chatId: "123@g.us", + chatType: "group", + to: "+2", + body: "hello group", + senderE164: "+111", + senderName: "Alice", + selfE164: "+999", + sendComposing: async () => {}, + reply: async () => {}, + sendMedia: async () => {}, + }, + }); + + expect(result.shouldProcess).toBe(true); + }); + + it("blocks group messages when whatsapp groups is set without a wildcard", () => { + const cfg = makeConfig({ + channels: { + whatsapp: { + allowFrom: ["*"], + groups: { + "999@g.us": { requireMention: false }, + }, + }, + }, + }); + + const { result } = runGroupGating({ + cfg, + msg: { + id: "g1", + from: "123@g.us", + conversationId: "123@g.us", + chatId: "123@g.us", + chatType: "group", + to: "+2", + body: "@workbot ping", + senderE164: "+111", + senderName: "Alice", + selfE164: "+999", + mentionedJids: ["999@s.whatsapp.net"], + selfJid: "999@s.whatsapp.net", + sendComposing: async () => {}, + reply: async () => {}, + sendMedia: async () => {}, + }, + }); + + expect(result.shouldProcess).toBe(false); + }); }); diff --git a/src/web/auto-reply/monitor/message-line.test.ts b/src/web/auto-reply/monitor/message-line.test.ts index 4bbdb883737..4fad746d407 100644 --- a/src/web/auto-reply/monitor/message-line.test.ts +++ b/src/web/auto-reply/monitor/message-line.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { buildInboundLine } from "./message-line.js"; +import { buildInboundLine, formatReplyContext } from "./message-line.js"; describe("buildInboundLine", () => { it("prefixes group messages with sender", () => { @@ -30,4 +30,53 @@ describe("buildInboundLine", () => { expect(line).toContain("Bob (+15550001111):"); expect(line).toContain("ping"); }); + + it("includes reply-to context blocks when replyToBody is present", () => { + const line = buildInboundLine({ + cfg: { + agents: { defaults: { workspace: "/tmp/openclaw" } }, + channels: { whatsapp: { messagePrefix: "" } }, + } as never, + agentId: "main", + msg: { + from: "+1555", + to: "+1555", + body: "hello", + chatType: "direct", + replyToId: "q1", + replyToBody: "original", + replyToSender: "+1999", + } as never, + envelope: { includeTimestamp: false }, + }); + + expect(line).toContain("[Replying to +1999 id:q1]"); + expect(line).toContain("original"); + expect(line).toContain("[/Replying]"); + }); + + it("applies the WhatsApp messagePrefix when configured", () => { + const line = buildInboundLine({ + cfg: { + agents: { defaults: { workspace: "/tmp/openclaw" } }, + channels: { whatsapp: { messagePrefix: "[PFX]" } }, + } as never, + agentId: "main", + msg: { + from: "+1555", + to: "+2666", + body: "ping", + chatType: "direct", + } as never, + envelope: { includeTimestamp: false }, + }); + + expect(line).toContain("[PFX] ping"); + }); +}); + +describe("formatReplyContext", () => { + it("returns null when replyToBody is missing", () => { + expect(formatReplyContext({} as never)).toBeNull(); + }); }); diff --git a/src/web/auto-reply/monitor/process-message.inbound-contract.test.ts b/src/web/auto-reply/monitor/process-message.inbound-contract.test.ts index df99466aef1..835b547c681 100644 --- a/src/web/auto-reply/monitor/process-message.inbound-contract.test.ts +++ b/src/web/auto-reply/monitor/process-message.inbound-contract.test.ts @@ -1,24 +1,49 @@ -import { describe, expect, it, vi } from "vitest"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { expectInboundContextContract } from "../../../../test/helpers/inbound-contract.js"; let capturedCtx: unknown; +let capturedDispatchParams: unknown; +let sessionDir: string | undefined; +let sessionStorePath: string; vi.mock("../../../auto-reply/reply/provider-dispatcher.js", () => ({ - dispatchReplyWithBufferedBlockDispatcher: vi.fn(async (params: { ctx: unknown }) => { + // oxlint-disable-next-line typescript/no-explicit-any + dispatchReplyWithBufferedBlockDispatcher: vi.fn(async (params: any) => { + capturedDispatchParams = params; capturedCtx = params.ctx; return { queuedFinal: false }; }), })); +vi.mock("./last-route.js", () => ({ + trackBackgroundTask: () => undefined, + updateLastRouteInBackground: vi.fn(), +})); + import { processMessage } from "./process-message.js"; describe("web processMessage inbound contract", () => { - it("passes a finalized MsgContext to the dispatcher", async () => { + beforeEach(async () => { capturedCtx = undefined; + capturedDispatchParams = undefined; + sessionDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-process-message-")); + sessionStorePath = path.join(sessionDir, "sessions.json"); + }); + afterEach(async () => { + if (sessionDir) { + await fs.rm(sessionDir, { recursive: true, force: true }); + sessionDir = undefined; + } + }); + + it("passes a finalized MsgContext to the dispatcher", async () => { await processMessage({ // oxlint-disable-next-line typescript/no-explicit-any - cfg: { messages: {} } as any, + cfg: { messages: {}, session: { store: sessionStorePath } } as any, msg: { id: "msg1", from: "123@g.us", @@ -61,4 +86,174 @@ describe("web processMessage inbound contract", () => { // oxlint-disable-next-line typescript/no-explicit-any expectInboundContextContract(capturedCtx as any); }); + + it("falls back SenderId to SenderE164 when senderJid is empty", async () => { + capturedCtx = undefined; + + await processMessage({ + // oxlint-disable-next-line typescript/no-explicit-any + cfg: { messages: {}, session: { store: sessionStorePath } } as any, + msg: { + id: "msg1", + from: "+1000", + to: "+2000", + chatType: "direct", + body: "hi", + senderJid: "", + senderE164: "+1000", + // oxlint-disable-next-line typescript/no-explicit-any + } as any, + route: { + agentId: "main", + accountId: "default", + sessionKey: "agent:main:whatsapp:direct:+1000", + // oxlint-disable-next-line typescript/no-explicit-any + } as any, + groupHistoryKey: "+1000", + groupHistories: new Map(), + groupMemberNames: new Map(), + connectionId: "conn", + verbose: false, + maxMediaBytes: 1, + // oxlint-disable-next-line typescript/no-explicit-any + replyResolver: (async () => undefined) as any, + // oxlint-disable-next-line typescript/no-explicit-any + replyLogger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} } as any, + backgroundTasks: new Set(), + rememberSentText: (_text: string | undefined, _opts: unknown) => {}, + echoHas: () => false, + echoForget: () => {}, + buildCombinedEchoKey: () => "echo", + // oxlint-disable-next-line typescript/no-explicit-any + } as any); + + expect(capturedCtx).toBeTruthy(); + // oxlint-disable-next-line typescript/no-explicit-any + const ctx = capturedCtx as any; + expect(ctx.SenderId).toBe("+1000"); + expect(ctx.SenderE164).toBe("+1000"); + expect(ctx.OriginatingChannel).toBe("whatsapp"); + expect(ctx.OriginatingTo).toBe("+1000"); + expect(ctx.To).toBe("+2000"); + expect(ctx.OriginatingTo).not.toBe(ctx.To); + }); + + it("defaults responsePrefix to identity name in self-chats when unset", async () => { + capturedDispatchParams = undefined; + + await processMessage({ + // oxlint-disable-next-line typescript/no-explicit-any + cfg: { + agents: { + list: [ + { + id: "main", + default: true, + identity: { name: "Mainbot", emoji: "🦞", theme: "space lobster" }, + }, + ], + }, + messages: {}, + session: { store: sessionStorePath }, + } as any, + msg: { + id: "msg1", + from: "+1555", + to: "+1555", + selfE164: "+1555", + chatType: "direct", + body: "hi", + // oxlint-disable-next-line typescript/no-explicit-any + } as any, + route: { + agentId: "main", + accountId: "default", + sessionKey: "agent:main:whatsapp:direct:+1555", + // oxlint-disable-next-line typescript/no-explicit-any + } as any, + groupHistoryKey: "+1555", + groupHistories: new Map(), + groupMemberNames: new Map(), + connectionId: "conn", + verbose: false, + maxMediaBytes: 1, + // oxlint-disable-next-line typescript/no-explicit-any + replyResolver: (async () => undefined) as any, + // oxlint-disable-next-line typescript/no-explicit-any + replyLogger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} } as any, + backgroundTasks: new Set(), + rememberSentText: (_text: string | undefined, _opts: unknown) => {}, + echoHas: () => false, + echoForget: () => {}, + buildCombinedEchoKey: () => "echo", + // oxlint-disable-next-line typescript/no-explicit-any + } as any); + + // oxlint-disable-next-line typescript/no-explicit-any + const dispatcherOptions = (capturedDispatchParams as any)?.dispatcherOptions; + expect(dispatcherOptions?.responsePrefix).toBe("[Mainbot]"); + }); + + it("clears pending group history when the dispatcher does not queue a final reply", async () => { + capturedCtx = undefined; + const groupHistories = new Map>([ + [ + "whatsapp:default:group:123@g.us", + [ + { + sender: "Alice (+111)", + body: "first", + }, + ], + ], + ]); + + await processMessage({ + // oxlint-disable-next-line typescript/no-explicit-any + cfg: { + messages: {}, + session: { store: sessionStorePath }, + } as any, + msg: { + id: "g1", + from: "123@g.us", + conversationId: "123@g.us", + to: "+2000", + chatType: "group", + chatId: "123@g.us", + body: "second", + senderName: "Bob", + senderE164: "+222", + selfE164: "+999", + sendComposing: async () => {}, + reply: async () => {}, + sendMedia: async () => {}, + // oxlint-disable-next-line typescript/no-explicit-any + } as any, + route: { + agentId: "main", + accountId: "default", + sessionKey: "agent:main:whatsapp:group:123@g.us", + // oxlint-disable-next-line typescript/no-explicit-any + } as any, + groupHistoryKey: "whatsapp:default:group:123@g.us", + groupHistories: groupHistories as never, + groupMemberNames: new Map(), + connectionId: "conn", + verbose: false, + maxMediaBytes: 1, + // oxlint-disable-next-line typescript/no-explicit-any + replyResolver: (async () => undefined) as any, + // oxlint-disable-next-line typescript/no-explicit-any + replyLogger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} } as any, + backgroundTasks: new Set(), + rememberSentText: (_text: string | undefined, _opts: unknown) => {}, + echoHas: () => false, + echoForget: () => {}, + buildCombinedEchoKey: () => "echo", + // oxlint-disable-next-line typescript/no-explicit-any + } as any); + + expect(groupHistories.get("whatsapp:default:group:123@g.us") ?? []).toHaveLength(0); + }); });