mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-10 08:41:13 +00:00
517 lines
18 KiB
TypeScript
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");
|
|
}
|