perf(test): consolidate web auto-reply suites

This commit is contained in:
Peter Steinberger
2026-02-14 23:10:06 +00:00
parent 5fd1822c7c
commit fb1d8f8361
8 changed files with 799 additions and 1524 deletions

View File

@@ -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();
});
});

View File

@@ -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<void>)
| undefined;
const listenerFactory = async (opts: {
onMessage: (msg: import("./inbound.js").WebInboundMessage) => Promise<void>;
}) => {
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<void>)
| undefined;
const listenerFactory = async (opts: {
onMessage: (msg: import("./inbound.js").WebInboundMessage) => Promise<void>;
}) => {
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<void>)
| undefined;
const listenerFactory = async (opts: {
onMessage: (msg: import("./inbound.js").WebInboundMessage) => Promise<void>;
}) => {
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<void>)
| undefined;
const listenerFactory = async (opts: {
onMessage: (msg: import("./inbound.js").WebInboundMessage) => Promise<void>;
}) => {
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<void>)
| undefined;
const listenerFactory = async (opts: {
onMessage: (msg: import("./inbound.js").WebInboundMessage) => Promise<void>;
}) => {
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<void>)
| undefined;
const listenerFactory = async (opts: {
onMessage: (msg: import("./inbound.js").WebInboundMessage) => Promise<void>;
}) => {
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<void>)
| undefined;
const listenerFactory = async (opts: {
onMessage: (msg: import("./inbound.js").WebInboundMessage) => Promise<void>;
}) => {
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<void>)
| undefined;
const listenerFactory = async (opts: {
onMessage: (msg: import("./inbound.js").WebInboundMessage) => Promise<void>;
}) => {
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<void>)
| undefined;
const listenerFactory = async (opts: {
onMessage: (msg: import("./inbound.js").WebInboundMessage) => Promise<void>;
}) => {
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<void>)
| undefined;
const listenerFactory = async (opts: {
onMessage: (msg: import("./inbound.js").WebInboundMessage) => Promise<void>;
}) => {
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<void>)
| undefined;
const listenerFactory = async (opts: {
onMessage: (msg: import("./inbound.js").WebInboundMessage) => Promise<void>;
}) => {
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<void>)
| undefined;
const listenerFactory = async (opts: {
onMessage: (msg: import("./inbound.js").WebInboundMessage) => Promise<void>;
}) => {
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<void>)
| undefined;
const listenerFactory = async (opts: {
onMessage: (msg: import("./inbound.js").WebInboundMessage) => Promise<void>;
}) => {
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<void>)
| undefined;
const listenerFactory = async (opts: {
onMessage: (msg: import("./inbound.js").WebInboundMessage) => Promise<void>;
}) => {
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<void>(() => {
// 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<void>)
| undefined;
const listenerFactory = async (opts: {
onMessage: (msg: import("./inbound.js").WebInboundMessage) => Promise<void>;
}) => {
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/);
});
});

View File

@@ -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<void>(() => {
// 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<void>)
| undefined;
const listenerFactory = async (opts: {
onMessage: (msg: import("./inbound.js").WebInboundMessage) => Promise<void>;
}) => {
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/);
});
});

View File

@@ -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<void>)
| undefined;
const listenerFactory = async (opts: {
onMessage: (msg: import("./inbound.js").WebInboundMessage) => Promise<void>;
}) => {
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();
});
});

View File

@@ -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 });
}
});
});

View File

@@ -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<string, unknown>) =>
({
channels: {
whatsapp: {
groupPolicy: "open",
groups: { "*": { requireMention: true } },
},
},
},
session: { store: "/tmp/openclaw-sessions.json" },
} as const;
session: { store: sessionStorePath },
...overrides,
}) as unknown as ReturnType<typeof import("../../../config/config.js").loadConfig>;
function runGroupGating(params: {
cfg: ReturnType<typeof import("../../../config/config.js").loadConfig>;
msg: Record<string, unknown>;
conversationId?: string;
agentId?: string;
}) {
const groupHistories = new Map<string, unknown[]>();
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);
});
});

View File

@@ -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();
});
});

View File

@@ -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<string, Array<{ sender: string; body: string }>>([
[
"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);
});
});