@openclaw/voice-call 2026.3.13 → 2026.5.2-beta.1

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 (103) hide show
  1. package/README.md +27 -5
  2. package/api.ts +16 -0
  3. package/cli-metadata.ts +10 -0
  4. package/config-api.ts +12 -0
  5. package/index.test.ts +943 -0
  6. package/index.ts +379 -149
  7. package/openclaw.plugin.json +384 -157
  8. package/package.json +35 -5
  9. package/runtime-api.ts +20 -0
  10. package/runtime-entry.ts +1 -0
  11. package/setup-api.ts +47 -0
  12. package/src/allowlist.test.ts +18 -0
  13. package/src/cli.ts +533 -68
  14. package/src/config-compat.test.ts +120 -0
  15. package/src/config-compat.ts +227 -0
  16. package/src/config.test.ts +273 -12
  17. package/src/config.ts +355 -72
  18. package/src/core-bridge.ts +2 -147
  19. package/src/deep-merge.test.ts +40 -0
  20. package/src/gateway-continue-operation.ts +200 -0
  21. package/src/http-headers.ts +6 -3
  22. package/src/manager/context.ts +6 -5
  23. package/src/manager/events.test.ts +243 -19
  24. package/src/manager/events.ts +61 -31
  25. package/src/manager/lifecycle.ts +53 -0
  26. package/src/manager/lookup.test.ts +52 -0
  27. package/src/manager/outbound.test.ts +528 -0
  28. package/src/manager/outbound.ts +163 -57
  29. package/src/manager/store.ts +18 -6
  30. package/src/manager/timers.test.ts +129 -0
  31. package/src/manager/timers.ts +4 -3
  32. package/src/manager/twiml.test.ts +13 -0
  33. package/src/manager/twiml.ts +8 -0
  34. package/src/manager.closed-loop.test.ts +30 -12
  35. package/src/manager.inbound-allowlist.test.ts +77 -10
  36. package/src/manager.notify.test.ts +344 -20
  37. package/src/manager.restore.test.ts +95 -8
  38. package/src/manager.test-harness.ts +8 -6
  39. package/src/manager.ts +79 -5
  40. package/src/media-stream.test.ts +578 -81
  41. package/src/media-stream.ts +235 -54
  42. package/src/providers/base.ts +19 -0
  43. package/src/providers/mock.ts +7 -1
  44. package/src/providers/plivo.test.ts +50 -6
  45. package/src/providers/plivo.ts +14 -6
  46. package/src/providers/shared/call-status.ts +2 -1
  47. package/src/providers/shared/guarded-json-api.test.ts +106 -0
  48. package/src/providers/shared/guarded-json-api.ts +1 -1
  49. package/src/providers/telnyx.test.ts +178 -6
  50. package/src/providers/telnyx.ts +40 -3
  51. package/src/providers/twilio/api.test.ts +145 -0
  52. package/src/providers/twilio/api.ts +67 -16
  53. package/src/providers/twilio/twiml-policy.ts +6 -10
  54. package/src/providers/twilio/webhook.ts +1 -1
  55. package/src/providers/twilio.test.ts +425 -25
  56. package/src/providers/twilio.ts +230 -77
  57. package/src/providers/twilio.types.ts +17 -0
  58. package/src/realtime-defaults.ts +3 -0
  59. package/src/realtime-fast-context.test.ts +88 -0
  60. package/src/realtime-fast-context.ts +165 -0
  61. package/src/realtime-transcription.runtime.ts +4 -0
  62. package/src/realtime-voice.runtime.ts +5 -0
  63. package/src/response-generator.test.ts +321 -0
  64. package/src/response-generator.ts +213 -53
  65. package/src/response-model.test.ts +71 -0
  66. package/src/response-model.ts +23 -0
  67. package/src/runtime.test.ts +429 -0
  68. package/src/runtime.ts +270 -24
  69. package/src/telephony-audio.test.ts +61 -0
  70. package/src/telephony-audio.ts +1 -79
  71. package/src/telephony-tts.test.ts +133 -12
  72. package/src/telephony-tts.ts +155 -2
  73. package/src/test-fixtures.ts +28 -7
  74. package/src/tts-provider-voice.test.ts +34 -0
  75. package/src/tts-provider-voice.ts +21 -0
  76. package/src/tunnel.test.ts +166 -0
  77. package/src/tunnel.ts +1 -1
  78. package/src/types.ts +24 -37
  79. package/src/utils.test.ts +17 -0
  80. package/src/voice-mapping.test.ts +34 -0
  81. package/src/voice-mapping.ts +3 -2
  82. package/src/webhook/realtime-handler.test.ts +598 -0
  83. package/src/webhook/realtime-handler.ts +485 -0
  84. package/src/webhook/stale-call-reaper.test.ts +88 -0
  85. package/src/webhook/stale-call-reaper.ts +5 -0
  86. package/src/webhook/tailscale.test.ts +214 -0
  87. package/src/webhook/tailscale.ts +19 -5
  88. package/src/webhook-exposure.test.ts +33 -0
  89. package/src/webhook-exposure.ts +84 -0
  90. package/src/webhook-security.test.ts +172 -21
  91. package/src/webhook-security.ts +43 -29
  92. package/src/webhook.hangup-once.lifecycle.test.ts +135 -0
  93. package/src/webhook.test.ts +1145 -27
  94. package/src/webhook.ts +523 -102
  95. package/src/webhook.types.ts +5 -0
  96. package/src/websocket-test-support.ts +72 -0
  97. package/tsconfig.json +16 -0
  98. package/CHANGELOG.md +0 -121
  99. package/src/providers/index.ts +0 -10
  100. package/src/providers/stt-openai-realtime.test.ts +0 -42
  101. package/src/providers/stt-openai-realtime.ts +0 -311
  102. package/src/providers/tts-openai.test.ts +0 -43
  103. package/src/providers/tts-openai.ts +0 -221
@@ -19,14 +19,15 @@ import type {
19
19
  } from "./types.js";
20
20
 
21
21
  export class FakeProvider implements VoiceCallProvider {
22
- readonly name: "plivo" | "twilio";
22
+ readonly name: "plivo" | "twilio" | "telnyx";
23
+ twilioStreamConnectEnabled = true;
23
24
  readonly playTtsCalls: PlayTtsInput[] = [];
24
25
  readonly hangupCalls: HangupCallInput[] = [];
25
26
  readonly startListeningCalls: StartListeningInput[] = [];
26
27
  readonly stopListeningCalls: StopListeningInput[] = [];
27
28
  getCallStatusResult: GetCallStatusResult = { status: "in-progress", isTerminal: false };
28
29
 
29
- constructor(name: "plivo" | "twilio" = "plivo") {
30
+ constructor(name: "plivo" | "twilio" | "telnyx" = "plivo") {
30
31
  this.name = name;
31
32
  }
32
33
 
@@ -61,13 +62,14 @@ export class FakeProvider implements VoiceCallProvider {
61
62
  async getCallStatus(_input: GetCallStatusInput): Promise<GetCallStatusResult> {
62
63
  return this.getCallStatusResult;
63
64
  }
64
- }
65
65
 
66
- let storeSeq = 0;
66
+ isConversationStreamConnectEnabled(): boolean {
67
+ return this.name === "twilio" && this.twilioStreamConnectEnabled;
68
+ }
69
+ }
67
70
 
68
71
  export function createTestStorePath(): string {
69
- storeSeq += 1;
70
- return path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}-${storeSeq}`);
72
+ return fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-voice-call-test-"));
71
73
  }
72
74
 
73
75
  export async function createManagerHarness(
package/src/manager.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
+ import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
5
+ import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
4
6
  import type { VoiceCallConfig } from "./config.js";
5
7
  import type { CallManagerContext } from "./manager/context.js";
6
8
  import { processEvent as processManagerEvent } from "./manager/events.js";
@@ -9,10 +11,15 @@ import {
9
11
  continueCall as continueCallWithContext,
10
12
  endCall as endCallWithContext,
11
13
  initiateCall as initiateCallWithContext,
14
+ sendDtmf as sendDtmfWithContext,
12
15
  speak as speakWithContext,
13
16
  speakInitialMessage as speakInitialMessageWithContext,
14
17
  } from "./manager/outbound.js";
15
- import { getCallHistoryFromStore, loadActiveCallsFromStore } from "./manager/store.js";
18
+ import {
19
+ getCallHistoryFromStore,
20
+ loadActiveCallsFromStore,
21
+ persistCallRecord,
22
+ } from "./manager/store.js";
16
23
  import { startMaxDurationTimer } from "./manager/timers.js";
17
24
  import type { VoiceCallProvider } from "./providers/base.js";
18
25
  import {
@@ -24,6 +31,12 @@ import {
24
31
  } from "./types.js";
25
32
  import { resolveUserPath } from "./utils.js";
26
33
 
34
+ function markRestoredCallSkipped(call: CallRecord, endReason: "completed" | "timeout"): void {
35
+ call.endedAt = Date.now();
36
+ call.endReason = endReason;
37
+ call.state = endReason;
38
+ }
39
+
27
40
  function resolveDefaultStoreBase(config: VoiceCallConfig, storePath?: string): string {
28
41
  const rawOverride = storePath?.trim() || config.store?.trim();
29
42
  if (rawOverride) {
@@ -64,6 +77,7 @@ export class CallManager {
64
77
  }
65
78
  >();
66
79
  private maxDurationTimers = new Map<CallId, NodeJS.Timeout>();
80
+ private initialMessageInFlight = new Set<CallId>();
67
81
 
68
82
  constructor(config: VoiceCallConfig, storePath?: string) {
69
83
  this.config = config;
@@ -114,8 +128,9 @@ export class CallManager {
114
128
  startMaxDurationTimer({
115
129
  ctx: this.getContext(),
116
130
  callId,
131
+ timeoutMs: maxDurationMs - elapsed,
117
132
  onTimeout: async (id) => {
118
- await endCallWithContext(this.getContext(), id);
133
+ await endCallWithContext(this.getContext(), id, { reason: "timeout" });
119
134
  },
120
135
  });
121
136
  console.log(`[voice-call] Restarted max-duration timer for restored call ${callId}`);
@@ -157,6 +172,20 @@ export class CallManager {
157
172
  console.log(
158
173
  `[voice-call] Skipping restored call ${callId} (older than maxDurationSeconds)`,
159
174
  );
175
+ markRestoredCallSkipped(call, "timeout");
176
+ persistCallRecord(this.storePath, call);
177
+ await provider
178
+ .hangupCall({
179
+ callId,
180
+ providerCallId: call.providerCallId,
181
+ reason: "timeout",
182
+ })
183
+ .catch((err) => {
184
+ console.warn(
185
+ `[voice-call] Failed to hang up expired restored call ${callId}:`,
186
+ err instanceof Error ? err.message : String(err),
187
+ );
188
+ });
160
189
  continue;
161
190
  }
162
191
 
@@ -170,6 +199,8 @@ export class CallManager {
170
199
  console.log(
171
200
  `[voice-call] Skipping restored call ${callId} (provider status: ${result.status})`,
172
201
  );
202
+ markRestoredCallSkipped(call, "completed");
203
+ persistCallRecord(this.storePath, call);
173
204
  } else if (result.isUnknown) {
174
205
  console.log(
175
206
  `[voice-call] Keeping restored call ${callId} (provider status unknown, relying on timer)`,
@@ -219,6 +250,13 @@ export class CallManager {
219
250
  return speakWithContext(this.getContext(), callId, text);
220
251
  }
221
252
 
253
+ /**
254
+ * Send DTMF digits to an active call.
255
+ */
256
+ async sendDtmf(callId: CallId, digits: string): Promise<{ success: boolean; error?: string }> {
257
+ return sendDtmfWithContext(this.getContext(), callId, digits);
258
+ }
259
+
222
260
  /**
223
261
  * Speak the initial message for a call (called when media stream connects).
224
262
  */
@@ -256,6 +294,7 @@ export class CallManager {
256
294
  activeTurnCalls: this.activeTurnCalls,
257
295
  transcriptWaiters: this.transcriptWaiters,
258
296
  maxDurationTimers: this.maxDurationTimers,
297
+ initialMessageInFlight: this.initialMessageInFlight,
259
298
  onCallAnswered: (call) => {
260
299
  this.maybeSpeakInitialMessageOnAnswered(call);
261
300
  },
@@ -269,19 +308,54 @@ export class CallManager {
269
308
  processManagerEvent(this.getContext(), event);
270
309
  }
271
310
 
311
+ private shouldDeferConversationInitialMessageUntilStreamConnect(): boolean {
312
+ if (!this.provider || this.provider.name !== "twilio" || !this.config.streaming.enabled) {
313
+ return false;
314
+ }
315
+
316
+ const streamAwareProvider = this.provider as VoiceCallProvider & {
317
+ isConversationStreamConnectEnabled?: () => boolean;
318
+ };
319
+ if (typeof streamAwareProvider.isConversationStreamConnectEnabled !== "function") {
320
+ return false;
321
+ }
322
+
323
+ return streamAwareProvider.isConversationStreamConnectEnabled();
324
+ }
325
+
272
326
  private maybeSpeakInitialMessageOnAnswered(call: CallRecord): void {
273
- const initialMessage =
274
- typeof call.metadata?.initialMessage === "string" ? call.metadata.initialMessage.trim() : "";
327
+ const initialMessage = normalizeOptionalString(call.metadata?.initialMessage) ?? "";
275
328
 
276
329
  if (!initialMessage) {
277
330
  return;
278
331
  }
279
332
 
333
+ // Notify mode should speak as soon as the provider reports "answered".
334
+ // Conversation mode should defer only when the Twilio stream-connect path
335
+ // is actually available; otherwise speak immediately on answered.
336
+ const mode = (call.metadata?.mode as string | undefined) ?? "conversation";
337
+ if (mode === "conversation") {
338
+ if (this.config.realtime.enabled) {
339
+ return;
340
+ }
341
+ const shouldWaitForStreamConnect =
342
+ this.shouldDeferConversationInitialMessageUntilStreamConnect();
343
+ if (shouldWaitForStreamConnect) {
344
+ return;
345
+ }
346
+ } else if (mode !== "notify") {
347
+ return;
348
+ }
349
+
280
350
  if (!this.provider || !call.providerCallId) {
281
351
  return;
282
352
  }
283
353
 
284
- void this.speakInitialMessage(call.providerCallId);
354
+ void this.speakInitialMessage(call.providerCallId).catch((err) => {
355
+ console.warn(
356
+ `[voice-call] Failed to speak initial message for call ${call.callId}: ${formatErrorMessage(err)}`,
357
+ );
358
+ });
285
359
  }
286
360
 
287
361
  /**