feat(whatsapp): add requester-bound MeowCaller calls (#99635)

* feat(whatsapp): add requester-bound MeowCaller calls

* fix(whatsapp): align MeowCaller CLI contract

* test(whatsapp): narrow MeowCaller audio path

* fix(whatsapp): budget MeowCaller setup phases

* feat(whatsapp): gate experimental calls

* fix(whatsapp): use managed call temp storage

* fix(whatsapp): preserve channel entry boundary
This commit is contained in:
Peter Steinberger
2026-07-04 02:19:28 -07:00
committed by GitHub
parent be95bb72d4
commit 82610d3b15
12 changed files with 813 additions and 4 deletions
+102
View File
@@ -116,6 +116,108 @@ from a remote machine. For remote/headless hosts, prefer a direct QR image
handoff path over manual terminal capture.
</Warning>
## Call the current requester with MeowCaller (experimental)
The WhatsApp plugin can expose `whatsapp_call` in WhatsApp-originated agent turns. The tool
uses [MeowCaller](https://github.com/purpshell/meowcaller) to place a WhatsApp voice call to
the current authorized requester and plays an OpenClaw TTS message after they answer. The tool
does not accept a destination number, so a prompt cannot redirect the call to a third party.
This experimental capability is disabled by default.
<Warning>
MeowCaller is experimental, has no tagged release, and uses a separately paired whatsmeow
linked-device session. It cannot reuse the WhatsApp plugin's Baileys credentials. Pairing adds
another linked device to the same WhatsApp account. Scan with the WhatsApp identity used by
OpenClaw. Personal-number/self-chat mode cannot call itself; use a dedicated OpenClaw number
to call your personal number.
</Warning>
<Steps>
<Step title="Enable experimental calls">
Add `actions.calls: true` to the WhatsApp channel in `openclaw.json`:
```json
{
"channels": {
"whatsapp": {
"actions": {
"calls": true
}
}
}
}
```
Merge this into your existing WhatsApp configuration, then restart the gateway. When the
setting is absent or `false`, OpenClaw does not expose the `whatsapp_call` tool to the agent.
</Step>
<Step title="Install the reviewed MeowCaller CLI">
The adapter expects an executable named `meowcaller` on the gateway host's `PATH`.
Until [MeowCaller PR #7](https://github.com/purpshell/meowcaller/pull/7) merges, build
the reviewed branch at commit `752050471fc2bf7a8cdfbf7dbd3cd4e865d85d3f`:
```bash
git clone --branch feat/send-only-notify https://github.com/steipete/meowcaller.git
cd meowcaller
git checkout 752050471fc2bf7a8cdfbf7dbd3cd4e865d85d3f
mkdir -p "$HOME/.local/bin"
go build -o "$HOME/.local/bin/meowcaller" ./cmd/meowcaller
```
Ensure `$HOME/.local/bin` is also on the gateway service's `PATH`. This revision provides
explicit `pair` and send-only `notify` commands. `notify` opens no microphone, speaker,
video device, inbound audio sink, or diagnostic capture. Do not substitute the example
CLI's `play` command.
</Step>
<Step title="Pair the MeowCaller linked device">
Ask the WhatsApp agent to check call setup. The `whatsapp_call` status action reports the
account-specific state directory and pairing command. For the default account:
```bash
state_dir="$HOME/.openclaw/credentials/whatsapp-calls/default"
mkdir -p "$state_dir"
chmod 700 "$state_dir"
meowcaller pair --store "$state_dir/wa-voip.db"
```
Run the command in an interactive terminal. Scan its QR from **WhatsApp > Linked devices**
and wait for `MeowCaller linked device ready`. The command then exits. Keep `wa-voip.db`
private; it is the MeowCaller linked-device session. The `whatsapp_call` status action
returns the account-specific command and shell when you use a non-default account. On
Windows, run its PowerShell command; MeowCaller creates the store directory.
</Step>
<Step title="Configure TTS and call from WhatsApp">
Configure a telephony-capable [TTS provider](/tools/tts), restart the gateway, then send a
WhatsApp request such as `Call me and say the build finished.` The tool resolves the sender
from trusted inbound context, synthesizes a temporary private WAV file, runs MeowCaller for a
bounded call window, and deletes the audio file afterward. OpenClaw passes the account's
store explicitly, waits for a zero exit status after answer, playback, and hangup, and treats
a timeout or nonzero exit as a failed tool call.
</Step>
</Steps>
Current limits:
- one-to-one outbound audio calls only
- no arbitrary destination numbers
- no shared auth with the chat connection
- no self-calls from personal-number/self-chat mode
- synthesized audio is limited to 60 seconds
- no handset-side audibility receipt beyond MeowCaller's answer/playback/hangup completion
- OpenClaw stops the companion process after a bounded 115175 second window, including
MeowCaller's connection, answer, playback, and shutdown phases
## Deployment patterns
<AccordionGroup>
+1
View File
@@ -1005,6 +1005,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- Headings:
- H2: Install (on demand)
- H2: Quick setup
- H2: Call the current requester with MeowCaller (experimental)
- H2: Deployment patterns
- H2: Runtime model
- H2: Approval prompts
+2
View File
@@ -0,0 +1,2 @@
// WhatsApp call tool facade keeps the bundled entrypoint light during discovery.
export { registerWhatsAppCallTool } from "./src/agent-tools-call.js";
+17 -1
View File
@@ -1,5 +1,20 @@
// Whatsapp plugin entrypoint registers its OpenClaw integration.
import { defineBundledChannelEntry } from "openclaw/plugin-sdk/channel-entry-contract";
import {
defineBundledChannelEntry,
loadBundledEntryExportSync,
} from "openclaw/plugin-sdk/channel-entry-contract";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/channel-entry-contract";
function registerWhatsAppCallTool(api: OpenClawPluginApi): void {
const registerTool = loadBundledEntryExportSync<(api: OpenClawPluginApi) => void>(
import.meta.url,
{
specifier: "./call-tool-api.js",
exportName: "registerWhatsAppCallTool",
},
);
registerTool(api);
}
export default defineBundledChannelEntry({
id: "whatsapp",
@@ -14,4 +29,5 @@ export default defineBundledChannelEntry({
specifier: "./runtime-setter-api.js",
exportName: "setWhatsAppRuntime",
},
registerFull: registerWhatsAppCallTool,
});
+3
View File
@@ -7,6 +7,9 @@
"activation": {
"onStartup": false
},
"contracts": {
"tools": ["whatsapp_call"]
},
"channels": ["whatsapp"],
"configSchema": {
"type": "object",
@@ -0,0 +1,308 @@
// 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 { OpenClawPluginApi, OpenClawPluginToolContext } from "openclaw/plugin-sdk/core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createWhatsAppCallTool, testing } from "./agent-tools-call.js";
import {
getRegisteredWhatsAppConnectionController,
registerWhatsAppConnectionController,
unregisterWhatsAppConnectionController,
} from "./connection-controller-registry.js";
function createApi(params?: {
speech?: Partial<
Awaited<ReturnType<OpenClawPluginApi["runtime"]["tts"]["textToSpeechTelephony"]>>
>;
runCommand?: OpenClawPluginApi["runtime"]["system"]["runCommandWithTimeout"];
}): OpenClawPluginApi {
return {
config: {},
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,
};
}
describe("WhatsApp call tool", () => {
let stateDir: string;
beforeEach(async () => {
stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-whatsapp-call-test-"));
});
afterEach(async () => {
await fs.rm(stateDir, { recursive: true, force: true });
});
it("is opt-in and available only for a trusted WhatsApp requester", () => {
const api = createApi();
expect(createWhatsAppCallTool(api, createContext({ config: {} }))).toBeNull();
expect(
createWhatsAppCallTool(
api,
createContext({
config: { channels: { whatsapp: { actions: { calls: false } } } },
}),
),
).toBeNull();
expect(createWhatsAppCallTool(api, createContext({ messageChannel: "telegram" }))).toBeNull();
expect(createWhatsAppCallTool(api, createContext({ requesterSenderId: undefined }))).toBeNull();
expect(createWhatsAppCallTool(api, createContext())?.name).toBe("whatsapp_call");
});
it("reports the separate companion setup without exposing a recipient argument", async () => {
const tool = testing.createWhatsAppCallToolWithDependencies(createApi(), createContext(), {
detectMeowCaller: async () => false,
resolveStateDir: () => stateDir,
});
const result = await tool?.execute("call-1", { action: "status" });
expect(result?.details).toMatchObject({
binaryFound: false,
sessionStoreFound: false,
accountId: "default",
stateDir,
});
expect(result?.details).toMatchObject({
setupCommand: expect.stringContaining("meowcaller pair --store"),
});
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 api = createApi({ runCommand });
const tool = testing.createWhatsAppCallToolWithDependencies(api, createContext(), {
detectMeowCaller: async () => true,
resolveStateDir: () => stateDir,
});
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 () => {
const controller = {
getActiveListener: () => null,
getCurrentSock: () =>
({
signalRepository: {
lidMapping: {
getPNForLID: vi.fn(async () => "15551234567@s.whatsapp.net"),
},
},
}) as never,
getSelfIdentity: () => null,
};
registerWhatsAppConnectionController("default", controller);
try {
await expect(
testing.resolveRequesterE164({
accountId: "default",
cfg: {},
requesterSenderId: "123456789@lid",
}),
).resolves.toBe("+15551234567");
expect(getRegisteredWhatsAppConnectionController("default")).toBe(controller);
} finally {
unregisterWhatsAppConnectionController("default", controller);
}
});
it("rejects calling the linked WhatsApp identity itself", async () => {
await fs.writeFile(path.join(stateDir, "wa-voip.db"), "sqlite");
const controller = {
getActiveListener: () => null,
getCurrentSock: () => null,
getSelfIdentity: () => ({ e164: "+15551234567" }),
};
registerWhatsAppConnectionController("default", controller);
try {
const tool = testing.createWhatsAppCallToolWithDependencies(createApi(), createContext(), {
detectMeowCaller: async () => true,
resolveStateDir: () => stateDir,
});
await expect(
tool?.execute("call-self", { action: "call", message: "Hello" }),
).rejects.toThrow("WhatsApp cannot call the linked account itself");
} finally {
unregisterWhatsAppConnectionController("default", controller);
}
});
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 = testing.createWhatsAppCallToolWithDependencies(
createApi({ runCommand }),
createContext(),
{
detectMeowCaller: async () => true,
resolveStateDir: () => stateDir,
},
);
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 = testing.createWhatsAppCallToolWithDependencies(
createApi({ runCommand }),
createContext(),
{
detectMeowCaller: async () => true,
resolveStateDir: () => stateDir,
},
);
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 to PCM",
(outputFormat) => {
const pcm = testing.normalizeTelephonyPcm(Buffer.from([0xff, 0x7f]), outputFormat);
expect(pcm.length).toBe(4);
expect(pcm.readInt16LE(0)).toBe(0);
},
);
it("writes valid PCM headers and enforces the call window", () => {
const wav = testing.wrapPcm16MonoInWav(Buffer.alloc(4), 16_000);
expect(wav.readUInt32LE(4)).toBe(40);
expect(wav.readUInt16LE(22)).toBe(1);
expect(wav.readUInt16LE(34)).toBe(16);
expect(() => testing.wrapPcm16MonoInWav(Buffer.alloc(3), 16_000)).toThrow("invalid 16-bit PCM");
expect(() => testing.normalizeTelephonyPcm(Buffer.alloc(2), "mp3")).toThrow(
"unsupported telephony format",
);
expect(testing.resolveCallWindowMs(0, 24_000)).toBe(115_000);
expect(testing.resolveCallWindowMs(24_000 * 2 * 60, 24_000)).toBe(175_000);
expect(() => testing.resolveCallWindowMs(24_000 * 2 * 61, 24_000)).toThrow(
"60-second WhatsApp call limit",
);
});
it("shell-quotes the pairing command", () => {
expect(
testing.resolveSetupCommand("/tmp/call dir/$HOME's", "/tmp/call dir/$HOME's/wa-voip.db"),
).toBe(
`mkdir -p '/tmp/call dir/$HOME'"'"'s' && chmod 700 '/tmp/call dir/$HOME'"'"'s' && meowcaller pair --store '/tmp/call dir/$HOME'"'"'s/wa-voip.db'`,
);
expect(
testing.resolveSetupCommand(
String.raw`C:\Users\Peter O'Neil\calls`,
String.raw`C:\Users\Peter O'Neil\calls\wa-voip.db`,
"win32",
),
).toBe(String.raw`meowcaller pair --store 'C:\Users\Peter O''Neil\calls\wa-voip.db'`);
});
});
+362
View File
@@ -0,0 +1,362 @@
// WhatsApp plugin tool places requester-bound calls through the MeowCaller companion CLI.
import fs from "node:fs/promises";
import path from "node:path";
import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import { normalizeE164 } from "openclaw/plugin-sdk/account-resolution";
import { createActionGate, stringEnum } from "openclaw/plugin-sdk/channel-actions";
import type {
AnyAgentTool,
OpenClawPluginApi,
OpenClawPluginToolContext,
} from "openclaw/plugin-sdk/core";
import { mulawToPcm } from "openclaw/plugin-sdk/realtime-voice";
import { detectBinary } from "openclaw/plugin-sdk/setup-tools";
import { resolveOAuthDir } from "openclaw/plugin-sdk/state-paths";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
import { Type } from "typebox";
import { resolveWhatsAppAccount } from "./accounts.js";
import { getRegisteredWhatsAppConnectionController } from "./connection-controller-registry.js";
import { resolveJidToE164 } from "./targets-runtime.js";
const MEOWCALLER_COMMAND = "meowcaller";
const SESSION_DATABASE = "wa-voip.db";
const MEOWCALLER_CONNECT_TIMEOUT_MS = 60_000;
const MEOWCALLER_ANSWER_TIMEOUT_MS = 45_000;
const CALL_SHUTDOWN_GRACE_MS = 10_000;
const MAX_AUDIO_DURATION_MS = 60_000;
const MIN_CALL_WINDOW_MS =
MEOWCALLER_CONNECT_TIMEOUT_MS + MEOWCALLER_ANSWER_TIMEOUT_MS + CALL_SHUTDOWN_GRACE_MS;
const MAX_CALL_WINDOW_MS = MIN_CALL_WINDOW_MS + MAX_AUDIO_DURATION_MS;
const MAX_MESSAGE_LENGTH = 4_000;
const MAX_COMMAND_OUTPUT_BYTES = 64 * 1024;
const MEOWCALLER_ANSWER_TIMEOUT = "45s";
const MEOWCALLER_MAX_DURATION = "65s";
// One whatsmeow session database must not be driven by concurrent companion clients.
// Reject overlap so model retries cannot duplicate calls or contend on auth state.
const activeCallAccounts = new Set<string>();
const WhatsAppCallToolSchema = Type.Object(
{
action: stringEnum(["status", "call"] as const, {
description: "Check MeowCaller setup or call the current WhatsApp requester",
}),
message: Type.Optional(
Type.String({
description: "Spoken message to play after the requester answers (maximum 60 seconds)",
maxLength: MAX_MESSAGE_LENGTH,
}),
),
},
{ additionalProperties: false },
);
type WhatsAppCallToolParams = {
action: "status" | "call";
message?: string;
};
type WhatsAppCallToolDependencies = {
detectMeowCaller: () => Promise<boolean>;
resolveStateDir: (accountId: string) => string;
};
const defaultDependencies: WhatsAppCallToolDependencies = {
detectMeowCaller: () => detectBinary(MEOWCALLER_COMMAND),
resolveStateDir: (accountId) =>
path.join(resolveOAuthDir(), "whatsapp-calls", normalizeAccountId(accountId)),
};
function jsonResult(payload: unknown) {
return {
content: [{ type: "text" as const, text: JSON.stringify(payload, null, 2) }],
details: payload,
};
}
async function isRegularFile(filePath: string): Promise<boolean> {
try {
return (await fs.stat(filePath)).isFile();
} catch {
return false;
}
}
function quotePosixShellArg(value: string): string {
return `'${value.replaceAll("'", `'"'"'`)}'`;
}
function quotePowerShellArg(value: string): string {
return `'${value.replaceAll("'", "''")}'`;
}
function resolveSetupCommand(
stateDir: string,
sessionStorePath: string,
platform: NodeJS.Platform = process.platform,
): string {
if (platform === "win32") {
return `meowcaller pair --store ${quotePowerShellArg(sessionStorePath)}`;
}
const quotedStateDir = quotePosixShellArg(stateDir);
const quotedStorePath = quotePosixShellArg(sessionStorePath);
return `mkdir -p ${quotedStateDir} && chmod 700 ${quotedStateDir} && meowcaller pair --store ${quotedStorePath}`;
}
function wrapPcm16MonoInWav(pcm: Buffer, sampleRate: number): Buffer {
if (!Number.isInteger(sampleRate) || sampleRate <= 0) {
throw new Error("TTS returned an invalid sample rate");
}
if (pcm.length === 0 || pcm.length % 2 !== 0) {
throw new Error("TTS returned invalid 16-bit PCM audio");
}
const header = Buffer.alloc(44);
header.write("RIFF", 0, "ascii");
header.writeUInt32LE(36 + pcm.length, 4);
header.write("WAVE", 8, "ascii");
header.write("fmt ", 12, "ascii");
header.writeUInt32LE(16, 16);
header.writeUInt16LE(1, 20);
header.writeUInt16LE(1, 22);
header.writeUInt32LE(sampleRate, 24);
header.writeUInt32LE(sampleRate * 2, 28);
header.writeUInt16LE(2, 32);
header.writeUInt16LE(16, 34);
header.write("data", 36, "ascii");
header.writeUInt32LE(pcm.length, 40);
return Buffer.concat([header, pcm]);
}
function normalizeTelephonyPcm(audio: Buffer, outputFormat: string | undefined): Buffer {
const normalizedFormat = outputFormat?.trim().toLowerCase();
if (normalizedFormat?.startsWith("pcm")) {
return audio;
}
if (normalizedFormat === "ulaw_8000" || normalizedFormat === "raw-8khz-8bit-mono-mulaw") {
return mulawToPcm(audio);
}
throw new Error(`TTS returned unsupported telephony format: ${outputFormat ?? "unknown"}`);
}
function resolveCallWindowMs(pcmBytes: number, sampleRate: number): number {
const audioDurationMs = (pcmBytes / 2 / sampleRate) * 1_000;
if (audioDurationMs > MAX_AUDIO_DURATION_MS) {
throw new Error("TTS audio exceeds the 60-second WhatsApp call limit");
}
return Math.min(MAX_CALL_WINDOW_MS, Math.ceil(audioDurationMs + MIN_CALL_WINDOW_MS));
}
async function resolveRequesterE164(params: {
accountId: string;
cfg: NonNullable<OpenClawPluginToolContext["config"]>;
requesterSenderId: string;
}): Promise<string | null> {
const senderId = params.requesterSenderId.trim();
if (!senderId.includes("@")) {
try {
return normalizeE164(senderId.replace(/^whatsapp:/i, ""));
} catch {
return null;
}
}
const account = resolveWhatsAppAccount({ cfg: params.cfg, accountId: params.accountId });
const lidLookup = getRegisteredWhatsAppConnectionController(params.accountId)?.getCurrentSock()
?.signalRepository.lidMapping;
return await resolveJidToE164(senderId, { authDir: account.authDir, lidLookup });
}
async function resolveLinkedWhatsAppSelfE164(params: {
accountId: string;
cfg: NonNullable<OpenClawPluginToolContext["config"]>;
}): Promise<string | null> {
const controller = getRegisteredWhatsAppConnectionController(params.accountId);
if (!controller) {
return null;
}
const identity = controller.getSelfIdentity();
if (!identity) {
return null;
}
if (identity.e164) {
return normalizeE164(identity.e164);
}
const account = resolveWhatsAppAccount({ cfg: params.cfg, accountId: params.accountId });
const lidLookup = controller.getCurrentSock()?.signalRepository.lidMapping;
return await resolveJidToE164(identity.jid ?? identity.lid, {
authDir: account.authDir,
lidLookup,
});
}
function resolveRuntimeConfig(api: OpenClawPluginApi, context: OpenClawPluginToolContext) {
return context.getRuntimeConfig?.() ?? context.runtimeConfig ?? context.config ?? api.config;
}
function createWhatsAppCallToolWithDependencies(
api: OpenClawPluginApi,
context: OpenClawPluginToolContext,
dependencies: WhatsAppCallToolDependencies,
): AnyAgentTool | null {
const cfg = resolveRuntimeConfig(api, context);
const isActionEnabled = createActionGate(cfg.channels?.whatsapp?.actions);
const requesterSenderId = context.requesterSenderId?.trim();
if (
!isActionEnabled("calls", false) ||
context.messageChannel !== "whatsapp" ||
!requesterSenderId
) {
return null;
}
const accountId = normalizeAccountId(context.agentAccountId);
const stateDir = dependencies.resolveStateDir(accountId);
const sessionStorePath = path.join(stateDir, SESSION_DATABASE);
return {
name: "whatsapp_call",
label: "WhatsApp Call",
description:
"Call the current WhatsApp requester and play a synthesized spoken message. This tool cannot call arbitrary phone numbers.",
parameters: WhatsAppCallToolSchema,
async execute(_toolCallId, rawParams, signal) {
const params = rawParams as WhatsAppCallToolParams;
const binaryFound = await dependencies.detectMeowCaller();
const sessionStoreFound = await isRegularFile(sessionStorePath);
if (params.action === "status") {
return jsonResult({
binaryFound,
sessionStoreFound,
accountId,
stateDir,
setupCommand: resolveSetupCommand(stateDir, sessionStorePath),
setupShell: process.platform === "win32" ? "PowerShell" : "POSIX shell",
requiredCommand:
"meowcaller notify --store <path> --answer-timeout 45s --max-duration 65s <target> <file>",
note: "MeowCaller uses a separate WhatsApp linked-device session; it cannot reuse OpenClaw's Baileys credentials.",
});
}
const message = params.message?.trim();
if (!message) {
throw new Error("message required for call action");
}
if (message.length > MAX_MESSAGE_LENGTH) {
throw new Error(`message must be at most ${MAX_MESSAGE_LENGTH} characters`);
}
if (!binaryFound) {
throw new Error("MeowCaller is not installed; run whatsapp_call with action=status");
}
if (!sessionStoreFound) {
throw new Error(
"MeowCaller has no session store; run whatsapp_call with action=status, then run its setupCommand in an interactive terminal and scan the QR as a linked device",
);
}
const target = await resolveRequesterE164({
accountId,
cfg,
requesterSenderId,
});
if (!target) {
throw new Error("Could not resolve the current WhatsApp requester to a phone number");
}
const linkedSelf = await resolveLinkedWhatsAppSelfE164({ accountId, cfg });
if (linkedSelf === target) {
throw new Error(
"WhatsApp cannot call the linked account itself; use a dedicated OpenClaw WhatsApp number",
);
}
if (activeCallAccounts.has(accountId)) {
throw new Error("A WhatsApp call is already active for this account");
}
activeCallAccounts.add(accountId);
try {
const speech = await api.runtime.tts.textToSpeechTelephony({ text: message, cfg });
if (!speech.success || !speech.audioBuffer || !speech.sampleRate) {
throw new Error(speech.error ?? "TTS synthesis failed");
}
const pcm = normalizeTelephonyPcm(speech.audioBuffer, speech.outputFormat);
const callWindowMs = resolveCallWindowMs(pcm.length, speech.sampleRate);
const tempDir = await fs.mkdtemp(
path.join(resolvePreferredOpenClawTmpDir(), "openclaw-whatsapp-call-"),
);
const audioPath = path.join(tempDir, "message.wav");
try {
await fs.writeFile(audioPath, wrapPcm16MonoInWav(pcm, speech.sampleRate), {
mode: 0o600,
});
const result = await api.runtime.system.runCommandWithTimeout(
[
MEOWCALLER_COMMAND,
"notify",
"--store",
sessionStorePath,
"--answer-timeout",
MEOWCALLER_ANSWER_TIMEOUT,
"--max-duration",
MEOWCALLER_MAX_DURATION,
target,
audioPath,
],
{
cwd: stateDir,
env: { MEOW_LOG_LEVEL: "warn" },
timeoutMs: callWindowMs,
signal,
killProcessTree: true,
maxOutputBytes: MAX_COMMAND_OUTPUT_BYTES,
},
);
if (result.termination === "signal") {
throw new Error("WhatsApp call cancelled");
}
if (result.termination === "timeout") {
throw new Error("MeowCaller exceeded the bounded WhatsApp call window");
}
if (result.termination !== "exit" || result.code !== 0) {
throw new Error(
`MeowCaller did not complete the call (code ${result.code ?? "unknown"})`,
);
}
return jsonResult({
completed: true,
recipient: "current WhatsApp requester",
callWindowSeconds: Math.ceil(callWindowMs / 1_000),
ttsProvider: speech.provider,
note: "MeowCaller completed answer, playback, and hangup for the requester-bound call.",
});
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
} finally {
activeCallAccounts.delete(accountId);
}
},
};
}
export function createWhatsAppCallTool(
api: OpenClawPluginApi,
context: OpenClawPluginToolContext,
): AnyAgentTool | null {
return createWhatsAppCallToolWithDependencies(api, context, defaultDependencies);
}
export function registerWhatsAppCallTool(api: OpenClawPluginApi): void {
api.registerTool((context) => createWhatsAppCallTool(api, context), {
name: "whatsapp_call",
});
}
export const testing = {
createWhatsAppCallToolWithDependencies,
normalizeTelephonyPcm,
resolveCallWindowMs,
resolveLinkedWhatsAppSelfE164,
resolveRequesterE164,
resolveSetupCommand,
wrapPcm16MonoInWav,
};
@@ -70,6 +70,14 @@ describe("whatsapp config schema", () => {
});
});
it("accepts the experimental call action opt-in", () => {
const res = expectWhatsAppConfigValid({ actions: { calls: true } });
if (res.success) {
expect(res.data.actions?.calls).toBe(true);
}
});
it("keeps inherited account defaults unset at account scope", () => {
const res = expectWhatsAppConfigValid({
dmPolicy: "allowlist",
@@ -22,6 +22,10 @@ export const whatsAppChannelConfigUiHints = {
label: "WhatsApp Config Writes",
help: "Allow WhatsApp to write config in response to channel events/commands (default: true).",
},
"actions.calls": {
label: "WhatsApp Voice Calls",
help: "Expose the experimental requester-bound WhatsApp voice-call tool. Default: false. Requires a separately paired MeowCaller CLI.",
},
mentionPatterns: {
label: "WhatsApp Mention Pattern Policy",
help: "Scopes configured groupChat mentionPatterns to selected WhatsApp conversation IDs such as 123@g.us.",
File diff suppressed because one or more lines are too long
+3 -1
View File
@@ -19,6 +19,8 @@ export type WhatsAppActionConfig = {
reactions?: boolean;
sendMessage?: boolean;
polls?: boolean;
/** Enable the experimental requester-bound voice-call tool. Default: false. */
calls?: boolean;
};
export type WhatsAppReactionLevel = ReactionLevel;
@@ -135,7 +137,7 @@ export type WhatsAppConfig = WhatsAppConfigCore &
accounts?: Record<string, WhatsAppAccountConfig>;
/** Optional default account id when multiple accounts are configured. */
defaultAccount?: string;
/** Per-action tool gating (default: true for all). */
/** Per-action tool gating. Calls default to false; existing actions default to true. */
actions?: WhatsAppActionConfig;
/** Plugin hook opt-in configuration for privacy-sensitive inbound events. */
pluginHooks?: {
@@ -180,6 +180,7 @@ const WhatsAppConfigObjectSchema = z
reactions: z.boolean().optional(),
sendMessage: z.boolean().optional(),
polls: z.boolean().optional(),
calls: z.boolean().optional(),
})
.strict()
.optional(),