mirror of
https://fastgit.cc/github.com/openclaw/openclaw
synced 2026-04-30 22:12:32 +08:00
feat: support DTMF for voice-call
This commit is contained in:
@@ -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>
|
||||
```
|
||||
|
||||
|
||||
@@ -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`)
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"TELNYX_PUBLIC_KEY",
|
||||
"TWILIO_ACCOUNT_SID",
|
||||
"TWILIO_AUTH_TOKEN",
|
||||
"TWILIO_FROM_NUMBER",
|
||||
"PLIVO_AUTH_ID",
|
||||
"PLIVO_AUTH_TOKEN",
|
||||
"NGROK_AUTHTOKEN",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")));
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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).
|
||||
*/
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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).
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user