@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.
- package/README.md +27 -5
- package/api.ts +16 -0
- package/cli-metadata.ts +10 -0
- package/config-api.ts +12 -0
- package/index.test.ts +943 -0
- package/index.ts +379 -149
- package/openclaw.plugin.json +384 -157
- package/package.json +35 -5
- package/runtime-api.ts +20 -0
- package/runtime-entry.ts +1 -0
- package/setup-api.ts +47 -0
- package/src/allowlist.test.ts +18 -0
- package/src/cli.ts +533 -68
- package/src/config-compat.test.ts +120 -0
- package/src/config-compat.ts +227 -0
- package/src/config.test.ts +273 -12
- package/src/config.ts +355 -72
- package/src/core-bridge.ts +2 -147
- package/src/deep-merge.test.ts +40 -0
- package/src/gateway-continue-operation.ts +200 -0
- package/src/http-headers.ts +6 -3
- package/src/manager/context.ts +6 -5
- package/src/manager/events.test.ts +243 -19
- package/src/manager/events.ts +61 -31
- package/src/manager/lifecycle.ts +53 -0
- package/src/manager/lookup.test.ts +52 -0
- package/src/manager/outbound.test.ts +528 -0
- package/src/manager/outbound.ts +163 -57
- package/src/manager/store.ts +18 -6
- package/src/manager/timers.test.ts +129 -0
- package/src/manager/timers.ts +4 -3
- package/src/manager/twiml.test.ts +13 -0
- package/src/manager/twiml.ts +8 -0
- package/src/manager.closed-loop.test.ts +30 -12
- package/src/manager.inbound-allowlist.test.ts +77 -10
- package/src/manager.notify.test.ts +344 -20
- package/src/manager.restore.test.ts +95 -8
- package/src/manager.test-harness.ts +8 -6
- package/src/manager.ts +79 -5
- package/src/media-stream.test.ts +578 -81
- package/src/media-stream.ts +235 -54
- package/src/providers/base.ts +19 -0
- package/src/providers/mock.ts +7 -1
- package/src/providers/plivo.test.ts +50 -6
- package/src/providers/plivo.ts +14 -6
- package/src/providers/shared/call-status.ts +2 -1
- package/src/providers/shared/guarded-json-api.test.ts +106 -0
- package/src/providers/shared/guarded-json-api.ts +1 -1
- package/src/providers/telnyx.test.ts +178 -6
- package/src/providers/telnyx.ts +40 -3
- package/src/providers/twilio/api.test.ts +145 -0
- package/src/providers/twilio/api.ts +67 -16
- package/src/providers/twilio/twiml-policy.ts +6 -10
- package/src/providers/twilio/webhook.ts +1 -1
- package/src/providers/twilio.test.ts +425 -25
- package/src/providers/twilio.ts +230 -77
- package/src/providers/twilio.types.ts +17 -0
- package/src/realtime-defaults.ts +3 -0
- package/src/realtime-fast-context.test.ts +88 -0
- package/src/realtime-fast-context.ts +165 -0
- package/src/realtime-transcription.runtime.ts +4 -0
- package/src/realtime-voice.runtime.ts +5 -0
- package/src/response-generator.test.ts +321 -0
- package/src/response-generator.ts +213 -53
- package/src/response-model.test.ts +71 -0
- package/src/response-model.ts +23 -0
- package/src/runtime.test.ts +429 -0
- package/src/runtime.ts +270 -24
- package/src/telephony-audio.test.ts +61 -0
- package/src/telephony-audio.ts +1 -79
- package/src/telephony-tts.test.ts +133 -12
- package/src/telephony-tts.ts +155 -2
- package/src/test-fixtures.ts +28 -7
- package/src/tts-provider-voice.test.ts +34 -0
- package/src/tts-provider-voice.ts +21 -0
- package/src/tunnel.test.ts +166 -0
- package/src/tunnel.ts +1 -1
- package/src/types.ts +24 -37
- package/src/utils.test.ts +17 -0
- package/src/voice-mapping.test.ts +34 -0
- package/src/voice-mapping.ts +3 -2
- package/src/webhook/realtime-handler.test.ts +598 -0
- package/src/webhook/realtime-handler.ts +485 -0
- package/src/webhook/stale-call-reaper.test.ts +88 -0
- package/src/webhook/stale-call-reaper.ts +5 -0
- package/src/webhook/tailscale.test.ts +214 -0
- package/src/webhook/tailscale.ts +19 -5
- package/src/webhook-exposure.test.ts +33 -0
- package/src/webhook-exposure.ts +84 -0
- package/src/webhook-security.test.ts +172 -21
- package/src/webhook-security.ts +43 -29
- package/src/webhook.hangup-once.lifecycle.test.ts +135 -0
- package/src/webhook.test.ts +1145 -27
- package/src/webhook.ts +523 -102
- package/src/webhook.types.ts +5 -0
- package/src/websocket-test-support.ts +72 -0
- package/tsconfig.json +16 -0
- package/CHANGELOG.md +0 -121
- package/src/providers/index.ts +0 -10
- package/src/providers/stt-openai-realtime.test.ts +0 -42
- package/src/providers/stt-openai-realtime.ts +0 -311
- package/src/providers/tts-openai.test.ts +0 -43
- 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(),
|
|
13
|
-
|
|
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).
|
|
151
|
-
|
|
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
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
expect(call
|
|
259
|
-
expect(call
|
|
260
|
-
expect(call
|
|
261
|
-
expect(call
|
|
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 =
|
|
283
|
-
expect(call
|
|
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 =
|
|
310
|
-
expect(call
|
|
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
|
-
|
|
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
|
});
|
package/src/manager/events.ts
CHANGED
|
@@ -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
|
|
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
|
-
?
|
|
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
|
-
|
|
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.
|
|
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.
|
|
231
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
+
});
|