@openclaw/voice-call 2026.3.13 → 2026.5.1-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 +25 -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 +866 -0
  6. package/index.ts +353 -148
  7. package/openclaw.plugin.json +336 -157
  8. package/package.json +33 -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 +160 -12
  17. package/src/config.ts +243 -74
  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 +179 -19
  24. package/src/manager/events.ts +48 -30
  25. package/src/manager/lifecycle.ts +53 -0
  26. package/src/manager/lookup.test.ts +52 -0
  27. package/src/manager/outbound.test.ts +464 -0
  28. package/src/manager/outbound.ts +148 -55
  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 +277 -0
  64. package/src/response-generator.ts +186 -40
  65. package/src/response-model.test.ts +71 -0
  66. package/src/response-model.ts +23 -0
  67. package/src/runtime.test.ts +351 -0
  68. package/src/runtime.ts +254 -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 +26 -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 +513 -100
  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
@@ -1,17 +1,14 @@
1
1
  import crypto from "node:crypto";
2
+ import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
2
3
  import { isAllowlistedCaller, normalizePhoneNumber } from "../allowlist.js";
3
- import type { CallRecord, CallState, NormalizedEvent } from "../types.js";
4
+ import type { CallRecord, NormalizedEvent } from "../types.js";
4
5
  import type { CallManagerContext } from "./context.js";
6
+ import { finalizeCall } from "./lifecycle.js";
5
7
  import { findCall } from "./lookup.js";
6
8
  import { endCall } from "./outbound.js";
7
9
  import { addTranscriptEntry, transitionState } from "./state.js";
8
10
  import { persistCallRecord } from "./store.js";
9
- import {
10
- clearMaxDurationTimer,
11
- rejectTranscriptWaiter,
12
- resolveTranscriptWaiter,
13
- startMaxDurationTimer,
14
- } from "./timers.js";
11
+ import { resolveTranscriptWaiter, startMaxDurationTimer } from "./timers.js";
15
12
 
16
13
  type EventContext = Pick<
17
14
  CallManagerContext,
@@ -102,7 +99,6 @@ export function processEvent(ctx: EventContext, event: NormalizedEvent): void {
102
99
  if (ctx.processedEventIds.has(dedupeKey)) {
103
100
  return;
104
101
  }
105
- ctx.processedEventIds.add(dedupeKey);
106
102
 
107
103
  let call = findCall({
108
104
  activeCalls: ctx.activeCalls,
@@ -128,6 +124,7 @@ export function processEvent(ctx: EventContext, event: NormalizedEvent): void {
128
124
  );
129
125
  return;
130
126
  }
127
+ ctx.processedEventIds.add(dedupeKey);
131
128
  if (ctx.rejectedProviderCallIds.has(pid)) {
132
129
  return;
133
130
  }
@@ -141,7 +138,8 @@ export function processEvent(ctx: EventContext, event: NormalizedEvent): void {
141
138
  reason: "hangup-bot",
142
139
  })
143
140
  .catch((err) => {
144
- const message = err instanceof Error ? err.message : String(err);
141
+ ctx.rejectedProviderCallIds.delete(pid);
142
+ const message = formatErrorMessage(err);
145
143
  console.warn(`[voice-call] Failed to reject inbound call ${pid}:`, message);
146
144
  });
147
145
  return;
@@ -175,11 +173,29 @@ export function processEvent(ctx: EventContext, event: NormalizedEvent): void {
175
173
  }
176
174
  }
177
175
 
178
- call.processedEventIds.push(dedupeKey);
176
+ const shouldCommitReplayKey = !(event.type === "call.error" && event.retryable);
177
+ if (shouldCommitReplayKey) {
178
+ ctx.processedEventIds.add(dedupeKey);
179
+ call.processedEventIds.push(dedupeKey);
180
+ }
179
181
 
180
182
  switch (event.type) {
181
183
  case "call.initiated":
182
184
  transitionState(call, "initiated");
185
+ if (call.direction === "inbound" && call.providerCallId && ctx.provider?.answerCall) {
186
+ void ctx.provider
187
+ .answerCall({
188
+ callId: call.callId,
189
+ providerCallId: call.providerCallId,
190
+ })
191
+ .catch((err) => {
192
+ const message = formatErrorMessage(err);
193
+ console.warn(
194
+ `[voice-call] Failed to answer inbound call ${call.providerCallId}:`,
195
+ message,
196
+ );
197
+ });
198
+ }
183
199
  break;
184
200
 
185
201
  case "call.ringing":
@@ -193,7 +209,7 @@ export function processEvent(ctx: EventContext, event: NormalizedEvent): void {
193
209
  ctx,
194
210
  callId: call.callId,
195
211
  onTimeout: async (callId) => {
196
- await endCall(ctx, callId);
212
+ await endCall(ctx, callId, { reason: "timeout" });
197
213
  },
198
214
  });
199
215
  ctx.onCallAnswered?.(call);
@@ -227,30 +243,32 @@ export function processEvent(ctx: EventContext, event: NormalizedEvent): void {
227
243
  transitionState(call, "listening");
228
244
  break;
229
245
 
230
- case "call.ended":
231
- call.endedAt = event.timestamp;
232
- call.endReason = event.reason;
233
- transitionState(call, event.reason as CallState);
234
- clearMaxDurationTimer(ctx, call.callId);
235
- rejectTranscriptWaiter(ctx, call.callId, `Call ended: ${event.reason}`);
236
- ctx.activeCalls.delete(call.callId);
237
- if (call.providerCallId) {
238
- ctx.providerCallIdMap.delete(call.providerCallId);
239
- }
246
+ case "call.silence":
247
+ case "call.dtmf":
240
248
  break;
241
249
 
250
+ case "call.ended":
251
+ finalizeCall({
252
+ ctx,
253
+ call,
254
+ endReason: event.reason,
255
+ endedAt: event.timestamp,
256
+ });
257
+ return;
258
+
242
259
  case "call.error":
243
260
  if (!event.retryable) {
244
- call.endedAt = event.timestamp;
245
- call.endReason = "error";
246
- transitionState(call, "error");
247
- clearMaxDurationTimer(ctx, call.callId);
248
- rejectTranscriptWaiter(ctx, call.callId, `Call error: ${event.error}`);
249
- ctx.activeCalls.delete(call.callId);
250
- if (call.providerCallId) {
251
- ctx.providerCallIdMap.delete(call.providerCallId);
252
- }
261
+ finalizeCall({
262
+ ctx,
263
+ call,
264
+ endReason: "error",
265
+ endedAt: event.timestamp,
266
+ transcriptRejectReason: `Call error: ${event.error}`,
267
+ });
268
+ return;
253
269
  }
270
+ // Keep retryable provider errors replayable so a redelivery can still
271
+ // drive later recovery or terminal handling for the same event key.
254
272
  break;
255
273
  }
256
274
 
@@ -0,0 +1,53 @@
1
+ import type { CallRecord, EndReason } from "../types.js";
2
+ import type { CallManagerContext } from "./context.js";
3
+ import { transitionState } from "./state.js";
4
+ import { persistCallRecord } from "./store.js";
5
+ import { clearMaxDurationTimer, rejectTranscriptWaiter } from "./timers.js";
6
+
7
+ type CallLifecycleContext = Pick<
8
+ CallManagerContext,
9
+ "activeCalls" | "providerCallIdMap" | "storePath"
10
+ > &
11
+ Partial<Pick<CallManagerContext, "transcriptWaiters" | "maxDurationTimers">>;
12
+
13
+ function removeProviderCallMapping(
14
+ providerCallIdMap: Map<string, string>,
15
+ call: Pick<CallRecord, "callId" | "providerCallId">,
16
+ ): void {
17
+ if (!call.providerCallId) {
18
+ return;
19
+ }
20
+ const mappedCallId = providerCallIdMap.get(call.providerCallId);
21
+ if (mappedCallId === call.callId) {
22
+ providerCallIdMap.delete(call.providerCallId);
23
+ }
24
+ }
25
+
26
+ export function finalizeCall(params: {
27
+ ctx: CallLifecycleContext;
28
+ call: CallRecord;
29
+ endReason: EndReason;
30
+ endedAt?: number;
31
+ transcriptRejectReason?: string;
32
+ }): void {
33
+ const { ctx, call, endReason } = params;
34
+
35
+ call.endedAt = params.endedAt ?? Date.now();
36
+ call.endReason = endReason;
37
+ transitionState(call, endReason);
38
+ persistCallRecord(ctx.storePath, call);
39
+
40
+ if (ctx.maxDurationTimers) {
41
+ clearMaxDurationTimer({ maxDurationTimers: ctx.maxDurationTimers }, call.callId);
42
+ }
43
+ if (ctx.transcriptWaiters) {
44
+ rejectTranscriptWaiter(
45
+ { transcriptWaiters: ctx.transcriptWaiters },
46
+ call.callId,
47
+ params.transcriptRejectReason ?? `Call ended: ${endReason}`,
48
+ );
49
+ }
50
+
51
+ ctx.activeCalls.delete(call.callId);
52
+ removeProviderCallMapping(ctx.providerCallIdMap, call);
53
+ }
@@ -0,0 +1,52 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { findCall, getCallByProviderCallId } from "./lookup.js";
3
+
4
+ describe("voice-call manager lookup", () => {
5
+ it("resolves provider call ids from the explicit map first", () => {
6
+ const activeCalls = new Map([
7
+ ["call-1", { id: "call-1", providerCallId: "prov-1" }],
8
+ ["call-2", { id: "call-2", providerCallId: "prov-2" }],
9
+ ]);
10
+ const providerCallIdMap = new Map([["provider-lookup", "call-2"]]);
11
+
12
+ expect(
13
+ getCallByProviderCallId({
14
+ activeCalls: activeCalls as never,
15
+ providerCallIdMap,
16
+ providerCallId: "provider-lookup",
17
+ }),
18
+ ).toEqual({ id: "call-2", providerCallId: "prov-2" });
19
+ });
20
+
21
+ it("falls back to scanning active calls and supports direct call ids", () => {
22
+ const activeCalls = new Map([
23
+ ["call-1", { id: "call-1", providerCallId: "prov-1" }],
24
+ ["call-2", { id: "call-2", providerCallId: "prov-2" }],
25
+ ]);
26
+ const providerCallIdMap = new Map<string, string>();
27
+
28
+ expect(
29
+ getCallByProviderCallId({
30
+ activeCalls: activeCalls as never,
31
+ providerCallIdMap,
32
+ providerCallId: "prov-1",
33
+ }),
34
+ ).toEqual({ id: "call-1", providerCallId: "prov-1" });
35
+
36
+ expect(
37
+ findCall({
38
+ activeCalls: activeCalls as never,
39
+ providerCallIdMap,
40
+ callIdOrProviderCallId: "call-2",
41
+ }),
42
+ ).toEqual({ id: "call-2", providerCallId: "prov-2" });
43
+
44
+ expect(
45
+ findCall({
46
+ activeCalls: activeCalls as never,
47
+ providerCallIdMap,
48
+ callIdOrProviderCallId: "missing",
49
+ }),
50
+ ).toBeUndefined();
51
+ });
52
+ });