Files
openclaw/extensions/whatsapp/src/test-helpers.ts
2026-04-03 16:03:32 +01:00

517 lines
18 KiB
TypeScript

import fsSync from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { vi } from "vitest";
import type { MockBaileysSocket } from "../../../test/mocks/baileys.js";
import { createMockBaileys } from "../../../test/mocks/baileys.js";
// Use globalThis to store the mock config so it survives vi.mock hoisting
const CONFIG_KEY = Symbol.for("openclaw:testConfigMock");
const DEFAULT_CONFIG = {
channels: {
whatsapp: {
// Tests can override; default remains open to avoid surprising fixtures
allowFrom: ["*"],
},
},
messages: {
messagePrefix: undefined,
responsePrefix: undefined,
},
};
// Initialize default if not set
if (!(globalThis as Record<symbol, unknown>)[CONFIG_KEY]) {
(globalThis as Record<symbol, unknown>)[CONFIG_KEY] = () => DEFAULT_CONFIG;
}
export function setLoadConfigMock(fn: unknown) {
(globalThis as Record<symbol, unknown>)[CONFIG_KEY] = typeof fn === "function" ? fn : () => fn;
}
export function resetLoadConfigMock() {
(globalThis as Record<symbol, unknown>)[CONFIG_KEY] = () => DEFAULT_CONFIG;
}
function resolveStorePathFallback(store?: string, opts?: { agentId?: string }) {
if (!store) {
const agentId = (opts?.agentId?.trim() || "main").toLowerCase();
return path.join(
process.env.HOME ?? "/tmp",
".openclaw",
"agents",
agentId,
"sessions",
"sessions.json",
);
}
return path.resolve(store.replaceAll("{agentId}", opts?.agentId?.trim() || "main"));
}
function loadConfigMock() {
const getter = (globalThis as Record<symbol, unknown>)[CONFIG_KEY];
if (typeof getter === "function") {
return getter();
}
return DEFAULT_CONFIG;
}
async function updateLastRouteMock(params: {
storePath: string;
sessionKey: string;
deliveryContext: { channel: string; to: string; accountId?: string };
}) {
const raw = await fs.readFile(params.storePath, "utf8").catch(() => "{}");
const store = JSON.parse(raw) as Record<string, Record<string, unknown>>;
const current = store[params.sessionKey] ?? {};
store[params.sessionKey] = {
...current,
lastChannel: params.deliveryContext.channel,
lastTo: params.deliveryContext.to,
lastAccountId: params.deliveryContext.accountId,
};
await fs.writeFile(params.storePath, JSON.stringify(store));
}
function loadSessionStoreMock(storePath: string) {
try {
return JSON.parse(fsSync.readFileSync(storePath, "utf8")) as Record<string, unknown>;
} catch {
return {};
}
}
function resolveChannelContextVisibilityModeMock(params: {
cfg: {
channels?: Record<
string,
{ contextVisibility?: string; accounts?: Record<string, { contextVisibility?: string }> }
>;
};
channel: string;
accountId?: string | null;
configuredContextVisibility?: string;
}) {
if (params.configuredContextVisibility) {
return params.configuredContextVisibility;
}
const channelConfig = params.cfg.channels?.[params.channel];
const accountMode =
(params.accountId
? channelConfig?.accounts?.[params.accountId]?.contextVisibility
: undefined) ?? channelConfig?.accounts?.main?.contextVisibility;
return accountMode ?? channelConfig?.contextVisibility ?? "all";
}
function resolveGroupSessionKeyMock(ctx: { From?: string; ChatType?: string; Provider?: string }) {
const from = ctx.From?.trim() ?? "";
const chatType = ctx.ChatType?.trim().toLowerCase();
if (!from) {
return null;
}
const isGroup =
chatType === "group" ||
chatType === "channel" ||
from.includes(":group:") ||
from.endsWith("@g.us");
if (!isGroup) {
return null;
}
return {
key: `whatsapp:group:${from.toLowerCase()}`,
channel: ctx.Provider?.trim().toLowerCase() || "whatsapp",
id: from.toLowerCase(),
chatType: chatType === "channel" ? "channel" : "group",
};
}
function resolveChannelGroupPolicyMock(params: {
cfg: {
channels?: {
whatsapp?: {
groups?: Record<string, Record<string, unknown>>;
groupPolicy?: string;
allowFrom?: string[];
groupAllowFrom?: string[];
};
};
};
groupId?: string | null;
hasGroupAllowFrom?: boolean;
}) {
const whatsappCfg = params.cfg.channels?.whatsapp;
const groups = whatsappCfg?.groups;
const groupConfig = params.groupId ? groups?.[params.groupId] : undefined;
const defaultConfig = groups?.["*"];
const hasGroups = Boolean(groups && Object.keys(groups).length > 0);
const allowAll = Boolean(defaultConfig);
const groupPolicy = whatsappCfg?.groupPolicy ?? "disabled";
const senderFilterBypass =
groupPolicy === "allowlist" && !hasGroups && Boolean(params.hasGroupAllowFrom);
const allowed =
groupPolicy === "disabled"
? false
: groupPolicy !== "allowlist" || allowAll || Boolean(groupConfig) || senderFilterBypass;
return {
allowlistEnabled: groupPolicy === "allowlist" || hasGroups,
allowed,
groupConfig,
defaultConfig,
};
}
function resolveChannelGroupRequireMentionMock(params: {
cfg: {
channels?: {
whatsapp?: {
groups?: Record<string, { requireMention?: boolean }>;
};
};
};
groupId?: string | null;
requireMentionOverride?: boolean;
}) {
const groups = params.cfg.channels?.whatsapp?.groups;
const groupConfig = params.groupId ? groups?.[params.groupId] : undefined;
const defaultConfig = groups?.["*"];
if (typeof groupConfig?.requireMention === "boolean") {
return groupConfig.requireMention;
}
if (typeof defaultConfig?.requireMention === "boolean") {
return defaultConfig.requireMention;
}
if (typeof params.requireMentionOverride === "boolean") {
return params.requireMentionOverride;
}
return true;
}
vi.mock("./auto-reply/config.runtime.js", () => ({
loadConfig: loadConfigMock,
updateLastRoute: updateLastRouteMock,
loadSessionStore: loadSessionStoreMock,
recordSessionMetaFromInbound: async () => undefined,
resolveStorePath: resolveStorePathFallback,
evaluateSessionFreshness: () => ({ fresh: false }),
resolveChannelContextVisibilityMode: resolveChannelContextVisibilityModeMock,
resolveChannelGroupPolicy: resolveChannelGroupPolicyMock,
resolveChannelGroupRequireMention: resolveChannelGroupRequireMentionMock,
resolveChannelResetConfig: () => undefined,
resolveGroupSessionKey: resolveGroupSessionKeyMock,
resolveSessionKey: (_scope: string, msg: { From?: string }, mainKey?: string) =>
msg.From?.trim() || mainKey || "main",
resolveSessionResetPolicy: () => undefined,
resolveSessionResetType: () => "message",
resolveThreadFlag: () => false,
}));
vi.mock("./inbound/runtime-api.js", () => ({
DisconnectReason: { loggedOut: 401 },
isJidGroup: (jid: string) => typeof jid === "string" && jid.endsWith("@g.us"),
normalizeMessageContent: (message: unknown) => message,
downloadMediaMessage: vi.fn().mockResolvedValue(Buffer.from("img")),
saveMediaBuffer: vi.fn().mockImplementation(async (_buf: Buffer, contentType?: string) => ({
id: "mid",
path: "/tmp/mid",
size: _buf.length,
contentType,
})),
}));
vi.mock("./auto-reply/monitor/inbound-dispatch.runtime.js", () => ({
createChannelReplyPipeline: () => ({
onModelSelected: undefined,
responsePrefix: undefined,
}),
dispatchReplyWithBufferedBlockDispatcher: vi.fn(
async (params: {
ctx: Record<string, unknown>;
replyResolver: (ctx: Record<string, unknown>) => Promise<Record<string, unknown> | undefined>;
dispatcherOptions: {
deliver: (
payload: Record<string, unknown>,
info: { kind: "tool" | "block" | "final" },
) => Promise<void>;
onReplyStart?: (() => Promise<void>) | (() => void);
};
}) => {
await params.dispatcherOptions.onReplyStart?.();
const payload = await params.replyResolver(params.ctx);
if (!payload || typeof payload !== "object") {
return {
queuedFinal: false,
counts: { tool: 0, block: 0, final: 0 },
};
}
const text = typeof payload.text === "string" ? payload.text.trim() : "";
const hasMedia =
typeof payload.mediaUrl === "string" ||
typeof payload.mediaPath === "string" ||
typeof payload.fileUrl === "string";
if (!text && !hasMedia) {
return {
queuedFinal: false,
counts: { tool: 0, block: 0, final: 0 },
};
}
await params.dispatcherOptions.deliver(payload, { kind: "final" });
return {
queuedFinal: true,
counts: { tool: 0, block: 0, final: 1 },
};
},
),
finalizeInboundContext: <T>(ctx: T) => ctx,
getAgentScopedMediaLocalRoots: () => [] as string[],
jidToE164: (jid: string) => {
const digits = jid.replace(/\D+/g, "");
return digits ? `+${digits}` : null;
},
logVerbose: (_msg: string) => undefined,
resolveChunkMode: () => undefined,
resolveIdentityNamePrefix: (cfg: { messages?: { responsePrefix?: string } }, _agentId: string) =>
cfg.messages?.responsePrefix,
resolveInboundLastRouteSessionKey: (params: { sessionKey: string }) => params.sessionKey,
resolveMarkdownTableMode: () => undefined,
resolveSendableOutboundReplyParts: (payload: Record<string, unknown>) => ({
text: typeof payload.text === "string" ? payload.text : "",
hasMedia:
typeof payload.mediaUrl === "string" ||
typeof payload.mediaPath === "string" ||
typeof payload.fileUrl === "string",
}),
resolveTextChunkLimit: () => 64_000,
shouldLogVerbose: () => false,
toLocationContext: (location: unknown) => ({ Location: location }),
}));
vi.mock("./auto-reply/monitor/runtime-api.js", () => ({
buildHistoryContextFromEntries: (params: {
entries: Array<{ sender?: string; body: string; timestamp?: number }>;
currentMessage: string;
formatEntry?: (entry: { sender?: string; body: string; timestamp?: number }) => string;
}) => {
const rendered = params.entries
.map((entry) => params.formatEntry?.(entry) ?? `${entry.sender ?? "Unknown"}: ${entry.body}`)
.join("\n");
return rendered
? `Chat messages since your last reply:\n${rendered}\n\n${params.currentMessage}`
: params.currentMessage;
},
createChannelReplyPipeline: () => ({
onModelSelected: undefined,
responsePrefix: undefined,
}),
dispatchReplyWithBufferedBlockDispatcher: vi.fn(
async (params: {
ctx: Record<string, unknown>;
replyResolver: (ctx: Record<string, unknown>) => Promise<Record<string, unknown> | undefined>;
dispatcherOptions: {
deliver: (
payload: Record<string, unknown>,
info: { kind: "tool" | "block" | "final" },
) => Promise<void>;
onReplyStart?: (() => Promise<void>) | (() => void);
};
}) => {
await params.dispatcherOptions.onReplyStart?.();
const payload = await params.replyResolver(params.ctx);
if (!payload || typeof payload !== "object") {
return {
queuedFinal: false,
counts: { tool: 0, block: 0, final: 0 },
};
}
const text = typeof payload.text === "string" ? payload.text.trim() : "";
const hasMedia =
typeof payload.mediaUrl === "string" ||
typeof payload.mediaPath === "string" ||
typeof payload.fileUrl === "string";
if (!text && !hasMedia) {
return {
queuedFinal: false,
counts: { tool: 0, block: 0, final: 0 },
};
}
await params.dispatcherOptions.deliver(payload, { kind: "final" });
return {
queuedFinal: true,
counts: { tool: 0, block: 0, final: 1 },
};
},
),
finalizeInboundContext: <T>(ctx: T) => ctx,
formatInboundEnvelope: (params: { body: string; senderLabel?: string }) =>
`${params.senderLabel ? `${params.senderLabel}: ` : ""}${params.body}`,
getAgentScopedMediaLocalRoots: () => [] as string[],
jidToE164: (jid: string) => {
const digits = jid.replace(/\D+/g, "");
return digits ? `+${digits}` : null;
},
logVerbose: (_msg: string) => undefined,
normalizeE164: (value: string) => {
const digits = String(value).replace(/\D+/g, "");
return digits ? `+${digits}` : null;
},
readStoreAllowFromForDmPolicy: async () => [] as string[],
recordSessionMetaFromInbound: async () => undefined,
resolveChannelContextVisibilityMode: resolveChannelContextVisibilityModeMock,
resolveChunkMode: () => undefined,
resolveIdentityNamePrefix: (cfg: { messages?: { responsePrefix?: string } }, _agentId: string) =>
cfg.messages?.responsePrefix,
resolveInboundLastRouteSessionKey: (params: { sessionKey: string }) => params.sessionKey,
resolveInboundSessionEnvelopeContext: (params: {
cfg: { session?: { store?: string } };
agentId: string;
}) => ({
storePath: resolveStorePathFallback(params.cfg.session?.store, { agentId: params.agentId }),
envelopeOptions: {},
previousTimestamp: undefined,
}),
resolveMarkdownTableMode: () => undefined,
resolvePinnedMainDmOwnerFromAllowlist: (params: {
allowFrom?: string[];
normalizeEntry: (entry: string) => string | null;
}) => {
const first = params.allowFrom?.[0];
return first ? params.normalizeEntry(first) : null;
},
resolveDmGroupAccessWithCommandGate: () => ({ commandAuthorized: true }),
resolveSendableOutboundReplyParts: (payload: Record<string, unknown>) => ({
text: typeof payload.text === "string" ? payload.text : "",
hasMedia:
typeof payload.mediaUrl === "string" ||
typeof payload.mediaPath === "string" ||
typeof payload.fileUrl === "string",
}),
resolveTextChunkLimit: () => 64_000,
shouldComputeCommandAuthorized: () => false,
shouldLogVerbose: () => false,
toLocationContext: (location: unknown) => ({ Location: location }),
}));
vi.mock("./auto-reply/monitor/group-gating.runtime.js", () => ({
hasControlCommand: (body: string) => body.trim().startsWith("/"),
normalizeE164: (value: string) => {
const digits = String(value).replace(/\D+/g, "");
return digits ? `+${digits}` : null;
},
parseActivationCommand: (body: string) => ({
hasCommand: body.trim().startsWith("/"),
}),
recordPendingHistoryEntryIfEnabled: (params: {
historyMap: Map<string, unknown[]>;
historyKey: string;
limit: number;
entry: unknown;
}) => {
const current = params.historyMap.get(params.historyKey) ?? [];
const next = [...current, params.entry].slice(-params.limit);
params.historyMap.set(params.historyKey, next);
},
resolveMentionGating: (params: {
requireMention: boolean;
wasMentioned: boolean;
implicitMention?: boolean;
shouldBypassMention?: boolean;
}) => {
const effectiveWasMentioned =
params.wasMentioned || Boolean(params.implicitMention) || Boolean(params.shouldBypassMention);
return {
effectiveWasMentioned,
shouldSkip: params.requireMention && !effectiveWasMentioned,
};
},
}));
vi.mock("./auto-reply/monitor/group-activation.runtime.js", () => ({
normalizeGroupActivation: (value: unknown) =>
value === "always" || value === "mention" ? value : undefined,
}));
vi.mock("./auto-reply/monitor/message-line.runtime.js", () => ({
formatInboundEnvelope: (params: {
body: string;
sender?: { name?: string; e164?: string; id?: string };
}) => {
const sender = params.sender?.name ?? params.sender?.e164 ?? params.sender?.id ?? undefined;
return sender ? `${sender}: ${params.body}` : params.body;
},
resolveMessagePrefix: (
cfg: {
channels?: { whatsapp?: { messagePrefix?: string; allowFrom?: string[] } };
messages?: { messagePrefix?: string };
},
_agentId: string,
params?: { configured?: string; hasAllowFrom?: boolean },
) => params?.configured ?? cfg.messages?.messagePrefix,
}));
vi.mock("./auth-store.runtime.js", () => ({
resolveOAuthDir: () => "/tmp/openclaw-oauth",
}));
vi.mock("./session.runtime.js", () => {
const created = createMockBaileys();
(globalThis as Record<PropertyKey, unknown>)[Symbol.for("openclaw:lastSocket")] =
created.lastSocket;
return {
...created.mod,
};
});
vi.mock("qrcode-terminal", () => ({
default: { generate: vi.fn() },
generate: vi.fn(),
}));
export const baileys = await import("./session.runtime.js");
export function resetBaileysMocks() {
const recreated = createMockBaileys();
(globalThis as Record<PropertyKey, unknown>)[Symbol.for("openclaw:lastSocket")] =
recreated.lastSocket;
const makeWASocket = vi.mocked(baileys.makeWASocket);
const makeWASocketImpl: typeof baileys.makeWASocket = (...args) =>
(recreated.mod.makeWASocket as unknown as typeof baileys.makeWASocket)(...args);
makeWASocket.mockReset();
makeWASocket.mockImplementation(makeWASocketImpl);
const useMultiFileAuthState = vi.mocked(baileys.useMultiFileAuthState);
const useMultiFileAuthStateImpl: typeof baileys.useMultiFileAuthState = (...args) =>
(recreated.mod.useMultiFileAuthState as unknown as typeof baileys.useMultiFileAuthState)(
...args,
);
useMultiFileAuthState.mockReset();
useMultiFileAuthState.mockImplementation(useMultiFileAuthStateImpl);
const fetchLatestBaileysVersion = vi.mocked(baileys.fetchLatestBaileysVersion);
const fetchLatestBaileysVersionImpl: typeof baileys.fetchLatestBaileysVersion = (...args) =>
(
recreated.mod.fetchLatestBaileysVersion as unknown as typeof baileys.fetchLatestBaileysVersion
)(...args);
fetchLatestBaileysVersion.mockReset();
fetchLatestBaileysVersion.mockImplementation(fetchLatestBaileysVersionImpl);
const makeCacheableSignalKeyStore = vi.mocked(baileys.makeCacheableSignalKeyStore);
const makeCacheableSignalKeyStoreImpl: typeof baileys.makeCacheableSignalKeyStore = (...args) =>
(
recreated.mod
.makeCacheableSignalKeyStore as unknown as typeof baileys.makeCacheableSignalKeyStore
)(...args);
makeCacheableSignalKeyStore.mockReset();
makeCacheableSignalKeyStore.mockImplementation(makeCacheableSignalKeyStoreImpl);
}
export function getLastSocket(): MockBaileysSocket {
const getter = (globalThis as Record<PropertyKey, unknown>)[Symbol.for("openclaw:lastSocket")];
if (typeof getter === "function") {
return (getter as () => MockBaileysSocket)();
}
if (!getter) {
throw new Error("Baileys mock not initialized");
}
throw new Error("Invalid Baileys socket getter");
}