feat: support DTMF for voice-call

This commit is contained in:
Peter Steinberger
2026-04-23 21:18:55 +01:00
parent 79066f5cab
commit 7c19c31144
16 changed files with 260 additions and 4 deletions

View File

@@ -2,7 +2,7 @@
summary: "CLI reference for `openclaw voicecall` (voice-call plugin command surface)"
read_when:
- You use the voice-call plugin and want the CLI entry points
- You want quick examples for `voicecall call|continue|status|tail|expose`
- You want quick examples for `voicecall call|continue|dtmf|status|tail|expose`
title: "Voicecall"
---
@@ -20,6 +20,7 @@ Primary doc:
openclaw voicecall status --call-id <id>
openclaw voicecall call --to "+15555550123" --message "Hello" --mode notify
openclaw voicecall continue --call-id <id> --message "Any questions?"
openclaw voicecall dtmf --call-id <id> --digits "ww123456#"
openclaw voicecall end --call-id <id>
```

View File

@@ -63,7 +63,7 @@ Set config under `plugins.entries.voice-call.config`:
enabled: true,
config: {
provider: "twilio", // or "telnyx" | "plivo" | "mock"
fromNumber: "+15550001234",
fromNumber: "+15550001234", // or TWILIO_FROM_NUMBER for Twilio
toNumber: "+15550005678",
twilio: {
@@ -468,6 +468,7 @@ openclaw voicecall call --to "+15555550123" --message "Hello from OpenClaw"
openclaw voicecall start --to "+15555550123" # alias for call
openclaw voicecall continue --call-id <id> --message "Any questions?"
openclaw voicecall speak --call-id <id> --message "One moment"
openclaw voicecall dtmf --call-id <id> --digits "ww123456#"
openclaw voicecall end --call-id <id>
openclaw voicecall status --call-id <id>
openclaw voicecall tail
@@ -489,6 +490,7 @@ Actions:
- `initiate_call` (message, to?, mode?)
- `continue_call` (callId, message)
- `speak_to_user` (callId, message)
- `send_dtmf` (callId, digits)
- `end_call` (callId)
- `get_status` (callId)
@@ -499,6 +501,7 @@ This repo ships a matching skill doc at `skills/voice-call/SKILL.md`.
- `voicecall.initiate` (`to?`, `message`, `mode?`)
- `voicecall.continue` (`callId`, `message`)
- `voicecall.speak` (`callId`, `message`)
- `voicecall.dtmf` (`callId`, `digits`)
- `voicecall.end` (`callId`)
- `voicecall.status` (`callId`)

View File

@@ -12,6 +12,7 @@ let runtimeStub: {
initiateCall: ReturnType<typeof vi.fn>;
continueCall: ReturnType<typeof vi.fn>;
speak: ReturnType<typeof vi.fn>;
sendDtmf: ReturnType<typeof vi.fn>;
endCall: ReturnType<typeof vi.fn>;
getCall: ReturnType<typeof vi.fn>;
getCallByProviderCallId: ReturnType<typeof vi.fn>;
@@ -123,6 +124,7 @@ describe("voice-call plugin", () => {
transcript: "hello",
})),
speak: vi.fn(async () => ({ success: true })),
sendDtmf: vi.fn(async () => ({ success: true })),
endCall: vi.fn(async () => ({ success: true })),
getCall: vi.fn((id: string) => (id === "call-1" ? { callId: "call-1" } : undefined)),
getCallByProviderCallId: vi.fn(() => undefined),
@@ -164,6 +166,22 @@ describe("voice-call plugin", () => {
expect(payload.found).toBe(true);
});
it("sends DTMF via voicecall.dtmf", async () => {
const { methods } = setup({ provider: "mock" });
const handler = methods.get("voicecall.dtmf") as
| ((ctx: {
params: Record<string, unknown>;
respond: ReturnType<typeof vi.fn>;
}) => Promise<void>)
| undefined;
const respond = vi.fn();
await handler?.({ params: { callId: "call-1", digits: "ww123#" }, respond });
expect(runtimeStub.manager.sendDtmf).toHaveBeenCalledWith("call-1", "ww123#");
expect(respond.mock.calls[0]).toEqual([true, { success: true }]);
});
it("normalizes legacy config through runtime creation and warns to run doctor", async () => {
const { methods } = setup({
enabled: true,
@@ -219,6 +237,20 @@ describe("voice-call plugin", () => {
expect(result.details.found).toBe(true);
});
it("tool send_dtmf returns json payload", async () => {
const { tools } = setup({ provider: "mock" });
const tool = tools[0] as {
execute: (id: string, params: unknown) => Promise<unknown>;
};
const result = (await tool.execute("id", {
action: "send_dtmf",
callId: "call-1",
digits: "ww123#",
})) as { details: { success?: boolean } };
expect(runtimeStub.manager.sendDtmf).toHaveBeenCalledWith("call-1", "ww123#");
expect(result.details.success).toBe(true);
});
it("legacy tool status without sid returns error payload", async () => {
const { tools } = setup({ provider: "mock" });
const tool = tools[0] as {

View File

@@ -122,6 +122,11 @@ const VoiceCallToolSchema = Type.Union([
callId: Type.String({ description: "Call ID" }),
message: Type.String({ description: "Message to speak" }),
}),
Type.Object({
action: Type.Literal("send_dtmf"),
callId: Type.String({ description: "Call ID" }),
digits: Type.String({ description: "DTMF digits to send" }),
}),
Type.Object({
action: Type.Literal("end_call"),
callId: Type.String({ description: "Call ID" }),
@@ -323,6 +328,29 @@ export default definePluginEntry({
},
);
api.registerGatewayMethod(
"voicecall.dtmf",
async ({ params, respond }: GatewayRequestHandlerOptions) => {
try {
const callId = normalizeOptionalString(params?.callId) ?? "";
const digits = normalizeOptionalString(params?.digits) ?? "";
if (!callId || !digits) {
respond(false, { error: "callId and digits required" });
return;
}
const rt = await ensureRuntime();
const result = await rt.manager.sendDtmf(callId, digits);
if (!result.success) {
respond(false, { error: result.error || "dtmf failed" });
return;
}
respond(true, { success: true });
} catch (err) {
sendError(respond, err);
}
},
);
api.registerGatewayMethod(
"voicecall.end",
async ({ params, respond }: GatewayRequestHandlerOptions) => {
@@ -453,6 +481,18 @@ export default definePluginEntry({
}
return json({ success: true });
}
case "send_dtmf": {
const callId = normalizeOptionalString(rawParams.callId) ?? "";
const digits = normalizeOptionalString(rawParams.digits) ?? "";
if (!callId || !digits) {
throw new Error("callId and digits required");
}
const result = await rt.manager.sendDtmf(callId, digits);
if (!result.success) {
throw new Error(result.error || "dtmf failed");
}
return json({ success: true });
}
case "end_call": {
const callId = normalizeOptionalString(rawParams.callId) ?? "";
if (!callId) {

View File

@@ -7,6 +7,7 @@
"TELNYX_PUBLIC_KEY",
"TWILIO_ACCOUNT_SID",
"TWILIO_AUTH_TOKEN",
"TWILIO_FROM_NUMBER",
"PLIVO_AUTH_ID",
"PLIVO_AUTH_TOKEN",
"NGROK_AUTHTOKEN",

View File

@@ -198,6 +198,20 @@ export function registerVoiceCallCli(params: {
writeStdoutJson(result);
});
root
.command("dtmf")
.description("Send DTMF digits to an active call")
.requiredOption("--call-id <id>", "Call ID")
.requiredOption("--digits <digits>", "DTMF digits")
.action(async (options: { callId: string; digits: string }) => {
const rt = await ensureRuntime();
const result = await rt.manager.sendDtmf(options.callId, options.digits);
if (!result.success) {
throw new Error(result.error || "dtmf failed");
}
writeStdoutJson(result);
});
root
.command("end")
.description("Hang up an active call")

View File

@@ -25,6 +25,7 @@ describe("validateProviderConfig", () => {
const clearProviderEnv = () => {
delete process.env.TWILIO_ACCOUNT_SID;
delete process.env.TWILIO_AUTH_TOKEN;
delete process.env.TWILIO_FROM_NUMBER;
delete process.env.TELNYX_API_KEY;
delete process.env.TELNYX_CONNECTION_ID;
delete process.env.TELNYX_PUBLIC_KEY;
@@ -63,6 +64,7 @@ describe("validateProviderConfig", () => {
if (provider === "twilio") {
process.env.TWILIO_ACCOUNT_SID = "AC123";
process.env.TWILIO_AUTH_TOKEN = "secret";
process.env.TWILIO_FROM_NUMBER = "+15550001234";
} else if (provider === "telnyx") {
process.env.TELNYX_API_KEY = "KEY123";
process.env.TELNYX_CONNECTION_ID = "CONN456";
@@ -90,6 +92,20 @@ describe("validateProviderConfig", () => {
expect(result.errors).toEqual([]);
});
it("resolves the Twilio from number from environment", () => {
process.env.TWILIO_ACCOUNT_SID = "AC123";
process.env.TWILIO_AUTH_TOKEN = "secret";
process.env.TWILIO_FROM_NUMBER = "+15550001234";
const config = resolveVoiceCallConfig({
...createBaseConfig("twilio"),
fromNumber: undefined,
});
expect(config.fromNumber).toBe("+15550001234");
expect(validateProviderConfig(config)).toMatchObject({ valid: true, errors: [] });
});
it("fails validation when required twilio credentials are missing", () => {
process.env.TWILIO_AUTH_TOKEN = "secret";
const missingSid = validateProviderConfig(resolveVoiceCallConfig(createBaseConfig("twilio")));

View File

@@ -502,6 +502,7 @@ export function resolveVoiceCallConfig(config: VoiceCallConfigInput): VoiceCallC
// Twilio
if (resolved.provider === "twilio") {
resolved.fromNumber = resolved.fromNumber ?? process.env.TWILIO_FROM_NUMBER;
resolved.twilio = resolved.twilio ?? {};
resolved.twilio.accountSid = resolved.twilio.accountSid ?? process.env.TWILIO_ACCOUNT_SID;
resolved.twilio.authToken = resolved.twilio.authToken ?? process.env.TWILIO_AUTH_TOKEN;
@@ -556,7 +557,11 @@ export function validateProviderConfig(config: VoiceCallConfig): {
}
if (!config.fromNumber && config.provider !== "mock") {
errors.push("plugins.entries.voice-call.config.fromNumber is required");
errors.push(
config.provider === "twilio"
? "plugins.entries.voice-call.config.fromNumber is required (or set TWILIO_FROM_NUMBER env)"
: "plugins.entries.voice-call.config.fromNumber is required",
);
}
if (config.provider === "telnyx") {

View File

@@ -10,6 +10,7 @@ import {
continueCall as continueCallWithContext,
endCall as endCallWithContext,
initiateCall as initiateCallWithContext,
sendDtmf as sendDtmfWithContext,
speak as speakWithContext,
speakInitialMessage as speakInitialMessageWithContext,
} from "./manager/outbound.js";
@@ -221,6 +222,13 @@ export class CallManager {
return speakWithContext(this.getContext(), callId, text);
}
/**
* Send DTMF digits to an active call.
*/
async sendDtmf(callId: CallId, digits: string): Promise<{ success: boolean; error?: string }> {
return sendDtmfWithContext(this.getContext(), callId, digits);
}
/**
* Speak the initial message for a call (called when media stream connects).
*/

View File

@@ -48,7 +48,7 @@ vi.mock("./twiml.js", () => ({
generateNotifyTwiml: generateNotifyTwimlMock,
}));
import { endCall, initiateCall, speak } from "./outbound.js";
import { endCall, initiateCall, sendDtmf, speak } from "./outbound.js";
function createActiveCallContext(params: { hangupCall?: ReturnType<typeof vi.fn> } = {}) {
const call = { callId: "call-1", providerCallId: "provider-1", state: "active" };
@@ -226,6 +226,47 @@ describe("voice-call outbound helpers", () => {
expect(transitionStateMock).toHaveBeenLastCalledWith(call, "listening");
});
it("sends DTMF through connected provider calls", async () => {
const call = { callId: "call-1", providerCallId: "provider-1", state: "active" };
const sendDtmfProvider = vi.fn(async () => {});
const ctx = {
activeCalls: new Map([["call-1", call]]),
providerCallIdMap: new Map(),
provider: { name: "twilio", sendDtmf: sendDtmfProvider },
config: {},
storePath: "/tmp/voice-call.json",
};
await expect(sendDtmf(ctx as never, "call-1", "ww123#")).resolves.toEqual({
success: true,
});
expect(sendDtmfProvider).toHaveBeenCalledWith({
callId: "call-1",
providerCallId: "provider-1",
digits: "ww123#",
});
});
it("rejects invalid or unsupported outbound DTMF", async () => {
const call = { callId: "call-1", providerCallId: "provider-1", state: "active" };
const ctx = {
activeCalls: new Map([["call-1", call]]),
providerCallIdMap: new Map(),
provider: { name: "telnyx" },
config: {},
storePath: "/tmp/voice-call.json",
};
await expect(sendDtmf(ctx as never, "call-1", "abc")).resolves.toEqual({
success: false,
error: "digits may only contain digits, *, #, comma, w, p",
});
await expect(sendDtmf(ctx as never, "call-1", "123#")).resolves.toEqual({
success: false,
error: "telnyx does not support outbound DTMF",
});
});
it("ends connected calls, clears timers, and rejects pending transcripts", async () => {
const { call, ctx, hangupCall } = createActiveCallContext();

View File

@@ -102,6 +102,12 @@ function requireConnectedCall(ctx: ConnectedCallContext, callId: CallId): Connec
};
}
function validateDtmfDigits(digits: string): string | null {
return /^[0-9*#wWpP,]+$/.test(digits)
? null
: "digits may only contain digits, *, #, comma, w, p";
}
export async function initiateCall(
ctx: InitiateContext,
to: string,
@@ -227,6 +233,35 @@ export async function speak(
}
}
export async function sendDtmf(
ctx: SpeakContext,
callId: CallId,
digits: string,
): Promise<{ success: boolean; error?: string }> {
const validationError = validateDtmfDigits(digits);
if (validationError) {
return { success: false, error: validationError };
}
const connected = requireConnectedCall(ctx, callId);
if (!connected.ok) {
return { success: false, error: connected.error };
}
if (!connected.provider.sendDtmf) {
return { success: false, error: `${connected.provider.name} does not support outbound DTMF` };
}
try {
await connected.provider.sendDtmf({
callId,
providerCallId: connected.providerCallId,
digits,
});
return { success: true };
} catch (err) {
return { success: false, error: formatErrorMessage(err) };
}
}
export async function speakInitialMessage(
ctx: ConversationContext,
providerCallId: string,

View File

@@ -6,6 +6,7 @@ import type {
InitiateCallResult,
PlayTtsInput,
ProviderName,
SendDtmfInput,
WebhookParseOptions,
ProviderWebhookParseResult,
StartListeningInput,
@@ -58,6 +59,11 @@ export interface VoiceCallProvider {
*/
playTts(input: PlayTtsInput): Promise<void>;
/**
* Send DTMF digits to an active call.
*/
sendDtmf?: (input: SendDtmfInput) => Promise<void>;
/**
* Start listening for user speech (activate STT).
*/

View File

@@ -11,6 +11,7 @@ import type {
PlayTtsInput,
WebhookParseOptions,
ProviderWebhookParseResult,
SendDtmfInput,
StartListeningInput,
StopListeningInput,
WebhookContext,
@@ -162,6 +163,10 @@ export class MockProvider implements VoiceCallProvider {
// No-op for mock
}
async sendDtmf(_input: SendDtmfInput): Promise<void> {
// No-op for mock
}
async startListening(_input: StartListeningInput): Promise<void> {
// No-op for mock
}

View File

@@ -280,6 +280,29 @@ describe("TwilioProvider", () => {
expect(params.Twiml).toContain("<Say");
});
it("sends DTMF by updating the call and redirecting back to the webhook", async () => {
const { provider, apiRequest } = configureTelephonyTwiMlFallback({
providerCallId: "CA-dtmf",
});
await expect(
provider.sendDtmf({
callId: "call-dtmf",
providerCallId: "CA-dtmf",
digits: "ww123#",
}),
).resolves.toBeUndefined();
expect(apiRequest).toHaveBeenCalledTimes(1);
const call = apiRequest.mock.calls[0];
const endpoint = call[0];
const params = call[1] as { Twiml?: string };
expect(endpoint).toBe("/Calls/CA-dtmf.json");
expect(params.Twiml).toContain('<Play digits="ww123#"');
expect(params.Twiml).toContain("<Redirect");
expect(params.Twiml).toContain("https://example.ngrok.app/voice/twilio");
});
it("ignores stale stream unregister requests that do not match current stream SID", () => {
const provider = createProvider();
provider.registerCallStream("CA-reconnect", "MZ-new");

View File

@@ -15,6 +15,7 @@ import type {
NormalizedEvent,
PlayTtsInput,
ProviderWebhookParseResult,
SendDtmfInput,
StartListeningInput,
StopListeningInput,
WebhookContext,
@@ -594,6 +595,23 @@ export class TwilioProvider implements VoiceCallProvider {
});
}
async sendDtmf(input: SendDtmfInput): Promise<void> {
const webhookUrl = this.callWebhookUrls.get(input.providerCallId);
if (!webhookUrl) {
throw new Error("Missing webhook URL for this call (provider state not initialized)");
}
const twiml = `<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Play digits="${escapeXml(input.digits)}" />
<Redirect method="POST">${escapeXml(webhookUrl)}</Redirect>
</Response>`;
await this.apiRequest(`/Calls/${input.providerCallId}.json`, {
Twiml: twiml,
});
}
/**
* Play TTS via core TTS and Twilio Media Streams.
* Generates audio with core TTS, converts to mu-law, and streams via WebSocket.

View File

@@ -235,6 +235,12 @@ export type PlayTtsInput = {
locale?: string;
};
export type SendDtmfInput = {
callId: CallId;
providerCallId: ProviderCallId;
digits: string;
};
export type StartListeningInput = {
callId: CallId;
providerCallId: ProviderCallId;
@@ -274,6 +280,8 @@ export type OutboundCallOptions = {
message?: string;
/** Call mode (overrides config default) */
mode?: CallMode;
/** DTMF digits to send after the call is connected */
dtmfSequence?: string;
};
// -----------------------------------------------------------------------------