@openclaw/voice-call 2026.3.1 → 2026.3.2
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/CHANGELOG.md +6 -0
- package/index.ts +27 -13
- package/package.json +1 -1
- package/src/cli.ts +1 -1
- package/src/manager/events.test.ts +75 -0
- package/src/manager/events.ts +25 -9
- package/src/manager.closed-loop.test.ts +218 -0
- package/src/manager.inbound-allowlist.test.ts +121 -0
- package/src/manager.notify.test.ts +53 -0
- package/src/manager.restore.test.ts +130 -0
- package/src/manager.test-harness.ts +125 -0
- package/src/manager.ts +119 -10
- package/src/providers/base.ts +10 -0
- package/src/providers/mock.ts +10 -0
- package/src/providers/plivo.ts +37 -0
- package/src/providers/shared/call-status.test.ts +24 -0
- package/src/providers/shared/call-status.ts +23 -0
- package/src/providers/telnyx.ts +33 -0
- package/src/providers/twilio/twiml-policy.test.ts +84 -0
- package/src/providers/twilio/twiml-policy.ts +91 -0
- package/src/providers/twilio.test.ts +70 -0
- package/src/providers/twilio.ts +94 -73
- package/src/runtime.test.ts +147 -0
- package/src/runtime.ts +123 -76
- package/src/tunnel.ts +1 -1
- package/src/types.ts +17 -0
- package/src/webhook/tailscale.ts +115 -0
- package/src/webhook-security.test.ts +42 -13
- package/src/webhook-security.ts +74 -0
- package/src/webhook.test.ts +105 -36
- package/src/webhook.ts +104 -171
- package/src/manager.test.ts +0 -467
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { WebhookContext } from "../../types.js";
|
|
2
|
+
|
|
3
|
+
export type TwimlResponseKind = "empty" | "pause" | "queue" | "stored" | "stream";
|
|
4
|
+
|
|
5
|
+
export type TwimlRequestView = {
|
|
6
|
+
callStatus: string | null;
|
|
7
|
+
direction: string | null;
|
|
8
|
+
isStatusCallback: boolean;
|
|
9
|
+
callSid?: string;
|
|
10
|
+
callIdFromQuery?: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type TwimlPolicyInput = TwimlRequestView & {
|
|
14
|
+
hasStoredTwiml: boolean;
|
|
15
|
+
isNotifyCall: boolean;
|
|
16
|
+
hasActiveStreams: boolean;
|
|
17
|
+
canStream: boolean;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type TwimlDecision =
|
|
21
|
+
| {
|
|
22
|
+
kind: "empty" | "pause" | "queue";
|
|
23
|
+
consumeStoredTwimlCallId?: string;
|
|
24
|
+
activateStreamCallSid?: string;
|
|
25
|
+
}
|
|
26
|
+
| {
|
|
27
|
+
kind: "stored";
|
|
28
|
+
consumeStoredTwimlCallId: string;
|
|
29
|
+
activateStreamCallSid?: string;
|
|
30
|
+
}
|
|
31
|
+
| {
|
|
32
|
+
kind: "stream";
|
|
33
|
+
consumeStoredTwimlCallId?: string;
|
|
34
|
+
activateStreamCallSid?: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function isOutboundDirection(direction: string | null): boolean {
|
|
38
|
+
return direction?.startsWith("outbound") ?? false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function readTwimlRequestView(ctx: WebhookContext): TwimlRequestView {
|
|
42
|
+
const params = new URLSearchParams(ctx.rawBody);
|
|
43
|
+
const type = typeof ctx.query?.type === "string" ? ctx.query.type.trim() : undefined;
|
|
44
|
+
const callIdFromQuery =
|
|
45
|
+
typeof ctx.query?.callId === "string" && ctx.query.callId.trim()
|
|
46
|
+
? ctx.query.callId.trim()
|
|
47
|
+
: undefined;
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
callStatus: params.get("CallStatus"),
|
|
51
|
+
direction: params.get("Direction"),
|
|
52
|
+
isStatusCallback: type === "status",
|
|
53
|
+
callSid: params.get("CallSid") || undefined,
|
|
54
|
+
callIdFromQuery,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function decideTwimlResponse(input: TwimlPolicyInput): TwimlDecision {
|
|
59
|
+
if (input.callIdFromQuery && !input.isStatusCallback) {
|
|
60
|
+
if (input.hasStoredTwiml) {
|
|
61
|
+
return { kind: "stored", consumeStoredTwimlCallId: input.callIdFromQuery };
|
|
62
|
+
}
|
|
63
|
+
if (input.isNotifyCall) {
|
|
64
|
+
return { kind: "empty" };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (isOutboundDirection(input.direction)) {
|
|
68
|
+
return input.canStream ? { kind: "stream" } : { kind: "pause" };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (input.isStatusCallback) {
|
|
73
|
+
return { kind: "empty" };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (input.direction === "inbound") {
|
|
77
|
+
if (input.hasActiveStreams) {
|
|
78
|
+
return { kind: "queue" };
|
|
79
|
+
}
|
|
80
|
+
if (input.canStream && input.callSid) {
|
|
81
|
+
return { kind: "stream", activateStreamCallSid: input.callSid };
|
|
82
|
+
}
|
|
83
|
+
return { kind: "pause" };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (input.callStatus !== "in-progress") {
|
|
87
|
+
return { kind: "empty" };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return input.canStream ? { kind: "stream" } : { kind: "pause" };
|
|
91
|
+
}
|
|
@@ -60,6 +60,76 @@ describe("TwilioProvider", () => {
|
|
|
60
60
|
expect(result.providerResponseBody).toContain("<Connect>");
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
+
it("returns queue TwiML for second inbound call when first call is active", () => {
|
|
64
|
+
const provider = createProvider();
|
|
65
|
+
const firstInbound = createContext("CallStatus=ringing&Direction=inbound&CallSid=CA111");
|
|
66
|
+
const secondInbound = createContext("CallStatus=ringing&Direction=inbound&CallSid=CA222");
|
|
67
|
+
|
|
68
|
+
const firstResult = provider.parseWebhookEvent(firstInbound);
|
|
69
|
+
const secondResult = provider.parseWebhookEvent(secondInbound);
|
|
70
|
+
|
|
71
|
+
expect(firstResult.providerResponseBody).toContain("<Connect>");
|
|
72
|
+
expect(secondResult.providerResponseBody).toContain("Please hold while we connect you.");
|
|
73
|
+
expect(secondResult.providerResponseBody).toContain("<Enqueue");
|
|
74
|
+
expect(secondResult.providerResponseBody).toContain("hold-queue");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("connects next inbound call after unregisterCallStream cleanup", () => {
|
|
78
|
+
const provider = createProvider();
|
|
79
|
+
const firstInbound = createContext("CallStatus=ringing&Direction=inbound&CallSid=CA311");
|
|
80
|
+
const secondInbound = createContext("CallStatus=ringing&Direction=inbound&CallSid=CA322");
|
|
81
|
+
|
|
82
|
+
provider.parseWebhookEvent(firstInbound);
|
|
83
|
+
provider.unregisterCallStream("CA311");
|
|
84
|
+
const secondResult = provider.parseWebhookEvent(secondInbound);
|
|
85
|
+
|
|
86
|
+
expect(secondResult.providerResponseBody).toContain("<Connect>");
|
|
87
|
+
expect(secondResult.providerResponseBody).not.toContain("hold-queue");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("cleans up active inbound call on completed status callback", () => {
|
|
91
|
+
const provider = createProvider();
|
|
92
|
+
const firstInbound = createContext("CallStatus=ringing&Direction=inbound&CallSid=CA411");
|
|
93
|
+
const completed = createContext("CallStatus=completed&Direction=inbound&CallSid=CA411", {
|
|
94
|
+
type: "status",
|
|
95
|
+
});
|
|
96
|
+
const nextInbound = createContext("CallStatus=ringing&Direction=inbound&CallSid=CA422");
|
|
97
|
+
|
|
98
|
+
provider.parseWebhookEvent(firstInbound);
|
|
99
|
+
provider.parseWebhookEvent(completed);
|
|
100
|
+
const nextResult = provider.parseWebhookEvent(nextInbound);
|
|
101
|
+
|
|
102
|
+
expect(nextResult.providerResponseBody).toContain("<Connect>");
|
|
103
|
+
expect(nextResult.providerResponseBody).not.toContain("hold-queue");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("cleans up active inbound call on canceled status callback", () => {
|
|
107
|
+
const provider = createProvider();
|
|
108
|
+
const firstInbound = createContext("CallStatus=ringing&Direction=inbound&CallSid=CA511");
|
|
109
|
+
const canceled = createContext("CallStatus=canceled&Direction=inbound&CallSid=CA511", {
|
|
110
|
+
type: "status",
|
|
111
|
+
});
|
|
112
|
+
const nextInbound = createContext("CallStatus=ringing&Direction=inbound&CallSid=CA522");
|
|
113
|
+
|
|
114
|
+
provider.parseWebhookEvent(firstInbound);
|
|
115
|
+
provider.parseWebhookEvent(canceled);
|
|
116
|
+
const nextResult = provider.parseWebhookEvent(nextInbound);
|
|
117
|
+
|
|
118
|
+
expect(nextResult.providerResponseBody).toContain("<Connect>");
|
|
119
|
+
expect(nextResult.providerResponseBody).not.toContain("hold-queue");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("QUEUE_TWIML references /voice/hold-music waitUrl", () => {
|
|
123
|
+
const provider = createProvider();
|
|
124
|
+
const firstInbound = createContext("CallStatus=ringing&Direction=inbound&CallSid=CA611");
|
|
125
|
+
const secondInbound = createContext("CallStatus=ringing&Direction=inbound&CallSid=CA622");
|
|
126
|
+
|
|
127
|
+
provider.parseWebhookEvent(firstInbound);
|
|
128
|
+
const result = provider.parseWebhookEvent(secondInbound);
|
|
129
|
+
|
|
130
|
+
expect(result.providerResponseBody).toContain('waitUrl="/voice/hold-music"');
|
|
131
|
+
});
|
|
132
|
+
|
|
63
133
|
it("uses a stable fallback dedupeKey for identical request payloads", () => {
|
|
64
134
|
const provider = createProvider();
|
|
65
135
|
const rawBody = "CallSid=CA789&Direction=inbound&SpeechResult=hello";
|
package/src/providers/twilio.ts
CHANGED
|
@@ -5,6 +5,8 @@ import type { MediaStreamHandler } from "../media-stream.js";
|
|
|
5
5
|
import { chunkAudio } from "../telephony-audio.js";
|
|
6
6
|
import type { TelephonyTtsProvider } from "../telephony-tts.js";
|
|
7
7
|
import type {
|
|
8
|
+
GetCallStatusInput,
|
|
9
|
+
GetCallStatusResult,
|
|
8
10
|
HangupCallInput,
|
|
9
11
|
InitiateCallInput,
|
|
10
12
|
InitiateCallResult,
|
|
@@ -19,7 +21,14 @@ import type {
|
|
|
19
21
|
} from "../types.js";
|
|
20
22
|
import { escapeXml, mapVoiceToPolly } from "../voice-mapping.js";
|
|
21
23
|
import type { VoiceCallProvider } from "./base.js";
|
|
24
|
+
import {
|
|
25
|
+
isProviderStatusTerminal,
|
|
26
|
+
mapProviderStatusToEndReason,
|
|
27
|
+
normalizeProviderStatus,
|
|
28
|
+
} from "./shared/call-status.js";
|
|
29
|
+
import { guardedJsonApiRequest } from "./shared/guarded-json-api.js";
|
|
22
30
|
import { twilioApiRequest } from "./twilio/api.js";
|
|
31
|
+
import { decideTwimlResponse, readTwimlRequestView } from "./twilio/twiml-policy.js";
|
|
23
32
|
import { verifyTwilioProviderWebhook } from "./twilio/webhook.js";
|
|
24
33
|
|
|
25
34
|
function createTwilioRequestDedupeKey(ctx: WebhookContext, verifiedRequestKey?: string): string {
|
|
@@ -92,6 +101,7 @@ export class TwilioProvider implements VoiceCallProvider {
|
|
|
92
101
|
private readonly twimlStorage = new Map<string, string>();
|
|
93
102
|
/** Track notify-mode calls to avoid streaming on follow-up callbacks */
|
|
94
103
|
private readonly notifyCalls = new Set<string>();
|
|
104
|
+
private readonly activeStreamCalls = new Set<string>();
|
|
95
105
|
|
|
96
106
|
/**
|
|
97
107
|
* Delete stored TwiML for a given `callId`.
|
|
@@ -164,6 +174,7 @@ export class TwilioProvider implements VoiceCallProvider {
|
|
|
164
174
|
|
|
165
175
|
unregisterCallStream(callSid: string): void {
|
|
166
176
|
this.callStreamMap.delete(callSid);
|
|
177
|
+
this.activeStreamCalls.delete(callSid);
|
|
167
178
|
}
|
|
168
179
|
|
|
169
180
|
isValidStreamToken(callSid: string, token?: string): boolean {
|
|
@@ -322,32 +333,28 @@ export class TwilioProvider implements VoiceCallProvider {
|
|
|
322
333
|
}
|
|
323
334
|
|
|
324
335
|
// Handle call status changes
|
|
325
|
-
const callStatus = params.get("CallStatus");
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
case "busy":
|
|
335
|
-
case "no-answer":
|
|
336
|
-
case "failed":
|
|
337
|
-
this.streamAuthTokens.delete(callSid);
|
|
338
|
-
if (callIdOverride) {
|
|
339
|
-
this.deleteStoredTwiml(callIdOverride);
|
|
340
|
-
}
|
|
341
|
-
return { ...baseEvent, type: "call.ended", reason: callStatus };
|
|
342
|
-
case "canceled":
|
|
343
|
-
this.streamAuthTokens.delete(callSid);
|
|
344
|
-
if (callIdOverride) {
|
|
345
|
-
this.deleteStoredTwiml(callIdOverride);
|
|
346
|
-
}
|
|
347
|
-
return { ...baseEvent, type: "call.ended", reason: "hangup-bot" };
|
|
348
|
-
default:
|
|
349
|
-
return null;
|
|
336
|
+
const callStatus = normalizeProviderStatus(params.get("CallStatus"));
|
|
337
|
+
if (callStatus === "initiated") {
|
|
338
|
+
return { ...baseEvent, type: "call.initiated" };
|
|
339
|
+
}
|
|
340
|
+
if (callStatus === "ringing") {
|
|
341
|
+
return { ...baseEvent, type: "call.ringing" };
|
|
342
|
+
}
|
|
343
|
+
if (callStatus === "in-progress") {
|
|
344
|
+
return { ...baseEvent, type: "call.answered" };
|
|
350
345
|
}
|
|
346
|
+
|
|
347
|
+
const endReason = mapProviderStatusToEndReason(callStatus);
|
|
348
|
+
if (endReason) {
|
|
349
|
+
this.streamAuthTokens.delete(callSid);
|
|
350
|
+
this.activeStreamCalls.delete(callSid);
|
|
351
|
+
if (callIdOverride) {
|
|
352
|
+
this.deleteStoredTwiml(callIdOverride);
|
|
353
|
+
}
|
|
354
|
+
return { ...baseEvent, type: "call.ended", reason: endReason };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return null;
|
|
351
358
|
}
|
|
352
359
|
|
|
353
360
|
private static readonly EMPTY_TWIML =
|
|
@@ -358,6 +365,12 @@ export class TwilioProvider implements VoiceCallProvider {
|
|
|
358
365
|
<Pause length="30"/>
|
|
359
366
|
</Response>`;
|
|
360
367
|
|
|
368
|
+
private static readonly QUEUE_TWIML = `<?xml version="1.0" encoding="UTF-8"?>
|
|
369
|
+
<Response>
|
|
370
|
+
<Say voice="alice">Please hold while we connect you.</Say>
|
|
371
|
+
<Enqueue waitUrl="/voice/hold-music">hold-queue</Enqueue>
|
|
372
|
+
</Response>`;
|
|
373
|
+
|
|
361
374
|
/**
|
|
362
375
|
* Generate TwiML response for webhook.
|
|
363
376
|
* When a call is answered, connects to media stream for bidirectional audio.
|
|
@@ -367,59 +380,40 @@ export class TwilioProvider implements VoiceCallProvider {
|
|
|
367
380
|
return TwilioProvider.EMPTY_TWIML;
|
|
368
381
|
}
|
|
369
382
|
|
|
370
|
-
const
|
|
371
|
-
const
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
const
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
// Avoid logging webhook params/TwiML (may contain PII).
|
|
383
|
-
|
|
384
|
-
// Handle initial TwiML request (when Twilio first initiates the call)
|
|
385
|
-
// Check if we have stored TwiML for this call (notify mode)
|
|
386
|
-
if (callIdFromQuery && !isStatusCallback) {
|
|
387
|
-
const storedTwiml = this.twimlStorage.get(callIdFromQuery);
|
|
388
|
-
if (storedTwiml) {
|
|
389
|
-
// Clean up after serving (one-time use)
|
|
390
|
-
this.deleteStoredTwiml(callIdFromQuery);
|
|
391
|
-
return storedTwiml;
|
|
392
|
-
}
|
|
393
|
-
if (this.notifyCalls.has(callIdFromQuery)) {
|
|
394
|
-
return TwilioProvider.EMPTY_TWIML;
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
// Conversation mode: return streaming TwiML immediately for outbound calls.
|
|
398
|
-
if (isOutbound) {
|
|
399
|
-
const streamUrl = callSid ? this.getStreamUrlForCall(callSid) : null;
|
|
400
|
-
return streamUrl ? this.getStreamConnectXml(streamUrl) : TwilioProvider.PAUSE_TWIML;
|
|
401
|
-
}
|
|
402
|
-
}
|
|
383
|
+
const view = readTwimlRequestView(ctx);
|
|
384
|
+
const storedTwiml = view.callIdFromQuery
|
|
385
|
+
? this.twimlStorage.get(view.callIdFromQuery)
|
|
386
|
+
: undefined;
|
|
387
|
+
const decision = decideTwimlResponse({
|
|
388
|
+
...view,
|
|
389
|
+
hasStoredTwiml: Boolean(storedTwiml),
|
|
390
|
+
isNotifyCall: view.callIdFromQuery ? this.notifyCalls.has(view.callIdFromQuery) : false,
|
|
391
|
+
hasActiveStreams: this.activeStreamCalls.size > 0,
|
|
392
|
+
canStream: Boolean(view.callSid && this.getStreamUrl()),
|
|
393
|
+
});
|
|
403
394
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
return TwilioProvider.EMPTY_TWIML;
|
|
395
|
+
if (decision.consumeStoredTwimlCallId) {
|
|
396
|
+
this.deleteStoredTwiml(decision.consumeStoredTwimlCallId);
|
|
407
397
|
}
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
// For inbound calls, answer immediately with stream
|
|
411
|
-
if (direction === "inbound") {
|
|
412
|
-
const streamUrl = callSid ? this.getStreamUrlForCall(callSid) : null;
|
|
413
|
-
return streamUrl ? this.getStreamConnectXml(streamUrl) : TwilioProvider.PAUSE_TWIML;
|
|
398
|
+
if (decision.activateStreamCallSid) {
|
|
399
|
+
this.activeStreamCalls.add(decision.activateStreamCallSid);
|
|
414
400
|
}
|
|
415
401
|
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
402
|
+
switch (decision.kind) {
|
|
403
|
+
case "stored":
|
|
404
|
+
return storedTwiml ?? TwilioProvider.EMPTY_TWIML;
|
|
405
|
+
case "queue":
|
|
406
|
+
return TwilioProvider.QUEUE_TWIML;
|
|
407
|
+
case "pause":
|
|
408
|
+
return TwilioProvider.PAUSE_TWIML;
|
|
409
|
+
case "stream": {
|
|
410
|
+
const streamUrl = view.callSid ? this.getStreamUrlForCall(view.callSid) : null;
|
|
411
|
+
return streamUrl ? this.getStreamConnectXml(streamUrl) : TwilioProvider.PAUSE_TWIML;
|
|
412
|
+
}
|
|
413
|
+
case "empty":
|
|
414
|
+
default:
|
|
415
|
+
return TwilioProvider.EMPTY_TWIML;
|
|
419
416
|
}
|
|
420
|
-
|
|
421
|
-
const streamUrl = callSid ? this.getStreamUrlForCall(callSid) : null;
|
|
422
|
-
return streamUrl ? this.getStreamConnectXml(streamUrl) : TwilioProvider.PAUSE_TWIML;
|
|
423
417
|
}
|
|
424
418
|
|
|
425
419
|
/**
|
|
@@ -543,6 +537,7 @@ export class TwilioProvider implements VoiceCallProvider {
|
|
|
543
537
|
|
|
544
538
|
this.callWebhookUrls.delete(input.providerCallId);
|
|
545
539
|
this.streamAuthTokens.delete(input.providerCallId);
|
|
540
|
+
this.activeStreamCalls.delete(input.providerCallId);
|
|
546
541
|
|
|
547
542
|
await this.apiRequest(
|
|
548
543
|
`/Calls/${input.providerCallId}.json`,
|
|
@@ -671,6 +666,32 @@ export class TwilioProvider implements VoiceCallProvider {
|
|
|
671
666
|
// Twilio's <Gather> automatically stops on speech end
|
|
672
667
|
// No explicit action needed
|
|
673
668
|
}
|
|
669
|
+
|
|
670
|
+
async getCallStatus(input: GetCallStatusInput): Promise<GetCallStatusResult> {
|
|
671
|
+
try {
|
|
672
|
+
const data = await guardedJsonApiRequest<{ status?: string }>({
|
|
673
|
+
url: `${this.baseUrl}/Calls/${input.providerCallId}.json`,
|
|
674
|
+
method: "GET",
|
|
675
|
+
headers: {
|
|
676
|
+
Authorization: `Basic ${Buffer.from(`${this.accountSid}:${this.authToken}`).toString("base64")}`,
|
|
677
|
+
},
|
|
678
|
+
allowNotFound: true,
|
|
679
|
+
allowedHostnames: ["api.twilio.com"],
|
|
680
|
+
auditContext: "twilio-get-call-status",
|
|
681
|
+
errorPrefix: "Twilio get call status error",
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
if (!data) {
|
|
685
|
+
return { status: "not-found", isTerminal: true };
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
const status = normalizeProviderStatus(data.status);
|
|
689
|
+
return { status, isTerminal: isProviderStatusTerminal(status) };
|
|
690
|
+
} catch {
|
|
691
|
+
// Transient error — keep the call and rely on timer fallback
|
|
692
|
+
return { status: "error", isTerminal: false, isUnknown: true };
|
|
693
|
+
}
|
|
694
|
+
}
|
|
674
695
|
}
|
|
675
696
|
|
|
676
697
|
// -----------------------------------------------------------------------------
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { VoiceCallConfig } from "./config.js";
|
|
3
|
+
import type { CoreConfig } from "./core-bridge.js";
|
|
4
|
+
|
|
5
|
+
const mocks = vi.hoisted(() => ({
|
|
6
|
+
resolveVoiceCallConfig: vi.fn(),
|
|
7
|
+
validateProviderConfig: vi.fn(),
|
|
8
|
+
managerInitialize: vi.fn(),
|
|
9
|
+
webhookStart: vi.fn(),
|
|
10
|
+
webhookStop: vi.fn(),
|
|
11
|
+
webhookGetMediaStreamHandler: vi.fn(),
|
|
12
|
+
startTunnel: vi.fn(),
|
|
13
|
+
setupTailscaleExposure: vi.fn(),
|
|
14
|
+
cleanupTailscaleExposure: vi.fn(),
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
vi.mock("./config.js", () => ({
|
|
18
|
+
resolveVoiceCallConfig: mocks.resolveVoiceCallConfig,
|
|
19
|
+
validateProviderConfig: mocks.validateProviderConfig,
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
vi.mock("./manager.js", () => ({
|
|
23
|
+
CallManager: class {
|
|
24
|
+
initialize = mocks.managerInitialize;
|
|
25
|
+
},
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
vi.mock("./webhook.js", () => ({
|
|
29
|
+
VoiceCallWebhookServer: class {
|
|
30
|
+
start = mocks.webhookStart;
|
|
31
|
+
stop = mocks.webhookStop;
|
|
32
|
+
getMediaStreamHandler = mocks.webhookGetMediaStreamHandler;
|
|
33
|
+
},
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
vi.mock("./tunnel.js", () => ({
|
|
37
|
+
startTunnel: mocks.startTunnel,
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
vi.mock("./webhook/tailscale.js", () => ({
|
|
41
|
+
setupTailscaleExposure: mocks.setupTailscaleExposure,
|
|
42
|
+
cleanupTailscaleExposure: mocks.cleanupTailscaleExposure,
|
|
43
|
+
}));
|
|
44
|
+
|
|
45
|
+
import { createVoiceCallRuntime } from "./runtime.js";
|
|
46
|
+
|
|
47
|
+
function createBaseConfig(): VoiceCallConfig {
|
|
48
|
+
return {
|
|
49
|
+
enabled: true,
|
|
50
|
+
provider: "mock",
|
|
51
|
+
fromNumber: "+15550001234",
|
|
52
|
+
inboundPolicy: "disabled",
|
|
53
|
+
allowFrom: [],
|
|
54
|
+
outbound: { defaultMode: "notify", notifyHangupDelaySec: 3 },
|
|
55
|
+
maxDurationSeconds: 300,
|
|
56
|
+
staleCallReaperSeconds: 600,
|
|
57
|
+
silenceTimeoutMs: 800,
|
|
58
|
+
transcriptTimeoutMs: 180000,
|
|
59
|
+
ringTimeoutMs: 30000,
|
|
60
|
+
maxConcurrentCalls: 1,
|
|
61
|
+
serve: { port: 3334, bind: "127.0.0.1", path: "/voice/webhook" },
|
|
62
|
+
tailscale: { mode: "off", path: "/voice/webhook" },
|
|
63
|
+
tunnel: { provider: "ngrok", allowNgrokFreeTierLoopbackBypass: false },
|
|
64
|
+
webhookSecurity: {
|
|
65
|
+
allowedHosts: [],
|
|
66
|
+
trustForwardingHeaders: false,
|
|
67
|
+
trustedProxyIPs: [],
|
|
68
|
+
},
|
|
69
|
+
streaming: {
|
|
70
|
+
enabled: false,
|
|
71
|
+
sttProvider: "openai-realtime",
|
|
72
|
+
sttModel: "gpt-4o-transcribe",
|
|
73
|
+
silenceDurationMs: 800,
|
|
74
|
+
vadThreshold: 0.5,
|
|
75
|
+
streamPath: "/voice/stream",
|
|
76
|
+
preStartTimeoutMs: 5000,
|
|
77
|
+
maxPendingConnections: 32,
|
|
78
|
+
maxPendingConnectionsPerIp: 4,
|
|
79
|
+
maxConnections: 128,
|
|
80
|
+
},
|
|
81
|
+
skipSignatureVerification: false,
|
|
82
|
+
stt: { provider: "openai", model: "whisper-1" },
|
|
83
|
+
tts: {
|
|
84
|
+
provider: "openai",
|
|
85
|
+
openai: { model: "gpt-4o-mini-tts", voice: "coral" },
|
|
86
|
+
},
|
|
87
|
+
responseModel: "openai/gpt-4o-mini",
|
|
88
|
+
responseTimeoutMs: 30000,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
describe("createVoiceCallRuntime lifecycle", () => {
|
|
93
|
+
beforeEach(() => {
|
|
94
|
+
vi.clearAllMocks();
|
|
95
|
+
mocks.resolveVoiceCallConfig.mockImplementation((cfg: VoiceCallConfig) => cfg);
|
|
96
|
+
mocks.validateProviderConfig.mockReturnValue({ valid: true, errors: [] });
|
|
97
|
+
mocks.managerInitialize.mockResolvedValue(undefined);
|
|
98
|
+
mocks.webhookStart.mockResolvedValue("http://127.0.0.1:3334/voice/webhook");
|
|
99
|
+
mocks.webhookStop.mockResolvedValue(undefined);
|
|
100
|
+
mocks.webhookGetMediaStreamHandler.mockReturnValue(undefined);
|
|
101
|
+
mocks.startTunnel.mockResolvedValue(null);
|
|
102
|
+
mocks.setupTailscaleExposure.mockResolvedValue(null);
|
|
103
|
+
mocks.cleanupTailscaleExposure.mockResolvedValue(undefined);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("cleans up tunnel, tailscale, and webhook server when init fails after start", async () => {
|
|
107
|
+
const tunnelStop = vi.fn().mockResolvedValue(undefined);
|
|
108
|
+
mocks.startTunnel.mockResolvedValue({
|
|
109
|
+
publicUrl: "https://public.example/voice/webhook",
|
|
110
|
+
provider: "ngrok",
|
|
111
|
+
stop: tunnelStop,
|
|
112
|
+
});
|
|
113
|
+
mocks.managerInitialize.mockRejectedValue(new Error("init failed"));
|
|
114
|
+
|
|
115
|
+
await expect(
|
|
116
|
+
createVoiceCallRuntime({
|
|
117
|
+
config: createBaseConfig(),
|
|
118
|
+
coreConfig: {},
|
|
119
|
+
}),
|
|
120
|
+
).rejects.toThrow("init failed");
|
|
121
|
+
|
|
122
|
+
expect(tunnelStop).toHaveBeenCalledTimes(1);
|
|
123
|
+
expect(mocks.cleanupTailscaleExposure).toHaveBeenCalledTimes(1);
|
|
124
|
+
expect(mocks.webhookStop).toHaveBeenCalledTimes(1);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("returns an idempotent stop handler", async () => {
|
|
128
|
+
const tunnelStop = vi.fn().mockResolvedValue(undefined);
|
|
129
|
+
mocks.startTunnel.mockResolvedValue({
|
|
130
|
+
publicUrl: "https://public.example/voice/webhook",
|
|
131
|
+
provider: "ngrok",
|
|
132
|
+
stop: tunnelStop,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const runtime = await createVoiceCallRuntime({
|
|
136
|
+
config: createBaseConfig(),
|
|
137
|
+
coreConfig: {} as CoreConfig,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
await runtime.stop();
|
|
141
|
+
await runtime.stop();
|
|
142
|
+
|
|
143
|
+
expect(tunnelStop).toHaveBeenCalledTimes(1);
|
|
144
|
+
expect(mocks.cleanupTailscaleExposure).toHaveBeenCalledTimes(1);
|
|
145
|
+
expect(mocks.webhookStop).toHaveBeenCalledTimes(1);
|
|
146
|
+
});
|
|
147
|
+
});
|