@kodelyth/voice-call 2026.5.39 → 2026.5.42
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +167 -0
- package/api.ts +16 -0
- package/cli-metadata.ts +10 -0
- package/config-api.ts +12 -0
- package/dist/api.js +2 -0
- package/dist/cli-metadata.js +12 -0
- package/dist/config-DAwbG2aw.js +621 -0
- package/dist/config-compat-BYfJ5ueI.js +129 -0
- package/dist/guarded-json-api-xAIbFPZh.js +591 -0
- package/dist/index.js +1341 -0
- package/dist/mock-jtSdKDQN.js +135 -0
- package/dist/plivo-L-JTeuEc.js +392 -0
- package/dist/realtime-handler-5pSItXxX.js +1227 -0
- package/dist/realtime-transcription.runtime-CAbQKwCN.js +2 -0
- package/dist/realtime-voice.runtime-vCpCAutg.js +2 -0
- package/dist/response-generator-B-MjbtsM.js +199 -0
- package/dist/runtime-api.js +6 -0
- package/dist/runtime-entry-ohPMJR46.js +3435 -0
- package/dist/runtime-entry.js +2 -0
- package/dist/setup-api.js +37 -0
- package/dist/telnyx-BWr9EZ4x.js +278 -0
- package/dist/twilio-D9B0zY1k.js +679 -0
- package/index.test.ts +1075 -0
- package/index.ts +863 -0
- package/klaw.plugin.json +30 -133
- package/package.json +3 -3
- package/runtime-api.ts +20 -0
- package/runtime-entry.ts +1 -0
- package/setup-api.ts +47 -0
- package/src/allowlist.test.ts +18 -0
- package/src/allowlist.ts +19 -0
- package/src/cli.test.ts +12 -0
- package/src/cli.ts +866 -0
- package/src/config-compat.test.ts +130 -0
- package/src/config-compat.ts +227 -0
- package/src/config.test.ts +542 -0
- package/src/config.ts +883 -0
- package/src/core-bridge.ts +14 -0
- package/src/deep-merge.test.ts +40 -0
- package/src/deep-merge.ts +23 -0
- package/src/gateway-continue-operation.ts +200 -0
- package/src/http-headers.test.ts +16 -0
- package/src/http-headers.ts +15 -0
- package/src/manager/context.ts +50 -0
- package/src/manager/events.test.ts +578 -0
- package/src/manager/events.ts +332 -0
- package/src/manager/lifecycle.ts +53 -0
- package/src/manager/lookup.test.ts +52 -0
- package/src/manager/lookup.ts +35 -0
- package/src/manager/outbound.test.ts +629 -0
- package/src/manager/outbound.ts +508 -0
- package/src/manager/state.ts +48 -0
- package/src/manager/store.ts +107 -0
- package/src/manager/timers.test.ts +127 -0
- package/src/manager/timers.ts +113 -0
- package/src/manager/twiml.test.ts +13 -0
- package/src/manager/twiml.ts +17 -0
- package/src/manager.closed-loop.test.ts +259 -0
- package/src/manager.inbound-allowlist.test.ts +183 -0
- package/src/manager.notify.test.ts +390 -0
- package/src/manager.restore.test.ts +310 -0
- package/src/manager.test-harness.ts +127 -0
- package/src/manager.ts +441 -0
- package/src/media-stream.test.ts +953 -0
- package/src/media-stream.ts +876 -0
- package/src/providers/base.ts +99 -0
- package/src/providers/mock.test.ts +86 -0
- package/src/providers/mock.ts +185 -0
- package/src/providers/plivo.test.ts +93 -0
- package/src/providers/plivo.ts +601 -0
- package/src/providers/shared/call-status.test.ts +24 -0
- package/src/providers/shared/call-status.ts +24 -0
- package/src/providers/shared/guarded-json-api.test.ts +127 -0
- package/src/providers/shared/guarded-json-api.ts +49 -0
- package/src/providers/telnyx.test.ts +489 -0
- package/src/providers/telnyx.ts +419 -0
- package/src/providers/twilio/api.test.ts +184 -0
- package/src/providers/twilio/api.ts +100 -0
- package/src/providers/twilio/twiml-policy.test.ts +84 -0
- package/src/providers/twilio/twiml-policy.ts +87 -0
- package/src/providers/twilio/webhook.ts +34 -0
- package/src/providers/twilio.test.ts +607 -0
- package/src/providers/twilio.ts +861 -0
- package/src/providers/twilio.types.ts +17 -0
- package/src/realtime-agent-context.test.ts +101 -0
- package/src/realtime-agent-context.ts +149 -0
- package/src/realtime-defaults.ts +3 -0
- package/src/realtime-fast-context.test.ts +74 -0
- package/src/realtime-fast-context.ts +27 -0
- package/src/realtime-transcription.runtime.ts +4 -0
- package/src/realtime-voice.runtime.ts +5 -0
- package/src/response-generator.test.ts +385 -0
- package/src/response-generator.ts +348 -0
- package/src/response-model.test.ts +71 -0
- package/src/response-model.ts +23 -0
- package/src/runtime.test.ts +625 -0
- package/src/runtime.ts +528 -0
- package/src/telephony-audio.test.ts +61 -0
- package/src/telephony-audio.ts +12 -0
- package/src/telephony-tts.test.ts +196 -0
- package/src/telephony-tts.ts +235 -0
- package/src/test-fixtures.ts +82 -0
- package/src/tts-provider-voice.test.ts +34 -0
- package/src/tts-provider-voice.ts +21 -0
- package/src/tunnel.test.ts +173 -0
- package/src/tunnel.ts +314 -0
- package/src/types.ts +311 -0
- package/src/utils.test.ts +17 -0
- package/src/utils.ts +14 -0
- package/src/voice-mapping.test.ts +32 -0
- package/src/voice-mapping.ts +65 -0
- package/src/webhook/realtime-audio-pacer.test.ts +146 -0
- package/src/webhook/realtime-audio-pacer.ts +204 -0
- package/src/webhook/realtime-handler.test.ts +1450 -0
- package/src/webhook/realtime-handler.ts +1382 -0
- package/src/webhook/stale-call-reaper.test.ts +89 -0
- package/src/webhook/stale-call-reaper.ts +38 -0
- package/src/webhook/stream-frame-adapter.test.ts +187 -0
- package/src/webhook/stream-frame-adapter.ts +219 -0
- package/src/webhook/tailscale.test.ts +216 -0
- package/src/webhook/tailscale.ts +129 -0
- package/src/webhook-exposure.test.ts +33 -0
- package/src/webhook-exposure.ts +84 -0
- package/src/webhook-security.test.ts +813 -0
- package/src/webhook-security.ts +982 -0
- package/src/webhook.hangup-once.lifecycle.test.ts +179 -0
- package/src/webhook.test.ts +1615 -0
- package/src/webhook.ts +933 -0
- package/src/webhook.types.ts +5 -0
- package/src/websocket-test-support.ts +72 -0
- package/tsconfig.json +16 -0
- package/api.js +0 -7
- package/cli-metadata.js +0 -7
- package/index.js +0 -7
- package/runtime-api.js +0 -7
- package/runtime-entry.js +0 -7
- package/setup-api.js +0 -7
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import type { TelnyxConfig } from "../config.js";
|
|
3
|
+
import type {
|
|
4
|
+
AnswerCallInput,
|
|
5
|
+
EndReason,
|
|
6
|
+
GetCallStatusInput,
|
|
7
|
+
GetCallStatusResult,
|
|
8
|
+
HangupCallInput,
|
|
9
|
+
InitiateCallInput,
|
|
10
|
+
InitiateCallResult,
|
|
11
|
+
NormalizedEvent,
|
|
12
|
+
PlayTtsInput,
|
|
13
|
+
ProviderWebhookParseResult,
|
|
14
|
+
StartListeningInput,
|
|
15
|
+
StopListeningInput,
|
|
16
|
+
WebhookContext,
|
|
17
|
+
WebhookParseOptions,
|
|
18
|
+
WebhookVerificationResult,
|
|
19
|
+
} from "../types.js";
|
|
20
|
+
import { verifyTelnyxWebhook } from "../webhook-security.js";
|
|
21
|
+
import type { VoiceCallProvider } from "./base.js";
|
|
22
|
+
import { guardedJsonApiRequest } from "./shared/guarded-json-api.js";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Telnyx Voice API provider implementation.
|
|
26
|
+
*
|
|
27
|
+
* Uses Telnyx Call Control API v2 for managing calls.
|
|
28
|
+
* @see https://developers.telnyx.com/docs/api/v2/call-control
|
|
29
|
+
*/
|
|
30
|
+
export interface TelnyxProviderOptions {
|
|
31
|
+
/** Skip webhook signature verification (development only, NOT for production) */
|
|
32
|
+
skipVerification?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function normalizeTelnyxDirection(
|
|
36
|
+
direction: string | undefined,
|
|
37
|
+
): "inbound" | "outbound" | undefined {
|
|
38
|
+
switch (direction) {
|
|
39
|
+
case "incoming":
|
|
40
|
+
case "inbound":
|
|
41
|
+
return "inbound";
|
|
42
|
+
case "outgoing":
|
|
43
|
+
case "outbound":
|
|
44
|
+
return "outbound";
|
|
45
|
+
default:
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function normalizeBase64ForCompare(value: string): string {
|
|
51
|
+
return value.replace(/=+$/u, "").replace(/-/gu, "+").replace(/_/gu, "/");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function decodeClientStateBase64(value: string): string | null {
|
|
55
|
+
const buffer = Buffer.from(value, "base64");
|
|
56
|
+
if (normalizeBase64ForCompare(buffer.toString("base64")) !== normalizeBase64ForCompare(value)) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
return buffer.toString("utf8");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export class TelnyxProvider implements VoiceCallProvider {
|
|
63
|
+
readonly name = "telnyx" as const;
|
|
64
|
+
|
|
65
|
+
private readonly apiKey: string;
|
|
66
|
+
private readonly connectionId: string;
|
|
67
|
+
private readonly publicKey: string | undefined;
|
|
68
|
+
private readonly options: TelnyxProviderOptions;
|
|
69
|
+
private readonly baseUrl = "https://api.telnyx.com/v2";
|
|
70
|
+
private readonly apiHost = "api.telnyx.com";
|
|
71
|
+
|
|
72
|
+
constructor(config: TelnyxConfig, options: TelnyxProviderOptions = {}) {
|
|
73
|
+
if (!config.apiKey) {
|
|
74
|
+
throw new Error("Telnyx API key is required");
|
|
75
|
+
}
|
|
76
|
+
if (!config.connectionId) {
|
|
77
|
+
throw new Error("Telnyx connection ID is required");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
this.apiKey = config.apiKey;
|
|
81
|
+
this.connectionId = config.connectionId;
|
|
82
|
+
this.publicKey = config.publicKey;
|
|
83
|
+
this.options = options;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Make an authenticated request to the Telnyx API.
|
|
88
|
+
*/
|
|
89
|
+
private async apiRequest<T = unknown>(
|
|
90
|
+
endpoint: string,
|
|
91
|
+
body: Record<string, unknown>,
|
|
92
|
+
options?: { allowNotFound?: boolean },
|
|
93
|
+
): Promise<T> {
|
|
94
|
+
return await guardedJsonApiRequest<T>({
|
|
95
|
+
url: `${this.baseUrl}${endpoint}`,
|
|
96
|
+
method: "POST",
|
|
97
|
+
headers: {
|
|
98
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
99
|
+
"Content-Type": "application/json",
|
|
100
|
+
},
|
|
101
|
+
body,
|
|
102
|
+
allowNotFound: options?.allowNotFound,
|
|
103
|
+
allowedHostnames: [this.apiHost],
|
|
104
|
+
auditContext: "voice-call.telnyx.api",
|
|
105
|
+
errorPrefix: "Telnyx API error",
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Verify Telnyx webhook signature using Ed25519.
|
|
111
|
+
*/
|
|
112
|
+
verifyWebhook(ctx: WebhookContext): WebhookVerificationResult {
|
|
113
|
+
const result = verifyTelnyxWebhook(ctx, this.publicKey, {
|
|
114
|
+
skipVerification: this.options.skipVerification,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
ok: result.ok,
|
|
119
|
+
reason: result.reason,
|
|
120
|
+
isReplay: result.isReplay,
|
|
121
|
+
verifiedRequestKey: result.verifiedRequestKey,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Parse Telnyx webhook event into normalized format.
|
|
127
|
+
*/
|
|
128
|
+
parseWebhookEvent(
|
|
129
|
+
ctx: WebhookContext,
|
|
130
|
+
options?: WebhookParseOptions,
|
|
131
|
+
): ProviderWebhookParseResult {
|
|
132
|
+
try {
|
|
133
|
+
const payload = JSON.parse(ctx.rawBody);
|
|
134
|
+
const data = payload.data;
|
|
135
|
+
|
|
136
|
+
if (!data || !data.event_type) {
|
|
137
|
+
return { events: [], statusCode: 200 };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const event = this.normalizeEvent(data, options?.verifiedRequestKey);
|
|
141
|
+
return {
|
|
142
|
+
events: event ? [event] : [],
|
|
143
|
+
statusCode: 200,
|
|
144
|
+
};
|
|
145
|
+
} catch {
|
|
146
|
+
return { events: [], statusCode: 400 };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Convert Telnyx event to normalized event format.
|
|
152
|
+
*/
|
|
153
|
+
private normalizeEvent(data: TelnyxEvent, dedupeKey?: string): NormalizedEvent | null {
|
|
154
|
+
// Decode client_state from Base64 (we encode it in initiateCall)
|
|
155
|
+
let callId = "";
|
|
156
|
+
if (data.payload?.client_state) {
|
|
157
|
+
callId = decodeClientStateBase64(data.payload.client_state) ?? data.payload.client_state;
|
|
158
|
+
}
|
|
159
|
+
if (!callId) {
|
|
160
|
+
callId = data.payload?.call_control_id || "";
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const baseEvent = {
|
|
164
|
+
id: data.id || crypto.randomUUID(),
|
|
165
|
+
dedupeKey,
|
|
166
|
+
callId,
|
|
167
|
+
providerCallId: data.payload?.call_control_id,
|
|
168
|
+
timestamp: Date.now(),
|
|
169
|
+
direction: normalizeTelnyxDirection(data.payload?.direction),
|
|
170
|
+
from: data.payload?.from,
|
|
171
|
+
to: data.payload?.to,
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
switch (data.event_type) {
|
|
175
|
+
case "call.initiated":
|
|
176
|
+
return { ...baseEvent, type: "call.initiated" };
|
|
177
|
+
|
|
178
|
+
case "call.ringing":
|
|
179
|
+
return { ...baseEvent, type: "call.ringing" };
|
|
180
|
+
|
|
181
|
+
case "call.answered":
|
|
182
|
+
return { ...baseEvent, type: "call.answered" };
|
|
183
|
+
|
|
184
|
+
case "call.bridged":
|
|
185
|
+
return { ...baseEvent, type: "call.active" };
|
|
186
|
+
|
|
187
|
+
case "call.speak.started":
|
|
188
|
+
return {
|
|
189
|
+
...baseEvent,
|
|
190
|
+
type: "call.speaking",
|
|
191
|
+
text: data.payload?.text || "",
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
case "call.transcription":
|
|
195
|
+
return {
|
|
196
|
+
...baseEvent,
|
|
197
|
+
type: "call.speech",
|
|
198
|
+
transcript:
|
|
199
|
+
data.payload?.transcription_data?.transcript ?? data.payload?.transcription ?? "",
|
|
200
|
+
isFinal: data.payload?.transcription_data?.is_final ?? data.payload?.is_final ?? true,
|
|
201
|
+
confidence: data.payload?.transcription_data?.confidence ?? data.payload?.confidence,
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
case "call.hangup":
|
|
205
|
+
return {
|
|
206
|
+
...baseEvent,
|
|
207
|
+
type: "call.ended",
|
|
208
|
+
reason: this.mapHangupCause(data.payload?.hangup_cause),
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
case "call.dtmf.received":
|
|
212
|
+
return {
|
|
213
|
+
...baseEvent,
|
|
214
|
+
type: "call.dtmf",
|
|
215
|
+
digits: data.payload?.digit || "",
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
case "streaming.started":
|
|
219
|
+
case "streaming.stopped":
|
|
220
|
+
return null;
|
|
221
|
+
|
|
222
|
+
default:
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Map Telnyx hangup cause to normalized end reason.
|
|
229
|
+
* @see https://developers.telnyx.com/docs/api/v2/call-control/Call-Commands#hangup-causes
|
|
230
|
+
*/
|
|
231
|
+
private mapHangupCause(cause?: string): EndReason {
|
|
232
|
+
switch (cause) {
|
|
233
|
+
case "normal_clearing":
|
|
234
|
+
case "normal_unspecified":
|
|
235
|
+
return "completed";
|
|
236
|
+
case "originator_cancel":
|
|
237
|
+
return "hangup-bot";
|
|
238
|
+
case "call_rejected":
|
|
239
|
+
case "user_busy":
|
|
240
|
+
return "busy";
|
|
241
|
+
case "no_answer":
|
|
242
|
+
case "no_user_response":
|
|
243
|
+
return "no-answer";
|
|
244
|
+
case "destination_out_of_order":
|
|
245
|
+
case "network_out_of_order":
|
|
246
|
+
case "service_unavailable":
|
|
247
|
+
case "recovery_on_timer_expire":
|
|
248
|
+
return "failed";
|
|
249
|
+
case "machine_detected":
|
|
250
|
+
case "fax_detected":
|
|
251
|
+
return "voicemail";
|
|
252
|
+
case "user_hangup":
|
|
253
|
+
case "subscriber_absent":
|
|
254
|
+
return "hangup-user";
|
|
255
|
+
default:
|
|
256
|
+
// Unknown cause - log it for debugging and return completed
|
|
257
|
+
if (cause) {
|
|
258
|
+
console.warn(`[telnyx] Unknown hangup cause: ${cause}`);
|
|
259
|
+
}
|
|
260
|
+
return "completed";
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async initiateCall(input: InitiateCallInput): Promise<InitiateCallResult> {
|
|
265
|
+
const body: Record<string, unknown> = {
|
|
266
|
+
connection_id: this.connectionId,
|
|
267
|
+
to: input.to,
|
|
268
|
+
from: input.from,
|
|
269
|
+
webhook_url: input.webhookUrl,
|
|
270
|
+
webhook_url_method: "POST",
|
|
271
|
+
client_state: Buffer.from(input.callId).toString("base64"),
|
|
272
|
+
timeout_secs: 30,
|
|
273
|
+
...(input.streamUrl
|
|
274
|
+
? buildTelnyxStreamingFields(input.streamUrl, input.streamAuthToken)
|
|
275
|
+
: {}),
|
|
276
|
+
};
|
|
277
|
+
const result = await this.apiRequest<TelnyxCallResponse>("/calls", body);
|
|
278
|
+
|
|
279
|
+
return {
|
|
280
|
+
providerCallId: result.data.call_control_id,
|
|
281
|
+
status: "initiated",
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Hang up a call via Telnyx API.
|
|
287
|
+
*/
|
|
288
|
+
async hangupCall(input: HangupCallInput): Promise<void> {
|
|
289
|
+
await this.apiRequest(
|
|
290
|
+
`/calls/${input.providerCallId}/actions/hangup`,
|
|
291
|
+
{ command_id: crypto.randomUUID() },
|
|
292
|
+
{ allowNotFound: true },
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async answerCall(input: AnswerCallInput): Promise<void> {
|
|
297
|
+
const body: Record<string, unknown> = {
|
|
298
|
+
command_id: `klaw-answer-${input.callId}`,
|
|
299
|
+
...(input.streamUrl
|
|
300
|
+
? buildTelnyxStreamingFields(input.streamUrl, input.streamAuthToken)
|
|
301
|
+
: {}),
|
|
302
|
+
};
|
|
303
|
+
await this.apiRequest(`/calls/${input.providerCallId}/actions/answer`, body);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Play TTS audio via Telnyx speak action.
|
|
308
|
+
*/
|
|
309
|
+
async playTts(input: PlayTtsInput): Promise<void> {
|
|
310
|
+
await this.apiRequest(`/calls/${input.providerCallId}/actions/speak`, {
|
|
311
|
+
command_id: crypto.randomUUID(),
|
|
312
|
+
payload: input.text,
|
|
313
|
+
voice: input.voice || "female",
|
|
314
|
+
language: input.locale || "en-US",
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Start transcription (STT) via Telnyx.
|
|
320
|
+
*/
|
|
321
|
+
async startListening(input: StartListeningInput): Promise<void> {
|
|
322
|
+
await this.apiRequest(`/calls/${input.providerCallId}/actions/transcription_start`, {
|
|
323
|
+
command_id: crypto.randomUUID(),
|
|
324
|
+
language: input.language || "en",
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Stop transcription via Telnyx.
|
|
330
|
+
*/
|
|
331
|
+
async stopListening(input: StopListeningInput): Promise<void> {
|
|
332
|
+
await this.apiRequest(
|
|
333
|
+
`/calls/${input.providerCallId}/actions/transcription_stop`,
|
|
334
|
+
{ command_id: crypto.randomUUID() },
|
|
335
|
+
{ allowNotFound: true },
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async getCallStatus(input: GetCallStatusInput): Promise<GetCallStatusResult> {
|
|
340
|
+
try {
|
|
341
|
+
const data = await guardedJsonApiRequest<{ data?: { state?: string; is_alive?: boolean } }>({
|
|
342
|
+
url: `${this.baseUrl}/calls/${input.providerCallId}`,
|
|
343
|
+
method: "GET",
|
|
344
|
+
headers: {
|
|
345
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
346
|
+
"Content-Type": "application/json",
|
|
347
|
+
},
|
|
348
|
+
allowNotFound: true,
|
|
349
|
+
allowedHostnames: [this.apiHost],
|
|
350
|
+
auditContext: "telnyx-get-call-status",
|
|
351
|
+
errorPrefix: "Telnyx get call status error",
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
if (!data) {
|
|
355
|
+
return { status: "not-found", isTerminal: true };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const state = data.data?.state ?? "unknown";
|
|
359
|
+
const isAlive = data.data?.is_alive;
|
|
360
|
+
// If is_alive is missing, treat as unknown rather than terminal (P1 fix)
|
|
361
|
+
if (isAlive === undefined) {
|
|
362
|
+
return { status: state, isTerminal: false, isUnknown: true };
|
|
363
|
+
}
|
|
364
|
+
return { status: state, isTerminal: !isAlive };
|
|
365
|
+
} catch {
|
|
366
|
+
return { status: "error", isTerminal: false, isUnknown: true };
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function buildTelnyxStreamingFields(
|
|
372
|
+
streamUrl: string,
|
|
373
|
+
streamAuthToken: string | undefined,
|
|
374
|
+
): Record<string, unknown> {
|
|
375
|
+
return {
|
|
376
|
+
stream_url: streamUrl,
|
|
377
|
+
stream_track: "inbound_track",
|
|
378
|
+
stream_codec: "PCMU",
|
|
379
|
+
stream_bidirectional_mode: "rtp",
|
|
380
|
+
stream_bidirectional_codec: "PCMU",
|
|
381
|
+
stream_bidirectional_sampling_rate: 8000,
|
|
382
|
+
stream_bidirectional_target_legs: "self",
|
|
383
|
+
...(streamAuthToken ? { stream_auth_token: streamAuthToken } : {}),
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
interface TelnyxEvent {
|
|
388
|
+
id?: string;
|
|
389
|
+
event_type: string;
|
|
390
|
+
payload?: {
|
|
391
|
+
call_control_id?: string;
|
|
392
|
+
client_state?: string;
|
|
393
|
+
direction?: string;
|
|
394
|
+
from?: string;
|
|
395
|
+
to?: string;
|
|
396
|
+
text?: string;
|
|
397
|
+
transcription?: string;
|
|
398
|
+
is_final?: boolean;
|
|
399
|
+
confidence?: number;
|
|
400
|
+
transcription_data?: {
|
|
401
|
+
transcript?: string;
|
|
402
|
+
is_final?: boolean;
|
|
403
|
+
confidence?: number;
|
|
404
|
+
};
|
|
405
|
+
hangup_cause?: string;
|
|
406
|
+
digit?: string;
|
|
407
|
+
[key: string]: unknown;
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
interface TelnyxCallResponse {
|
|
412
|
+
data: {
|
|
413
|
+
call_control_id: string;
|
|
414
|
+
call_leg_id: string;
|
|
415
|
+
call_session_id: string;
|
|
416
|
+
is_alive: boolean;
|
|
417
|
+
record_type: string;
|
|
418
|
+
};
|
|
419
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
const { fetchWithSsrFGuardMock } = vi.hoisted(() => ({
|
|
4
|
+
fetchWithSsrFGuardMock: vi.fn(),
|
|
5
|
+
}));
|
|
6
|
+
|
|
7
|
+
vi.mock("../../../api.js", () => ({
|
|
8
|
+
fetchWithSsrFGuard: fetchWithSsrFGuardMock,
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
import { TwilioApiError, twilioApiRequest } from "./api.js";
|
|
12
|
+
|
|
13
|
+
type FetchGuardRequest = {
|
|
14
|
+
url?: string;
|
|
15
|
+
init?: RequestInit;
|
|
16
|
+
auditContext?: string;
|
|
17
|
+
policy?: unknown;
|
|
18
|
+
timeoutMs?: number;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function requireFirstFetchGuardRequest(): FetchGuardRequest {
|
|
22
|
+
const [call] = fetchWithSsrFGuardMock.mock.calls;
|
|
23
|
+
if (!call) {
|
|
24
|
+
throw new Error("expected guarded fetch call");
|
|
25
|
+
}
|
|
26
|
+
const [request] = call;
|
|
27
|
+
if (!request || typeof request !== "object" || Array.isArray(request)) {
|
|
28
|
+
throw new Error("expected guarded fetch request");
|
|
29
|
+
}
|
|
30
|
+
return request as FetchGuardRequest;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("twilioApiRequest", () => {
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
fetchWithSsrFGuardMock.mockReset();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("posts form bodies with basic auth and parses json", async () => {
|
|
39
|
+
const release = vi.fn(async () => {});
|
|
40
|
+
fetchWithSsrFGuardMock.mockResolvedValue({
|
|
41
|
+
response: new Response(JSON.stringify({ sid: "CA123" }), { status: 200 }),
|
|
42
|
+
release,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
await expect(
|
|
46
|
+
twilioApiRequest({
|
|
47
|
+
baseUrl: "https://api.twilio.com",
|
|
48
|
+
accountSid: "AC123",
|
|
49
|
+
authToken: "secret",
|
|
50
|
+
endpoint: "/Calls.json",
|
|
51
|
+
body: {
|
|
52
|
+
To: "+14155550123",
|
|
53
|
+
StatusCallbackEvent: ["initiated", "completed"],
|
|
54
|
+
},
|
|
55
|
+
}),
|
|
56
|
+
).resolves.toEqual({ sid: "CA123" });
|
|
57
|
+
|
|
58
|
+
const { url, init, auditContext, policy, timeoutMs } = requireFirstFetchGuardRequest();
|
|
59
|
+
expect(url).toBe("https://api.twilio.com/Calls.json");
|
|
60
|
+
expect(auditContext).toBe("voice-call.twilio.api");
|
|
61
|
+
expect(policy).toEqual({ allowedHostnames: ["api.twilio.com"] });
|
|
62
|
+
expect(timeoutMs).toBe(30_000);
|
|
63
|
+
expect(init?.method).toBe("POST");
|
|
64
|
+
expect(init?.headers).toEqual({
|
|
65
|
+
Authorization: `Basic ${Buffer.from("AC123:secret").toString("base64")}`,
|
|
66
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
67
|
+
});
|
|
68
|
+
const requestBody = init?.body;
|
|
69
|
+
if (!(requestBody instanceof URLSearchParams)) {
|
|
70
|
+
throw new Error("expected URLSearchParams request body");
|
|
71
|
+
}
|
|
72
|
+
expect(requestBody.toString()).toBe(
|
|
73
|
+
"To=%2B14155550123&StatusCallbackEvent=initiated&StatusCallbackEvent=completed",
|
|
74
|
+
);
|
|
75
|
+
expect(release).toHaveBeenCalledTimes(1);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("passes through URLSearchParams, allows 404s, and returns undefined for empty bodies", async () => {
|
|
79
|
+
const responses = [
|
|
80
|
+
new Response(null, { status: 204 }),
|
|
81
|
+
new Response("missing", { status: 404 }),
|
|
82
|
+
];
|
|
83
|
+
const release = vi.fn(async () => {});
|
|
84
|
+
fetchWithSsrFGuardMock.mockImplementation(async () => ({
|
|
85
|
+
response: responses.shift()!,
|
|
86
|
+
release,
|
|
87
|
+
}));
|
|
88
|
+
|
|
89
|
+
await expect(
|
|
90
|
+
twilioApiRequest({
|
|
91
|
+
baseUrl: "https://api.twilio.com",
|
|
92
|
+
accountSid: "AC123",
|
|
93
|
+
authToken: "secret",
|
|
94
|
+
endpoint: "/Calls.json",
|
|
95
|
+
body: new URLSearchParams({ To: "+14155550123" }),
|
|
96
|
+
}),
|
|
97
|
+
).resolves.toBeUndefined();
|
|
98
|
+
|
|
99
|
+
await expect(
|
|
100
|
+
twilioApiRequest({
|
|
101
|
+
baseUrl: "https://api.twilio.com",
|
|
102
|
+
accountSid: "AC123",
|
|
103
|
+
authToken: "secret",
|
|
104
|
+
endpoint: "/Calls/missing.json",
|
|
105
|
+
body: {},
|
|
106
|
+
allowNotFound: true,
|
|
107
|
+
}),
|
|
108
|
+
).resolves.toBeUndefined();
|
|
109
|
+
expect(release).toHaveBeenCalledTimes(2);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("throws twilio api errors for non-ok responses", async () => {
|
|
113
|
+
const release = vi.fn(async () => {});
|
|
114
|
+
fetchWithSsrFGuardMock.mockResolvedValue({
|
|
115
|
+
response: new Response("bad request", { status: 400 }),
|
|
116
|
+
release,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
await expect(
|
|
120
|
+
twilioApiRequest({
|
|
121
|
+
baseUrl: "https://api.twilio.com",
|
|
122
|
+
accountSid: "AC123",
|
|
123
|
+
authToken: "secret",
|
|
124
|
+
endpoint: "/Calls.json",
|
|
125
|
+
body: {},
|
|
126
|
+
}),
|
|
127
|
+
).rejects.toThrow("Twilio API error: 400 bad request");
|
|
128
|
+
expect(release).toHaveBeenCalledTimes(1);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("wraps malformed json success responses with an owned error", async () => {
|
|
132
|
+
const release = vi.fn(async () => {});
|
|
133
|
+
fetchWithSsrFGuardMock.mockResolvedValue({
|
|
134
|
+
response: new Response("{not json", { status: 200 }),
|
|
135
|
+
release,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
await expect(
|
|
139
|
+
twilioApiRequest({
|
|
140
|
+
baseUrl: "https://api.twilio.com",
|
|
141
|
+
accountSid: "AC123",
|
|
142
|
+
authToken: "secret",
|
|
143
|
+
endpoint: "/Calls.json",
|
|
144
|
+
body: {},
|
|
145
|
+
}),
|
|
146
|
+
).rejects.toThrow("Twilio API returned malformed JSON.");
|
|
147
|
+
expect(release).toHaveBeenCalledTimes(1);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("exposes structured Twilio error codes from json error bodies", async () => {
|
|
151
|
+
const release = vi.fn(async () => {});
|
|
152
|
+
fetchWithSsrFGuardMock.mockResolvedValue({
|
|
153
|
+
response: new Response(
|
|
154
|
+
JSON.stringify({
|
|
155
|
+
code: 21220,
|
|
156
|
+
message: "Call is not in-progress. Cannot redirect.",
|
|
157
|
+
}),
|
|
158
|
+
{ status: 400 },
|
|
159
|
+
),
|
|
160
|
+
release,
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
await twilioApiRequest({
|
|
165
|
+
baseUrl: "https://api.twilio.com",
|
|
166
|
+
accountSid: "AC123",
|
|
167
|
+
authToken: "secret",
|
|
168
|
+
endpoint: "/Calls/CA123.json",
|
|
169
|
+
body: {},
|
|
170
|
+
});
|
|
171
|
+
throw new Error("expected Twilio API request to reject");
|
|
172
|
+
} catch (error) {
|
|
173
|
+
expect(error).toBeInstanceOf(TwilioApiError);
|
|
174
|
+
const twilioError = error as TwilioApiError;
|
|
175
|
+
expect(twilioError.name).toBe("TwilioApiError");
|
|
176
|
+
expect(twilioError.httpStatus).toBe(400);
|
|
177
|
+
expect(twilioError.twilioCode).toBe(21220);
|
|
178
|
+
expect(twilioError.message).toBe(
|
|
179
|
+
"Twilio API error: 400 Call is not in-progress. Cannot redirect.",
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
expect(release).toHaveBeenCalledTimes(1);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { fetchWithSsrFGuard } from "../../../api.js";
|
|
2
|
+
|
|
3
|
+
type ParsedTwilioApiError = {
|
|
4
|
+
code?: number;
|
|
5
|
+
message?: string;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
const TWILIO_API_TIMEOUT_MS = 30_000;
|
|
9
|
+
|
|
10
|
+
function parseTwilioApiError(text: string): ParsedTwilioApiError {
|
|
11
|
+
try {
|
|
12
|
+
const parsed: unknown = JSON.parse(text);
|
|
13
|
+
if (!parsed || typeof parsed !== "object") {
|
|
14
|
+
return {};
|
|
15
|
+
}
|
|
16
|
+
const record = parsed as Record<string, unknown>;
|
|
17
|
+
return {
|
|
18
|
+
code: typeof record.code === "number" ? record.code : undefined,
|
|
19
|
+
message: typeof record.message === "string" ? record.message : undefined,
|
|
20
|
+
};
|
|
21
|
+
} catch {
|
|
22
|
+
return {};
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class TwilioApiError extends Error {
|
|
27
|
+
readonly httpStatus: number;
|
|
28
|
+
readonly responseText: string;
|
|
29
|
+
readonly twilioCode?: number;
|
|
30
|
+
|
|
31
|
+
constructor(httpStatus: number, responseText: string) {
|
|
32
|
+
const parsed = parseTwilioApiError(responseText);
|
|
33
|
+
const detail = parsed.message ?? responseText;
|
|
34
|
+
super(`Twilio API error: ${httpStatus} ${detail}`);
|
|
35
|
+
this.name = "TwilioApiError";
|
|
36
|
+
this.httpStatus = httpStatus;
|
|
37
|
+
this.responseText = responseText;
|
|
38
|
+
this.twilioCode = parsed.code;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function twilioApiRequest<T = unknown>(params: {
|
|
43
|
+
baseUrl: string;
|
|
44
|
+
accountSid: string;
|
|
45
|
+
authToken: string;
|
|
46
|
+
endpoint: string;
|
|
47
|
+
body: URLSearchParams | Record<string, string | string[]>;
|
|
48
|
+
allowNotFound?: boolean;
|
|
49
|
+
}): Promise<T> {
|
|
50
|
+
const bodyParams =
|
|
51
|
+
params.body instanceof URLSearchParams
|
|
52
|
+
? params.body
|
|
53
|
+
: Object.entries(params.body).reduce((acc, [key, value]) => {
|
|
54
|
+
if (Array.isArray(value)) {
|
|
55
|
+
for (const entry of value) {
|
|
56
|
+
acc.append(key, entry);
|
|
57
|
+
}
|
|
58
|
+
} else if (typeof value === "string") {
|
|
59
|
+
acc.append(key, value);
|
|
60
|
+
}
|
|
61
|
+
return acc;
|
|
62
|
+
}, new URLSearchParams());
|
|
63
|
+
|
|
64
|
+
const requestUrl = `${params.baseUrl}${params.endpoint}`;
|
|
65
|
+
const { response, release } = await fetchWithSsrFGuard({
|
|
66
|
+
url: requestUrl,
|
|
67
|
+
init: {
|
|
68
|
+
method: "POST",
|
|
69
|
+
headers: {
|
|
70
|
+
Authorization: `Basic ${Buffer.from(`${params.accountSid}:${params.authToken}`).toString("base64")}`,
|
|
71
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
72
|
+
},
|
|
73
|
+
body: bodyParams,
|
|
74
|
+
},
|
|
75
|
+
policy: { allowedHostnames: ["api.twilio.com"] },
|
|
76
|
+
timeoutMs: TWILIO_API_TIMEOUT_MS,
|
|
77
|
+
auditContext: "voice-call.twilio.api",
|
|
78
|
+
});
|
|
79
|
+
try {
|
|
80
|
+
if (!response.ok) {
|
|
81
|
+
if (params.allowNotFound && response.status === 404) {
|
|
82
|
+
return undefined as T;
|
|
83
|
+
}
|
|
84
|
+
const errorText = await response.text();
|
|
85
|
+
throw new TwilioApiError(response.status, errorText);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const text = await response.text();
|
|
89
|
+
if (!text) {
|
|
90
|
+
return undefined as T;
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
return JSON.parse(text) as T;
|
|
94
|
+
} catch {
|
|
95
|
+
throw new Error("Twilio API returned malformed JSON.");
|
|
96
|
+
}
|
|
97
|
+
} finally {
|
|
98
|
+
await release();
|
|
99
|
+
}
|
|
100
|
+
}
|