@openclaw/voice-call 2026.2.25 → 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 +18 -0
- package/index.ts +27 -13
- package/package.json +1 -1
- package/src/cli.ts +1 -1
- package/src/http-headers.test.ts +16 -0
- package/src/http-headers.ts +12 -0
- 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 +12 -1
- package/src/providers/mock.ts +15 -1
- package/src/providers/plivo.test.ts +22 -0
- package/src/providers/plivo.ts +60 -27
- package/src/providers/shared/call-status.test.ts +24 -0
- package/src/providers/shared/call-status.ts +23 -0
- package/src/providers/shared/guarded-json-api.ts +42 -0
- package/src/providers/telnyx.test.ts +27 -0
- package/src/providers/telnyx.ts +56 -17
- package/src/providers/twilio/twiml-policy.test.ts +84 -0
- package/src/providers/twilio/twiml-policy.ts +91 -0
- package/src/providers/twilio/webhook.ts +1 -0
- package/src/providers/twilio.test.ts +93 -2
- package/src/providers/twilio.ts +111 -91
- package/src/runtime.test.ts +147 -0
- package/src/runtime.ts +123 -76
- package/src/tunnel.ts +1 -1
- package/src/types.ts +24 -0
- package/src/webhook/stale-call-reaper.ts +33 -0
- package/src/webhook/tailscale.ts +115 -0
- package/src/webhook-security.test.ts +135 -4
- package/src/webhook-security.ts +142 -42
- package/src/webhook.test.ts +168 -14
- package/src/webhook.ts +118 -203
- package/src/manager.test.ts +0 -467
|
@@ -60,7 +60,77 @@ describe("TwilioProvider", () => {
|
|
|
60
60
|
expect(result.providerResponseBody).toContain("<Connect>");
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
-
it("
|
|
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
|
+
|
|
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";
|
|
66
136
|
const ctxA = {
|
|
@@ -78,10 +148,31 @@ describe("TwilioProvider", () => {
|
|
|
78
148
|
expect(eventA).toBeDefined();
|
|
79
149
|
expect(eventB).toBeDefined();
|
|
80
150
|
expect(eventA?.id).not.toBe(eventB?.id);
|
|
81
|
-
expect(eventA?.dedupeKey).
|
|
151
|
+
expect(eventA?.dedupeKey).toContain("twilio:fallback:");
|
|
82
152
|
expect(eventA?.dedupeKey).toBe(eventB?.dedupeKey);
|
|
83
153
|
});
|
|
84
154
|
|
|
155
|
+
it("uses verified request key for dedupe and ignores idempotency header changes", () => {
|
|
156
|
+
const provider = createProvider();
|
|
157
|
+
const rawBody = "CallSid=CA790&Direction=inbound&SpeechResult=hello";
|
|
158
|
+
const ctxA = {
|
|
159
|
+
...createContext(rawBody, { callId: "call-1", turnToken: "turn-1" }),
|
|
160
|
+
headers: { "i-twilio-idempotency-token": "idem-a" },
|
|
161
|
+
};
|
|
162
|
+
const ctxB = {
|
|
163
|
+
...createContext(rawBody, { callId: "call-1", turnToken: "turn-1" }),
|
|
164
|
+
headers: { "i-twilio-idempotency-token": "idem-b" },
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const eventA = provider.parseWebhookEvent(ctxA, { verifiedRequestKey: "twilio:req:abc" })
|
|
168
|
+
.events[0];
|
|
169
|
+
const eventB = provider.parseWebhookEvent(ctxB, { verifiedRequestKey: "twilio:req:abc" })
|
|
170
|
+
.events[0];
|
|
171
|
+
|
|
172
|
+
expect(eventA?.dedupeKey).toBe("twilio:req:abc");
|
|
173
|
+
expect(eventB?.dedupeKey).toBe("twilio:req:abc");
|
|
174
|
+
});
|
|
175
|
+
|
|
85
176
|
it("keeps turnToken from query on speech events", () => {
|
|
86
177
|
const provider = createProvider();
|
|
87
178
|
const ctx = createContext("CallSid=CA222&Direction=inbound&SpeechResult=hello", {
|
package/src/providers/twilio.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
import type { TwilioConfig, WebhookSecurityConfig } from "../config.js";
|
|
3
|
+
import { getHeader } from "../http-headers.js";
|
|
3
4
|
import type { MediaStreamHandler } from "../media-stream.js";
|
|
4
5
|
import { chunkAudio } from "../telephony-audio.js";
|
|
5
6
|
import type { TelephonyTtsProvider } from "../telephony-tts.js";
|
|
6
7
|
import type {
|
|
8
|
+
GetCallStatusInput,
|
|
9
|
+
GetCallStatusResult,
|
|
7
10
|
HangupCallInput,
|
|
8
11
|
InitiateCallInput,
|
|
9
12
|
InitiateCallResult,
|
|
@@ -13,37 +16,39 @@ import type {
|
|
|
13
16
|
StartListeningInput,
|
|
14
17
|
StopListeningInput,
|
|
15
18
|
WebhookContext,
|
|
19
|
+
WebhookParseOptions,
|
|
16
20
|
WebhookVerificationResult,
|
|
17
21
|
} from "../types.js";
|
|
18
22
|
import { escapeXml, mapVoiceToPolly } from "../voice-mapping.js";
|
|
19
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";
|
|
20
30
|
import { twilioApiRequest } from "./twilio/api.js";
|
|
31
|
+
import { decideTwimlResponse, readTwimlRequestView } from "./twilio/twiml-policy.js";
|
|
21
32
|
import { verifyTwilioProviderWebhook } from "./twilio/webhook.js";
|
|
22
33
|
|
|
23
|
-
function
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
): string | undefined {
|
|
27
|
-
const value = headers[name.toLowerCase()];
|
|
28
|
-
if (Array.isArray(value)) {
|
|
29
|
-
return value[0];
|
|
30
|
-
}
|
|
31
|
-
return value;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function createTwilioRequestDedupeKey(ctx: WebhookContext): string {
|
|
35
|
-
const idempotencyToken = getHeader(ctx.headers, "i-twilio-idempotency-token");
|
|
36
|
-
if (idempotencyToken) {
|
|
37
|
-
return `twilio:idempotency:${idempotencyToken}`;
|
|
34
|
+
function createTwilioRequestDedupeKey(ctx: WebhookContext, verifiedRequestKey?: string): string {
|
|
35
|
+
if (verifiedRequestKey) {
|
|
36
|
+
return verifiedRequestKey;
|
|
38
37
|
}
|
|
39
38
|
|
|
40
39
|
const signature = getHeader(ctx.headers, "x-twilio-signature") ?? "";
|
|
40
|
+
const params = new URLSearchParams(ctx.rawBody);
|
|
41
|
+
const callSid = params.get("CallSid") ?? "";
|
|
42
|
+
const callStatus = params.get("CallStatus") ?? "";
|
|
43
|
+
const direction = params.get("Direction") ?? "";
|
|
41
44
|
const callId = typeof ctx.query?.callId === "string" ? ctx.query.callId.trim() : "";
|
|
42
45
|
const flow = typeof ctx.query?.flow === "string" ? ctx.query.flow.trim() : "";
|
|
43
46
|
const turnToken = typeof ctx.query?.turnToken === "string" ? ctx.query.turnToken.trim() : "";
|
|
44
47
|
return `twilio:fallback:${crypto
|
|
45
48
|
.createHash("sha256")
|
|
46
|
-
.update(
|
|
49
|
+
.update(
|
|
50
|
+
`${signature}\n${callSid}\n${callStatus}\n${direction}\n${callId}\n${flow}\n${turnToken}\n${ctx.rawBody}`,
|
|
51
|
+
)
|
|
47
52
|
.digest("hex")}`;
|
|
48
53
|
}
|
|
49
54
|
|
|
@@ -96,6 +101,7 @@ export class TwilioProvider implements VoiceCallProvider {
|
|
|
96
101
|
private readonly twimlStorage = new Map<string, string>();
|
|
97
102
|
/** Track notify-mode calls to avoid streaming on follow-up callbacks */
|
|
98
103
|
private readonly notifyCalls = new Set<string>();
|
|
104
|
+
private readonly activeStreamCalls = new Set<string>();
|
|
99
105
|
|
|
100
106
|
/**
|
|
101
107
|
* Delete stored TwiML for a given `callId`.
|
|
@@ -168,6 +174,7 @@ export class TwilioProvider implements VoiceCallProvider {
|
|
|
168
174
|
|
|
169
175
|
unregisterCallStream(callSid: string): void {
|
|
170
176
|
this.callStreamMap.delete(callSid);
|
|
177
|
+
this.activeStreamCalls.delete(callSid);
|
|
171
178
|
}
|
|
172
179
|
|
|
173
180
|
isValidStreamToken(callSid: string, token?: string): boolean {
|
|
@@ -232,7 +239,10 @@ export class TwilioProvider implements VoiceCallProvider {
|
|
|
232
239
|
/**
|
|
233
240
|
* Parse Twilio webhook event into normalized format.
|
|
234
241
|
*/
|
|
235
|
-
parseWebhookEvent(
|
|
242
|
+
parseWebhookEvent(
|
|
243
|
+
ctx: WebhookContext,
|
|
244
|
+
options?: WebhookParseOptions,
|
|
245
|
+
): ProviderWebhookParseResult {
|
|
236
246
|
try {
|
|
237
247
|
const params = new URLSearchParams(ctx.rawBody);
|
|
238
248
|
const callIdFromQuery =
|
|
@@ -243,7 +253,7 @@ export class TwilioProvider implements VoiceCallProvider {
|
|
|
243
253
|
typeof ctx.query?.turnToken === "string" && ctx.query.turnToken.trim()
|
|
244
254
|
? ctx.query.turnToken.trim()
|
|
245
255
|
: undefined;
|
|
246
|
-
const dedupeKey = createTwilioRequestDedupeKey(ctx);
|
|
256
|
+
const dedupeKey = createTwilioRequestDedupeKey(ctx, options?.verifiedRequestKey);
|
|
247
257
|
const event = this.normalizeEvent(params, {
|
|
248
258
|
callIdOverride: callIdFromQuery,
|
|
249
259
|
dedupeKey,
|
|
@@ -323,32 +333,28 @@ export class TwilioProvider implements VoiceCallProvider {
|
|
|
323
333
|
}
|
|
324
334
|
|
|
325
335
|
// Handle call status changes
|
|
326
|
-
const callStatus = params.get("CallStatus");
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
if (callIdOverride) {
|
|
346
|
-
this.deleteStoredTwiml(callIdOverride);
|
|
347
|
-
}
|
|
348
|
-
return { ...baseEvent, type: "call.ended", reason: "hangup-bot" };
|
|
349
|
-
default:
|
|
350
|
-
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" };
|
|
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 };
|
|
351
355
|
}
|
|
356
|
+
|
|
357
|
+
return null;
|
|
352
358
|
}
|
|
353
359
|
|
|
354
360
|
private static readonly EMPTY_TWIML =
|
|
@@ -359,6 +365,12 @@ export class TwilioProvider implements VoiceCallProvider {
|
|
|
359
365
|
<Pause length="30"/>
|
|
360
366
|
</Response>`;
|
|
361
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
|
+
|
|
362
374
|
/**
|
|
363
375
|
* Generate TwiML response for webhook.
|
|
364
376
|
* When a call is answered, connects to media stream for bidirectional audio.
|
|
@@ -368,59 +380,40 @@ export class TwilioProvider implements VoiceCallProvider {
|
|
|
368
380
|
return TwilioProvider.EMPTY_TWIML;
|
|
369
381
|
}
|
|
370
382
|
|
|
371
|
-
const
|
|
372
|
-
const
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
const
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
// Avoid logging webhook params/TwiML (may contain PII).
|
|
384
|
-
|
|
385
|
-
// Handle initial TwiML request (when Twilio first initiates the call)
|
|
386
|
-
// Check if we have stored TwiML for this call (notify mode)
|
|
387
|
-
if (callIdFromQuery && !isStatusCallback) {
|
|
388
|
-
const storedTwiml = this.twimlStorage.get(callIdFromQuery);
|
|
389
|
-
if (storedTwiml) {
|
|
390
|
-
// Clean up after serving (one-time use)
|
|
391
|
-
this.deleteStoredTwiml(callIdFromQuery);
|
|
392
|
-
return storedTwiml;
|
|
393
|
-
}
|
|
394
|
-
if (this.notifyCalls.has(callIdFromQuery)) {
|
|
395
|
-
return TwilioProvider.EMPTY_TWIML;
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
// Conversation mode: return streaming TwiML immediately for outbound calls.
|
|
399
|
-
if (isOutbound) {
|
|
400
|
-
const streamUrl = callSid ? this.getStreamUrlForCall(callSid) : null;
|
|
401
|
-
return streamUrl ? this.getStreamConnectXml(streamUrl) : TwilioProvider.PAUSE_TWIML;
|
|
402
|
-
}
|
|
403
|
-
}
|
|
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
|
+
});
|
|
404
394
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
return TwilioProvider.EMPTY_TWIML;
|
|
395
|
+
if (decision.consumeStoredTwimlCallId) {
|
|
396
|
+
this.deleteStoredTwiml(decision.consumeStoredTwimlCallId);
|
|
408
397
|
}
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
// For inbound calls, answer immediately with stream
|
|
412
|
-
if (direction === "inbound") {
|
|
413
|
-
const streamUrl = callSid ? this.getStreamUrlForCall(callSid) : null;
|
|
414
|
-
return streamUrl ? this.getStreamConnectXml(streamUrl) : TwilioProvider.PAUSE_TWIML;
|
|
398
|
+
if (decision.activateStreamCallSid) {
|
|
399
|
+
this.activeStreamCalls.add(decision.activateStreamCallSid);
|
|
415
400
|
}
|
|
416
401
|
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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;
|
|
420
416
|
}
|
|
421
|
-
|
|
422
|
-
const streamUrl = callSid ? this.getStreamUrlForCall(callSid) : null;
|
|
423
|
-
return streamUrl ? this.getStreamConnectXml(streamUrl) : TwilioProvider.PAUSE_TWIML;
|
|
424
417
|
}
|
|
425
418
|
|
|
426
419
|
/**
|
|
@@ -544,6 +537,7 @@ export class TwilioProvider implements VoiceCallProvider {
|
|
|
544
537
|
|
|
545
538
|
this.callWebhookUrls.delete(input.providerCallId);
|
|
546
539
|
this.streamAuthTokens.delete(input.providerCallId);
|
|
540
|
+
this.activeStreamCalls.delete(input.providerCallId);
|
|
547
541
|
|
|
548
542
|
await this.apiRequest(
|
|
549
543
|
`/Calls/${input.providerCallId}.json`,
|
|
@@ -672,6 +666,32 @@ export class TwilioProvider implements VoiceCallProvider {
|
|
|
672
666
|
// Twilio's <Gather> automatically stops on speech end
|
|
673
667
|
// No explicit action needed
|
|
674
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
|
+
}
|
|
675
695
|
}
|
|
676
696
|
|
|
677
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
|
+
});
|