@openclaw/voice-call 2026.5.2 → 2026.5.3-beta.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/dist/api.js +2 -0
- package/dist/call-status-CXldV5o8.js +32 -0
- package/dist/cli-metadata.js +12 -0
- package/dist/config-7w04YpHh.js +548 -0
- package/dist/config-compat-B0me39_4.js +129 -0
- package/dist/guarded-json-api-Btx5EE4w.js +591 -0
- package/dist/http-headers-BrnxBasF.js +10 -0
- package/dist/index.js +1284 -0
- package/dist/mock-CeKvfVEd.js +135 -0
- package/dist/plivo-B-a7KFoT.js +393 -0
- package/dist/realtime-handler-B63CIDP2.js +325 -0
- package/dist/realtime-transcription.runtime-B2h70y2W.js +2 -0
- package/dist/realtime-voice.runtime-Bkh4nvLn.js +2 -0
- package/dist/response-generator-BrcmwDZU.js +182 -0
- package/dist/response-model-CyF5K80p.js +12 -0
- package/dist/runtime-api.js +6 -0
- package/dist/runtime-entry-88ytYAQa.js +3119 -0
- package/dist/runtime-entry.js +2 -0
- package/dist/setup-api.js +37 -0
- package/dist/telnyx-jjBE8boz.js +260 -0
- package/dist/twilio-1OqbcXLL.js +676 -0
- package/dist/voice-mapping-BYDGdWGx.js +40 -0
- package/package.json +14 -6
- package/api.ts +0 -16
- package/cli-metadata.ts +0 -10
- package/config-api.ts +0 -12
- package/index.test.ts +0 -943
- package/index.ts +0 -794
- package/runtime-api.ts +0 -20
- package/runtime-entry.ts +0 -1
- package/setup-api.ts +0 -47
- package/src/allowlist.test.ts +0 -18
- package/src/allowlist.ts +0 -19
- package/src/cli.ts +0 -845
- package/src/config-compat.test.ts +0 -120
- package/src/config-compat.ts +0 -227
- package/src/config.test.ts +0 -479
- package/src/config.ts +0 -808
- package/src/core-bridge.ts +0 -14
- package/src/deep-merge.test.ts +0 -40
- package/src/deep-merge.ts +0 -23
- package/src/gateway-continue-operation.ts +0 -200
- package/src/http-headers.test.ts +0 -16
- package/src/http-headers.ts +0 -15
- package/src/manager/context.ts +0 -42
- package/src/manager/events.test.ts +0 -581
- package/src/manager/events.ts +0 -288
- package/src/manager/lifecycle.ts +0 -53
- package/src/manager/lookup.test.ts +0 -52
- package/src/manager/lookup.ts +0 -35
- package/src/manager/outbound.test.ts +0 -528
- package/src/manager/outbound.ts +0 -486
- package/src/manager/state.ts +0 -48
- package/src/manager/store.ts +0 -106
- package/src/manager/timers.test.ts +0 -129
- package/src/manager/timers.ts +0 -113
- package/src/manager/twiml.test.ts +0 -13
- package/src/manager/twiml.ts +0 -17
- package/src/manager.closed-loop.test.ts +0 -236
- package/src/manager.inbound-allowlist.test.ts +0 -188
- package/src/manager.notify.test.ts +0 -377
- package/src/manager.restore.test.ts +0 -183
- package/src/manager.test-harness.ts +0 -127
- package/src/manager.ts +0 -392
- package/src/media-stream.test.ts +0 -768
- package/src/media-stream.ts +0 -708
- package/src/providers/base.ts +0 -97
- package/src/providers/mock.test.ts +0 -78
- package/src/providers/mock.ts +0 -185
- package/src/providers/plivo.test.ts +0 -93
- package/src/providers/plivo.ts +0 -601
- package/src/providers/shared/call-status.test.ts +0 -24
- package/src/providers/shared/call-status.ts +0 -24
- package/src/providers/shared/guarded-json-api.test.ts +0 -106
- package/src/providers/shared/guarded-json-api.ts +0 -42
- package/src/providers/telnyx.test.ts +0 -340
- package/src/providers/telnyx.ts +0 -394
- package/src/providers/twilio/api.test.ts +0 -145
- package/src/providers/twilio/api.ts +0 -93
- package/src/providers/twilio/twiml-policy.test.ts +0 -84
- package/src/providers/twilio/twiml-policy.ts +0 -87
- package/src/providers/twilio/webhook.ts +0 -34
- package/src/providers/twilio.test.ts +0 -591
- package/src/providers/twilio.ts +0 -861
- package/src/providers/twilio.types.ts +0 -17
- package/src/realtime-defaults.ts +0 -3
- package/src/realtime-fast-context.test.ts +0 -88
- package/src/realtime-fast-context.ts +0 -165
- package/src/realtime-transcription.runtime.ts +0 -4
- package/src/realtime-voice.runtime.ts +0 -5
- package/src/response-generator.test.ts +0 -321
- package/src/response-generator.ts +0 -318
- package/src/response-model.test.ts +0 -71
- package/src/response-model.ts +0 -23
- package/src/runtime.test.ts +0 -536
- package/src/runtime.ts +0 -510
- package/src/telephony-audio.test.ts +0 -61
- package/src/telephony-audio.ts +0 -12
- package/src/telephony-tts.test.ts +0 -196
- package/src/telephony-tts.ts +0 -235
- package/src/test-fixtures.ts +0 -73
- package/src/tts-provider-voice.test.ts +0 -34
- package/src/tts-provider-voice.ts +0 -21
- package/src/tunnel.test.ts +0 -166
- package/src/tunnel.ts +0 -314
- package/src/types.ts +0 -291
- package/src/utils.test.ts +0 -17
- package/src/utils.ts +0 -14
- package/src/voice-mapping.test.ts +0 -34
- package/src/voice-mapping.ts +0 -68
- package/src/webhook/realtime-handler.test.ts +0 -598
- package/src/webhook/realtime-handler.ts +0 -485
- package/src/webhook/stale-call-reaper.test.ts +0 -88
- package/src/webhook/stale-call-reaper.ts +0 -38
- package/src/webhook/tailscale.test.ts +0 -214
- package/src/webhook/tailscale.ts +0 -129
- package/src/webhook-exposure.test.ts +0 -33
- package/src/webhook-exposure.ts +0 -84
- package/src/webhook-security.test.ts +0 -770
- package/src/webhook-security.ts +0 -994
- package/src/webhook.hangup-once.lifecycle.test.ts +0 -135
- package/src/webhook.test.ts +0 -1470
- package/src/webhook.ts +0 -908
- package/src/webhook.types.ts +0 -5
- package/src/websocket-test-support.ts +0 -72
- package/tsconfig.json +0 -16
package/src/providers/telnyx.ts
DELETED
|
@@ -1,394 +0,0 @@
|
|
|
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
|
-
export class TelnyxProvider implements VoiceCallProvider {
|
|
51
|
-
readonly name = "telnyx" as const;
|
|
52
|
-
|
|
53
|
-
private readonly apiKey: string;
|
|
54
|
-
private readonly connectionId: string;
|
|
55
|
-
private readonly publicKey: string | undefined;
|
|
56
|
-
private readonly options: TelnyxProviderOptions;
|
|
57
|
-
private readonly baseUrl = "https://api.telnyx.com/v2";
|
|
58
|
-
private readonly apiHost = "api.telnyx.com";
|
|
59
|
-
|
|
60
|
-
constructor(config: TelnyxConfig, options: TelnyxProviderOptions = {}) {
|
|
61
|
-
if (!config.apiKey) {
|
|
62
|
-
throw new Error("Telnyx API key is required");
|
|
63
|
-
}
|
|
64
|
-
if (!config.connectionId) {
|
|
65
|
-
throw new Error("Telnyx connection ID is required");
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
this.apiKey = config.apiKey;
|
|
69
|
-
this.connectionId = config.connectionId;
|
|
70
|
-
this.publicKey = config.publicKey;
|
|
71
|
-
this.options = options;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Make an authenticated request to the Telnyx API.
|
|
76
|
-
*/
|
|
77
|
-
private async apiRequest<T = unknown>(
|
|
78
|
-
endpoint: string,
|
|
79
|
-
body: Record<string, unknown>,
|
|
80
|
-
options?: { allowNotFound?: boolean },
|
|
81
|
-
): Promise<T> {
|
|
82
|
-
return await guardedJsonApiRequest<T>({
|
|
83
|
-
url: `${this.baseUrl}${endpoint}`,
|
|
84
|
-
method: "POST",
|
|
85
|
-
headers: {
|
|
86
|
-
Authorization: `Bearer ${this.apiKey}`,
|
|
87
|
-
"Content-Type": "application/json",
|
|
88
|
-
},
|
|
89
|
-
body,
|
|
90
|
-
allowNotFound: options?.allowNotFound,
|
|
91
|
-
allowedHostnames: [this.apiHost],
|
|
92
|
-
auditContext: "voice-call.telnyx.api",
|
|
93
|
-
errorPrefix: "Telnyx API error",
|
|
94
|
-
});
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* Verify Telnyx webhook signature using Ed25519.
|
|
99
|
-
*/
|
|
100
|
-
verifyWebhook(ctx: WebhookContext): WebhookVerificationResult {
|
|
101
|
-
const result = verifyTelnyxWebhook(ctx, this.publicKey, {
|
|
102
|
-
skipVerification: this.options.skipVerification,
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
return {
|
|
106
|
-
ok: result.ok,
|
|
107
|
-
reason: result.reason,
|
|
108
|
-
isReplay: result.isReplay,
|
|
109
|
-
verifiedRequestKey: result.verifiedRequestKey,
|
|
110
|
-
};
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* Parse Telnyx webhook event into normalized format.
|
|
115
|
-
*/
|
|
116
|
-
parseWebhookEvent(
|
|
117
|
-
ctx: WebhookContext,
|
|
118
|
-
options?: WebhookParseOptions,
|
|
119
|
-
): ProviderWebhookParseResult {
|
|
120
|
-
try {
|
|
121
|
-
const payload = JSON.parse(ctx.rawBody);
|
|
122
|
-
const data = payload.data;
|
|
123
|
-
|
|
124
|
-
if (!data || !data.event_type) {
|
|
125
|
-
return { events: [], statusCode: 200 };
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
const event = this.normalizeEvent(data, options?.verifiedRequestKey);
|
|
129
|
-
return {
|
|
130
|
-
events: event ? [event] : [],
|
|
131
|
-
statusCode: 200,
|
|
132
|
-
};
|
|
133
|
-
} catch {
|
|
134
|
-
return { events: [], statusCode: 400 };
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/**
|
|
139
|
-
* Convert Telnyx event to normalized event format.
|
|
140
|
-
*/
|
|
141
|
-
private normalizeEvent(data: TelnyxEvent, dedupeKey?: string): NormalizedEvent | null {
|
|
142
|
-
// Decode client_state from Base64 (we encode it in initiateCall)
|
|
143
|
-
let callId = "";
|
|
144
|
-
if (data.payload?.client_state) {
|
|
145
|
-
try {
|
|
146
|
-
callId = Buffer.from(data.payload.client_state, "base64").toString("utf8");
|
|
147
|
-
} catch {
|
|
148
|
-
// Fallback if not valid Base64
|
|
149
|
-
callId = data.payload.client_state;
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
if (!callId) {
|
|
153
|
-
callId = data.payload?.call_control_id || "";
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
const baseEvent = {
|
|
157
|
-
id: data.id || crypto.randomUUID(),
|
|
158
|
-
dedupeKey,
|
|
159
|
-
callId,
|
|
160
|
-
providerCallId: data.payload?.call_control_id,
|
|
161
|
-
timestamp: Date.now(),
|
|
162
|
-
direction: normalizeTelnyxDirection(data.payload?.direction),
|
|
163
|
-
from: data.payload?.from,
|
|
164
|
-
to: data.payload?.to,
|
|
165
|
-
};
|
|
166
|
-
|
|
167
|
-
switch (data.event_type) {
|
|
168
|
-
case "call.initiated":
|
|
169
|
-
return { ...baseEvent, type: "call.initiated" };
|
|
170
|
-
|
|
171
|
-
case "call.ringing":
|
|
172
|
-
return { ...baseEvent, type: "call.ringing" };
|
|
173
|
-
|
|
174
|
-
case "call.answered":
|
|
175
|
-
return { ...baseEvent, type: "call.answered" };
|
|
176
|
-
|
|
177
|
-
case "call.bridged":
|
|
178
|
-
return { ...baseEvent, type: "call.active" };
|
|
179
|
-
|
|
180
|
-
case "call.speak.started":
|
|
181
|
-
return {
|
|
182
|
-
...baseEvent,
|
|
183
|
-
type: "call.speaking",
|
|
184
|
-
text: data.payload?.text || "",
|
|
185
|
-
};
|
|
186
|
-
|
|
187
|
-
case "call.transcription":
|
|
188
|
-
return {
|
|
189
|
-
...baseEvent,
|
|
190
|
-
type: "call.speech",
|
|
191
|
-
transcript:
|
|
192
|
-
data.payload?.transcription_data?.transcript ?? data.payload?.transcription ?? "",
|
|
193
|
-
isFinal: data.payload?.transcription_data?.is_final ?? data.payload?.is_final ?? true,
|
|
194
|
-
confidence: data.payload?.transcription_data?.confidence ?? data.payload?.confidence,
|
|
195
|
-
};
|
|
196
|
-
|
|
197
|
-
case "call.hangup":
|
|
198
|
-
return {
|
|
199
|
-
...baseEvent,
|
|
200
|
-
type: "call.ended",
|
|
201
|
-
reason: this.mapHangupCause(data.payload?.hangup_cause),
|
|
202
|
-
};
|
|
203
|
-
|
|
204
|
-
case "call.dtmf.received":
|
|
205
|
-
return {
|
|
206
|
-
...baseEvent,
|
|
207
|
-
type: "call.dtmf",
|
|
208
|
-
digits: data.payload?.digit || "",
|
|
209
|
-
};
|
|
210
|
-
|
|
211
|
-
default:
|
|
212
|
-
return null;
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
/**
|
|
217
|
-
* Map Telnyx hangup cause to normalized end reason.
|
|
218
|
-
* @see https://developers.telnyx.com/docs/api/v2/call-control/Call-Commands#hangup-causes
|
|
219
|
-
*/
|
|
220
|
-
private mapHangupCause(cause?: string): EndReason {
|
|
221
|
-
switch (cause) {
|
|
222
|
-
case "normal_clearing":
|
|
223
|
-
case "normal_unspecified":
|
|
224
|
-
return "completed";
|
|
225
|
-
case "originator_cancel":
|
|
226
|
-
return "hangup-bot";
|
|
227
|
-
case "call_rejected":
|
|
228
|
-
case "user_busy":
|
|
229
|
-
return "busy";
|
|
230
|
-
case "no_answer":
|
|
231
|
-
case "no_user_response":
|
|
232
|
-
return "no-answer";
|
|
233
|
-
case "destination_out_of_order":
|
|
234
|
-
case "network_out_of_order":
|
|
235
|
-
case "service_unavailable":
|
|
236
|
-
case "recovery_on_timer_expire":
|
|
237
|
-
return "failed";
|
|
238
|
-
case "machine_detected":
|
|
239
|
-
case "fax_detected":
|
|
240
|
-
return "voicemail";
|
|
241
|
-
case "user_hangup":
|
|
242
|
-
case "subscriber_absent":
|
|
243
|
-
return "hangup-user";
|
|
244
|
-
default:
|
|
245
|
-
// Unknown cause - log it for debugging and return completed
|
|
246
|
-
if (cause) {
|
|
247
|
-
console.warn(`[telnyx] Unknown hangup cause: ${cause}`);
|
|
248
|
-
}
|
|
249
|
-
return "completed";
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
/**
|
|
254
|
-
* Initiate an outbound call via Telnyx API.
|
|
255
|
-
*/
|
|
256
|
-
async initiateCall(input: InitiateCallInput): Promise<InitiateCallResult> {
|
|
257
|
-
const result = await this.apiRequest<TelnyxCallResponse>("/calls", {
|
|
258
|
-
connection_id: this.connectionId,
|
|
259
|
-
to: input.to,
|
|
260
|
-
from: input.from,
|
|
261
|
-
webhook_url: input.webhookUrl,
|
|
262
|
-
webhook_url_method: "POST",
|
|
263
|
-
client_state: Buffer.from(input.callId).toString("base64"),
|
|
264
|
-
timeout_secs: 30,
|
|
265
|
-
});
|
|
266
|
-
|
|
267
|
-
return {
|
|
268
|
-
providerCallId: result.data.call_control_id,
|
|
269
|
-
status: "initiated",
|
|
270
|
-
};
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
/**
|
|
274
|
-
* Hang up a call via Telnyx API.
|
|
275
|
-
*/
|
|
276
|
-
async hangupCall(input: HangupCallInput): Promise<void> {
|
|
277
|
-
await this.apiRequest(
|
|
278
|
-
`/calls/${input.providerCallId}/actions/hangup`,
|
|
279
|
-
{ command_id: crypto.randomUUID() },
|
|
280
|
-
{ allowNotFound: true },
|
|
281
|
-
);
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
/**
|
|
285
|
-
* Answer an inbound Telnyx Call Control leg.
|
|
286
|
-
*/
|
|
287
|
-
async answerCall(input: AnswerCallInput): Promise<void> {
|
|
288
|
-
await this.apiRequest(`/calls/${input.providerCallId}/actions/answer`, {
|
|
289
|
-
command_id: `openclaw-answer-${input.callId}`,
|
|
290
|
-
});
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
/**
|
|
294
|
-
* Play TTS audio via Telnyx speak action.
|
|
295
|
-
*/
|
|
296
|
-
async playTts(input: PlayTtsInput): Promise<void> {
|
|
297
|
-
await this.apiRequest(`/calls/${input.providerCallId}/actions/speak`, {
|
|
298
|
-
command_id: crypto.randomUUID(),
|
|
299
|
-
payload: input.text,
|
|
300
|
-
voice: input.voice || "female",
|
|
301
|
-
language: input.locale || "en-US",
|
|
302
|
-
});
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
/**
|
|
306
|
-
* Start transcription (STT) via Telnyx.
|
|
307
|
-
*/
|
|
308
|
-
async startListening(input: StartListeningInput): Promise<void> {
|
|
309
|
-
await this.apiRequest(`/calls/${input.providerCallId}/actions/transcription_start`, {
|
|
310
|
-
command_id: crypto.randomUUID(),
|
|
311
|
-
language: input.language || "en",
|
|
312
|
-
});
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
/**
|
|
316
|
-
* Stop transcription via Telnyx.
|
|
317
|
-
*/
|
|
318
|
-
async stopListening(input: StopListeningInput): Promise<void> {
|
|
319
|
-
await this.apiRequest(
|
|
320
|
-
`/calls/${input.providerCallId}/actions/transcription_stop`,
|
|
321
|
-
{ command_id: crypto.randomUUID() },
|
|
322
|
-
{ allowNotFound: true },
|
|
323
|
-
);
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
async getCallStatus(input: GetCallStatusInput): Promise<GetCallStatusResult> {
|
|
327
|
-
try {
|
|
328
|
-
const data = await guardedJsonApiRequest<{ data?: { state?: string; is_alive?: boolean } }>({
|
|
329
|
-
url: `${this.baseUrl}/calls/${input.providerCallId}`,
|
|
330
|
-
method: "GET",
|
|
331
|
-
headers: {
|
|
332
|
-
Authorization: `Bearer ${this.apiKey}`,
|
|
333
|
-
"Content-Type": "application/json",
|
|
334
|
-
},
|
|
335
|
-
allowNotFound: true,
|
|
336
|
-
allowedHostnames: [this.apiHost],
|
|
337
|
-
auditContext: "telnyx-get-call-status",
|
|
338
|
-
errorPrefix: "Telnyx get call status error",
|
|
339
|
-
});
|
|
340
|
-
|
|
341
|
-
if (!data) {
|
|
342
|
-
return { status: "not-found", isTerminal: true };
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
const state = data.data?.state ?? "unknown";
|
|
346
|
-
const isAlive = data.data?.is_alive;
|
|
347
|
-
// If is_alive is missing, treat as unknown rather than terminal (P1 fix)
|
|
348
|
-
if (isAlive === undefined) {
|
|
349
|
-
return { status: state, isTerminal: false, isUnknown: true };
|
|
350
|
-
}
|
|
351
|
-
return { status: state, isTerminal: !isAlive };
|
|
352
|
-
} catch {
|
|
353
|
-
return { status: "error", isTerminal: false, isUnknown: true };
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
// -----------------------------------------------------------------------------
|
|
359
|
-
// Telnyx-specific types
|
|
360
|
-
// -----------------------------------------------------------------------------
|
|
361
|
-
|
|
362
|
-
interface TelnyxEvent {
|
|
363
|
-
id?: string;
|
|
364
|
-
event_type: string;
|
|
365
|
-
payload?: {
|
|
366
|
-
call_control_id?: string;
|
|
367
|
-
client_state?: string;
|
|
368
|
-
direction?: string;
|
|
369
|
-
from?: string;
|
|
370
|
-
to?: string;
|
|
371
|
-
text?: string;
|
|
372
|
-
transcription?: string;
|
|
373
|
-
is_final?: boolean;
|
|
374
|
-
confidence?: number;
|
|
375
|
-
transcription_data?: {
|
|
376
|
-
transcript?: string;
|
|
377
|
-
is_final?: boolean;
|
|
378
|
-
confidence?: number;
|
|
379
|
-
};
|
|
380
|
-
hangup_cause?: string;
|
|
381
|
-
digit?: string;
|
|
382
|
-
[key: string]: unknown;
|
|
383
|
-
};
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
interface TelnyxCallResponse {
|
|
387
|
-
data: {
|
|
388
|
-
call_control_id: string;
|
|
389
|
-
call_leg_id: string;
|
|
390
|
-
call_session_id: string;
|
|
391
|
-
is_alive: boolean;
|
|
392
|
-
record_type: string;
|
|
393
|
-
};
|
|
394
|
-
}
|
|
@@ -1,145 +0,0 @@
|
|
|
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
|
-
describe("twilioApiRequest", () => {
|
|
14
|
-
afterEach(() => {
|
|
15
|
-
fetchWithSsrFGuardMock.mockReset();
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
it("posts form bodies with basic auth and parses json", async () => {
|
|
19
|
-
const release = vi.fn(async () => {});
|
|
20
|
-
fetchWithSsrFGuardMock.mockResolvedValue({
|
|
21
|
-
response: new Response(JSON.stringify({ sid: "CA123" }), { status: 200 }),
|
|
22
|
-
release,
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
await expect(
|
|
26
|
-
twilioApiRequest({
|
|
27
|
-
baseUrl: "https://api.twilio.com",
|
|
28
|
-
accountSid: "AC123",
|
|
29
|
-
authToken: "secret",
|
|
30
|
-
endpoint: "/Calls.json",
|
|
31
|
-
body: {
|
|
32
|
-
To: "+14155550123",
|
|
33
|
-
StatusCallbackEvent: ["initiated", "completed"],
|
|
34
|
-
},
|
|
35
|
-
}),
|
|
36
|
-
).resolves.toEqual({ sid: "CA123" });
|
|
37
|
-
|
|
38
|
-
const [{ url, init, auditContext, policy, timeoutMs }] =
|
|
39
|
-
fetchWithSsrFGuardMock.mock.calls[0] ?? [];
|
|
40
|
-
expect(url).toBe("https://api.twilio.com/Calls.json");
|
|
41
|
-
expect(auditContext).toBe("voice-call.twilio.api");
|
|
42
|
-
expect(policy).toEqual({ allowedHostnames: ["api.twilio.com"] });
|
|
43
|
-
expect(timeoutMs).toBe(30_000);
|
|
44
|
-
expect(init).toEqual(
|
|
45
|
-
expect.objectContaining({
|
|
46
|
-
method: "POST",
|
|
47
|
-
headers: {
|
|
48
|
-
Authorization: `Basic ${Buffer.from("AC123:secret").toString("base64")}`,
|
|
49
|
-
"Content-Type": "application/x-www-form-urlencoded",
|
|
50
|
-
},
|
|
51
|
-
}),
|
|
52
|
-
);
|
|
53
|
-
const requestBody = init?.body;
|
|
54
|
-
if (!(requestBody instanceof URLSearchParams)) {
|
|
55
|
-
throw new Error("expected URLSearchParams request body");
|
|
56
|
-
}
|
|
57
|
-
expect(requestBody.toString()).toBe(
|
|
58
|
-
"To=%2B14155550123&StatusCallbackEvent=initiated&StatusCallbackEvent=completed",
|
|
59
|
-
);
|
|
60
|
-
expect(release).toHaveBeenCalledTimes(1);
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it("passes through URLSearchParams, allows 404s, and returns undefined for empty bodies", async () => {
|
|
64
|
-
const responses = [
|
|
65
|
-
new Response(null, { status: 204 }),
|
|
66
|
-
new Response("missing", { status: 404 }),
|
|
67
|
-
];
|
|
68
|
-
const release = vi.fn(async () => {});
|
|
69
|
-
fetchWithSsrFGuardMock.mockImplementation(async () => ({
|
|
70
|
-
response: responses.shift()!,
|
|
71
|
-
release,
|
|
72
|
-
}));
|
|
73
|
-
|
|
74
|
-
await expect(
|
|
75
|
-
twilioApiRequest({
|
|
76
|
-
baseUrl: "https://api.twilio.com",
|
|
77
|
-
accountSid: "AC123",
|
|
78
|
-
authToken: "secret",
|
|
79
|
-
endpoint: "/Calls.json",
|
|
80
|
-
body: new URLSearchParams({ To: "+14155550123" }),
|
|
81
|
-
}),
|
|
82
|
-
).resolves.toBeUndefined();
|
|
83
|
-
|
|
84
|
-
await expect(
|
|
85
|
-
twilioApiRequest({
|
|
86
|
-
baseUrl: "https://api.twilio.com",
|
|
87
|
-
accountSid: "AC123",
|
|
88
|
-
authToken: "secret",
|
|
89
|
-
endpoint: "/Calls/missing.json",
|
|
90
|
-
body: {},
|
|
91
|
-
allowNotFound: true,
|
|
92
|
-
}),
|
|
93
|
-
).resolves.toBeUndefined();
|
|
94
|
-
expect(release).toHaveBeenCalledTimes(2);
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
it("throws twilio api errors for non-ok responses", async () => {
|
|
98
|
-
const release = vi.fn(async () => {});
|
|
99
|
-
fetchWithSsrFGuardMock.mockResolvedValue({
|
|
100
|
-
response: new Response("bad request", { status: 400 }),
|
|
101
|
-
release,
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
await expect(
|
|
105
|
-
twilioApiRequest({
|
|
106
|
-
baseUrl: "https://api.twilio.com",
|
|
107
|
-
accountSid: "AC123",
|
|
108
|
-
authToken: "secret",
|
|
109
|
-
endpoint: "/Calls.json",
|
|
110
|
-
body: {},
|
|
111
|
-
}),
|
|
112
|
-
).rejects.toThrow("Twilio API error: 400 bad request");
|
|
113
|
-
expect(release).toHaveBeenCalledTimes(1);
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
it("exposes structured Twilio error codes from json error bodies", async () => {
|
|
117
|
-
const release = vi.fn(async () => {});
|
|
118
|
-
fetchWithSsrFGuardMock.mockResolvedValue({
|
|
119
|
-
response: new Response(
|
|
120
|
-
JSON.stringify({
|
|
121
|
-
code: 21220,
|
|
122
|
-
message: "Call is not in-progress. Cannot redirect.",
|
|
123
|
-
}),
|
|
124
|
-
{ status: 400 },
|
|
125
|
-
),
|
|
126
|
-
release,
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
await expect(
|
|
130
|
-
twilioApiRequest({
|
|
131
|
-
baseUrl: "https://api.twilio.com",
|
|
132
|
-
accountSid: "AC123",
|
|
133
|
-
authToken: "secret",
|
|
134
|
-
endpoint: "/Calls/CA123.json",
|
|
135
|
-
body: {},
|
|
136
|
-
}),
|
|
137
|
-
).rejects.toMatchObject({
|
|
138
|
-
name: "TwilioApiError",
|
|
139
|
-
httpStatus: 400,
|
|
140
|
-
twilioCode: 21220,
|
|
141
|
-
message: "Twilio API error: 400 Call is not in-progress. Cannot redirect.",
|
|
142
|
-
} satisfies Partial<TwilioApiError>);
|
|
143
|
-
expect(release).toHaveBeenCalledTimes(1);
|
|
144
|
-
});
|
|
145
|
-
});
|
|
@@ -1,93 +0,0 @@
|
|
|
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
|
-
return text ? (JSON.parse(text) as T) : (undefined as T);
|
|
90
|
-
} finally {
|
|
91
|
-
await release();
|
|
92
|
-
}
|
|
93
|
-
}
|
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
import type { WebhookContext } from "../../types.js";
|
|
3
|
-
import { decideTwimlResponse, readTwimlRequestView } from "./twiml-policy.js";
|
|
4
|
-
|
|
5
|
-
function createContext(rawBody: string, query?: WebhookContext["query"]): WebhookContext {
|
|
6
|
-
return {
|
|
7
|
-
headers: {},
|
|
8
|
-
rawBody,
|
|
9
|
-
url: "https://example.ngrok.app/voice/twilio",
|
|
10
|
-
method: "POST",
|
|
11
|
-
query,
|
|
12
|
-
};
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
describe("twiml policy", () => {
|
|
16
|
-
it("returns stored twiml decision for initial notify callback", () => {
|
|
17
|
-
const view = readTwimlRequestView(
|
|
18
|
-
createContext("CallStatus=initiated&Direction=outbound-api&CallSid=CA123", {
|
|
19
|
-
callId: "call-1",
|
|
20
|
-
}),
|
|
21
|
-
);
|
|
22
|
-
|
|
23
|
-
const decision = decideTwimlResponse({
|
|
24
|
-
...view,
|
|
25
|
-
hasStoredTwiml: true,
|
|
26
|
-
isNotifyCall: true,
|
|
27
|
-
hasActiveStreams: false,
|
|
28
|
-
canStream: true,
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
expect(decision.kind).toBe("stored");
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
it("returns queue for inbound when another stream is active", () => {
|
|
35
|
-
const view = readTwimlRequestView(
|
|
36
|
-
createContext("CallStatus=ringing&Direction=inbound&CallSid=CA456"),
|
|
37
|
-
);
|
|
38
|
-
|
|
39
|
-
const decision = decideTwimlResponse({
|
|
40
|
-
...view,
|
|
41
|
-
hasStoredTwiml: false,
|
|
42
|
-
isNotifyCall: false,
|
|
43
|
-
hasActiveStreams: true,
|
|
44
|
-
canStream: true,
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
expect(decision.kind).toBe("queue");
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it("returns stream + activation for inbound call when available", () => {
|
|
51
|
-
const view = readTwimlRequestView(
|
|
52
|
-
createContext("CallStatus=ringing&Direction=inbound&CallSid=CA789"),
|
|
53
|
-
);
|
|
54
|
-
|
|
55
|
-
const decision = decideTwimlResponse({
|
|
56
|
-
...view,
|
|
57
|
-
hasStoredTwiml: false,
|
|
58
|
-
isNotifyCall: false,
|
|
59
|
-
hasActiveStreams: false,
|
|
60
|
-
canStream: true,
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
expect(decision.kind).toBe("stream");
|
|
64
|
-
expect(decision.activateStreamCallSid).toBe("CA789");
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
it("returns empty for status callbacks", () => {
|
|
68
|
-
const view = readTwimlRequestView(
|
|
69
|
-
createContext("CallStatus=completed&Direction=inbound&CallSid=CA123", {
|
|
70
|
-
type: "status",
|
|
71
|
-
}),
|
|
72
|
-
);
|
|
73
|
-
|
|
74
|
-
const decision = decideTwimlResponse({
|
|
75
|
-
...view,
|
|
76
|
-
hasStoredTwiml: false,
|
|
77
|
-
isNotifyCall: false,
|
|
78
|
-
hasActiveStreams: false,
|
|
79
|
-
canStream: true,
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
expect(decision.kind).toBe("empty");
|
|
83
|
-
});
|
|
84
|
-
});
|