@kodelyth/voice-call 2026.5.42 → 2026.6.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.
Files changed (111) hide show
  1. package/package.json +18 -6
  2. package/api.ts +0 -16
  3. package/cli-metadata.ts +0 -10
  4. package/config-api.ts +0 -12
  5. package/index.test.ts +0 -1075
  6. package/index.ts +0 -863
  7. package/runtime-api.ts +0 -20
  8. package/runtime-entry.ts +0 -1
  9. package/setup-api.ts +0 -47
  10. package/src/allowlist.test.ts +0 -18
  11. package/src/allowlist.ts +0 -19
  12. package/src/cli.test.ts +0 -12
  13. package/src/cli.ts +0 -866
  14. package/src/config-compat.test.ts +0 -130
  15. package/src/config-compat.ts +0 -227
  16. package/src/config.test.ts +0 -542
  17. package/src/config.ts +0 -883
  18. package/src/core-bridge.ts +0 -14
  19. package/src/deep-merge.test.ts +0 -40
  20. package/src/deep-merge.ts +0 -23
  21. package/src/gateway-continue-operation.ts +0 -200
  22. package/src/http-headers.test.ts +0 -16
  23. package/src/http-headers.ts +0 -15
  24. package/src/manager/context.ts +0 -50
  25. package/src/manager/events.test.ts +0 -578
  26. package/src/manager/events.ts +0 -332
  27. package/src/manager/lifecycle.ts +0 -53
  28. package/src/manager/lookup.test.ts +0 -52
  29. package/src/manager/lookup.ts +0 -35
  30. package/src/manager/outbound.test.ts +0 -629
  31. package/src/manager/outbound.ts +0 -508
  32. package/src/manager/state.ts +0 -48
  33. package/src/manager/store.ts +0 -107
  34. package/src/manager/timers.test.ts +0 -127
  35. package/src/manager/timers.ts +0 -113
  36. package/src/manager/twiml.test.ts +0 -13
  37. package/src/manager/twiml.ts +0 -17
  38. package/src/manager.closed-loop.test.ts +0 -259
  39. package/src/manager.inbound-allowlist.test.ts +0 -183
  40. package/src/manager.notify.test.ts +0 -390
  41. package/src/manager.restore.test.ts +0 -310
  42. package/src/manager.test-harness.ts +0 -127
  43. package/src/manager.ts +0 -441
  44. package/src/media-stream.test.ts +0 -953
  45. package/src/media-stream.ts +0 -876
  46. package/src/providers/base.ts +0 -99
  47. package/src/providers/mock.test.ts +0 -86
  48. package/src/providers/mock.ts +0 -185
  49. package/src/providers/plivo.test.ts +0 -93
  50. package/src/providers/plivo.ts +0 -601
  51. package/src/providers/shared/call-status.test.ts +0 -24
  52. package/src/providers/shared/call-status.ts +0 -24
  53. package/src/providers/shared/guarded-json-api.test.ts +0 -127
  54. package/src/providers/shared/guarded-json-api.ts +0 -49
  55. package/src/providers/telnyx.test.ts +0 -489
  56. package/src/providers/telnyx.ts +0 -419
  57. package/src/providers/twilio/api.test.ts +0 -184
  58. package/src/providers/twilio/api.ts +0 -100
  59. package/src/providers/twilio/twiml-policy.test.ts +0 -84
  60. package/src/providers/twilio/twiml-policy.ts +0 -87
  61. package/src/providers/twilio/webhook.ts +0 -34
  62. package/src/providers/twilio.test.ts +0 -607
  63. package/src/providers/twilio.ts +0 -861
  64. package/src/providers/twilio.types.ts +0 -17
  65. package/src/realtime-agent-context.test.ts +0 -101
  66. package/src/realtime-agent-context.ts +0 -149
  67. package/src/realtime-defaults.ts +0 -3
  68. package/src/realtime-fast-context.test.ts +0 -74
  69. package/src/realtime-fast-context.ts +0 -27
  70. package/src/realtime-transcription.runtime.ts +0 -4
  71. package/src/realtime-voice.runtime.ts +0 -5
  72. package/src/response-generator.test.ts +0 -385
  73. package/src/response-generator.ts +0 -348
  74. package/src/response-model.test.ts +0 -71
  75. package/src/response-model.ts +0 -23
  76. package/src/runtime.test.ts +0 -625
  77. package/src/runtime.ts +0 -528
  78. package/src/telephony-audio.test.ts +0 -61
  79. package/src/telephony-audio.ts +0 -12
  80. package/src/telephony-tts.test.ts +0 -196
  81. package/src/telephony-tts.ts +0 -235
  82. package/src/test-fixtures.ts +0 -82
  83. package/src/tts-provider-voice.test.ts +0 -34
  84. package/src/tts-provider-voice.ts +0 -21
  85. package/src/tunnel.test.ts +0 -173
  86. package/src/tunnel.ts +0 -314
  87. package/src/types.ts +0 -311
  88. package/src/utils.test.ts +0 -17
  89. package/src/utils.ts +0 -14
  90. package/src/voice-mapping.test.ts +0 -32
  91. package/src/voice-mapping.ts +0 -65
  92. package/src/webhook/realtime-audio-pacer.test.ts +0 -146
  93. package/src/webhook/realtime-audio-pacer.ts +0 -204
  94. package/src/webhook/realtime-handler.test.ts +0 -1450
  95. package/src/webhook/realtime-handler.ts +0 -1382
  96. package/src/webhook/stale-call-reaper.test.ts +0 -89
  97. package/src/webhook/stale-call-reaper.ts +0 -38
  98. package/src/webhook/stream-frame-adapter.test.ts +0 -187
  99. package/src/webhook/stream-frame-adapter.ts +0 -219
  100. package/src/webhook/tailscale.test.ts +0 -216
  101. package/src/webhook/tailscale.ts +0 -129
  102. package/src/webhook-exposure.test.ts +0 -33
  103. package/src/webhook-exposure.ts +0 -84
  104. package/src/webhook-security.test.ts +0 -813
  105. package/src/webhook-security.ts +0 -982
  106. package/src/webhook.hangup-once.lifecycle.test.ts +0 -179
  107. package/src/webhook.test.ts +0 -1615
  108. package/src/webhook.ts +0 -933
  109. package/src/webhook.types.ts +0 -5
  110. package/src/websocket-test-support.ts +0 -72
  111. package/tsconfig.json +0 -16
@@ -1,601 +0,0 @@
1
- import crypto from "node:crypto";
2
- import {
3
- normalizeLowercaseStringOrEmpty,
4
- normalizeOptionalString,
5
- } from "klaw/plugin-sdk/string-coerce-runtime";
6
- import type { PlivoConfig, WebhookSecurityConfig } from "../config.js";
7
- import { getHeader } from "../http-headers.js";
8
- import type {
9
- GetCallStatusInput,
10
- GetCallStatusResult,
11
- HangupCallInput,
12
- InitiateCallInput,
13
- InitiateCallResult,
14
- NormalizedEvent,
15
- PlayTtsInput,
16
- ProviderWebhookParseResult,
17
- StartListeningInput,
18
- StopListeningInput,
19
- WebhookContext,
20
- WebhookParseOptions,
21
- WebhookVerificationResult,
22
- } from "../types.js";
23
- import { escapeXml } from "../voice-mapping.js";
24
- import { reconstructWebhookUrl, verifyPlivoWebhook } from "../webhook-security.js";
25
- import type { VoiceCallProvider } from "./base.js";
26
- import { guardedJsonApiRequest } from "./shared/guarded-json-api.js";
27
-
28
- export interface PlivoProviderOptions {
29
- /** Override public URL origin for signature verification */
30
- publicUrl?: string;
31
- /** Skip webhook signature verification (development only) */
32
- skipVerification?: boolean;
33
- /** Outbound ring timeout in seconds */
34
- ringTimeoutSec?: number;
35
- /** Webhook security options (forwarded headers/allowlist) */
36
- webhookSecurity?: WebhookSecurityConfig;
37
- }
38
-
39
- type PendingSpeak = { text: string; locale?: string };
40
- type PendingListen = { language?: string };
41
-
42
- function createPlivoRequestDedupeKey(ctx: WebhookContext): string {
43
- const nonceV3 = getHeader(ctx.headers, "x-plivo-signature-v3-nonce");
44
- if (nonceV3) {
45
- return `plivo:v3:${nonceV3}`;
46
- }
47
- const nonceV2 = getHeader(ctx.headers, "x-plivo-signature-v2-nonce");
48
- if (nonceV2) {
49
- return `plivo:v2:${nonceV2}`;
50
- }
51
- return `plivo:fallback:${crypto.createHash("sha256").update(ctx.rawBody).digest("hex")}`;
52
- }
53
-
54
- export class PlivoProvider implements VoiceCallProvider {
55
- readonly name = "plivo" as const;
56
-
57
- private readonly authId: string;
58
- private readonly authToken: string;
59
- private readonly baseUrl: string;
60
- private readonly options: PlivoProviderOptions;
61
- private readonly apiHost: string;
62
-
63
- // Best-effort mapping between create-call request UUID and call UUID.
64
- private requestUuidToCallUuid = new Map<string, string>();
65
-
66
- // Used for transfer URLs and GetInput action URLs.
67
- private callIdToWebhookUrl = new Map<string, string>();
68
- private callUuidToWebhookUrl = new Map<string, string>();
69
-
70
- private pendingSpeakByCallId = new Map<string, PendingSpeak>();
71
- private pendingListenByCallId = new Map<string, PendingListen>();
72
-
73
- constructor(config: PlivoConfig, options: PlivoProviderOptions = {}) {
74
- if (!config.authId) {
75
- throw new Error("Plivo Auth ID is required");
76
- }
77
- if (!config.authToken) {
78
- throw new Error("Plivo Auth Token is required");
79
- }
80
-
81
- this.authId = config.authId;
82
- this.authToken = config.authToken;
83
- this.baseUrl = `https://api.plivo.com/v1/Account/${this.authId}`;
84
- this.apiHost = new URL(this.baseUrl).hostname;
85
- this.options = options;
86
- }
87
-
88
- private async apiRequest<T = unknown>(params: {
89
- method: "GET" | "POST" | "DELETE";
90
- endpoint: string;
91
- body?: Record<string, unknown>;
92
- allowNotFound?: boolean;
93
- }): Promise<T> {
94
- const { method, endpoint, body, allowNotFound } = params;
95
- return await guardedJsonApiRequest<T>({
96
- url: `${this.baseUrl}${endpoint}`,
97
- method,
98
- headers: {
99
- Authorization: `Basic ${Buffer.from(`${this.authId}:${this.authToken}`).toString("base64")}`,
100
- "Content-Type": "application/json",
101
- },
102
- body,
103
- allowNotFound,
104
- allowedHostnames: [this.apiHost],
105
- auditContext: "voice-call.plivo.api",
106
- errorPrefix: "Plivo API error",
107
- });
108
- }
109
-
110
- verifyWebhook(ctx: WebhookContext): WebhookVerificationResult {
111
- const result = verifyPlivoWebhook(ctx, this.authToken, {
112
- publicUrl: this.options.publicUrl,
113
- skipVerification: this.options.skipVerification,
114
- allowedHosts: this.options.webhookSecurity?.allowedHosts,
115
- trustForwardingHeaders: this.options.webhookSecurity?.trustForwardingHeaders,
116
- trustedProxyIPs: this.options.webhookSecurity?.trustedProxyIPs,
117
- remoteIP: ctx.remoteAddress,
118
- });
119
-
120
- if (!result.ok) {
121
- console.warn(`[plivo] Webhook verification failed: ${result.reason}`);
122
- }
123
-
124
- return {
125
- ok: result.ok,
126
- reason: result.reason,
127
- isReplay: result.isReplay,
128
- verifiedRequestKey: result.verifiedRequestKey,
129
- };
130
- }
131
-
132
- parseWebhookEvent(
133
- ctx: WebhookContext,
134
- options?: WebhookParseOptions,
135
- ): ProviderWebhookParseResult {
136
- const flow = normalizeOptionalString(ctx.query?.flow) ?? "";
137
-
138
- const parsed = this.parseBody(ctx.rawBody);
139
- if (!parsed) {
140
- return { events: [], statusCode: 400 };
141
- }
142
-
143
- // Keep providerCallId mapping for later call control.
144
- const callUuid = parsed.get("CallUUID") || undefined;
145
- if (callUuid) {
146
- const webhookBase = this.baseWebhookUrlFromCtx(ctx);
147
- if (webhookBase) {
148
- this.callUuidToWebhookUrl.set(callUuid, webhookBase);
149
- }
150
- }
151
-
152
- // Special flows that exist only to return Plivo XML (no events).
153
- if (flow === "xml-speak") {
154
- const callId = this.getCallIdFromQuery(ctx);
155
- const pending = callId ? this.pendingSpeakByCallId.get(callId) : undefined;
156
- if (callId) {
157
- this.pendingSpeakByCallId.delete(callId);
158
- }
159
-
160
- const xml = pending
161
- ? PlivoProvider.xmlSpeak(pending.text, pending.locale)
162
- : PlivoProvider.xmlKeepAlive();
163
- return {
164
- events: [],
165
- providerResponseBody: xml,
166
- providerResponseHeaders: { "Content-Type": "text/xml" },
167
- statusCode: 200,
168
- };
169
- }
170
-
171
- if (flow === "xml-listen") {
172
- const callId = this.getCallIdFromQuery(ctx);
173
- const pending = callId ? this.pendingListenByCallId.get(callId) : undefined;
174
- if (callId) {
175
- this.pendingListenByCallId.delete(callId);
176
- }
177
-
178
- const actionUrl = this.buildActionUrl(ctx, {
179
- flow: "getinput",
180
- callId,
181
- });
182
-
183
- const xml =
184
- actionUrl && callId
185
- ? PlivoProvider.xmlGetInputSpeech({
186
- actionUrl,
187
- language: pending?.language,
188
- })
189
- : PlivoProvider.xmlKeepAlive();
190
-
191
- return {
192
- events: [],
193
- providerResponseBody: xml,
194
- providerResponseHeaders: { "Content-Type": "text/xml" },
195
- statusCode: 200,
196
- };
197
- }
198
-
199
- // Normal events.
200
- const callIdFromQuery = this.getCallIdFromQuery(ctx);
201
- const dedupeKey = options?.verifiedRequestKey ?? createPlivoRequestDedupeKey(ctx);
202
- const event = this.normalizeEvent(parsed, callIdFromQuery, dedupeKey);
203
-
204
- return {
205
- events: event ? [event] : [],
206
- providerResponseBody:
207
- flow === "answer" || flow === "getinput"
208
- ? PlivoProvider.xmlKeepAlive()
209
- : PlivoProvider.xmlEmpty(),
210
- providerResponseHeaders: { "Content-Type": "text/xml" },
211
- statusCode: 200,
212
- };
213
- }
214
-
215
- private normalizeEvent(
216
- params: URLSearchParams,
217
- callIdOverride?: string,
218
- dedupeKey?: string,
219
- ): NormalizedEvent | null {
220
- const callUuid = params.get("CallUUID") || "";
221
- const requestUuid = params.get("RequestUUID") || "";
222
-
223
- if (requestUuid && callUuid) {
224
- this.requestUuidToCallUuid.set(requestUuid, callUuid);
225
- }
226
-
227
- const direction = params.get("Direction");
228
- const from = params.get("From") || undefined;
229
- const to = params.get("To") || undefined;
230
- const callStatus = params.get("CallStatus");
231
-
232
- const baseEvent = {
233
- id: crypto.randomUUID(),
234
- dedupeKey,
235
- callId: callIdOverride || callUuid || requestUuid,
236
- providerCallId: callUuid || requestUuid || undefined,
237
- timestamp: Date.now(),
238
- direction:
239
- direction === "inbound"
240
- ? ("inbound" as const)
241
- : direction === "outbound"
242
- ? ("outbound" as const)
243
- : undefined,
244
- from,
245
- to,
246
- };
247
-
248
- const digits = params.get("Digits");
249
- if (digits) {
250
- return { ...baseEvent, type: "call.dtmf", digits };
251
- }
252
-
253
- const transcript = PlivoProvider.extractTranscript(params);
254
- if (transcript) {
255
- return {
256
- ...baseEvent,
257
- type: "call.speech",
258
- transcript,
259
- isFinal: true,
260
- };
261
- }
262
-
263
- // Call lifecycle.
264
- if (callStatus === "ringing") {
265
- return { ...baseEvent, type: "call.ringing" };
266
- }
267
-
268
- if (callStatus === "in-progress") {
269
- return { ...baseEvent, type: "call.answered" };
270
- }
271
-
272
- if (
273
- callStatus === "completed" ||
274
- callStatus === "busy" ||
275
- callStatus === "no-answer" ||
276
- callStatus === "failed"
277
- ) {
278
- return {
279
- ...baseEvent,
280
- type: "call.ended",
281
- reason:
282
- callStatus === "completed"
283
- ? "completed"
284
- : callStatus === "busy"
285
- ? "busy"
286
- : callStatus === "no-answer"
287
- ? "no-answer"
288
- : "failed",
289
- };
290
- }
291
-
292
- // Plivo will call our answer_url when the call is answered; if we don't have
293
- // a CallStatus for some reason, treat it as answered so the call can proceed.
294
- if (params.get("Event") === "StartApp" && callUuid) {
295
- return { ...baseEvent, type: "call.answered" };
296
- }
297
-
298
- return null;
299
- }
300
-
301
- async initiateCall(input: InitiateCallInput): Promise<InitiateCallResult> {
302
- const webhookUrl = new URL(input.webhookUrl);
303
- webhookUrl.searchParams.set("provider", "plivo");
304
- webhookUrl.searchParams.set("callId", input.callId);
305
-
306
- const answerUrl = new URL(webhookUrl);
307
- answerUrl.searchParams.set("flow", "answer");
308
-
309
- const hangupUrl = new URL(webhookUrl);
310
- hangupUrl.searchParams.set("flow", "hangup");
311
-
312
- this.callIdToWebhookUrl.set(input.callId, input.webhookUrl);
313
-
314
- const ringTimeoutSec = this.options.ringTimeoutSec ?? 30;
315
-
316
- const result = await this.apiRequest<PlivoCreateCallResponse>({
317
- method: "POST",
318
- endpoint: "/Call/",
319
- body: {
320
- from: PlivoProvider.normalizeNumber(input.from),
321
- to: PlivoProvider.normalizeNumber(input.to),
322
- answer_url: answerUrl.toString(),
323
- answer_method: "POST",
324
- hangup_url: hangupUrl.toString(),
325
- hangup_method: "POST",
326
- // Plivo's API uses `hangup_on_ring` for outbound ring timeout.
327
- hangup_on_ring: ringTimeoutSec,
328
- },
329
- });
330
-
331
- const requestUuid = Array.isArray(result.request_uuid)
332
- ? result.request_uuid[0]
333
- : result.request_uuid;
334
- if (!requestUuid) {
335
- throw new Error("Plivo call create returned no request_uuid");
336
- }
337
-
338
- return { providerCallId: requestUuid, status: "initiated" };
339
- }
340
-
341
- async hangupCall(input: HangupCallInput): Promise<void> {
342
- const callUuid = this.requestUuidToCallUuid.get(input.providerCallId);
343
- if (callUuid) {
344
- await this.apiRequest({
345
- method: "DELETE",
346
- endpoint: `/Call/${callUuid}/`,
347
- allowNotFound: true,
348
- });
349
- return;
350
- }
351
-
352
- // Best-effort: try hangup (call UUID), then cancel (request UUID).
353
- await this.apiRequest({
354
- method: "DELETE",
355
- endpoint: `/Call/${input.providerCallId}/`,
356
- allowNotFound: true,
357
- });
358
- await this.apiRequest({
359
- method: "DELETE",
360
- endpoint: `/Request/${input.providerCallId}/`,
361
- allowNotFound: true,
362
- });
363
- }
364
-
365
- private resolveCallContext(params: {
366
- providerCallId: string;
367
- callId: string;
368
- operation: string;
369
- }): {
370
- callUuid: string;
371
- webhookBase: string;
372
- } {
373
- const callUuid = this.requestUuidToCallUuid.get(params.providerCallId) ?? params.providerCallId;
374
- const webhookBase =
375
- this.callUuidToWebhookUrl.get(callUuid) || this.callIdToWebhookUrl.get(params.callId);
376
- if (!webhookBase) {
377
- throw new Error("Missing webhook URL for this call (provider state missing)");
378
- }
379
- if (!callUuid) {
380
- throw new Error(`Missing Plivo CallUUID for ${params.operation}`);
381
- }
382
- return { callUuid, webhookBase };
383
- }
384
-
385
- private async transferCallLeg(params: {
386
- callUuid: string;
387
- webhookBase: string;
388
- callId: string;
389
- flow: "xml-speak" | "xml-listen";
390
- }): Promise<void> {
391
- const transferUrl = new URL(params.webhookBase);
392
- transferUrl.searchParams.set("provider", "plivo");
393
- transferUrl.searchParams.set("flow", params.flow);
394
- transferUrl.searchParams.set("callId", params.callId);
395
-
396
- await this.apiRequest({
397
- method: "POST",
398
- endpoint: `/Call/${params.callUuid}/`,
399
- body: {
400
- legs: "aleg",
401
- aleg_url: transferUrl.toString(),
402
- aleg_method: "POST",
403
- },
404
- });
405
- }
406
-
407
- async playTts(input: PlayTtsInput): Promise<void> {
408
- const { callUuid, webhookBase } = this.resolveCallContext({
409
- providerCallId: input.providerCallId,
410
- callId: input.callId,
411
- operation: "playTts",
412
- });
413
-
414
- this.pendingSpeakByCallId.set(input.callId, {
415
- text: input.text,
416
- locale: input.locale,
417
- });
418
-
419
- await this.transferCallLeg({
420
- callUuid,
421
- webhookBase,
422
- callId: input.callId,
423
- flow: "xml-speak",
424
- });
425
- }
426
-
427
- async startListening(input: StartListeningInput): Promise<void> {
428
- const { callUuid, webhookBase } = this.resolveCallContext({
429
- providerCallId: input.providerCallId,
430
- callId: input.callId,
431
- operation: "startListening",
432
- });
433
-
434
- this.pendingListenByCallId.set(input.callId, {
435
- language: input.language,
436
- });
437
-
438
- await this.transferCallLeg({
439
- callUuid,
440
- webhookBase,
441
- callId: input.callId,
442
- flow: "xml-listen",
443
- });
444
- }
445
-
446
- async stopListening(_input: StopListeningInput): Promise<void> {
447
- // GetInput ends automatically when speech ends.
448
- }
449
-
450
- async getCallStatus(input: GetCallStatusInput): Promise<GetCallStatusResult> {
451
- const terminalStatuses = new Set([
452
- "completed",
453
- "busy",
454
- "failed",
455
- "timeout",
456
- "no-answer",
457
- "cancel",
458
- "machine",
459
- "hangup",
460
- ]);
461
- try {
462
- const data = await guardedJsonApiRequest<{ call_status?: string }>({
463
- url: `${this.baseUrl}/Call/${input.providerCallId}/`,
464
- method: "GET",
465
- headers: {
466
- Authorization: `Basic ${Buffer.from(`${this.authId}:${this.authToken}`).toString("base64")}`,
467
- },
468
- allowNotFound: true,
469
- allowedHostnames: [this.apiHost],
470
- auditContext: "plivo-get-call-status",
471
- errorPrefix: "Plivo get call status error",
472
- });
473
-
474
- if (!data) {
475
- return { status: "not-found", isTerminal: true };
476
- }
477
-
478
- const status = data.call_status ?? "unknown";
479
- return { status, isTerminal: terminalStatuses.has(status) };
480
- } catch {
481
- return { status: "error", isTerminal: false, isUnknown: true };
482
- }
483
- }
484
-
485
- private static normalizeNumber(numberOrSip: string): string {
486
- const trimmed = numberOrSip.trim();
487
- if (normalizeLowercaseStringOrEmpty(trimmed).startsWith("sip:")) {
488
- return trimmed;
489
- }
490
- return trimmed.replace(/[^\d+]/g, "");
491
- }
492
-
493
- private static xmlEmpty(): string {
494
- return `<?xml version="1.0" encoding="UTF-8"?><Response></Response>`;
495
- }
496
-
497
- private static xmlKeepAlive(): string {
498
- return `<?xml version="1.0" encoding="UTF-8"?>
499
- <Response>
500
- <Wait length="300" />
501
- </Response>`;
502
- }
503
-
504
- private static xmlSpeak(text: string, locale?: string): string {
505
- const language = locale || "en-US";
506
- return `<?xml version="1.0" encoding="UTF-8"?>
507
- <Response>
508
- <Speak language="${escapeXml(language)}">${escapeXml(text)}</Speak>
509
- <Wait length="300" />
510
- </Response>`;
511
- }
512
-
513
- private static xmlGetInputSpeech(params: { actionUrl: string; language?: string }): string {
514
- const language = params.language || "en-US";
515
- return `<?xml version="1.0" encoding="UTF-8"?>
516
- <Response>
517
- <GetInput inputType="speech" method="POST" action="${escapeXml(params.actionUrl)}" language="${escapeXml(language)}" executionTimeout="30" speechEndTimeout="1" redirect="false">
518
- </GetInput>
519
- <Wait length="300" />
520
- </Response>`;
521
- }
522
-
523
- private getCallIdFromQuery(ctx: WebhookContext): string | undefined {
524
- const callId = normalizeOptionalString(ctx.query?.callId);
525
- return callId || undefined;
526
- }
527
-
528
- private buildActionUrl(
529
- ctx: WebhookContext,
530
- opts: { flow: string; callId?: string },
531
- ): string | null {
532
- const base = this.baseWebhookUrlFromCtx(ctx);
533
- if (!base) {
534
- return null;
535
- }
536
-
537
- const u = new URL(base);
538
- u.searchParams.set("provider", "plivo");
539
- u.searchParams.set("flow", opts.flow);
540
- if (opts.callId) {
541
- u.searchParams.set("callId", opts.callId);
542
- }
543
- return u.toString();
544
- }
545
-
546
- private baseWebhookUrlFromCtx(ctx: WebhookContext): string | null {
547
- try {
548
- if (this.options.publicUrl) {
549
- const base = new URL(this.options.publicUrl);
550
- const requestUrl = new URL(ctx.url);
551
- base.pathname = requestUrl.pathname;
552
- return `${base.origin}${base.pathname}`;
553
- }
554
-
555
- const u = new URL(
556
- reconstructWebhookUrl(ctx, {
557
- allowedHosts: this.options.webhookSecurity?.allowedHosts,
558
- trustForwardingHeaders: this.options.webhookSecurity?.trustForwardingHeaders,
559
- trustedProxyIPs: this.options.webhookSecurity?.trustedProxyIPs,
560
- remoteIP: ctx.remoteAddress,
561
- }),
562
- );
563
- return `${u.origin}${u.pathname}`;
564
- } catch {
565
- return null;
566
- }
567
- }
568
-
569
- private parseBody(rawBody: string): URLSearchParams | null {
570
- try {
571
- return new URLSearchParams(rawBody);
572
- } catch {
573
- return null;
574
- }
575
- }
576
-
577
- private static extractTranscript(params: URLSearchParams): string | null {
578
- const candidates = [
579
- "Speech",
580
- "Transcription",
581
- "TranscriptionText",
582
- "SpeechResult",
583
- "RecognizedSpeech",
584
- "Text",
585
- ] as const;
586
-
587
- for (const key of candidates) {
588
- const value = params.get(key);
589
- if (value && value.trim()) {
590
- return value.trim();
591
- }
592
- }
593
- return null;
594
- }
595
- }
596
-
597
- type PlivoCreateCallResponse = {
598
- api_id?: string;
599
- message?: string;
600
- request_uuid?: string | string[];
601
- };
@@ -1,24 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import {
3
- isProviderStatusTerminal,
4
- mapProviderStatusToEndReason,
5
- normalizeProviderStatus,
6
- } from "./call-status.js";
7
-
8
- describe("provider call status mapping", () => {
9
- it("normalizes missing statuses to unknown", () => {
10
- expect(normalizeProviderStatus(undefined)).toBe("unknown");
11
- expect(normalizeProviderStatus(" ")).toBe("unknown");
12
- });
13
-
14
- it("maps terminal provider statuses to end reasons", () => {
15
- expect(mapProviderStatusToEndReason("completed")).toBe("completed");
16
- expect(mapProviderStatusToEndReason("CANCELED")).toBe("hangup-bot");
17
- expect(mapProviderStatusToEndReason("no-answer")).toBe("no-answer");
18
- });
19
-
20
- it("flags terminal provider statuses", () => {
21
- expect(isProviderStatusTerminal("busy")).toBe(true);
22
- expect(isProviderStatusTerminal("in-progress")).toBe(false);
23
- });
24
- });
@@ -1,24 +0,0 @@
1
- import { normalizeOptionalLowercaseString } from "klaw/plugin-sdk/string-coerce-runtime";
2
- import type { EndReason } from "../../types.js";
3
-
4
- const TERMINAL_PROVIDER_STATUS_TO_END_REASON: Record<string, EndReason> = {
5
- completed: "completed",
6
- failed: "failed",
7
- busy: "busy",
8
- "no-answer": "no-answer",
9
- canceled: "hangup-bot",
10
- };
11
-
12
- export function normalizeProviderStatus(status: string | null | undefined): string {
13
- const normalized = normalizeOptionalLowercaseString(status);
14
- return normalized && normalized.length > 0 ? normalized : "unknown";
15
- }
16
-
17
- export function mapProviderStatusToEndReason(status: string | null | undefined): EndReason | null {
18
- const normalized = normalizeProviderStatus(status);
19
- return TERMINAL_PROVIDER_STATUS_TO_END_REASON[normalized] ?? null;
20
- }
21
-
22
- export function isProviderStatusTerminal(status: string | null | undefined): boolean {
23
- return mapProviderStatusToEndReason(status) !== null;
24
- }