mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
be95bb72d4
commit
82610d3b15
@@ -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 115–175 second window, including
|
||||
MeowCaller's connection, answer, playback, and shutdown phases
|
||||
|
||||
## Deployment patterns
|
||||
|
||||
<AccordionGroup>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
// WhatsApp call tool facade keeps the bundled entrypoint light during discovery.
|
||||
export { registerWhatsAppCallTool } from "./src/agent-tools-call.js";
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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'`);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user