refactor(channels): dedupe transport and gateway test scaffolds

This commit is contained in:
Peter Steinberger
2026-02-16 14:52:15 +00:00
parent f717a13039
commit 93ca0ed54f
95 changed files with 4068 additions and 5221 deletions

View File

@@ -1,12 +1,14 @@
import "./test-helpers.js";
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { monitorWebChannelWithCapture } from "./auto-reply.broadcast-groups.test-harness.js";
import {
monitorWebChannelWithCapture,
sendWebDirectInboundAndCollectSessionKeys,
} from "./auto-reply.broadcast-groups.test-harness.js";
import {
installWebAutoReplyTestHomeHooks,
installWebAutoReplyUnitTestHooks,
resetLoadConfigMock,
sendWebDirectInboundMessage,
sendWebGroupInboundMessage,
setLoadConfigMock,
} from "./auto-reply.test-harness.js";
@@ -29,22 +31,7 @@ describe("broadcast groups", () => {
},
} satisfies OpenClawConfig);
const seen: string[] = [];
const resolver = vi.fn(async (ctx: { SessionKey?: unknown }) => {
seen.push(String(ctx.SessionKey));
return { text: "ok" };
});
const { spies, onMessage } = await monitorWebChannelWithCapture(resolver);
await sendWebDirectInboundMessage({
onMessage,
spies,
id: "m1",
from: "+1000",
to: "+2000",
body: "hello",
});
const { seen, resolver } = await sendWebDirectInboundAndCollectSessionKeys();
expect(resolver).toHaveBeenCalledTimes(2);
expect(seen[0]).toContain("agent:alfred:");

View File

@@ -1,12 +1,11 @@
import "./test-helpers.js";
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { monitorWebChannelWithCapture } from "./auto-reply.broadcast-groups.test-harness.js";
import { sendWebDirectInboundAndCollectSessionKeys } from "./auto-reply.broadcast-groups.test-harness.js";
import {
installWebAutoReplyTestHomeHooks,
installWebAutoReplyUnitTestHooks,
resetLoadConfigMock,
sendWebDirectInboundMessage,
setLoadConfigMock,
} from "./auto-reply.test-harness.js";
@@ -27,22 +26,7 @@ describe("broadcast groups", () => {
},
} satisfies OpenClawConfig);
const seen: string[] = [];
const resolver = vi.fn(async (ctx: { SessionKey?: unknown }) => {
seen.push(String(ctx.SessionKey));
return { text: "ok" };
});
const { spies, onMessage } = await monitorWebChannelWithCapture(resolver);
await sendWebDirectInboundMessage({
onMessage,
spies,
id: "m1",
from: "+1000",
to: "+2000",
body: "hello",
});
const { seen, resolver } = await sendWebDirectInboundAndCollectSessionKeys();
expect(resolver).toHaveBeenCalledTimes(1);
expect(seen[0]).toContain("agent:alfred:");

View File

@@ -1,8 +1,10 @@
import { vi } from "vitest";
import type { WebInboundMessage } from "./inbound.js";
import { monitorWebChannel } from "./auto-reply.js";
import {
createWebInboundDeliverySpies,
createWebListenerFactoryCapture,
sendWebDirectInboundMessage,
} from "./auto-reply.test-harness.js";
export async function monitorWebChannelWithCapture(resolver: unknown): Promise<{
@@ -20,3 +22,26 @@ export async function monitorWebChannelWithCapture(resolver: unknown): Promise<{
return { spies, onMessage };
}
export async function sendWebDirectInboundAndCollectSessionKeys(): Promise<{
seen: string[];
resolver: ReturnType<typeof vi.fn>;
}> {
const seen: string[] = [];
const resolver = vi.fn(async (ctx: { SessionKey?: unknown }) => {
seen.push(String(ctx.SessionKey));
return { text: "ok" };
});
const { spies, onMessage } = await monitorWebChannelWithCapture(resolver);
await sendWebDirectInboundMessage({
onMessage,
spies,
id: "m1",
from: "+1000",
to: "+2000",
body: "hello",
});
return { seen, resolver };
}

View File

@@ -14,6 +14,53 @@ installWebAutoReplyTestHomeHooks();
describe("web auto-reply", () => {
installWebAutoReplyUnitTestHooks({ pinDns: true });
async function setupSingleInboundMessage(params: {
resolverValue: { text: string; mediaUrl: string };
sendMedia: ReturnType<typeof vi.fn>;
reply?: ReturnType<typeof vi.fn>;
}) {
const reply = params.reply ?? vi.fn().mockResolvedValue(undefined);
const sendComposing = vi.fn();
const resolver = vi.fn().mockResolvedValue(params.resolverValue);
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();
return {
reply,
dispatch: async (id = "msg1") => {
await capturedOnMessage?.({
body: "hello",
from: "+1",
to: "+2",
id,
sendComposing,
reply,
sendMedia: params.sendMedia,
});
},
};
}
function getSingleImagePayload(sendMedia: ReturnType<typeof vi.fn>) {
expect(sendMedia).toHaveBeenCalledTimes(1);
return sendMedia.mock.calls[0][0] as {
image: Buffer;
caption?: string;
mimetype?: string;
};
}
it("compresses common formats to jpeg under the cap", { timeout: 45_000 }, async () => {
const formats = [
{
@@ -179,23 +226,11 @@ describe("web auto-reply", () => {
});
it("falls back to text when media is unsupported", async () => {
const sendMedia = vi.fn();
const reply = vi.fn().mockResolvedValue(undefined);
const sendComposing = vi.fn();
const resolver = vi.fn().mockResolvedValue({
text: "hi",
mediaUrl: "https://example.com/file.pdf",
const { reply, dispatch } = await setupSingleInboundMessage({
resolverValue: { text: "hi", mediaUrl: "https://example.com/file.pdf" },
sendMedia,
});
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 fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
body: true,
@@ -204,18 +239,7 @@ describe("web auto-reply", () => {
status: 200,
} as Response);
await monitorWebChannel(false, listenerFactory, false, resolver);
expect(capturedOnMessage).toBeDefined();
await capturedOnMessage?.({
body: "hello",
from: "+1",
to: "+2",
id: "msg-pdf",
sendComposing,
reply,
sendMedia,
});
await dispatch("msg-pdf");
expect(sendMedia).toHaveBeenCalledTimes(1);
const payload = sendMedia.mock.calls[0][0] as {
@@ -233,23 +257,14 @@ describe("web auto-reply", () => {
it("falls back to text when media send fails", async () => {
const sendMedia = vi.fn().mockRejectedValue(new Error("boom"));
const reply = vi.fn().mockResolvedValue(undefined);
const sendComposing = vi.fn();
const resolver = vi.fn().mockResolvedValue({
text: "hi",
mediaUrl: "https://example.com/img.png",
const { reply, dispatch } = await setupSingleInboundMessage({
resolverValue: {
text: "hi",
mediaUrl: "https://example.com/img.png",
},
sendMedia,
});
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 smallPng = await sharp({
create: {
width: 64,
@@ -269,18 +284,7 @@ describe("web auto-reply", () => {
status: 200,
} as Response);
await monitorWebChannel(false, listenerFactory, false, resolver);
expect(capturedOnMessage).toBeDefined();
await capturedOnMessage?.({
body: "hello",
from: "+1",
to: "+2",
id: "msg1",
sendComposing,
reply,
sendMedia,
});
await dispatch("msg1");
expect(sendMedia).toHaveBeenCalledTimes(1);
const fallback = reply.mock.calls[0]?.[0] as string;
@@ -290,23 +294,14 @@ describe("web auto-reply", () => {
});
it("returns a warning when remote media fetch 404s", async () => {
const sendMedia = vi.fn();
const reply = vi.fn().mockResolvedValue(undefined);
const sendComposing = vi.fn();
const resolver = vi.fn().mockResolvedValue({
text: "caption",
mediaUrl: "https://example.com/missing.jpg",
const { reply, dispatch } = await setupSingleInboundMessage({
resolverValue: {
text: "caption",
mediaUrl: "https://example.com/missing.jpg",
},
sendMedia,
});
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 fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: false,
status: 404,
@@ -315,18 +310,7 @@ describe("web auto-reply", () => {
headers: { get: () => "text/plain" },
} as unknown as Response);
await monitorWebChannel(false, listenerFactory, false, resolver);
expect(capturedOnMessage).toBeDefined();
await capturedOnMessage?.({
body: "hello",
from: "+1",
to: "+2",
id: "msg1",
sendComposing,
reply,
sendMedia,
});
await dispatch("msg1");
expect(sendMedia).not.toHaveBeenCalled();
const fallback = reply.mock.calls[0]?.[0] as string;
@@ -338,23 +322,14 @@ describe("web auto-reply", () => {
});
it("sends media with a caption when delivery succeeds", async () => {
const sendMedia = vi.fn().mockResolvedValue(undefined);
const reply = vi.fn().mockResolvedValue(undefined);
const sendComposing = vi.fn();
const resolver = vi.fn().mockResolvedValue({
text: "hi",
mediaUrl: "https://example.com/img.png",
const { reply, dispatch } = await setupSingleInboundMessage({
resolverValue: {
text: "hi",
mediaUrl: "https://example.com/img.png",
},
sendMedia,
});
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 png = await sharp({
create: {
width: 64,
@@ -374,25 +349,9 @@ describe("web auto-reply", () => {
status: 200,
} as Response);
await monitorWebChannel(false, listenerFactory, false, resolver);
expect(capturedOnMessage).toBeDefined();
await dispatch("msg1");
await capturedOnMessage?.({
body: "hello",
from: "+1",
to: "+2",
id: "msg1",
sendComposing,
reply,
sendMedia,
});
expect(sendMedia).toHaveBeenCalledTimes(1);
const payload = sendMedia.mock.calls[0][0] as {
image: Buffer;
caption?: string;
mimetype?: string;
};
const payload = getSingleImagePayload(sendMedia);
expect(payload.caption).toBe("hi");
expect(payload.image.length).toBeGreaterThan(0);
// Should not fall back to separate text reply because caption is used.

View File

@@ -45,6 +45,52 @@ function createHandlerForTest(opts: { cfg: OpenClawConfig; replyResolver: unknow
return { handler, backgroundTasks };
}
function createLastRouteHarness(storePath: string) {
const replyResolver = vi.fn().mockResolvedValue(undefined);
const cfg = makeCfg(storePath);
return createHandlerForTest({ cfg, replyResolver });
}
function buildInboundMessage(params: {
id: string;
from: string;
conversationId: string;
chatType: "direct" | "group";
chatId: string;
timestamp: number;
body?: string;
to?: string;
accountId?: string;
senderE164?: string;
senderName?: string;
selfE164?: string;
}) {
return {
id: params.id,
from: params.from,
conversationId: params.conversationId,
to: params.to ?? "+2000",
body: params.body ?? "hello",
timestamp: params.timestamp,
chatType: params.chatType,
chatId: params.chatId,
accountId: params.accountId,
senderE164: params.senderE164,
senderName: params.senderName,
selfE164: params.selfE164,
sendComposing: vi.fn().mockResolvedValue(undefined),
reply: vi.fn().mockResolvedValue(undefined),
sendMedia: vi.fn().mockResolvedValue(undefined),
};
}
async function readStoredRoutes(storePath: string) {
return JSON.parse(await fs.readFile(storePath, "utf8")) as Record<
string,
{ lastChannel?: string; lastTo?: string; lastAccountId?: string }
>;
}
describe("web auto-reply last-route", () => {
installWebAutoReplyUnitTestHooks();
@@ -55,30 +101,22 @@ describe("web auto-reply last-route", () => {
[mainSessionKey]: { sessionId: "sid", updatedAt: now - 1 },
});
const replyResolver = vi.fn().mockResolvedValue(undefined);
const cfg = makeCfg(store.storePath);
const { handler, backgroundTasks } = createHandlerForTest({ cfg, replyResolver });
const { handler, backgroundTasks } = createLastRouteHarness(store.storePath);
await handler({
id: "m1",
from: "+1000",
conversationId: "+1000",
to: "+2000",
body: "hello",
timestamp: now,
chatType: "direct",
chatId: "direct:+1000",
sendComposing: vi.fn().mockResolvedValue(undefined),
reply: vi.fn().mockResolvedValue(undefined),
sendMedia: vi.fn().mockResolvedValue(undefined),
});
await handler(
buildInboundMessage({
id: "m1",
from: "+1000",
conversationId: "+1000",
chatType: "direct",
chatId: "direct:+1000",
timestamp: now,
}),
);
await awaitBackgroundTasks(backgroundTasks);
const stored = JSON.parse(await fs.readFile(store.storePath, "utf8")) as Record<
string,
{ lastChannel?: string; lastTo?: string }
>;
const stored = await readStoredRoutes(store.storePath);
expect(stored[mainSessionKey]?.lastChannel).toBe("whatsapp");
expect(stored[mainSessionKey]?.lastTo).toBe("+1000");
@@ -92,34 +130,26 @@ describe("web auto-reply last-route", () => {
[groupSessionKey]: { sessionId: "sid", updatedAt: now - 1 },
});
const replyResolver = vi.fn().mockResolvedValue(undefined);
const cfg = makeCfg(store.storePath);
const { handler, backgroundTasks } = createHandlerForTest({ cfg, replyResolver });
const { handler, backgroundTasks } = createLastRouteHarness(store.storePath);
await handler({
id: "g1",
from: "123@g.us",
conversationId: "123@g.us",
to: "+2000",
body: "hello",
timestamp: now,
chatType: "group",
chatId: "123@g.us",
accountId: "work",
senderE164: "+1000",
senderName: "Alice",
selfE164: "+2000",
sendComposing: vi.fn().mockResolvedValue(undefined),
reply: vi.fn().mockResolvedValue(undefined),
sendMedia: vi.fn().mockResolvedValue(undefined),
});
await handler(
buildInboundMessage({
id: "g1",
from: "123@g.us",
conversationId: "123@g.us",
chatType: "group",
chatId: "123@g.us",
timestamp: now,
accountId: "work",
senderE164: "+1000",
senderName: "Alice",
selfE164: "+2000",
}),
);
await awaitBackgroundTasks(backgroundTasks);
const stored = JSON.parse(await fs.readFile(store.storePath, "utf8")) as Record<
string,
{ lastChannel?: string; lastTo?: string; lastAccountId?: string }
>;
const stored = await readStoredRoutes(store.storePath);
expect(stored[groupSessionKey]?.lastChannel).toBe("whatsapp");
expect(stored[groupSessionKey]?.lastTo).toBe("123@g.us");
expect(stored[groupSessionKey]?.lastAccountId).toBe("work");

View File

@@ -9,6 +9,40 @@ import {
installWebAutoReplyTestHomeHooks();
function createRuntime() {
return {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
}
function startMonitorWebChannel(params: {
monitorWebChannelFn: (...args: unknown[]) => Promise<unknown>;
listenerFactory: (...args: unknown[]) => Promise<unknown>;
sleep: ReturnType<typeof vi.fn>;
signal?: AbortSignal;
reconnect?: { initialMs: number; maxMs: number; maxAttempts: number; factor: number };
}) {
const runtime = createRuntime();
const controller = new AbortController();
const run = params.monitorWebChannelFn(
false,
params.listenerFactory as never,
true,
async () => ({ text: "ok" }),
runtime as never,
params.signal ?? controller.signal,
{
heartbeatSeconds: 1,
reconnect: params.reconnect ?? { initialMs: 10, maxMs: 10, maxAttempts: 3, factor: 1.1 },
sleep: params.sleep,
},
);
return { runtime, controller, run };
}
describe("web auto-reply", () => {
installWebAutoReplyUnitTestHooks();
@@ -34,25 +68,11 @@ describe("web auto-reply", () => {
});
return { close: vi.fn(), onClose };
});
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
const controller = new AbortController();
const run = monitorWebChannel(
false,
const { runtime, controller, run } = startMonitorWebChannel({
monitorWebChannelFn: monitorWebChannel as never,
listenerFactory,
true,
async () => ({ text: "ok" }),
runtime as never,
controller.signal,
{
heartbeatSeconds: 1,
reconnect: { initialMs: 10, maxMs: 10, maxAttempts: 3, factor: 1.1 },
sleep,
},
);
sleep,
});
await Promise.resolve();
expect(listenerFactory).toHaveBeenCalledTimes(1);
@@ -98,25 +118,11 @@ describe("web auto-reply", () => {
};
},
);
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
const controller = new AbortController();
const run = monitorWebChannel(
false,
const { controller, run } = startMonitorWebChannel({
monitorWebChannelFn: monitorWebChannel as never,
listenerFactory,
true,
async () => ({ text: "ok" }),
runtime as never,
controller.signal,
{
heartbeatSeconds: 1,
reconnect: { initialMs: 10, maxMs: 10, maxAttempts: 3, factor: 1.1 },
sleep,
},
);
sleep,
});
await Promise.resolve();
expect(listenerFactory).toHaveBeenCalledTimes(1);

View File

@@ -10,6 +10,52 @@ let sessionDir: string | undefined;
let sessionStorePath: string;
let backgroundTasks: Set<Promise<unknown>>;
const defaultReplyLogger = {
info: () => {},
warn: () => {},
error: () => {},
debug: () => {},
};
function makeProcessMessageArgs(params: {
msg: Record<string, unknown>;
routeSessionKey: string;
groupHistoryKey: string;
cfg?: unknown;
groupHistories?: Map<string, Array<{ sender: string; body: string }>>;
groupHistory?: Array<{ sender: string; body: string }>;
}) {
return {
// oxlint-disable-next-line typescript/no-explicit-any
cfg: (params.cfg ?? { messages: {}, session: { store: sessionStorePath } }) as any,
// oxlint-disable-next-line typescript/no-explicit-any
msg: params.msg as any,
route: {
agentId: "main",
accountId: "default",
sessionKey: params.routeSessionKey,
// oxlint-disable-next-line typescript/no-explicit-any
} as any,
groupHistoryKey: params.groupHistoryKey,
groupHistories: params.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: defaultReplyLogger as any,
backgroundTasks,
rememberSentText: (_text: string | undefined, _opts: unknown) => {},
echoHas: () => false,
echoForget: () => {},
buildCombinedEchoKey: () => "echo",
...(params.groupHistory ? { groupHistory: params.groupHistory } : {}),
// oxlint-disable-next-line typescript/no-explicit-any
} as any;
}
vi.mock("../../../auto-reply/reply/provider-dispatcher.js", () => ({
// oxlint-disable-next-line typescript/no-explicit-any
dispatchReplyWithBufferedBlockDispatcher: vi.fn(async (params: any) => {
@@ -49,46 +95,25 @@ describe("web processMessage inbound contract", () => {
});
it("passes a finalized MsgContext to the dispatcher", async () => {
await processMessage({
// oxlint-disable-next-line typescript/no-explicit-any
cfg: { messages: {}, session: { store: sessionStorePath } } as any,
msg: {
id: "msg1",
from: "123@g.us",
to: "+15550001111",
chatType: "group",
body: "hi",
senderName: "Alice",
senderJid: "alice@s.whatsapp.net",
senderE164: "+15550002222",
groupSubject: "Test Group",
groupParticipants: [],
// oxlint-disable-next-line typescript/no-explicit-any
} as any,
route: {
agentId: "main",
accountId: "default",
sessionKey: "agent:main:whatsapp:group:123",
// oxlint-disable-next-line typescript/no-explicit-any
} as any,
groupHistoryKey: "123@g.us",
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,
rememberSentText: (_text: string | undefined, _opts: unknown) => {},
echoHas: () => false,
echoForget: () => {},
buildCombinedEchoKey: () => "echo",
groupHistory: [],
// oxlint-disable-next-line typescript/no-explicit-any
} as any);
await processMessage(
makeProcessMessageArgs({
routeSessionKey: "agent:main:whatsapp:group:123",
groupHistoryKey: "123@g.us",
groupHistory: [],
msg: {
id: "msg1",
from: "123@g.us",
to: "+15550001111",
chatType: "group",
body: "hi",
senderName: "Alice",
senderJid: "alice@s.whatsapp.net",
senderE164: "+15550002222",
groupSubject: "Test Group",
groupParticipants: [],
},
}),
);
expect(capturedCtx).toBeTruthy();
// oxlint-disable-next-line typescript/no-explicit-any
@@ -98,42 +123,21 @@ describe("web processMessage inbound contract", () => {
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,
rememberSentText: (_text: string | undefined, _opts: unknown) => {},
echoHas: () => false,
echoForget: () => {},
buildCombinedEchoKey: () => "echo",
// oxlint-disable-next-line typescript/no-explicit-any
} as any);
await processMessage(
makeProcessMessageArgs({
routeSessionKey: "agent:main:whatsapp:direct:+1000",
groupHistoryKey: "+1000",
msg: {
id: "msg1",
from: "+1000",
to: "+2000",
chatType: "direct",
body: "hi",
senderJid: "",
senderE164: "+1000",
},
}),
);
expect(capturedCtx).toBeTruthy();
// oxlint-disable-next-line typescript/no-explicit-any
@@ -149,53 +153,33 @@ describe("web processMessage inbound contract", () => {
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" },
},
],
await processMessage(
makeProcessMessageArgs({
routeSessionKey: "agent:main:whatsapp:direct:+1555",
groupHistoryKey: "+1555",
cfg: {
agents: {
list: [
{
id: "main",
default: true,
identity: { name: "Mainbot", emoji: "🦞", theme: "space lobster" },
},
],
},
messages: {},
session: { store: sessionStorePath },
} as unknown as ReturnType<typeof import("../../../config/config.js").loadConfig>,
msg: {
id: "msg1",
from: "+1555",
to: "+1555",
selfE164: "+1555",
chatType: "direct",
body: "hi",
},
messages: {},
session: { store: sessionStorePath },
} as unknown as ReturnType<typeof import("../../../config/config.js").loadConfig>,
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,
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;
@@ -216,51 +200,32 @@ describe("web processMessage inbound contract", () => {
],
]);
await processMessage({
// oxlint-disable-next-line typescript/no-explicit-any
cfg: {
messages: {},
session: { store: sessionStorePath },
} as unknown as ReturnType<typeof import("../../../config/config.js").loadConfig>,
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,
rememberSentText: (_text: string | undefined, _opts: unknown) => {},
echoHas: () => false,
echoForget: () => {},
buildCombinedEchoKey: () => "echo",
// oxlint-disable-next-line typescript/no-explicit-any
} as any);
await processMessage(
makeProcessMessageArgs({
routeSessionKey: "agent:main:whatsapp:group:123@g.us",
groupHistoryKey: "whatsapp:default:group:123@g.us",
groupHistories,
cfg: {
messages: {},
session: { store: sessionStorePath },
} as unknown as ReturnType<typeof import("../../../config/config.js").loadConfig>,
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 () => {},
},
}),
);
expect(groupHistories.get("whatsapp:default:group:123@g.us") ?? []).toHaveLength(0);
});

View File

@@ -10,12 +10,24 @@ setupAccessControlTestHarness();
const { checkInboundAccessControl } = await import("./access-control.js");
describe("checkInboundAccessControl pairing grace", () => {
it("suppresses pairing replies for historical DMs on connect", async () => {
const connectedAtMs = 1_000_000;
const messageTimestampMs = connectedAtMs - 31_000;
async function checkUnauthorizedWorkDmSender() {
return checkInboundAccessControl({
accountId: "work",
from: "+15550001111",
selfE164: "+15550009999",
senderE164: "+15550001111",
group: false,
pushName: "Stranger",
isFromMe: false,
sock: { sendMessage: sendMessageMock },
remoteJid: "15550001111@s.whatsapp.net",
});
}
const result = await checkInboundAccessControl({
describe("checkInboundAccessControl pairing grace", () => {
async function runPairingGraceCase(messageTimestampMs: number) {
const connectedAtMs = 1_000_000;
return await checkInboundAccessControl({
accountId: "default",
from: "+15550001111",
selfE164: "+15550009999",
@@ -29,6 +41,10 @@ describe("checkInboundAccessControl pairing grace", () => {
sock: { sendMessage: sendMessageMock },
remoteJid: "15550001111@s.whatsapp.net",
});
}
it("suppresses pairing replies for historical DMs on connect", async () => {
const result = await runPairingGraceCase(1_000_000 - 31_000);
expect(result.allowed).toBe(false);
expect(upsertPairingRequestMock).not.toHaveBeenCalled();
@@ -36,23 +52,7 @@ describe("checkInboundAccessControl pairing grace", () => {
});
it("sends pairing replies for live DMs", async () => {
const connectedAtMs = 1_000_000;
const messageTimestampMs = connectedAtMs - 10_000;
const result = await checkInboundAccessControl({
accountId: "default",
from: "+15550001111",
selfE164: "+15550009999",
senderE164: "+15550001111",
group: false,
pushName: "Sam",
isFromMe: false,
messageTimestampMs,
connectedAtMs,
pairingGraceMs: 30_000,
sock: { sendMessage: sendMessageMock },
remoteJid: "15550001111@s.whatsapp.net",
});
const result = await runPairingGraceCase(1_000_000 - 10_000);
expect(result.allowed).toBe(false);
expect(upsertPairingRequestMock).toHaveBeenCalled();
@@ -79,17 +79,7 @@ describe("WhatsApp dmPolicy precedence", () => {
},
});
const result = await checkInboundAccessControl({
accountId: "work",
from: "+15550001111",
selfE164: "+15550009999",
senderE164: "+15550001111",
group: false,
pushName: "Stranger",
isFromMe: false,
sock: { sendMessage: sendMessageMock },
remoteJid: "15550001111@s.whatsapp.net",
});
const result = await checkUnauthorizedWorkDmSender();
expect(result.allowed).toBe(false);
expect(upsertPairingRequestMock).not.toHaveBeenCalled();
@@ -112,17 +102,7 @@ describe("WhatsApp dmPolicy precedence", () => {
},
});
const result = await checkInboundAccessControl({
accountId: "work",
from: "+15550001111",
selfE164: "+15550009999",
senderE164: "+15550001111",
group: false,
pushName: "Stranger",
isFromMe: false,
sock: { sendMessage: sendMessageMock },
remoteJid: "15550001111@s.whatsapp.net",
});
const result = await checkUnauthorizedWorkDmSender();
expect(result.allowed).toBe(false);
expect(upsertPairingRequestMock).not.toHaveBeenCalled();

View File

@@ -11,6 +11,63 @@ import {
} from "./monitor-inbox.test-harness.js";
const nowSeconds = (offsetMs = 0) => Math.floor((Date.now() + offsetMs) / 1000);
const DEFAULT_MESSAGES_CFG = {
messagePrefix: undefined,
responsePrefix: undefined,
} as const;
async function expectOutboundDmSkipsPairing(params: {
selfChatMode: boolean;
messageId: string;
body: string;
}) {
mockLoadConfig.mockReturnValue({
channels: {
whatsapp: {
dmPolicy: "pairing",
selfChatMode: params.selfChatMode,
},
},
messages: DEFAULT_MESSAGES_CFG,
});
const onMessage = vi.fn();
const listener = await monitorWebInbox({
verbose: false,
accountId: DEFAULT_ACCOUNT_ID,
authDir: getAuthDir(),
onMessage,
});
const sock = getSock();
try {
sock.ev.emit("messages.upsert", {
type: "notify",
messages: [
{
key: {
id: params.messageId,
fromMe: true,
remoteJid: "999@s.whatsapp.net",
},
message: { conversation: params.body },
messageTimestamp: nowSeconds(),
},
],
});
await new Promise((resolve) => setImmediate(resolve));
expect(onMessage).not.toHaveBeenCalled();
expect(upsertPairingRequestMock).not.toHaveBeenCalled();
expect(sock.sendMessage).not.toHaveBeenCalled();
} finally {
mockLoadConfig.mockReturnValue({
channels: { whatsapp: { allowFrom: ["*"] } },
messages: DEFAULT_MESSAGES_CFG,
});
await listener.close();
}
}
describe("web monitor inbox", () => {
installWebMonitorInboxUnitTestHooks();
@@ -207,115 +264,19 @@ describe("web monitor inbox", () => {
});
it("skips pairing replies for outbound DMs in same-phone mode", async () => {
mockLoadConfig.mockReturnValue({
channels: {
whatsapp: {
dmPolicy: "pairing",
selfChatMode: true,
},
},
messages: {
messagePrefix: undefined,
responsePrefix: undefined,
},
await expectOutboundDmSkipsPairing({
selfChatMode: true,
messageId: "fromme-1",
body: "hello",
});
const onMessage = vi.fn();
const listener = await monitorWebInbox({
verbose: false,
accountId: DEFAULT_ACCOUNT_ID,
authDir: getAuthDir(),
onMessage,
});
const sock = getSock();
const upsert = {
type: "notify",
messages: [
{
key: {
id: "fromme-1",
fromMe: true,
remoteJid: "999@s.whatsapp.net",
},
message: { conversation: "hello" },
messageTimestamp: nowSeconds(),
},
],
};
sock.ev.emit("messages.upsert", upsert);
await new Promise((resolve) => setImmediate(resolve));
expect(onMessage).not.toHaveBeenCalled();
expect(upsertPairingRequestMock).not.toHaveBeenCalled();
expect(sock.sendMessage).not.toHaveBeenCalled();
mockLoadConfig.mockReturnValue({
channels: { whatsapp: { allowFrom: ["*"] } },
messages: {
messagePrefix: undefined,
responsePrefix: undefined,
},
});
await listener.close();
});
it("skips pairing replies for outbound DMs when same-phone mode is disabled", async () => {
mockLoadConfig.mockReturnValue({
channels: {
whatsapp: {
dmPolicy: "pairing",
selfChatMode: false,
},
},
messages: {
messagePrefix: undefined,
responsePrefix: undefined,
},
await expectOutboundDmSkipsPairing({
selfChatMode: false,
messageId: "fromme-2",
body: "hello again",
});
const onMessage = vi.fn();
const listener = await monitorWebInbox({
verbose: false,
accountId: DEFAULT_ACCOUNT_ID,
authDir: getAuthDir(),
onMessage,
});
const sock = getSock();
const upsert = {
type: "notify",
messages: [
{
key: {
id: "fromme-2",
fromMe: true,
remoteJid: "999@s.whatsapp.net",
},
message: { conversation: "hello again" },
messageTimestamp: nowSeconds(),
},
],
};
sock.ev.emit("messages.upsert", upsert);
await new Promise((resolve) => setImmediate(resolve));
expect(onMessage).not.toHaveBeenCalled();
expect(upsertPairingRequestMock).not.toHaveBeenCalled();
expect(sock.sendMessage).not.toHaveBeenCalled();
mockLoadConfig.mockReturnValue({
channels: { whatsapp: { allowFrom: ["*"] } },
messages: {
messagePrefix: undefined,
responsePrefix: undefined,
},
});
await listener.close();
});
it("handles append messages by marking them read but skipping auto-reply", async () => {

View File

@@ -10,6 +10,74 @@ import {
} from "./monitor-inbox.test-harness.js";
const nowSeconds = (offsetMs = 0) => Math.floor((Date.now() + offsetMs) / 1000);
const DEFAULT_MESSAGES_CFG = {
messagePrefix: undefined,
responsePrefix: undefined,
} as const;
const TIMESTAMP_OFF_MESSAGES_CFG = {
...DEFAULT_MESSAGES_CFG,
timestampPrefix: false,
} as const;
async function flushInboundQueue() {
await new Promise((resolve) => setImmediate(resolve));
}
const createNotifyUpsert = (message: Record<string, unknown>) => ({
type: "notify",
messages: [message],
});
const createDmMessage = (params: { id: string; remoteJid: string; conversation: string }) => ({
key: {
id: params.id,
fromMe: false,
remoteJid: params.remoteJid,
},
message: { conversation: params.conversation },
messageTimestamp: nowSeconds(),
});
const createGroupMessage = (params: {
id: string;
remoteJid?: string;
participant: string;
conversation: string;
}) => ({
key: {
id: params.id,
fromMe: false,
remoteJid: params.remoteJid ?? "11111@g.us",
participant: params.participant,
},
message: { conversation: params.conversation },
messageTimestamp: nowSeconds(),
});
async function startWebInboxMonitor(params: {
config?: Record<string, unknown>;
sendReadReceipts?: boolean;
}) {
if (params.config) {
mockLoadConfig.mockReturnValue(params.config);
}
const onMessage = vi.fn();
const base = {
verbose: false,
accountId: DEFAULT_ACCOUNT_ID,
authDir: getAuthDir(),
onMessage,
};
const listener = await monitorWebInbox(
params.sendReadReceipts === undefined
? base
: {
...base,
sendReadReceipts: params.sendReadReceipts,
},
);
return { onMessage, listener, sock: getSock() };
}
describe("web monitor inbox", () => {
installWebMonitorInboxUnitTestHooks();
@@ -17,46 +85,32 @@ describe("web monitor inbox", () => {
it("blocks messages from unauthorized senders not in allowFrom", async () => {
// Test for auto-recovery fix: early allowFrom filtering prevents Bad MAC errors
// from unauthorized senders corrupting sessions
mockLoadConfig.mockReturnValue({
const config = {
channels: {
whatsapp: {
// Only allow +111
allowFrom: ["+111"],
},
},
messages: {
messagePrefix: undefined,
responsePrefix: undefined,
},
});
const onMessage = vi.fn();
const listener = await monitorWebInbox({
verbose: false,
accountId: DEFAULT_ACCOUNT_ID,
authDir: getAuthDir(),
onMessage,
});
const sock = getSock();
// Message from unauthorized sender +999 (not in allowFrom)
const upsert = {
type: "notify",
messages: [
{
key: {
id: "unauth1",
fromMe: false,
remoteJid: "999@s.whatsapp.net",
},
message: { conversation: "unauthorized message" },
messageTimestamp: nowSeconds(),
},
],
messages: DEFAULT_MESSAGES_CFG,
};
sock.ev.emit("messages.upsert", upsert);
await new Promise((resolve) => setImmediate(resolve));
const { onMessage, listener, sock } = await startWebInboxMonitor({
config,
});
// Message from unauthorized sender +999 (not in allowFrom)
sock.ev.emit(
"messages.upsert",
createNotifyUpsert(
createDmMessage({
id: "unauth1",
remoteJid: "999@s.whatsapp.net",
conversation: "unauthorized message",
}),
),
);
await flushInboundQueue();
// Should NOT call onMessage for unauthorized senders
expect(onMessage).not.toHaveBeenCalled();
@@ -74,41 +128,31 @@ describe("web monitor inbox", () => {
});
it("skips read receipts in self-chat mode", async () => {
mockLoadConfig.mockReturnValue({
const config = {
channels: {
whatsapp: {
// Self-chat heuristic: allowFrom includes selfE164 (+123).
allowFrom: ["+123"],
},
},
messages: {
messagePrefix: undefined,
responsePrefix: undefined,
},
});
const onMessage = vi.fn();
const listener = await monitorWebInbox({
verbose: false,
accountId: DEFAULT_ACCOUNT_ID,
authDir: getAuthDir(),
onMessage,
});
const sock = getSock();
const upsert = {
type: "notify",
messages: [
{
key: { id: "self1", fromMe: false, remoteJid: "123@s.whatsapp.net" },
message: { conversation: "self ping" },
messageTimestamp: nowSeconds(),
},
],
messages: DEFAULT_MESSAGES_CFG,
};
sock.ev.emit("messages.upsert", upsert);
await new Promise((resolve) => setImmediate(resolve));
const { onMessage, listener, sock } = await startWebInboxMonitor({
config,
});
sock.ev.emit(
"messages.upsert",
createNotifyUpsert(
createDmMessage({
id: "self1",
remoteJid: "123@s.whatsapp.net",
conversation: "self ping",
}),
),
);
await flushInboundQueue();
expect(onMessage).toHaveBeenCalledTimes(1);
expect(onMessage).toHaveBeenCalledWith(
@@ -120,29 +164,20 @@ describe("web monitor inbox", () => {
});
it("skips read receipts when disabled", async () => {
const onMessage = vi.fn();
const listener = await monitorWebInbox({
verbose: false,
accountId: DEFAULT_ACCOUNT_ID,
authDir: getAuthDir(),
onMessage,
const { onMessage, listener, sock } = await startWebInboxMonitor({
sendReadReceipts: false,
});
const sock = getSock();
const upsert = {
type: "notify",
messages: [
{
key: { id: "rr-off-1", fromMe: false, remoteJid: "222@s.whatsapp.net" },
message: { conversation: "read receipts off" },
messageTimestamp: nowSeconds(),
},
],
};
sock.ev.emit("messages.upsert", upsert);
await new Promise((resolve) => setImmediate(resolve));
sock.ev.emit(
"messages.upsert",
createNotifyUpsert(
createDmMessage({
id: "rr-off-1",
remoteJid: "222@s.whatsapp.net",
conversation: "read receipts off",
}),
),
);
await flushInboundQueue();
expect(onMessage).toHaveBeenCalledTimes(1);
expect(sock.readMessages).not.toHaveBeenCalled();
@@ -151,41 +186,23 @@ describe("web monitor inbox", () => {
});
it("lets group messages through even when sender not in allowFrom", async () => {
mockLoadConfig.mockReturnValue({
channels: { whatsapp: { allowFrom: ["+1234"], groupPolicy: "open" } },
messages: {
messagePrefix: undefined,
responsePrefix: undefined,
const { onMessage, listener, sock } = await startWebInboxMonitor({
config: {
channels: { whatsapp: { allowFrom: ["+1234"], groupPolicy: "open" } },
messages: DEFAULT_MESSAGES_CFG,
},
});
const onMessage = vi.fn();
const listener = await monitorWebInbox({
verbose: false,
accountId: DEFAULT_ACCOUNT_ID,
authDir: getAuthDir(),
onMessage,
});
const sock = getSock();
const upsert = {
type: "notify",
messages: [
{
key: {
id: "grp3",
fromMe: false,
remoteJid: "11111@g.us",
participant: "999@s.whatsapp.net",
},
message: { conversation: "unauthorized group message" },
messageTimestamp: nowSeconds(),
},
],
};
sock.ev.emit("messages.upsert", upsert);
await new Promise((resolve) => setImmediate(resolve));
sock.ev.emit(
"messages.upsert",
createNotifyUpsert(
createGroupMessage({
id: "grp3",
participant: "999@s.whatsapp.net",
conversation: "unauthorized group message",
}),
),
);
await flushInboundQueue();
expect(onMessage).toHaveBeenCalledTimes(1);
const payload = onMessage.mock.calls[0][0];
@@ -196,42 +213,23 @@ describe("web monitor inbox", () => {
});
it("blocks all group messages when groupPolicy is 'disabled'", async () => {
mockLoadConfig.mockReturnValue({
channels: { whatsapp: { allowFrom: ["+1234"], groupPolicy: "disabled" } },
messages: {
messagePrefix: undefined,
responsePrefix: undefined,
timestampPrefix: false,
const { onMessage, listener, sock } = await startWebInboxMonitor({
config: {
channels: { whatsapp: { allowFrom: ["+1234"], groupPolicy: "disabled" } },
messages: TIMESTAMP_OFF_MESSAGES_CFG,
},
});
const onMessage = vi.fn();
const listener = await monitorWebInbox({
verbose: false,
accountId: DEFAULT_ACCOUNT_ID,
authDir: getAuthDir(),
onMessage,
});
const sock = getSock();
const upsert = {
type: "notify",
messages: [
{
key: {
id: "grp-disabled",
fromMe: false,
remoteJid: "11111@g.us",
participant: "999@s.whatsapp.net",
},
message: { conversation: "group message should be blocked" },
messageTimestamp: nowSeconds(),
},
],
};
sock.ev.emit("messages.upsert", upsert);
await new Promise((resolve) => setImmediate(resolve));
sock.ev.emit(
"messages.upsert",
createNotifyUpsert(
createGroupMessage({
id: "grp-disabled",
participant: "999@s.whatsapp.net",
conversation: "group message should be blocked",
}),
),
);
await flushInboundQueue();
// Should NOT call onMessage because groupPolicy is disabled
expect(onMessage).not.toHaveBeenCalled();
@@ -240,47 +238,28 @@ describe("web monitor inbox", () => {
});
it("blocks group messages from senders not in groupAllowFrom when groupPolicy is 'allowlist'", async () => {
mockLoadConfig.mockReturnValue({
channels: {
whatsapp: {
groupAllowFrom: ["+1234"], // Does not include +999
groupPolicy: "allowlist",
},
},
messages: {
messagePrefix: undefined,
responsePrefix: undefined,
timestampPrefix: false,
},
});
const onMessage = vi.fn();
const listener = await monitorWebInbox({
verbose: false,
accountId: DEFAULT_ACCOUNT_ID,
authDir: getAuthDir(),
onMessage,
});
const sock = getSock();
const upsert = {
type: "notify",
messages: [
{
key: {
id: "grp-allowlist-blocked",
fromMe: false,
remoteJid: "11111@g.us",
participant: "999@s.whatsapp.net",
const { onMessage, listener, sock } = await startWebInboxMonitor({
config: {
channels: {
whatsapp: {
groupAllowFrom: ["+1234"], // Does not include +999
groupPolicy: "allowlist",
},
message: { conversation: "unauthorized group sender" },
messageTimestamp: nowSeconds(),
},
],
};
sock.ev.emit("messages.upsert", upsert);
await new Promise((resolve) => setImmediate(resolve));
messages: TIMESTAMP_OFF_MESSAGES_CFG,
},
});
sock.ev.emit(
"messages.upsert",
createNotifyUpsert(
createGroupMessage({
id: "grp-allowlist-blocked",
participant: "999@s.whatsapp.net",
conversation: "unauthorized group sender",
}),
),
);
await flushInboundQueue();
// Should NOT call onMessage because sender +999 not in groupAllowFrom
expect(onMessage).not.toHaveBeenCalled();
@@ -289,47 +268,28 @@ describe("web monitor inbox", () => {
});
it("allows group messages from senders in groupAllowFrom when groupPolicy is 'allowlist'", async () => {
mockLoadConfig.mockReturnValue({
channels: {
whatsapp: {
groupAllowFrom: ["+15551234567"], // Includes the sender
groupPolicy: "allowlist",
},
},
messages: {
messagePrefix: undefined,
responsePrefix: undefined,
timestampPrefix: false,
},
});
const onMessage = vi.fn();
const listener = await monitorWebInbox({
verbose: false,
accountId: DEFAULT_ACCOUNT_ID,
authDir: getAuthDir(),
onMessage,
});
const sock = getSock();
const upsert = {
type: "notify",
messages: [
{
key: {
id: "grp-allowlist-allowed",
fromMe: false,
remoteJid: "11111@g.us",
participant: "15551234567@s.whatsapp.net",
const { onMessage, listener, sock } = await startWebInboxMonitor({
config: {
channels: {
whatsapp: {
groupAllowFrom: ["+15551234567"], // Includes the sender
groupPolicy: "allowlist",
},
message: { conversation: "authorized group sender" },
messageTimestamp: nowSeconds(),
},
],
};
sock.ev.emit("messages.upsert", upsert);
await new Promise((resolve) => setImmediate(resolve));
messages: TIMESTAMP_OFF_MESSAGES_CFG,
},
});
sock.ev.emit(
"messages.upsert",
createNotifyUpsert(
createGroupMessage({
id: "grp-allowlist-allowed",
participant: "15551234567@s.whatsapp.net",
conversation: "authorized group sender",
}),
),
);
await flushInboundQueue();
// Should call onMessage because sender is in groupAllowFrom
expect(onMessage).toHaveBeenCalledTimes(1);
@@ -341,47 +301,29 @@ describe("web monitor inbox", () => {
});
it("allows all group senders with wildcard in groupPolicy allowlist", async () => {
mockLoadConfig.mockReturnValue({
channels: {
whatsapp: {
groupAllowFrom: ["*"], // Wildcard allows everyone
groupPolicy: "allowlist",
},
},
messages: {
messagePrefix: undefined,
responsePrefix: undefined,
timestampPrefix: false,
},
});
const onMessage = vi.fn();
const listener = await monitorWebInbox({
verbose: false,
accountId: DEFAULT_ACCOUNT_ID,
authDir: getAuthDir(),
onMessage,
});
const sock = getSock();
const upsert = {
type: "notify",
messages: [
{
key: {
id: "grp-wildcard-test",
fromMe: false,
remoteJid: "22222@g.us",
participant: "9999999999@s.whatsapp.net", // Random sender
const { onMessage, listener, sock } = await startWebInboxMonitor({
config: {
channels: {
whatsapp: {
groupAllowFrom: ["*"], // Wildcard allows everyone
groupPolicy: "allowlist",
},
message: { conversation: "wildcard group sender" },
messageTimestamp: nowSeconds(),
},
],
};
sock.ev.emit("messages.upsert", upsert);
await new Promise((resolve) => setImmediate(resolve));
messages: TIMESTAMP_OFF_MESSAGES_CFG,
},
});
sock.ev.emit(
"messages.upsert",
createNotifyUpsert(
createGroupMessage({
id: "grp-wildcard-test",
remoteJid: "22222@g.us",
participant: "9999999999@s.whatsapp.net",
conversation: "wildcard group sender",
}),
),
);
await flushInboundQueue();
// Should call onMessage because wildcard allows all senders
expect(onMessage).toHaveBeenCalledTimes(1);
@@ -392,46 +334,27 @@ describe("web monitor inbox", () => {
});
it("blocks group messages when groupPolicy allowlist has no groupAllowFrom", async () => {
mockLoadConfig.mockReturnValue({
channels: {
whatsapp: {
groupPolicy: "allowlist",
},
},
messages: {
messagePrefix: undefined,
responsePrefix: undefined,
timestampPrefix: false,
},
});
const onMessage = vi.fn();
const listener = await monitorWebInbox({
verbose: false,
accountId: DEFAULT_ACCOUNT_ID,
authDir: getAuthDir(),
onMessage,
});
const sock = getSock();
const upsert = {
type: "notify",
messages: [
{
key: {
id: "grp-allowlist-empty",
fromMe: false,
remoteJid: "11111@g.us",
participant: "999@s.whatsapp.net",
const { onMessage, listener, sock } = await startWebInboxMonitor({
config: {
channels: {
whatsapp: {
groupPolicy: "allowlist",
},
message: { conversation: "blocked by empty allowlist" },
messageTimestamp: nowSeconds(),
},
],
};
sock.ev.emit("messages.upsert", upsert);
await new Promise((resolve) => setImmediate(resolve));
messages: TIMESTAMP_OFF_MESSAGES_CFG,
},
});
sock.ev.emit(
"messages.upsert",
createNotifyUpsert(
createGroupMessage({
id: "grp-allowlist-empty",
participant: "999@s.whatsapp.net",
conversation: "blocked by empty allowlist",
}),
),
);
await flushInboundQueue();
expect(onMessage).not.toHaveBeenCalled();

View File

@@ -12,18 +12,54 @@ import {
describe("web monitor inbox", () => {
installWebMonitorInboxUnitTestHooks();
type InboxOnMessage = NonNullable<Parameters<typeof monitorWebInbox>[0]["onMessage"]>;
async function tick() {
await new Promise((resolve) => setImmediate(resolve));
}
async function startInboxMonitor(onMessage: InboxOnMessage) {
const listener = await monitorWebInbox({
verbose: false,
onMessage,
accountId: DEFAULT_ACCOUNT_ID,
authDir: getAuthDir(),
});
return { listener, sock: getSock() };
}
function buildMessageUpsert(params: {
id: string;
remoteJid: string;
text: string;
timestamp: number;
pushName?: string;
participant?: string;
}) {
return {
type: "notify",
messages: [
{
key: {
id: params.id,
fromMe: false,
remoteJid: params.remoteJid,
participant: params.participant,
},
message: { conversation: params.text },
messageTimestamp: params.timestamp,
pushName: params.pushName,
},
],
};
}
async function expectQuotedReplyContext(quotedMessage: unknown) {
const onMessage = vi.fn(async (msg) => {
await msg.reply("pong");
});
const listener = await monitorWebInbox({ verbose: false, onMessage });
const sock = getSock();
const { listener, sock } = await startInboxMonitor(onMessage);
const upsert = {
type: "notify",
messages: [
@@ -68,25 +104,15 @@ describe("web monitor inbox", () => {
await msg.reply("pong");
});
const listener = await monitorWebInbox({
verbose: false,
onMessage,
accountId: DEFAULT_ACCOUNT_ID,
authDir: getAuthDir(),
});
const sock = getSock();
const { listener, sock } = await startInboxMonitor(onMessage);
expect(sock.sendPresenceUpdate).toHaveBeenCalledWith("available");
const upsert = {
type: "notify",
messages: [
{
key: { id: "abc", fromMe: false, remoteJid: "999@s.whatsapp.net" },
message: { conversation: "ping" },
messageTimestamp: 1_700_000_000,
pushName: "Tester",
},
],
};
const upsert = buildMessageUpsert({
id: "abc",
remoteJid: "999@s.whatsapp.net",
text: "ping",
timestamp: 1_700_000_000,
pushName: "Tester",
});
sock.ev.emit("messages.upsert", upsert);
await tick();
@@ -116,24 +142,14 @@ describe("web monitor inbox", () => {
return;
});
const listener = await monitorWebInbox({
verbose: false,
onMessage,
accountId: DEFAULT_ACCOUNT_ID,
authDir: getAuthDir(),
const { listener, sock } = await startInboxMonitor(onMessage);
const upsert = buildMessageUpsert({
id: "abc",
remoteJid: "999@s.whatsapp.net",
text: "ping",
timestamp: 1_700_000_000,
pushName: "Tester",
});
const sock = getSock();
const upsert = {
type: "notify",
messages: [
{
key: { id: "abc", fromMe: false, remoteJid: "999@s.whatsapp.net" },
message: { conversation: "ping" },
messageTimestamp: 1_700_000_000,
pushName: "Tester",
},
],
};
sock.ev.emit("messages.upsert", upsert);
sock.ev.emit("messages.upsert", upsert);
@@ -149,26 +165,16 @@ describe("web monitor inbox", () => {
return;
});
const listener = await monitorWebInbox({
verbose: false,
onMessage,
accountId: DEFAULT_ACCOUNT_ID,
authDir: getAuthDir(),
});
const sock = getSock();
const { listener, sock } = await startInboxMonitor(onMessage);
const getPNForLID = vi.spyOn(sock.signalRepository.lidMapping, "getPNForLID");
sock.signalRepository.lidMapping.getPNForLID.mockResolvedValueOnce("999:0@s.whatsapp.net");
const upsert = {
type: "notify",
messages: [
{
key: { id: "abc", fromMe: false, remoteJid: "999@lid" },
message: { conversation: "ping" },
messageTimestamp: 1_700_000_000,
pushName: "Tester",
},
],
};
const upsert = buildMessageUpsert({
id: "abc",
remoteJid: "999@lid",
text: "ping",
timestamp: 1_700_000_000,
pushName: "Tester",
});
sock.ev.emit("messages.upsert", upsert);
await tick();
@@ -190,25 +196,15 @@ describe("web monitor inbox", () => {
JSON.stringify("1555"),
);
const listener = await monitorWebInbox({
verbose: false,
onMessage,
accountId: DEFAULT_ACCOUNT_ID,
authDir: getAuthDir(),
});
const sock = getSock();
const { listener, sock } = await startInboxMonitor(onMessage);
const getPNForLID = vi.spyOn(sock.signalRepository.lidMapping, "getPNForLID");
const upsert = {
type: "notify",
messages: [
{
key: { id: "abc", fromMe: false, remoteJid: "555@lid" },
message: { conversation: "ping" },
messageTimestamp: 1_700_000_000,
pushName: "Tester",
},
],
};
const upsert = buildMessageUpsert({
id: "abc",
remoteJid: "555@lid",
text: "ping",
timestamp: 1_700_000_000,
pushName: "Tester",
});
sock.ev.emit("messages.upsert", upsert);
await tick();
@@ -226,30 +222,16 @@ describe("web monitor inbox", () => {
return;
});
const listener = await monitorWebInbox({
verbose: false,
onMessage,
accountId: DEFAULT_ACCOUNT_ID,
authDir: getAuthDir(),
});
const sock = getSock();
const { listener, sock } = await startInboxMonitor(onMessage);
const getPNForLID = vi.spyOn(sock.signalRepository.lidMapping, "getPNForLID");
sock.signalRepository.lidMapping.getPNForLID.mockResolvedValueOnce("444:0@s.whatsapp.net");
const upsert = {
type: "notify",
messages: [
{
key: {
id: "abc",
fromMe: false,
remoteJid: "123@g.us",
participant: "444@lid",
},
message: { conversation: "ping" },
messageTimestamp: 1_700_000_000,
},
],
};
const upsert = buildMessageUpsert({
id: "abc",
remoteJid: "123@g.us",
participant: "444@lid",
text: "ping",
timestamp: 1_700_000_000,
});
sock.ev.emit("messages.upsert", upsert);
await tick();
@@ -277,13 +259,7 @@ describe("web monitor inbox", () => {
}
});
const listener = await monitorWebInbox({
verbose: false,
onMessage,
accountId: DEFAULT_ACCOUNT_ID,
authDir: getAuthDir(),
});
const sock = getSock();
const { listener, sock } = await startInboxMonitor(onMessage);
const upsert = {
type: "notify",
messages: [

View File

@@ -8,6 +8,39 @@ import { baileys, getLastSocket, resetBaileysMocks, resetLoadConfigMock } from "
const { createWaSocket, formatError, logWebSelfId, waitForWaConnection } =
await import("./session.js");
function mockCredsJsonSpies(readContents: string) {
const credsSuffix = path.join(".openclaw", "credentials", "whatsapp", "default", "creds.json");
const copySpy = vi.spyOn(fsSync, "copyFileSync").mockImplementation(() => {});
const existsSpy = vi.spyOn(fsSync, "existsSync").mockImplementation((p) => {
if (typeof p !== "string") {
return false;
}
return p.endsWith(credsSuffix);
});
const statSpy = vi.spyOn(fsSync, "statSync").mockImplementation((p) => {
if (typeof p === "string" && p.endsWith(credsSuffix)) {
return { isFile: () => true, size: 12 } as never;
}
throw new Error(`unexpected statSync path: ${String(p)}`);
});
const readSpy = vi.spyOn(fsSync, "readFileSync").mockImplementation((p) => {
if (typeof p === "string" && p.endsWith(credsSuffix)) {
return readContents as never;
}
throw new Error(`unexpected readFileSync path: ${String(p)}`);
});
return {
copySpy,
credsSuffix,
restore: () => {
copySpy.mockRestore();
existsSpy.mockRestore();
statSpy.mockRestore();
readSpy.mockRestore();
},
};
}
describe("web session", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -108,27 +141,7 @@ describe("web session", () => {
});
it("does not clobber creds backup when creds.json is corrupted", async () => {
const credsSuffix = path.join(".openclaw", "credentials", "whatsapp", "default", "creds.json");
const copySpy = vi.spyOn(fsSync, "copyFileSync").mockImplementation(() => {});
const existsSpy = vi.spyOn(fsSync, "existsSync").mockImplementation((p) => {
if (typeof p !== "string") {
return false;
}
return p.endsWith(credsSuffix);
});
const statSpy = vi.spyOn(fsSync, "statSync").mockImplementation((p) => {
if (typeof p === "string" && p.endsWith(credsSuffix)) {
return { isFile: () => true, size: 12 } as never;
}
throw new Error(`unexpected statSync path: ${String(p)}`);
});
const readSpy = vi.spyOn(fsSync, "readFileSync").mockImplementation((p) => {
if (typeof p === "string" && p.endsWith(credsSuffix)) {
return "{" as never;
}
throw new Error(`unexpected readFileSync path: ${String(p)}`);
});
const creds = mockCredsJsonSpies("{");
await createWaSocket(false, false);
const sock = getLastSocket();
@@ -137,13 +150,10 @@ describe("web session", () => {
sock.ev.emit("creds.update", {});
await new Promise<void>((resolve) => setImmediate(resolve));
expect(copySpy).not.toHaveBeenCalled();
expect(creds.copySpy).not.toHaveBeenCalled();
expect(saveCreds).toHaveBeenCalled();
copySpy.mockRestore();
existsSpy.mockRestore();
statSpy.mockRestore();
readSpy.mockRestore();
creds.restore();
});
it("serializes creds.update saves to avoid overlapping writes", async () => {
@@ -186,7 +196,7 @@ describe("web session", () => {
});
it("rotates creds backup when creds.json is valid JSON", async () => {
const credsSuffix = path.join(".openclaw", "credentials", "whatsapp", "default", "creds.json");
const creds = mockCredsJsonSpies("{}");
const backupSuffix = path.join(
".openclaw",
"credentials",
@@ -195,26 +205,6 @@ describe("web session", () => {
"creds.json.bak",
);
const copySpy = vi.spyOn(fsSync, "copyFileSync").mockImplementation(() => {});
const existsSpy = vi.spyOn(fsSync, "existsSync").mockImplementation((p) => {
if (typeof p !== "string") {
return false;
}
return p.endsWith(credsSuffix);
});
const statSpy = vi.spyOn(fsSync, "statSync").mockImplementation((p) => {
if (typeof p === "string" && p.endsWith(credsSuffix)) {
return { isFile: () => true, size: 12 } as never;
}
throw new Error(`unexpected statSync path: ${String(p)}`);
});
const readSpy = vi.spyOn(fsSync, "readFileSync").mockImplementation((p) => {
if (typeof p === "string" && p.endsWith(credsSuffix)) {
return "{}" as never;
}
throw new Error(`unexpected readFileSync path: ${String(p)}`);
});
await createWaSocket(false, false);
const sock = getLastSocket();
const saveCreds = (await baileys.useMultiFileAuthState.mock.results[0].value).saveCreds;
@@ -222,15 +212,12 @@ describe("web session", () => {
sock.ev.emit("creds.update", {});
await new Promise<void>((resolve) => setImmediate(resolve));
expect(copySpy).toHaveBeenCalledTimes(1);
const args = copySpy.mock.calls[0] ?? [];
expect(String(args[0] ?? "")).toContain(credsSuffix);
expect(creds.copySpy).toHaveBeenCalledTimes(1);
const args = creds.copySpy.mock.calls[0] ?? [];
expect(String(args[0] ?? "")).toContain(creds.credsSuffix);
expect(String(args[1] ?? "")).toContain(backupSuffix);
expect(saveCreds).toHaveBeenCalled();
copySpy.mockRestore();
existsSpy.mockRestore();
statSpy.mockRestore();
readSpy.mockRestore();
creds.restore();
});
});