mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 20:05:46 -06:00
355 lines
12 KiB
TypeScript
355 lines
12 KiB
TypeScript
// WhatsApp call tool tests cover requester binding, audio framing, and process cleanup.
|
|
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import type {
|
|
AnyAgentTool,
|
|
OpenClawPluginApi,
|
|
OpenClawPluginToolContext,
|
|
} from "openclaw/plugin-sdk/core";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { registerWhatsAppCallTool } from "./agent-tools-call.js";
|
|
|
|
const runtimeContextMocks = vi.hoisted(() => ({
|
|
controllers: new Map<string, unknown>(),
|
|
}));
|
|
const defaultDependencyMocks = vi.hoisted(() => ({
|
|
binaryFound: true,
|
|
oauthDir: "",
|
|
}));
|
|
|
|
vi.mock("openclaw/plugin-sdk/setup-tools", () => ({
|
|
detectBinary: vi.fn(async () => defaultDependencyMocks.binaryFound),
|
|
}));
|
|
|
|
vi.mock("openclaw/plugin-sdk/state-paths", () => ({
|
|
resolveOAuthDir: () => defaultDependencyMocks.oauthDir,
|
|
}));
|
|
|
|
vi.mock("./connection-controller-runtime-context.js", () => ({
|
|
getWhatsAppConnectionController: (accountId: string) =>
|
|
runtimeContextMocks.controllers.get(accountId) ?? null,
|
|
}));
|
|
|
|
function createApi(params?: {
|
|
speech?: Partial<
|
|
Awaited<ReturnType<OpenClawPluginApi["runtime"]["tts"]["textToSpeechTelephony"]>>
|
|
>;
|
|
runCommand?: OpenClawPluginApi["runtime"]["system"]["runCommandWithTimeout"];
|
|
}): OpenClawPluginApi {
|
|
return {
|
|
config: {},
|
|
registerTool: vi.fn(),
|
|
runtime: {
|
|
tts: {
|
|
textToSpeechTelephony: vi.fn(async () => ({
|
|
success: true,
|
|
audioBuffer: Buffer.alloc(48_000, 1),
|
|
outputFormat: "pcm",
|
|
sampleRate: 24_000,
|
|
provider: "openai",
|
|
...params?.speech,
|
|
})),
|
|
},
|
|
system: {
|
|
runCommandWithTimeout:
|
|
params?.runCommand ??
|
|
vi.fn(async () => ({
|
|
stdout: "",
|
|
stderr: "",
|
|
code: 0,
|
|
signal: null,
|
|
killed: false,
|
|
termination: "exit" as const,
|
|
})),
|
|
},
|
|
},
|
|
} as unknown as OpenClawPluginApi;
|
|
}
|
|
|
|
function createContext(
|
|
overrides: Partial<OpenClawPluginToolContext> = {},
|
|
): OpenClawPluginToolContext {
|
|
return {
|
|
config: { channels: { whatsapp: { actions: { calls: true } } } },
|
|
messageChannel: "whatsapp",
|
|
agentAccountId: "default",
|
|
requesterSenderId: "+15551234567",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function resolveRegisteredCallTool(
|
|
api: OpenClawPluginApi,
|
|
context: OpenClawPluginToolContext,
|
|
): AnyAgentTool | null {
|
|
registerWhatsAppCallTool(api);
|
|
const factory = vi.mocked(api.registerTool).mock.calls.at(-1)?.[0];
|
|
if (typeof factory !== "function") {
|
|
throw new Error("WhatsApp call tool factory was not registered");
|
|
}
|
|
return factory(context) as AnyAgentTool | null;
|
|
}
|
|
|
|
function quotePosixShellArgForExpected(value: string): string {
|
|
return `'${value.replaceAll("'", `'"'"'`)}'`;
|
|
}
|
|
|
|
describe("WhatsApp call tool", () => {
|
|
let rootDir: string;
|
|
let stateDir: string;
|
|
|
|
beforeEach(async () => {
|
|
runtimeContextMocks.controllers.clear();
|
|
defaultDependencyMocks.binaryFound = true;
|
|
rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-whatsapp-call-test-"));
|
|
defaultDependencyMocks.oauthDir = path.join(rootDir, "call dir", "$HOME's");
|
|
stateDir = path.join(defaultDependencyMocks.oauthDir, "whatsapp-calls", "default");
|
|
await fs.mkdir(stateDir, { recursive: true });
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await fs.rm(rootDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it("is opt-in and available only for a trusted WhatsApp requester", () => {
|
|
const api = createApi();
|
|
|
|
expect(resolveRegisteredCallTool(api, createContext({ config: {} }))).toBeNull();
|
|
expect(
|
|
resolveRegisteredCallTool(
|
|
api,
|
|
createContext({
|
|
config: { channels: { whatsapp: { actions: { calls: false } } } },
|
|
}),
|
|
),
|
|
).toBeNull();
|
|
expect(
|
|
resolveRegisteredCallTool(api, createContext({ messageChannel: "telegram" })),
|
|
).toBeNull();
|
|
expect(
|
|
resolveRegisteredCallTool(api, createContext({ requesterSenderId: undefined })),
|
|
).toBeNull();
|
|
expect(resolveRegisteredCallTool(api, createContext())?.name).toBe("whatsapp_call");
|
|
});
|
|
|
|
it("reports the companion setup without exposing a recipient argument", async () => {
|
|
defaultDependencyMocks.binaryFound = false;
|
|
const tool = resolveRegisteredCallTool(createApi(), createContext());
|
|
|
|
const result = await tool?.execute("call-1", { action: "status" });
|
|
expect(result?.details).toMatchObject({
|
|
binaryFound: false,
|
|
sessionStoreFound: false,
|
|
accountId: "default",
|
|
stateDir,
|
|
setupCommand: `mkdir -p ${quotePosixShellArgForExpected(stateDir)} && chmod 700 ${quotePosixShellArgForExpected(stateDir)} && meowcaller pair --store ${quotePosixShellArgForExpected(path.join(stateDir, "wa-voip.db"))}`,
|
|
});
|
|
expect(JSON.stringify(tool?.parameters)).not.toContain('"to"');
|
|
});
|
|
|
|
it("synthesizes a private WAV and calls only the current requester", async () => {
|
|
await fs.writeFile(path.join(stateDir, "wa-voip.db"), "sqlite");
|
|
let audioPath: string | undefined;
|
|
const runCommand = vi.fn(async (argv: string[]) => {
|
|
const commandAudioPath = argv.at(-1);
|
|
if (!commandAudioPath) {
|
|
throw new Error("missing audio path");
|
|
}
|
|
audioPath = commandAudioPath;
|
|
const wav = await fs.readFile(commandAudioPath);
|
|
expect(wav.toString("ascii", 0, 4)).toBe("RIFF");
|
|
expect(wav.toString("ascii", 8, 12)).toBe("WAVE");
|
|
expect(wav.readUInt32LE(24)).toBe(24_000);
|
|
expect(wav.readUInt32LE(40)).toBe(48_000);
|
|
expect(wav.subarray(44)).toEqual(Buffer.alloc(48_000, 1));
|
|
return {
|
|
stdout: "",
|
|
stderr: "",
|
|
code: 0,
|
|
signal: null,
|
|
killed: false,
|
|
termination: "exit" as const,
|
|
};
|
|
}) as OpenClawPluginApi["runtime"]["system"]["runCommandWithTimeout"];
|
|
const tool = resolveRegisteredCallTool(createApi({ runCommand }), createContext());
|
|
|
|
const result = await tool?.execute("call-2", {
|
|
action: "call",
|
|
message: "The build finished successfully.",
|
|
});
|
|
|
|
expect(runCommand).toHaveBeenCalledOnce();
|
|
expect(vi.mocked(runCommand).mock.calls[0]?.[0]).toEqual([
|
|
"meowcaller",
|
|
"notify",
|
|
"--store",
|
|
path.join(stateDir, "wa-voip.db"),
|
|
"--answer-timeout",
|
|
"45s",
|
|
"--max-duration",
|
|
"65s",
|
|
"+15551234567",
|
|
audioPath,
|
|
]);
|
|
expect(result?.details).toMatchObject({
|
|
completed: true,
|
|
recipient: "current WhatsApp requester",
|
|
callWindowSeconds: 116,
|
|
ttsProvider: "openai",
|
|
});
|
|
expect(audioPath).toBeDefined();
|
|
await expect(fs.stat(path.dirname(audioPath ?? ""))).rejects.toThrow();
|
|
});
|
|
|
|
it("resolves a requester LID through the active WhatsApp account", async () => {
|
|
await fs.writeFile(path.join(stateDir, "wa-voip.db"), "sqlite");
|
|
const runCommand = vi.fn(async () => ({
|
|
stdout: "",
|
|
stderr: "",
|
|
code: 0,
|
|
signal: null,
|
|
killed: false,
|
|
termination: "exit" as const,
|
|
})) as OpenClawPluginApi["runtime"]["system"]["runCommandWithTimeout"];
|
|
runtimeContextMocks.controllers.set("default", {
|
|
getActiveListener: () => null,
|
|
getCurrentSock: () => ({
|
|
signalRepository: {
|
|
lidMapping: {
|
|
getPNForLID: vi.fn(async () => "15551234567@s.whatsapp.net"),
|
|
},
|
|
},
|
|
}),
|
|
getSelfIdentity: () => null,
|
|
});
|
|
|
|
const tool = resolveRegisteredCallTool(
|
|
createApi({ runCommand }),
|
|
createContext({ requesterSenderId: "123456789@lid" }),
|
|
);
|
|
await expect(
|
|
tool?.execute("call-lid", { action: "call", message: "Hello" }),
|
|
).resolves.toBeDefined();
|
|
expect(vi.mocked(runCommand).mock.calls[0]?.[0]).toContain("+15551234567");
|
|
});
|
|
|
|
it("rejects calling the linked WhatsApp identity itself", async () => {
|
|
await fs.writeFile(path.join(stateDir, "wa-voip.db"), "sqlite");
|
|
runtimeContextMocks.controllers.set("default", {
|
|
getActiveListener: () => null,
|
|
getCurrentSock: () => null,
|
|
getSelfIdentity: () => ({ e164: "+15551234567" }),
|
|
});
|
|
const tool = resolveRegisteredCallTool(createApi(), createContext());
|
|
|
|
await expect(tool?.execute("call-self", { action: "call", message: "Hello" })).rejects.toThrow(
|
|
"WhatsApp cannot call the linked account itself",
|
|
);
|
|
});
|
|
|
|
it("rejects an early MeowCaller failure and removes the temporary audio", async () => {
|
|
await fs.writeFile(path.join(stateDir, "wa-voip.db"), "sqlite");
|
|
let audioPath: string | undefined;
|
|
const runCommand = vi.fn(async (argv: string[]) => {
|
|
audioPath = argv.at(-1);
|
|
return {
|
|
stdout: "",
|
|
stderr: "sensitive upstream diagnostics",
|
|
code: 1,
|
|
signal: null,
|
|
killed: false,
|
|
termination: "exit" as const,
|
|
};
|
|
}) as OpenClawPluginApi["runtime"]["system"]["runCommandWithTimeout"];
|
|
const tool = resolveRegisteredCallTool(createApi({ runCommand }), createContext());
|
|
|
|
await expect(tool?.execute("call-3", { action: "call", message: "Hello" })).rejects.toThrow(
|
|
"MeowCaller did not complete the call (code 1)",
|
|
);
|
|
expect(audioPath).toBeDefined();
|
|
await expect(fs.stat(path.dirname(audioPath ?? ""))).rejects.toThrow();
|
|
});
|
|
|
|
it("does not report success when MeowCaller times out", async () => {
|
|
await fs.writeFile(path.join(stateDir, "wa-voip.db"), "sqlite");
|
|
const runCommand = vi.fn(async () => ({
|
|
stdout: "",
|
|
stderr: "",
|
|
code: 124,
|
|
signal: "SIGTERM" as const,
|
|
killed: true,
|
|
termination: "timeout" as const,
|
|
})) as OpenClawPluginApi["runtime"]["system"]["runCommandWithTimeout"];
|
|
const tool = resolveRegisteredCallTool(createApi({ runCommand }), createContext());
|
|
|
|
await expect(
|
|
tool?.execute("call-unpaired", { action: "call", message: "Hello" }),
|
|
).rejects.toThrow("MeowCaller exceeded the bounded WhatsApp call window");
|
|
});
|
|
|
|
it.each(["ulaw_8000", "raw-8khz-8bit-mono-mulaw"])(
|
|
"decodes %s telephony audio through the registered tool",
|
|
async (outputFormat) => {
|
|
await fs.writeFile(path.join(stateDir, "wa-voip.db"), "sqlite");
|
|
const runCommand = vi.fn(async (argv: string[]) => {
|
|
const wav = await fs.readFile(argv.at(-1) ?? "");
|
|
expect(wav.subarray(44).length).toBe(4);
|
|
expect(wav.readInt16LE(44)).toBe(0);
|
|
return {
|
|
stdout: "",
|
|
stderr: "",
|
|
code: 0,
|
|
signal: null,
|
|
killed: false,
|
|
termination: "exit" as const,
|
|
};
|
|
}) as OpenClawPluginApi["runtime"]["system"]["runCommandWithTimeout"];
|
|
const tool = resolveRegisteredCallTool(
|
|
createApi({
|
|
runCommand,
|
|
speech: {
|
|
audioBuffer: Buffer.from([0xff, 0x7f]),
|
|
outputFormat,
|
|
sampleRate: 8_000,
|
|
},
|
|
}),
|
|
createContext(),
|
|
);
|
|
|
|
await expect(
|
|
tool?.execute("call-ulaw", { action: "call", message: "Hello" }),
|
|
).resolves.toBeDefined();
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
{
|
|
name: "unsupported format",
|
|
speech: { audioBuffer: Buffer.alloc(2), outputFormat: "mp3", sampleRate: 24_000 },
|
|
message: "unsupported telephony format",
|
|
},
|
|
{
|
|
name: "invalid PCM framing",
|
|
speech: { audioBuffer: Buffer.alloc(3), outputFormat: "pcm", sampleRate: 24_000 },
|
|
message: "invalid 16-bit PCM",
|
|
},
|
|
{
|
|
name: "overlong audio",
|
|
speech: {
|
|
audioBuffer: Buffer.alloc(24_000 * 2 * 61),
|
|
outputFormat: "pcm",
|
|
sampleRate: 24_000,
|
|
},
|
|
message: "60-second WhatsApp call limit",
|
|
},
|
|
])("rejects $name through the registered tool", async ({ speech, message }) => {
|
|
await fs.writeFile(path.join(stateDir, "wa-voip.db"), "sqlite");
|
|
const tool = resolveRegisteredCallTool(createApi({ speech }), createContext());
|
|
|
|
await expect(
|
|
tool?.execute("call-invalid", { action: "call", message: "Hello" }),
|
|
).rejects.toThrow(message);
|
|
});
|
|
});
|