@openclaw/voice-call 2026.1.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/README.md +135 -0
  3. package/index.ts +497 -0
  4. package/openclaw.plugin.json +601 -0
  5. package/package.json +16 -0
  6. package/src/cli.ts +312 -0
  7. package/src/config.test.ts +204 -0
  8. package/src/config.ts +502 -0
  9. package/src/core-bridge.ts +198 -0
  10. package/src/manager/context.ts +21 -0
  11. package/src/manager/events.ts +177 -0
  12. package/src/manager/lookup.ts +33 -0
  13. package/src/manager/outbound.ts +248 -0
  14. package/src/manager/state.ts +50 -0
  15. package/src/manager/store.ts +88 -0
  16. package/src/manager/timers.ts +86 -0
  17. package/src/manager/twiml.ts +9 -0
  18. package/src/manager.test.ts +108 -0
  19. package/src/manager.ts +888 -0
  20. package/src/media-stream.test.ts +97 -0
  21. package/src/media-stream.ts +393 -0
  22. package/src/providers/base.ts +67 -0
  23. package/src/providers/index.ts +10 -0
  24. package/src/providers/mock.ts +168 -0
  25. package/src/providers/plivo.test.ts +28 -0
  26. package/src/providers/plivo.ts +504 -0
  27. package/src/providers/stt-openai-realtime.ts +311 -0
  28. package/src/providers/telnyx.ts +364 -0
  29. package/src/providers/tts-openai.ts +264 -0
  30. package/src/providers/twilio/api.ts +45 -0
  31. package/src/providers/twilio/webhook.ts +30 -0
  32. package/src/providers/twilio.test.ts +64 -0
  33. package/src/providers/twilio.ts +595 -0
  34. package/src/response-generator.ts +171 -0
  35. package/src/runtime.ts +217 -0
  36. package/src/telephony-audio.ts +88 -0
  37. package/src/telephony-tts.ts +95 -0
  38. package/src/tunnel.ts +331 -0
  39. package/src/types.ts +273 -0
  40. package/src/utils.ts +12 -0
  41. package/src/voice-mapping.ts +65 -0
  42. package/src/webhook-security.test.ts +260 -0
  43. package/src/webhook-security.ts +469 -0
  44. package/src/webhook.ts +491 -0
package/src/cli.ts ADDED
@@ -0,0 +1,312 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ import type { Command } from "commander";
6
+
7
+ import type { VoiceCallConfig } from "./config.js";
8
+ import type { VoiceCallRuntime } from "./runtime.js";
9
+ import { resolveUserPath } from "./utils.js";
10
+ import {
11
+ cleanupTailscaleExposureRoute,
12
+ getTailscaleSelfInfo,
13
+ setupTailscaleExposureRoute,
14
+ } from "./webhook.js";
15
+
16
+ type Logger = {
17
+ info: (message: string) => void;
18
+ warn: (message: string) => void;
19
+ error: (message: string) => void;
20
+ };
21
+
22
+ function resolveMode(input: string): "off" | "serve" | "funnel" {
23
+ const raw = input.trim().toLowerCase();
24
+ if (raw === "serve" || raw === "off") return raw;
25
+ return "funnel";
26
+ }
27
+
28
+ function resolveDefaultStorePath(config: VoiceCallConfig): string {
29
+ const preferred = path.join(os.homedir(), ".openclaw", "voice-calls");
30
+ const resolvedPreferred = resolveUserPath(preferred);
31
+ const existing =
32
+ [resolvedPreferred].find((dir) => {
33
+ try {
34
+ return (
35
+ fs.existsSync(path.join(dir, "calls.jsonl")) ||
36
+ fs.existsSync(dir)
37
+ );
38
+ } catch {
39
+ return false;
40
+ }
41
+ }) ?? resolvedPreferred;
42
+ const base = config.store?.trim() ? resolveUserPath(config.store) : existing;
43
+ return path.join(base, "calls.jsonl");
44
+ }
45
+
46
+ function sleep(ms: number): Promise<void> {
47
+ return new Promise((resolve) => setTimeout(resolve, ms));
48
+ }
49
+
50
+ export function registerVoiceCallCli(params: {
51
+ program: Command;
52
+ config: VoiceCallConfig;
53
+ ensureRuntime: () => Promise<VoiceCallRuntime>;
54
+ logger: Logger;
55
+ }) {
56
+ const { program, config, ensureRuntime, logger } = params;
57
+ const root = program
58
+ .command("voicecall")
59
+ .description("Voice call utilities")
60
+ .addHelpText("after", () => `\nDocs: https://docs.openclaw.ai/cli/voicecall\n`);
61
+
62
+ root
63
+ .command("call")
64
+ .description("Initiate an outbound voice call")
65
+ .requiredOption(
66
+ "-m, --message <text>",
67
+ "Message to speak when call connects",
68
+ )
69
+ .option(
70
+ "-t, --to <phone>",
71
+ "Phone number to call (E.164 format, uses config toNumber if not set)",
72
+ )
73
+ .option(
74
+ "--mode <mode>",
75
+ "Call mode: notify (hangup after message) or conversation (stay open)",
76
+ "conversation",
77
+ )
78
+ .action(
79
+ async (options: { message: string; to?: string; mode?: string }) => {
80
+ const rt = await ensureRuntime();
81
+ const to = options.to ?? rt.config.toNumber;
82
+ if (!to) {
83
+ throw new Error("Missing --to and no toNumber configured");
84
+ }
85
+ const result = await rt.manager.initiateCall(to, undefined, {
86
+ message: options.message,
87
+ mode:
88
+ options.mode === "notify" || options.mode === "conversation"
89
+ ? options.mode
90
+ : undefined,
91
+ });
92
+ if (!result.success) {
93
+ throw new Error(result.error || "initiate failed");
94
+ }
95
+ // eslint-disable-next-line no-console
96
+ console.log(JSON.stringify({ callId: result.callId }, null, 2));
97
+ },
98
+ );
99
+
100
+ root
101
+ .command("start")
102
+ .description("Alias for voicecall call")
103
+ .requiredOption("--to <phone>", "Phone number to call")
104
+ .option("--message <text>", "Message to speak when call connects")
105
+ .option(
106
+ "--mode <mode>",
107
+ "Call mode: notify (hangup after message) or conversation (stay open)",
108
+ "conversation",
109
+ )
110
+ .action(
111
+ async (options: { to: string; message?: string; mode?: string }) => {
112
+ const rt = await ensureRuntime();
113
+ const result = await rt.manager.initiateCall(options.to, undefined, {
114
+ message: options.message,
115
+ mode:
116
+ options.mode === "notify" || options.mode === "conversation"
117
+ ? options.mode
118
+ : undefined,
119
+ });
120
+ if (!result.success) {
121
+ throw new Error(result.error || "initiate failed");
122
+ }
123
+ // eslint-disable-next-line no-console
124
+ console.log(JSON.stringify({ callId: result.callId }, null, 2));
125
+ },
126
+ );
127
+
128
+ root
129
+ .command("continue")
130
+ .description("Speak a message and wait for a response")
131
+ .requiredOption("--call-id <id>", "Call ID")
132
+ .requiredOption("--message <text>", "Message to speak")
133
+ .action(async (options: { callId: string; message: string }) => {
134
+ const rt = await ensureRuntime();
135
+ const result = await rt.manager.continueCall(
136
+ options.callId,
137
+ options.message,
138
+ );
139
+ if (!result.success) {
140
+ throw new Error(result.error || "continue failed");
141
+ }
142
+ // eslint-disable-next-line no-console
143
+ console.log(JSON.stringify(result, null, 2));
144
+ });
145
+
146
+ root
147
+ .command("speak")
148
+ .description("Speak a message without waiting for response")
149
+ .requiredOption("--call-id <id>", "Call ID")
150
+ .requiredOption("--message <text>", "Message to speak")
151
+ .action(async (options: { callId: string; message: string }) => {
152
+ const rt = await ensureRuntime();
153
+ const result = await rt.manager.speak(options.callId, options.message);
154
+ if (!result.success) {
155
+ throw new Error(result.error || "speak failed");
156
+ }
157
+ // eslint-disable-next-line no-console
158
+ console.log(JSON.stringify(result, null, 2));
159
+ });
160
+
161
+ root
162
+ .command("end")
163
+ .description("Hang up an active call")
164
+ .requiredOption("--call-id <id>", "Call ID")
165
+ .action(async (options: { callId: string }) => {
166
+ const rt = await ensureRuntime();
167
+ const result = await rt.manager.endCall(options.callId);
168
+ if (!result.success) {
169
+ throw new Error(result.error || "end failed");
170
+ }
171
+ // eslint-disable-next-line no-console
172
+ console.log(JSON.stringify(result, null, 2));
173
+ });
174
+
175
+ root
176
+ .command("status")
177
+ .description("Show call status")
178
+ .requiredOption("--call-id <id>", "Call ID")
179
+ .action(async (options: { callId: string }) => {
180
+ const rt = await ensureRuntime();
181
+ const call = rt.manager.getCall(options.callId);
182
+ // eslint-disable-next-line no-console
183
+ console.log(JSON.stringify(call ?? { found: false }, null, 2));
184
+ });
185
+
186
+ root
187
+ .command("tail")
188
+ .description(
189
+ "Tail voice-call JSONL logs (prints new lines; useful during provider tests)",
190
+ )
191
+ .option("--file <path>", "Path to calls.jsonl", resolveDefaultStorePath(config))
192
+ .option("--since <n>", "Print last N lines first", "25")
193
+ .option("--poll <ms>", "Poll interval in ms", "250")
194
+ .action(
195
+ async (options: { file: string; since?: string; poll?: string }) => {
196
+ const file = options.file;
197
+ const since = Math.max(0, Number(options.since ?? 0));
198
+ const pollMs = Math.max(50, Number(options.poll ?? 250));
199
+
200
+ if (!fs.existsSync(file)) {
201
+ logger.error(`No log file at ${file}`);
202
+ process.exit(1);
203
+ }
204
+
205
+ const initial = fs.readFileSync(file, "utf8");
206
+ const lines = initial.split("\n").filter(Boolean);
207
+ for (const line of lines.slice(Math.max(0, lines.length - since))) {
208
+ // eslint-disable-next-line no-console
209
+ console.log(line);
210
+ }
211
+
212
+ let offset = Buffer.byteLength(initial, "utf8");
213
+
214
+ for (;;) {
215
+ try {
216
+ const stat = fs.statSync(file);
217
+ if (stat.size < offset) {
218
+ offset = 0;
219
+ }
220
+ if (stat.size > offset) {
221
+ const fd = fs.openSync(file, "r");
222
+ try {
223
+ const buf = Buffer.alloc(stat.size - offset);
224
+ fs.readSync(fd, buf, 0, buf.length, offset);
225
+ offset = stat.size;
226
+ const text = buf.toString("utf8");
227
+ for (const line of text.split("\n").filter(Boolean)) {
228
+ // eslint-disable-next-line no-console
229
+ console.log(line);
230
+ }
231
+ } finally {
232
+ fs.closeSync(fd);
233
+ }
234
+ }
235
+ } catch {
236
+ // ignore and retry
237
+ }
238
+ await sleep(pollMs);
239
+ }
240
+ },
241
+ );
242
+
243
+ root
244
+ .command("expose")
245
+ .description("Enable/disable Tailscale serve/funnel for the webhook")
246
+ .option("--mode <mode>", "off | serve (tailnet) | funnel (public)", "funnel")
247
+ .option(
248
+ "--path <path>",
249
+ "Tailscale path to expose (recommend matching serve.path)",
250
+ )
251
+ .option("--port <port>", "Local webhook port")
252
+ .option("--serve-path <path>", "Local webhook path")
253
+ .action(
254
+ async (options: {
255
+ mode?: string;
256
+ port?: string;
257
+ path?: string;
258
+ servePath?: string;
259
+ }) => {
260
+ const mode = resolveMode(options.mode ?? "funnel");
261
+ const servePort = Number(options.port ?? config.serve.port ?? 3334);
262
+ const servePath = String(
263
+ options.servePath ?? config.serve.path ?? "/voice/webhook",
264
+ );
265
+ const tsPath = String(
266
+ options.path ?? config.tailscale?.path ?? servePath,
267
+ );
268
+
269
+ const localUrl = `http://127.0.0.1:${servePort}`;
270
+
271
+ if (mode === "off") {
272
+ await cleanupTailscaleExposureRoute({ mode: "serve", path: tsPath });
273
+ await cleanupTailscaleExposureRoute({ mode: "funnel", path: tsPath });
274
+ // eslint-disable-next-line no-console
275
+ console.log(JSON.stringify({ ok: true, mode: "off", path: tsPath }, null, 2));
276
+ return;
277
+ }
278
+
279
+ const publicUrl = await setupTailscaleExposureRoute({
280
+ mode,
281
+ path: tsPath,
282
+ localUrl,
283
+ });
284
+
285
+ const tsInfo = publicUrl ? null : await getTailscaleSelfInfo();
286
+ const enableUrl = tsInfo?.nodeId
287
+ ? `https://login.tailscale.com/f/${mode}?node=${tsInfo.nodeId}`
288
+ : null;
289
+
290
+ // eslint-disable-next-line no-console
291
+ console.log(
292
+ JSON.stringify(
293
+ {
294
+ ok: Boolean(publicUrl),
295
+ mode,
296
+ path: tsPath,
297
+ localUrl,
298
+ publicUrl,
299
+ hint: publicUrl
300
+ ? undefined
301
+ : {
302
+ note: "Tailscale serve/funnel may be disabled on this tailnet (or require admin enable).",
303
+ enableUrl,
304
+ },
305
+ },
306
+ null,
307
+ 2,
308
+ ),
309
+ );
310
+ },
311
+ );
312
+ }
@@ -0,0 +1,204 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
2
+
3
+ import { validateProviderConfig, resolveVoiceCallConfig, type VoiceCallConfig } from "./config.js";
4
+
5
+ function createBaseConfig(
6
+ provider: "telnyx" | "twilio" | "plivo" | "mock",
7
+ ): VoiceCallConfig {
8
+ return {
9
+ enabled: true,
10
+ provider,
11
+ fromNumber: "+15550001234",
12
+ inboundPolicy: "disabled",
13
+ allowFrom: [],
14
+ outbound: { defaultMode: "notify", notifyHangupDelaySec: 3 },
15
+ maxDurationSeconds: 300,
16
+ silenceTimeoutMs: 800,
17
+ transcriptTimeoutMs: 180000,
18
+ ringTimeoutMs: 30000,
19
+ maxConcurrentCalls: 1,
20
+ serve: { port: 3334, bind: "127.0.0.1", path: "/voice/webhook" },
21
+ tailscale: { mode: "off", path: "/voice/webhook" },
22
+ tunnel: { provider: "none", allowNgrokFreeTierLoopbackBypass: false },
23
+ streaming: {
24
+ enabled: false,
25
+ sttProvider: "openai-realtime",
26
+ sttModel: "gpt-4o-transcribe",
27
+ silenceDurationMs: 800,
28
+ vadThreshold: 0.5,
29
+ streamPath: "/voice/stream",
30
+ },
31
+ skipSignatureVerification: false,
32
+ stt: { provider: "openai", model: "whisper-1" },
33
+ tts: { provider: "openai", model: "gpt-4o-mini-tts", voice: "coral" },
34
+ responseModel: "openai/gpt-4o-mini",
35
+ responseTimeoutMs: 30000,
36
+ };
37
+ }
38
+
39
+ describe("validateProviderConfig", () => {
40
+ const originalEnv = { ...process.env };
41
+
42
+ beforeEach(() => {
43
+ // Clear all relevant env vars before each test
44
+ delete process.env.TWILIO_ACCOUNT_SID;
45
+ delete process.env.TWILIO_AUTH_TOKEN;
46
+ delete process.env.TELNYX_API_KEY;
47
+ delete process.env.TELNYX_CONNECTION_ID;
48
+ delete process.env.PLIVO_AUTH_ID;
49
+ delete process.env.PLIVO_AUTH_TOKEN;
50
+ });
51
+
52
+ afterEach(() => {
53
+ // Restore original env
54
+ process.env = { ...originalEnv };
55
+ });
56
+
57
+ describe("twilio provider", () => {
58
+ it("passes validation when credentials are in config", () => {
59
+ const config = createBaseConfig("twilio");
60
+ config.twilio = { accountSid: "AC123", authToken: "secret" };
61
+
62
+ const result = validateProviderConfig(config);
63
+
64
+ expect(result.valid).toBe(true);
65
+ expect(result.errors).toEqual([]);
66
+ });
67
+
68
+ it("passes validation when credentials are in environment variables", () => {
69
+ process.env.TWILIO_ACCOUNT_SID = "AC123";
70
+ process.env.TWILIO_AUTH_TOKEN = "secret";
71
+ let config = createBaseConfig("twilio");
72
+ config = resolveVoiceCallConfig(config);
73
+
74
+ const result = validateProviderConfig(config);
75
+
76
+ expect(result.valid).toBe(true);
77
+ expect(result.errors).toEqual([]);
78
+ });
79
+
80
+ it("passes validation with mixed config and env vars", () => {
81
+ process.env.TWILIO_AUTH_TOKEN = "secret";
82
+ let config = createBaseConfig("twilio");
83
+ config.twilio = { accountSid: "AC123" };
84
+ config = resolveVoiceCallConfig(config);
85
+
86
+ const result = validateProviderConfig(config);
87
+
88
+ expect(result.valid).toBe(true);
89
+ expect(result.errors).toEqual([]);
90
+ });
91
+
92
+ it("fails validation when accountSid is missing everywhere", () => {
93
+ process.env.TWILIO_AUTH_TOKEN = "secret";
94
+ let config = createBaseConfig("twilio");
95
+ config = resolveVoiceCallConfig(config);
96
+
97
+ const result = validateProviderConfig(config);
98
+
99
+ expect(result.valid).toBe(false);
100
+ expect(result.errors).toContain(
101
+ "plugins.entries.voice-call.config.twilio.accountSid is required (or set TWILIO_ACCOUNT_SID env)",
102
+ );
103
+ });
104
+
105
+ it("fails validation when authToken is missing everywhere", () => {
106
+ process.env.TWILIO_ACCOUNT_SID = "AC123";
107
+ let config = createBaseConfig("twilio");
108
+ config = resolveVoiceCallConfig(config);
109
+
110
+ const result = validateProviderConfig(config);
111
+
112
+ expect(result.valid).toBe(false);
113
+ expect(result.errors).toContain(
114
+ "plugins.entries.voice-call.config.twilio.authToken is required (or set TWILIO_AUTH_TOKEN env)",
115
+ );
116
+ });
117
+ });
118
+
119
+ describe("telnyx provider", () => {
120
+ it("passes validation when credentials are in config", () => {
121
+ const config = createBaseConfig("telnyx");
122
+ config.telnyx = { apiKey: "KEY123", connectionId: "CONN456" };
123
+
124
+ const result = validateProviderConfig(config);
125
+
126
+ expect(result.valid).toBe(true);
127
+ expect(result.errors).toEqual([]);
128
+ });
129
+
130
+ it("passes validation when credentials are in environment variables", () => {
131
+ process.env.TELNYX_API_KEY = "KEY123";
132
+ process.env.TELNYX_CONNECTION_ID = "CONN456";
133
+ let config = createBaseConfig("telnyx");
134
+ config = resolveVoiceCallConfig(config);
135
+
136
+ const result = validateProviderConfig(config);
137
+
138
+ expect(result.valid).toBe(true);
139
+ expect(result.errors).toEqual([]);
140
+ });
141
+
142
+ it("fails validation when apiKey is missing everywhere", () => {
143
+ process.env.TELNYX_CONNECTION_ID = "CONN456";
144
+ let config = createBaseConfig("telnyx");
145
+ config = resolveVoiceCallConfig(config);
146
+
147
+ const result = validateProviderConfig(config);
148
+
149
+ expect(result.valid).toBe(false);
150
+ expect(result.errors).toContain(
151
+ "plugins.entries.voice-call.config.telnyx.apiKey is required (or set TELNYX_API_KEY env)",
152
+ );
153
+ });
154
+ });
155
+
156
+ describe("plivo provider", () => {
157
+ it("passes validation when credentials are in config", () => {
158
+ const config = createBaseConfig("plivo");
159
+ config.plivo = { authId: "MA123", authToken: "secret" };
160
+
161
+ const result = validateProviderConfig(config);
162
+
163
+ expect(result.valid).toBe(true);
164
+ expect(result.errors).toEqual([]);
165
+ });
166
+
167
+ it("passes validation when credentials are in environment variables", () => {
168
+ process.env.PLIVO_AUTH_ID = "MA123";
169
+ process.env.PLIVO_AUTH_TOKEN = "secret";
170
+ let config = createBaseConfig("plivo");
171
+ config = resolveVoiceCallConfig(config);
172
+
173
+ const result = validateProviderConfig(config);
174
+
175
+ expect(result.valid).toBe(true);
176
+ expect(result.errors).toEqual([]);
177
+ });
178
+
179
+ it("fails validation when authId is missing everywhere", () => {
180
+ process.env.PLIVO_AUTH_TOKEN = "secret";
181
+ let config = createBaseConfig("plivo");
182
+ config = resolveVoiceCallConfig(config);
183
+
184
+ const result = validateProviderConfig(config);
185
+
186
+ expect(result.valid).toBe(false);
187
+ expect(result.errors).toContain(
188
+ "plugins.entries.voice-call.config.plivo.authId is required (or set PLIVO_AUTH_ID env)",
189
+ );
190
+ });
191
+ });
192
+
193
+ describe("disabled config", () => {
194
+ it("skips validation when enabled is false", () => {
195
+ const config = createBaseConfig("twilio");
196
+ config.enabled = false;
197
+
198
+ const result = validateProviderConfig(config);
199
+
200
+ expect(result.valid).toBe(true);
201
+ expect(result.errors).toEqual([]);
202
+ });
203
+ });
204
+ });