@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
@@ -1,17 +1,34 @@
1
1
  import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
- import { describe, expect, it } from "vitest";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
5
  import { VoiceCallConfigSchema } from "../config.js";
6
6
  import type { VoiceCallProvider } from "../providers/base.js";
7
- import type { HangupCallInput, NormalizedEvent } from "../types.js";
7
+ import type { AnswerCallInput, HangupCallInput, NormalizedEvent } from "../types.js";
8
8
  import type { CallManagerContext } from "./context.js";
9
9
  import { processEvent } from "./events.js";
10
+ import { flushPendingCallRecordWritesForTest } from "./store.js";
11
+
12
+ const contexts: CallManagerContext[] = [];
13
+
14
+ afterEach(async () => {
15
+ for (const ctx of contexts.splice(0)) {
16
+ for (const timer of ctx.maxDurationTimers.values()) {
17
+ clearTimeout(timer);
18
+ }
19
+ ctx.maxDurationTimers.clear();
20
+ for (const waiter of ctx.transcriptWaiters.values()) {
21
+ clearTimeout(waiter.timeout);
22
+ }
23
+ ctx.transcriptWaiters.clear();
24
+ await flushPendingCallRecordWritesForTest();
25
+ fs.rmSync(ctx.storePath, { recursive: true, force: true });
26
+ }
27
+ });
10
28
 
11
29
  function createContext(overrides: Partial<CallManagerContext> = {}): CallManagerContext {
12
- const storePath = path.join(os.tmpdir(), `openclaw-voice-call-events-test-${Date.now()}`);
13
- fs.mkdirSync(storePath, { recursive: true });
14
- return {
30
+ const storePath = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-voice-call-events-test-"));
31
+ const ctx: CallManagerContext = {
15
32
  activeCalls: new Map(),
16
33
  providerCallIdMap: new Map(),
17
34
  processedEventIds: new Set(),
@@ -27,8 +44,11 @@ function createContext(overrides: Partial<CallManagerContext> = {}): CallManager
27
44
  activeTurnCalls: new Set(),
28
45
  transcriptWaiters: new Map(),
29
46
  maxDurationTimers: new Map(),
47
+ initialMessageInFlight: new Set(),
30
48
  ...overrides,
31
49
  };
50
+ contexts.push(ctx);
51
+ return ctx;
32
52
  }
33
53
 
34
54
  function createProvider(overrides: Partial<VoiceCallProvider> = {}): VoiceCallProvider {
@@ -89,6 +109,14 @@ function createRejectingInboundContext(): {
89
109
  return { ctx, hangupCalls };
90
110
  }
91
111
 
112
+ function requireFirstActiveCall(ctx: CallManagerContext) {
113
+ const call = [...ctx.activeCalls.values()][0];
114
+ if (!call) {
115
+ throw new Error("expected one active call");
116
+ }
117
+ return call;
118
+ }
119
+
92
120
  describe("processEvent (functional)", () => {
93
121
  it("calls provider hangup when rejecting inbound call", () => {
94
122
  const { ctx, hangupCalls } = createRejectingInboundContext();
@@ -147,8 +175,50 @@ describe("processEvent (functional)", () => {
147
175
  processEvent(ctx, event2);
148
176
 
149
177
  expect(ctx.activeCalls.size).toBe(0);
150
- expect(hangupCalls).toHaveLength(1);
151
- expect(hangupCalls[0]?.providerCallId).toBe("prov-dup");
178
+ expect(hangupCalls).toEqual([
179
+ expect.objectContaining({
180
+ providerCallId: "prov-dup",
181
+ reason: "hangup-bot",
182
+ }),
183
+ ]);
184
+ });
185
+
186
+ it("answers accepted inbound calls when the provider requires an answer command", () => {
187
+ const answerCalls: AnswerCallInput[] = [];
188
+ const provider = createProvider({
189
+ answerCall: async (input: AnswerCallInput): Promise<void> => {
190
+ answerCalls.push(input);
191
+ },
192
+ });
193
+ const ctx = createContext({
194
+ config: VoiceCallConfigSchema.parse({
195
+ enabled: true,
196
+ provider: "telnyx",
197
+ fromNumber: "+15550000000",
198
+ inboundPolicy: "open",
199
+ telnyx: {
200
+ apiKey: "KEY123",
201
+ connectionId: "CONN456",
202
+ },
203
+ skipSignatureVerification: true,
204
+ }),
205
+ provider,
206
+ });
207
+ const event = createInboundInitiatedEvent({
208
+ id: "evt-answer",
209
+ providerCallId: "call-control-1",
210
+ from: "+15552222222",
211
+ });
212
+
213
+ processEvent(ctx, event);
214
+
215
+ const call = requireFirstActiveCall(ctx);
216
+ expect(answerCalls).toEqual([
217
+ {
218
+ callId: call.callId,
219
+ providerCallId: "call-control-1",
220
+ },
221
+ ]);
152
222
  });
153
223
 
154
224
  it("updates providerCallId map when provider ID changes", () => {
@@ -177,11 +247,57 @@ describe("processEvent (functional)", () => {
177
247
  timestamp: now + 1,
178
248
  });
179
249
 
180
- expect(ctx.activeCalls.get("call-1")?.providerCallId).toBe("call-uuid");
250
+ const activeCall = ctx.activeCalls.get("call-1");
251
+ if (!activeCall) {
252
+ throw new Error("expected active call after provider id change");
253
+ }
254
+ expect(activeCall.providerCallId).toBe("call-uuid");
181
255
  expect(ctx.providerCallIdMap.get("call-uuid")).toBe("call-1");
182
256
  expect(ctx.providerCallIdMap.has("request-uuid")).toBe(false);
183
257
  });
184
258
 
259
+ it("does not burn replay keys for unknown calls before a later replay can resolve them", () => {
260
+ const now = Date.now();
261
+ const ctx = createContext();
262
+ const event: NormalizedEvent = {
263
+ id: "evt-late-call",
264
+ dedupeKey: "stable-late-call",
265
+ type: "call.answered",
266
+ callId: "call-late",
267
+ providerCallId: "provider-late",
268
+ timestamp: now + 1,
269
+ };
270
+
271
+ processEvent(ctx, event);
272
+
273
+ expect(ctx.processedEventIds.size).toBe(0);
274
+
275
+ ctx.activeCalls.set("call-late", {
276
+ callId: "call-late",
277
+ providerCallId: "provider-late",
278
+ provider: "plivo",
279
+ direction: "inbound",
280
+ state: "ringing",
281
+ from: "+15550000002",
282
+ to: "+15550000000",
283
+ startedAt: now,
284
+ transcript: [],
285
+ processedEventIds: [],
286
+ metadata: {},
287
+ });
288
+ ctx.providerCallIdMap.set("provider-late", "call-late");
289
+
290
+ processEvent(ctx, event);
291
+
292
+ const call = ctx.activeCalls.get("call-late");
293
+ if (!call) {
294
+ throw new Error("expected replayed event to resolve after call registration");
295
+ }
296
+ expect(call.state).toBe("answered");
297
+ expect(call.answeredAt).toBe(now + 1);
298
+ expect(Array.from(ctx.processedEventIds)).toEqual(["stable-late-call"]);
299
+ });
300
+
185
301
  it("invokes onCallAnswered hook for answered events", () => {
186
302
  const now = Date.now();
187
303
  let answeredCallId: string | null = null;
@@ -253,12 +369,12 @@ describe("processEvent (functional)", () => {
253
369
 
254
370
  // Call should be registered in activeCalls and providerCallIdMap
255
371
  expect(ctx.activeCalls.size).toBe(1);
256
- expect(ctx.providerCallIdMap.get("CA-external-123")).toBeDefined();
257
- const call = [...ctx.activeCalls.values()][0];
258
- expect(call?.providerCallId).toBe("CA-external-123");
259
- expect(call?.direction).toBe("outbound");
260
- expect(call?.from).toBe("+15550000000");
261
- expect(call?.to).toBe("+15559876543");
372
+ const call = requireFirstActiveCall(ctx);
373
+ expect(ctx.providerCallIdMap.get("CA-external-123")).toBe(call.callId);
374
+ expect(call.providerCallId).toBe("CA-external-123");
375
+ expect(call.direction).toBe("outbound");
376
+ expect(call.from).toBe("+15550000000");
377
+ expect(call.to).toBe("+15559876543");
262
378
  });
263
379
 
264
380
  it("does not reject externally-initiated outbound calls even with disabled inbound policy", () => {
@@ -279,8 +395,8 @@ describe("processEvent (functional)", () => {
279
395
  // External outbound calls bypass inbound policy — they should be accepted
280
396
  expect(ctx.activeCalls.size).toBe(1);
281
397
  expect(hangupCalls).toHaveLength(0);
282
- const call = [...ctx.activeCalls.values()][0];
283
- expect(call?.direction).toBe("outbound");
398
+ const call = requireFirstActiveCall(ctx);
399
+ expect(call.direction).toBe("outbound");
284
400
  });
285
401
 
286
402
  it("preserves inbound direction for auto-registered inbound calls", () => {
@@ -306,8 +422,72 @@ describe("processEvent (functional)", () => {
306
422
  processEvent(ctx, event);
307
423
 
308
424
  expect(ctx.activeCalls.size).toBe(1);
309
- const call = [...ctx.activeCalls.values()][0];
310
- expect(call?.direction).toBe("inbound");
425
+ const call = requireFirstActiveCall(ctx);
426
+ expect(call.direction).toBe("inbound");
427
+ });
428
+
429
+ it("assigns per-call session keys to inbound calls when configured", () => {
430
+ const ctx = createContext({
431
+ config: VoiceCallConfigSchema.parse({
432
+ enabled: true,
433
+ provider: "plivo",
434
+ fromNumber: "+15550000000",
435
+ inboundPolicy: "open",
436
+ sessionScope: "per-call",
437
+ }),
438
+ });
439
+ const event: NormalizedEvent = {
440
+ id: "evt-inbound-session-scope",
441
+ type: "call.initiated",
442
+ callId: "CA-inbound-session-scope",
443
+ providerCallId: "CA-inbound-session-scope",
444
+ timestamp: Date.now(),
445
+ direction: "inbound",
446
+ from: "+15554444444",
447
+ to: "+15550000000",
448
+ };
449
+
450
+ processEvent(ctx, event);
451
+
452
+ const call = requireFirstActiveCall(ctx);
453
+ expect(call.sessionKey).toBe(`voice:call:${call.callId}`);
454
+ });
455
+
456
+ it("applies per-number inbound greeting and stores the matched route key", () => {
457
+ const ctx = createContext({
458
+ config: VoiceCallConfigSchema.parse({
459
+ enabled: true,
460
+ provider: "plivo",
461
+ fromNumber: "+15550000000",
462
+ inboundPolicy: "open",
463
+ inboundGreeting: "Hello from global.",
464
+ numbers: {
465
+ "+15550002222": {
466
+ inboundGreeting: "Silver Fox Cards, how can I help?",
467
+ },
468
+ },
469
+ }),
470
+ });
471
+ const event: NormalizedEvent = {
472
+ id: "evt-inbound-number-route",
473
+ type: "call.initiated",
474
+ callId: "CA-inbound-number-route",
475
+ providerCallId: "CA-inbound-number-route",
476
+ timestamp: Date.now(),
477
+ direction: "inbound",
478
+ from: "+15554444444",
479
+ to: "+1 (555) 000-2222",
480
+ };
481
+
482
+ processEvent(ctx, event);
483
+
484
+ const call = requireFirstActiveCall(ctx);
485
+ expect(call.metadata).toEqual(
486
+ expect.objectContaining({
487
+ initialMessage: "Silver Fox Cards, how can I help?",
488
+ numberRouteKey: "+15550002222",
489
+ }),
490
+ );
311
491
  });
312
492
 
313
493
  it("deduplicates by dedupeKey even when event IDs differ", () => {
@@ -351,7 +531,51 @@ describe("processEvent (functional)", () => {
351
531
  });
352
532
 
353
533
  const call = ctx.activeCalls.get("call-dedupe");
354
- expect(call?.transcript).toHaveLength(1);
534
+ if (!call) {
535
+ throw new Error("expected deduped call to remain active");
536
+ }
537
+ expect(call.transcript).toHaveLength(1);
355
538
  expect(Array.from(ctx.processedEventIds)).toEqual(["stable-key-1"]);
356
539
  });
540
+
541
+ it("keeps retryable call.error events replayable", () => {
542
+ const now = Date.now();
543
+ const ctx = createContext();
544
+ ctx.activeCalls.set("call-retryable-error", {
545
+ callId: "call-retryable-error",
546
+ providerCallId: "provider-retryable-error",
547
+ provider: "plivo",
548
+ direction: "outbound",
549
+ state: "active",
550
+ from: "+15550000000",
551
+ to: "+15550000001",
552
+ startedAt: now,
553
+ transcript: [],
554
+ processedEventIds: [],
555
+ metadata: {},
556
+ });
557
+ ctx.providerCallIdMap.set("provider-retryable-error", "call-retryable-error");
558
+
559
+ const event: NormalizedEvent = {
560
+ id: "evt-retryable-error",
561
+ dedupeKey: "stable-retryable-error",
562
+ type: "call.error",
563
+ callId: "call-retryable-error",
564
+ providerCallId: "provider-retryable-error",
565
+ timestamp: now + 1,
566
+ error: "temporary upstream failure",
567
+ retryable: true,
568
+ };
569
+
570
+ processEvent(ctx, event);
571
+ processEvent(ctx, event);
572
+
573
+ const call = ctx.activeCalls.get("call-retryable-error");
574
+ if (!call) {
575
+ throw new Error("expected retryable error call to remain active");
576
+ }
577
+ expect(call.state).toBe("active");
578
+ expect(Array.from(ctx.processedEventIds)).toEqual([]);
579
+ expect(call.processedEventIds).toEqual([]);
580
+ });
357
581
  });
@@ -1,17 +1,15 @@
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 { resolveVoiceCallEffectiveConfig, resolveVoiceCallSessionKey } from "../config.js";
5
+ import type { CallRecord, NormalizedEvent } from "../types.js";
4
6
  import type { CallManagerContext } from "./context.js";
7
+ import { finalizeCall } from "./lifecycle.js";
5
8
  import { findCall } from "./lookup.js";
6
9
  import { endCall } from "./outbound.js";
7
10
  import { addTranscriptEntry, transitionState } from "./state.js";
8
11
  import { persistCallRecord } from "./store.js";
9
- import {
10
- clearMaxDurationTimer,
11
- rejectTranscriptWaiter,
12
- resolveTranscriptWaiter,
13
- startMaxDurationTimer,
14
- } from "./timers.js";
12
+ import { resolveTranscriptWaiter, startMaxDurationTimer } from "./timers.js";
15
13
 
16
14
  type EventContext = Pick<
17
15
  CallManagerContext,
@@ -67,6 +65,11 @@ function createWebhookCall(params: {
67
65
  to: string;
68
66
  }): CallRecord {
69
67
  const callId = crypto.randomUUID();
68
+ const effective = resolveVoiceCallEffectiveConfig(
69
+ params.ctx.config,
70
+ params.direction === "inbound" ? params.to : undefined,
71
+ );
72
+ const effectiveConfig = effective.config;
70
73
 
71
74
  const callRecord: CallRecord = {
72
75
  callId,
@@ -76,14 +79,20 @@ function createWebhookCall(params: {
76
79
  state: "ringing",
77
80
  from: params.from,
78
81
  to: params.to,
82
+ sessionKey: resolveVoiceCallSessionKey({
83
+ config: effectiveConfig,
84
+ callId,
85
+ phone: params.direction === "outbound" ? params.to : params.from,
86
+ }),
79
87
  startedAt: Date.now(),
80
88
  transcript: [],
81
89
  processedEventIds: [],
82
90
  metadata: {
83
91
  initialMessage:
84
92
  params.direction === "inbound"
85
- ? params.ctx.config.inboundGreeting || "Hello! How can I help you today?"
93
+ ? effectiveConfig.inboundGreeting || "Hello! How can I help you today?"
86
94
  : undefined,
95
+ ...(effective.numberRouteKey ? { numberRouteKey: effective.numberRouteKey } : {}),
87
96
  },
88
97
  };
89
98
 
@@ -102,7 +111,6 @@ export function processEvent(ctx: EventContext, event: NormalizedEvent): void {
102
111
  if (ctx.processedEventIds.has(dedupeKey)) {
103
112
  return;
104
113
  }
105
- ctx.processedEventIds.add(dedupeKey);
106
114
 
107
115
  let call = findCall({
108
116
  activeCalls: ctx.activeCalls,
@@ -128,6 +136,7 @@ export function processEvent(ctx: EventContext, event: NormalizedEvent): void {
128
136
  );
129
137
  return;
130
138
  }
139
+ ctx.processedEventIds.add(dedupeKey);
131
140
  if (ctx.rejectedProviderCallIds.has(pid)) {
132
141
  return;
133
142
  }
@@ -141,7 +150,8 @@ export function processEvent(ctx: EventContext, event: NormalizedEvent): void {
141
150
  reason: "hangup-bot",
142
151
  })
143
152
  .catch((err) => {
144
- const message = err instanceof Error ? err.message : String(err);
153
+ ctx.rejectedProviderCallIds.delete(pid);
154
+ const message = formatErrorMessage(err);
145
155
  console.warn(`[voice-call] Failed to reject inbound call ${pid}:`, message);
146
156
  });
147
157
  return;
@@ -175,11 +185,29 @@ export function processEvent(ctx: EventContext, event: NormalizedEvent): void {
175
185
  }
176
186
  }
177
187
 
178
- call.processedEventIds.push(dedupeKey);
188
+ const shouldCommitReplayKey = !(event.type === "call.error" && event.retryable);
189
+ if (shouldCommitReplayKey) {
190
+ ctx.processedEventIds.add(dedupeKey);
191
+ call.processedEventIds.push(dedupeKey);
192
+ }
179
193
 
180
194
  switch (event.type) {
181
195
  case "call.initiated":
182
196
  transitionState(call, "initiated");
197
+ if (call.direction === "inbound" && call.providerCallId && ctx.provider?.answerCall) {
198
+ void ctx.provider
199
+ .answerCall({
200
+ callId: call.callId,
201
+ providerCallId: call.providerCallId,
202
+ })
203
+ .catch((err) => {
204
+ const message = formatErrorMessage(err);
205
+ console.warn(
206
+ `[voice-call] Failed to answer inbound call ${call.providerCallId}:`,
207
+ message,
208
+ );
209
+ });
210
+ }
183
211
  break;
184
212
 
185
213
  case "call.ringing":
@@ -193,7 +221,7 @@ export function processEvent(ctx: EventContext, event: NormalizedEvent): void {
193
221
  ctx,
194
222
  callId: call.callId,
195
223
  onTimeout: async (callId) => {
196
- await endCall(ctx, callId);
224
+ await endCall(ctx, callId, { reason: "timeout" });
197
225
  },
198
226
  });
199
227
  ctx.onCallAnswered?.(call);
@@ -227,30 +255,32 @@ export function processEvent(ctx: EventContext, event: NormalizedEvent): void {
227
255
  transitionState(call, "listening");
228
256
  break;
229
257
 
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
- }
258
+ case "call.silence":
259
+ case "call.dtmf":
240
260
  break;
241
261
 
262
+ case "call.ended":
263
+ finalizeCall({
264
+ ctx,
265
+ call,
266
+ endReason: event.reason,
267
+ endedAt: event.timestamp,
268
+ });
269
+ return;
270
+
242
271
  case "call.error":
243
272
  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
- }
273
+ finalizeCall({
274
+ ctx,
275
+ call,
276
+ endReason: "error",
277
+ endedAt: event.timestamp,
278
+ transcriptRejectReason: `Call error: ${event.error}`,
279
+ });
280
+ return;
253
281
  }
282
+ // Keep retryable provider errors replayable so a redelivery can still
283
+ // drive later recovery or terminal handling for the same event key.
254
284
  break;
255
285
  }
256
286
 
@@ -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
+ });