@clawdbot/voice-call 0.1.0 → 2026.1.16

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.
@@ -0,0 +1,504 @@
1
+ import crypto from "node:crypto";
2
+
3
+ import type { PlivoConfig } from "../config.js";
4
+ import type {
5
+ HangupCallInput,
6
+ InitiateCallInput,
7
+ InitiateCallResult,
8
+ NormalizedEvent,
9
+ PlayTtsInput,
10
+ ProviderWebhookParseResult,
11
+ StartListeningInput,
12
+ StopListeningInput,
13
+ WebhookContext,
14
+ WebhookVerificationResult,
15
+ } from "../types.js";
16
+ import { escapeXml } from "../voice-mapping.js";
17
+ import { reconstructWebhookUrl, verifyPlivoWebhook } from "../webhook-security.js";
18
+ import type { VoiceCallProvider } from "./base.js";
19
+
20
+ export interface PlivoProviderOptions {
21
+ /** Override public URL origin for signature verification */
22
+ publicUrl?: string;
23
+ /** Skip webhook signature verification (development only) */
24
+ skipVerification?: boolean;
25
+ /** Outbound ring timeout in seconds */
26
+ ringTimeoutSec?: number;
27
+ }
28
+
29
+ type PendingSpeak = { text: string; locale?: string };
30
+ type PendingListen = { language?: string };
31
+
32
+ export class PlivoProvider implements VoiceCallProvider {
33
+ readonly name = "plivo" as const;
34
+
35
+ private readonly authId: string;
36
+ private readonly authToken: string;
37
+ private readonly baseUrl: string;
38
+ private readonly options: PlivoProviderOptions;
39
+
40
+ // Best-effort mapping between create-call request UUID and call UUID.
41
+ private requestUuidToCallUuid = new Map<string, string>();
42
+
43
+ // Used for transfer URLs and GetInput action URLs.
44
+ private callIdToWebhookUrl = new Map<string, string>();
45
+ private callUuidToWebhookUrl = new Map<string, string>();
46
+
47
+ private pendingSpeakByCallId = new Map<string, PendingSpeak>();
48
+ private pendingListenByCallId = new Map<string, PendingListen>();
49
+
50
+ constructor(config: PlivoConfig, options: PlivoProviderOptions = {}) {
51
+ if (!config.authId) {
52
+ throw new Error("Plivo Auth ID is required");
53
+ }
54
+ if (!config.authToken) {
55
+ throw new Error("Plivo Auth Token is required");
56
+ }
57
+
58
+ this.authId = config.authId;
59
+ this.authToken = config.authToken;
60
+ this.baseUrl = `https://api.plivo.com/v1/Account/${this.authId}`;
61
+ this.options = options;
62
+ }
63
+
64
+ private async apiRequest<T = unknown>(params: {
65
+ method: "GET" | "POST" | "DELETE";
66
+ endpoint: string;
67
+ body?: Record<string, unknown>;
68
+ allowNotFound?: boolean;
69
+ }): Promise<T> {
70
+ const { method, endpoint, body, allowNotFound } = params;
71
+ const response = await fetch(`${this.baseUrl}${endpoint}`, {
72
+ method,
73
+ headers: {
74
+ Authorization: `Basic ${Buffer.from(`${this.authId}:${this.authToken}`).toString("base64")}`,
75
+ "Content-Type": "application/json",
76
+ },
77
+ body: body ? JSON.stringify(body) : undefined,
78
+ });
79
+
80
+ if (!response.ok) {
81
+ if (allowNotFound && response.status === 404) {
82
+ return undefined as T;
83
+ }
84
+ const errorText = await response.text();
85
+ throw new Error(`Plivo API error: ${response.status} ${errorText}`);
86
+ }
87
+
88
+ const text = await response.text();
89
+ return text ? (JSON.parse(text) as T) : (undefined as T);
90
+ }
91
+
92
+ verifyWebhook(ctx: WebhookContext): WebhookVerificationResult {
93
+ const result = verifyPlivoWebhook(ctx, this.authToken, {
94
+ publicUrl: this.options.publicUrl,
95
+ skipVerification: this.options.skipVerification,
96
+ });
97
+
98
+ if (!result.ok) {
99
+ console.warn(`[plivo] Webhook verification failed: ${result.reason}`);
100
+ }
101
+
102
+ return { ok: result.ok, reason: result.reason };
103
+ }
104
+
105
+ parseWebhookEvent(ctx: WebhookContext): ProviderWebhookParseResult {
106
+ const flow =
107
+ typeof ctx.query?.flow === "string" ? ctx.query.flow.trim() : "";
108
+
109
+ const parsed = this.parseBody(ctx.rawBody);
110
+ if (!parsed) {
111
+ return { events: [], statusCode: 400 };
112
+ }
113
+
114
+ // Keep providerCallId mapping for later call control.
115
+ const callUuid = parsed.get("CallUUID") || undefined;
116
+ if (callUuid) {
117
+ const webhookBase = PlivoProvider.baseWebhookUrlFromCtx(ctx);
118
+ if (webhookBase) {
119
+ this.callUuidToWebhookUrl.set(callUuid, webhookBase);
120
+ }
121
+ }
122
+
123
+ // Special flows that exist only to return Plivo XML (no events).
124
+ if (flow === "xml-speak") {
125
+ const callId = this.getCallIdFromQuery(ctx);
126
+ const pending = callId ? this.pendingSpeakByCallId.get(callId) : undefined;
127
+ if (callId) this.pendingSpeakByCallId.delete(callId);
128
+
129
+ const xml = pending
130
+ ? PlivoProvider.xmlSpeak(pending.text, pending.locale)
131
+ : PlivoProvider.xmlKeepAlive();
132
+ return {
133
+ events: [],
134
+ providerResponseBody: xml,
135
+ providerResponseHeaders: { "Content-Type": "text/xml" },
136
+ statusCode: 200,
137
+ };
138
+ }
139
+
140
+ if (flow === "xml-listen") {
141
+ const callId = this.getCallIdFromQuery(ctx);
142
+ const pending = callId
143
+ ? this.pendingListenByCallId.get(callId)
144
+ : undefined;
145
+ if (callId) this.pendingListenByCallId.delete(callId);
146
+
147
+ const actionUrl = this.buildActionUrl(ctx, {
148
+ flow: "getinput",
149
+ callId,
150
+ });
151
+
152
+ const xml =
153
+ actionUrl && callId
154
+ ? PlivoProvider.xmlGetInputSpeech({
155
+ actionUrl,
156
+ language: pending?.language,
157
+ })
158
+ : PlivoProvider.xmlKeepAlive();
159
+
160
+ return {
161
+ events: [],
162
+ providerResponseBody: xml,
163
+ providerResponseHeaders: { "Content-Type": "text/xml" },
164
+ statusCode: 200,
165
+ };
166
+ }
167
+
168
+ // Normal events.
169
+ const callIdFromQuery = this.getCallIdFromQuery(ctx);
170
+ const event = this.normalizeEvent(parsed, callIdFromQuery);
171
+
172
+ return {
173
+ events: event ? [event] : [],
174
+ providerResponseBody:
175
+ flow === "answer" || flow === "getinput"
176
+ ? PlivoProvider.xmlKeepAlive()
177
+ : PlivoProvider.xmlEmpty(),
178
+ providerResponseHeaders: { "Content-Type": "text/xml" },
179
+ statusCode: 200,
180
+ };
181
+ }
182
+
183
+ private normalizeEvent(
184
+ params: URLSearchParams,
185
+ callIdOverride?: string,
186
+ ): NormalizedEvent | null {
187
+ const callUuid = params.get("CallUUID") || "";
188
+ const requestUuid = params.get("RequestUUID") || "";
189
+
190
+ if (requestUuid && callUuid) {
191
+ this.requestUuidToCallUuid.set(requestUuid, callUuid);
192
+ }
193
+
194
+ const direction = params.get("Direction");
195
+ const from = params.get("From") || undefined;
196
+ const to = params.get("To") || undefined;
197
+ const callStatus = params.get("CallStatus");
198
+
199
+ const baseEvent = {
200
+ id: crypto.randomUUID(),
201
+ callId: callIdOverride || callUuid || requestUuid,
202
+ providerCallId: callUuid || requestUuid || undefined,
203
+ timestamp: Date.now(),
204
+ direction:
205
+ direction === "inbound"
206
+ ? ("inbound" as const)
207
+ : direction === "outbound"
208
+ ? ("outbound" as const)
209
+ : undefined,
210
+ from,
211
+ to,
212
+ };
213
+
214
+ const digits = params.get("Digits");
215
+ if (digits) {
216
+ return { ...baseEvent, type: "call.dtmf", digits };
217
+ }
218
+
219
+ const transcript = PlivoProvider.extractTranscript(params);
220
+ if (transcript) {
221
+ return {
222
+ ...baseEvent,
223
+ type: "call.speech",
224
+ transcript,
225
+ isFinal: true,
226
+ };
227
+ }
228
+
229
+ // Call lifecycle.
230
+ if (callStatus === "ringing") {
231
+ return { ...baseEvent, type: "call.ringing" };
232
+ }
233
+
234
+ if (callStatus === "in-progress") {
235
+ return { ...baseEvent, type: "call.answered" };
236
+ }
237
+
238
+ if (
239
+ callStatus === "completed" ||
240
+ callStatus === "busy" ||
241
+ callStatus === "no-answer" ||
242
+ callStatus === "failed"
243
+ ) {
244
+ return {
245
+ ...baseEvent,
246
+ type: "call.ended",
247
+ reason:
248
+ callStatus === "completed"
249
+ ? "completed"
250
+ : callStatus === "busy"
251
+ ? "busy"
252
+ : callStatus === "no-answer"
253
+ ? "no-answer"
254
+ : "failed",
255
+ };
256
+ }
257
+
258
+ // Plivo will call our answer_url when the call is answered; if we don't have
259
+ // a CallStatus for some reason, treat it as answered so the call can proceed.
260
+ if (params.get("Event") === "StartApp" && callUuid) {
261
+ return { ...baseEvent, type: "call.answered" };
262
+ }
263
+
264
+ return null;
265
+ }
266
+
267
+ async initiateCall(input: InitiateCallInput): Promise<InitiateCallResult> {
268
+ const webhookUrl = new URL(input.webhookUrl);
269
+ webhookUrl.searchParams.set("provider", "plivo");
270
+ webhookUrl.searchParams.set("callId", input.callId);
271
+
272
+ const answerUrl = new URL(webhookUrl);
273
+ answerUrl.searchParams.set("flow", "answer");
274
+
275
+ const hangupUrl = new URL(webhookUrl);
276
+ hangupUrl.searchParams.set("flow", "hangup");
277
+
278
+ this.callIdToWebhookUrl.set(input.callId, input.webhookUrl);
279
+
280
+ const ringTimeoutSec = this.options.ringTimeoutSec ?? 30;
281
+
282
+ const result = await this.apiRequest<PlivoCreateCallResponse>({
283
+ method: "POST",
284
+ endpoint: "/Call/",
285
+ body: {
286
+ from: PlivoProvider.normalizeNumber(input.from),
287
+ to: PlivoProvider.normalizeNumber(input.to),
288
+ answer_url: answerUrl.toString(),
289
+ answer_method: "POST",
290
+ hangup_url: hangupUrl.toString(),
291
+ hangup_method: "POST",
292
+ // Plivo's API uses `hangup_on_ring` for outbound ring timeout.
293
+ hangup_on_ring: ringTimeoutSec,
294
+ },
295
+ });
296
+
297
+ const requestUuid = Array.isArray(result.request_uuid)
298
+ ? result.request_uuid[0]
299
+ : result.request_uuid;
300
+ if (!requestUuid) {
301
+ throw new Error("Plivo call create returned no request_uuid");
302
+ }
303
+
304
+ return { providerCallId: requestUuid, status: "initiated" };
305
+ }
306
+
307
+ async hangupCall(input: HangupCallInput): Promise<void> {
308
+ const callUuid = this.requestUuidToCallUuid.get(input.providerCallId);
309
+ if (callUuid) {
310
+ await this.apiRequest({
311
+ method: "DELETE",
312
+ endpoint: `/Call/${callUuid}/`,
313
+ allowNotFound: true,
314
+ });
315
+ return;
316
+ }
317
+
318
+ // Best-effort: try hangup (call UUID), then cancel (request UUID).
319
+ await this.apiRequest({
320
+ method: "DELETE",
321
+ endpoint: `/Call/${input.providerCallId}/`,
322
+ allowNotFound: true,
323
+ });
324
+ await this.apiRequest({
325
+ method: "DELETE",
326
+ endpoint: `/Request/${input.providerCallId}/`,
327
+ allowNotFound: true,
328
+ });
329
+ }
330
+
331
+ async playTts(input: PlayTtsInput): Promise<void> {
332
+ const callUuid = this.requestUuidToCallUuid.get(input.providerCallId) ??
333
+ input.providerCallId;
334
+ const webhookBase =
335
+ this.callUuidToWebhookUrl.get(callUuid) ||
336
+ this.callIdToWebhookUrl.get(input.callId);
337
+ if (!webhookBase) {
338
+ throw new Error("Missing webhook URL for this call (provider state missing)");
339
+ }
340
+
341
+ if (!callUuid) {
342
+ throw new Error("Missing Plivo CallUUID for playTts");
343
+ }
344
+
345
+ const transferUrl = new URL(webhookBase);
346
+ transferUrl.searchParams.set("provider", "plivo");
347
+ transferUrl.searchParams.set("flow", "xml-speak");
348
+ transferUrl.searchParams.set("callId", input.callId);
349
+
350
+ this.pendingSpeakByCallId.set(input.callId, {
351
+ text: input.text,
352
+ locale: input.locale,
353
+ });
354
+
355
+ await this.apiRequest({
356
+ method: "POST",
357
+ endpoint: `/Call/${callUuid}/`,
358
+ body: {
359
+ legs: "aleg",
360
+ aleg_url: transferUrl.toString(),
361
+ aleg_method: "POST",
362
+ },
363
+ });
364
+ }
365
+
366
+ async startListening(input: StartListeningInput): Promise<void> {
367
+ const callUuid = this.requestUuidToCallUuid.get(input.providerCallId) ??
368
+ input.providerCallId;
369
+ const webhookBase =
370
+ this.callUuidToWebhookUrl.get(callUuid) ||
371
+ this.callIdToWebhookUrl.get(input.callId);
372
+ if (!webhookBase) {
373
+ throw new Error("Missing webhook URL for this call (provider state missing)");
374
+ }
375
+
376
+ if (!callUuid) {
377
+ throw new Error("Missing Plivo CallUUID for startListening");
378
+ }
379
+
380
+ const transferUrl = new URL(webhookBase);
381
+ transferUrl.searchParams.set("provider", "plivo");
382
+ transferUrl.searchParams.set("flow", "xml-listen");
383
+ transferUrl.searchParams.set("callId", input.callId);
384
+
385
+ this.pendingListenByCallId.set(input.callId, {
386
+ language: input.language,
387
+ });
388
+
389
+ await this.apiRequest({
390
+ method: "POST",
391
+ endpoint: `/Call/${callUuid}/`,
392
+ body: {
393
+ legs: "aleg",
394
+ aleg_url: transferUrl.toString(),
395
+ aleg_method: "POST",
396
+ },
397
+ });
398
+ }
399
+
400
+ async stopListening(_input: StopListeningInput): Promise<void> {
401
+ // GetInput ends automatically when speech ends.
402
+ }
403
+
404
+ private static normalizeNumber(numberOrSip: string): string {
405
+ const trimmed = numberOrSip.trim();
406
+ if (trimmed.toLowerCase().startsWith("sip:")) return trimmed;
407
+ return trimmed.replace(/[^\d+]/g, "");
408
+ }
409
+
410
+ private static xmlEmpty(): string {
411
+ return `<?xml version="1.0" encoding="UTF-8"?><Response></Response>`;
412
+ }
413
+
414
+ private static xmlKeepAlive(): string {
415
+ return `<?xml version="1.0" encoding="UTF-8"?>
416
+ <Response>
417
+ <Wait length="300" />
418
+ </Response>`;
419
+ }
420
+
421
+ private static xmlSpeak(text: string, locale?: string): string {
422
+ const language = locale || "en-US";
423
+ return `<?xml version="1.0" encoding="UTF-8"?>
424
+ <Response>
425
+ <Speak language="${escapeXml(language)}">${escapeXml(text)}</Speak>
426
+ <Wait length="300" />
427
+ </Response>`;
428
+ }
429
+
430
+ private static xmlGetInputSpeech(params: {
431
+ actionUrl: string;
432
+ language?: string;
433
+ }): string {
434
+ const language = params.language || "en-US";
435
+ return `<?xml version="1.0" encoding="UTF-8"?>
436
+ <Response>
437
+ <GetInput inputType="speech" method="POST" action="${escapeXml(params.actionUrl)}" language="${escapeXml(language)}" executionTimeout="30" speechEndTimeout="1" redirect="false">
438
+ </GetInput>
439
+ <Wait length="300" />
440
+ </Response>`;
441
+ }
442
+
443
+ private getCallIdFromQuery(ctx: WebhookContext): string | undefined {
444
+ const callId =
445
+ typeof ctx.query?.callId === "string" && ctx.query.callId.trim()
446
+ ? ctx.query.callId.trim()
447
+ : undefined;
448
+ return callId || undefined;
449
+ }
450
+
451
+ private buildActionUrl(
452
+ ctx: WebhookContext,
453
+ opts: { flow: string; callId?: string },
454
+ ): string | null {
455
+ const base = PlivoProvider.baseWebhookUrlFromCtx(ctx);
456
+ if (!base) return null;
457
+
458
+ const u = new URL(base);
459
+ u.searchParams.set("provider", "plivo");
460
+ u.searchParams.set("flow", opts.flow);
461
+ if (opts.callId) u.searchParams.set("callId", opts.callId);
462
+ return u.toString();
463
+ }
464
+
465
+ private static baseWebhookUrlFromCtx(ctx: WebhookContext): string | null {
466
+ try {
467
+ const u = new URL(reconstructWebhookUrl(ctx));
468
+ return `${u.origin}${u.pathname}`;
469
+ } catch {
470
+ return null;
471
+ }
472
+ }
473
+
474
+ private parseBody(rawBody: string): URLSearchParams | null {
475
+ try {
476
+ return new URLSearchParams(rawBody);
477
+ } catch {
478
+ return null;
479
+ }
480
+ }
481
+
482
+ private static extractTranscript(params: URLSearchParams): string | null {
483
+ const candidates = [
484
+ "Speech",
485
+ "Transcription",
486
+ "TranscriptionText",
487
+ "SpeechResult",
488
+ "RecognizedSpeech",
489
+ "Text",
490
+ ] as const;
491
+
492
+ for (const key of candidates) {
493
+ const value = params.get(key);
494
+ if (value && value.trim()) return value.trim();
495
+ }
496
+ return null;
497
+ }
498
+ }
499
+
500
+ type PlivoCreateCallResponse = {
501
+ api_id?: string;
502
+ message?: string;
503
+ request_uuid?: string | string[];
504
+ };
@@ -0,0 +1,29 @@
1
+ export async function twilioApiRequest<T = unknown>(params: {
2
+ baseUrl: string;
3
+ accountSid: string;
4
+ authToken: string;
5
+ endpoint: string;
6
+ body: Record<string, string>;
7
+ allowNotFound?: boolean;
8
+ }): Promise<T> {
9
+ const response = await fetch(`${params.baseUrl}${params.endpoint}`, {
10
+ method: "POST",
11
+ headers: {
12
+ Authorization: `Basic ${Buffer.from(`${params.accountSid}:${params.authToken}`).toString("base64")}`,
13
+ "Content-Type": "application/x-www-form-urlencoded",
14
+ },
15
+ body: new URLSearchParams(params.body),
16
+ });
17
+
18
+ if (!response.ok) {
19
+ if (params.allowNotFound && response.status === 404) {
20
+ return undefined as T;
21
+ }
22
+ const errorText = await response.text();
23
+ throw new Error(`Twilio API error: ${response.status} ${errorText}`);
24
+ }
25
+
26
+ const text = await response.text();
27
+ return text ? (JSON.parse(text) as T) : (undefined as T);
28
+ }
29
+
@@ -0,0 +1,30 @@
1
+ import type { WebhookContext, WebhookVerificationResult } from "../../types.js";
2
+ import { verifyTwilioWebhook } from "../../webhook-security.js";
3
+
4
+ import type { TwilioProviderOptions } from "../twilio.js";
5
+
6
+ export function verifyTwilioProviderWebhook(params: {
7
+ ctx: WebhookContext;
8
+ authToken: string;
9
+ currentPublicUrl?: string | null;
10
+ options: TwilioProviderOptions;
11
+ }): WebhookVerificationResult {
12
+ const result = verifyTwilioWebhook(params.ctx, params.authToken, {
13
+ publicUrl: params.currentPublicUrl || undefined,
14
+ allowNgrokFreeTier: params.options.allowNgrokFreeTier ?? true,
15
+ skipVerification: params.options.skipVerification,
16
+ });
17
+
18
+ if (!result.ok) {
19
+ console.warn(`[twilio] Webhook verification failed: ${result.reason}`);
20
+ if (result.verificationUrl) {
21
+ console.warn(`[twilio] Verification URL: ${result.verificationUrl}`);
22
+ }
23
+ }
24
+
25
+ return {
26
+ ok: result.ok,
27
+ reason: result.reason,
28
+ };
29
+ }
30
+
@@ -15,10 +15,11 @@ import type {
15
15
  WebhookVerificationResult,
16
16
  } from "../types.js";
17
17
  import { escapeXml, mapVoiceToPolly } from "../voice-mapping.js";
18
- import { verifyTwilioWebhook } from "../webhook-security.js";
19
18
  import type { VoiceCallProvider } from "./base.js";
20
19
  import type { OpenAITTSProvider } from "./tts-openai.js";
21
20
  import { chunkAudio } from "./tts-openai.js";
21
+ import { twilioApiRequest } from "./twilio/api.js";
22
+ import { verifyTwilioProviderWebhook } from "./twilio/webhook.js";
22
23
 
23
24
  /**
24
25
  * Twilio Voice API provider implementation.
@@ -79,45 +80,26 @@ export class TwilioProvider implements VoiceCallProvider {
79
80
  }
80
81
  }
81
82
 
82
- /**
83
- * Set the current public webhook URL (called when tunnel starts).
84
- */
85
83
  setPublicUrl(url: string): void {
86
84
  this.currentPublicUrl = url;
87
85
  }
88
86
 
89
- /**
90
- * Get the current public webhook URL.
91
- */
92
87
  getPublicUrl(): string | null {
93
88
  return this.currentPublicUrl;
94
89
  }
95
90
 
96
- /**
97
- * Set the OpenAI TTS provider for streaming TTS.
98
- * When set, playTts will use OpenAI audio via media streams.
99
- */
100
91
  setTTSProvider(provider: OpenAITTSProvider): void {
101
92
  this.ttsProvider = provider;
102
93
  }
103
94
 
104
- /**
105
- * Set the media stream handler for sending audio.
106
- */
107
95
  setMediaStreamHandler(handler: MediaStreamHandler): void {
108
96
  this.mediaStreamHandler = handler;
109
97
  }
110
98
 
111
- /**
112
- * Register a call's stream SID for audio routing.
113
- */
114
99
  registerCallStream(callSid: string, streamSid: string): void {
115
100
  this.callStreamMap.set(callSid, streamSid);
116
101
  }
117
102
 
118
- /**
119
- * Unregister a call's stream SID.
120
- */
121
103
  unregisterCallStream(callSid: string): void {
122
104
  this.callStreamMap.delete(callSid);
123
105
  }
@@ -130,25 +112,14 @@ export class TwilioProvider implements VoiceCallProvider {
130
112
  params: Record<string, string>,
131
113
  options?: { allowNotFound?: boolean },
132
114
  ): Promise<T> {
133
- const response = await fetch(`${this.baseUrl}${endpoint}`, {
134
- method: "POST",
135
- headers: {
136
- Authorization: `Basic ${Buffer.from(`${this.accountSid}:${this.authToken}`).toString("base64")}`,
137
- "Content-Type": "application/x-www-form-urlencoded",
138
- },
139
- body: new URLSearchParams(params),
115
+ return await twilioApiRequest<T>({
116
+ baseUrl: this.baseUrl,
117
+ accountSid: this.accountSid,
118
+ authToken: this.authToken,
119
+ endpoint,
120
+ body: params,
121
+ allowNotFound: options?.allowNotFound,
140
122
  });
141
-
142
- if (!response.ok) {
143
- if (options?.allowNotFound && response.status === 404) {
144
- return undefined as T;
145
- }
146
- const errorText = await response.text();
147
- throw new Error(`Twilio API error: ${response.status} ${errorText}`);
148
- }
149
-
150
- const text = await response.text();
151
- return text ? (JSON.parse(text) as T) : (undefined as T);
152
123
  }
153
124
 
154
125
  /**
@@ -160,23 +131,12 @@ export class TwilioProvider implements VoiceCallProvider {
160
131
  * @see https://www.twilio.com/docs/usage/webhooks/webhooks-security
161
132
  */
162
133
  verifyWebhook(ctx: WebhookContext): WebhookVerificationResult {
163
- const result = verifyTwilioWebhook(ctx, this.authToken, {
164
- publicUrl: this.currentPublicUrl || undefined,
165
- allowNgrokFreeTier: this.options.allowNgrokFreeTier ?? true,
166
- skipVerification: this.options.skipVerification,
134
+ return verifyTwilioProviderWebhook({
135
+ ctx,
136
+ authToken: this.authToken,
137
+ currentPublicUrl: this.currentPublicUrl,
138
+ options: this.options,
167
139
  });
168
-
169
- if (!result.ok) {
170
- console.warn(`[twilio] Webhook verification failed: ${result.reason}`);
171
- if (result.verificationUrl) {
172
- console.warn(`[twilio] Verification URL: ${result.verificationUrl}`);
173
- }
174
- }
175
-
176
- return {
177
- ok: result.ok,
178
- reason: result.reason,
179
- };
180
140
  }
181
141
 
182
142
  /**