mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-07 20:51:23 +00:00
refactor(channels): dedupe transport and gateway test scaffolds
This commit is contained in:
@@ -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:");
|
||||
|
||||
@@ -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:");
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user