@openclaw/voice-call 2026.3.13 → 2026.5.2-beta.1

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 (103) hide show
  1. package/README.md +27 -5
  2. package/api.ts +16 -0
  3. package/cli-metadata.ts +10 -0
  4. package/config-api.ts +12 -0
  5. package/index.test.ts +943 -0
  6. package/index.ts +379 -149
  7. package/openclaw.plugin.json +384 -157
  8. package/package.json +35 -5
  9. package/runtime-api.ts +20 -0
  10. package/runtime-entry.ts +1 -0
  11. package/setup-api.ts +47 -0
  12. package/src/allowlist.test.ts +18 -0
  13. package/src/cli.ts +533 -68
  14. package/src/config-compat.test.ts +120 -0
  15. package/src/config-compat.ts +227 -0
  16. package/src/config.test.ts +273 -12
  17. package/src/config.ts +355 -72
  18. package/src/core-bridge.ts +2 -147
  19. package/src/deep-merge.test.ts +40 -0
  20. package/src/gateway-continue-operation.ts +200 -0
  21. package/src/http-headers.ts +6 -3
  22. package/src/manager/context.ts +6 -5
  23. package/src/manager/events.test.ts +243 -19
  24. package/src/manager/events.ts +61 -31
  25. package/src/manager/lifecycle.ts +53 -0
  26. package/src/manager/lookup.test.ts +52 -0
  27. package/src/manager/outbound.test.ts +528 -0
  28. package/src/manager/outbound.ts +163 -57
  29. package/src/manager/store.ts +18 -6
  30. package/src/manager/timers.test.ts +129 -0
  31. package/src/manager/timers.ts +4 -3
  32. package/src/manager/twiml.test.ts +13 -0
  33. package/src/manager/twiml.ts +8 -0
  34. package/src/manager.closed-loop.test.ts +30 -12
  35. package/src/manager.inbound-allowlist.test.ts +77 -10
  36. package/src/manager.notify.test.ts +344 -20
  37. package/src/manager.restore.test.ts +95 -8
  38. package/src/manager.test-harness.ts +8 -6
  39. package/src/manager.ts +79 -5
  40. package/src/media-stream.test.ts +578 -81
  41. package/src/media-stream.ts +235 -54
  42. package/src/providers/base.ts +19 -0
  43. package/src/providers/mock.ts +7 -1
  44. package/src/providers/plivo.test.ts +50 -6
  45. package/src/providers/plivo.ts +14 -6
  46. package/src/providers/shared/call-status.ts +2 -1
  47. package/src/providers/shared/guarded-json-api.test.ts +106 -0
  48. package/src/providers/shared/guarded-json-api.ts +1 -1
  49. package/src/providers/telnyx.test.ts +178 -6
  50. package/src/providers/telnyx.ts +40 -3
  51. package/src/providers/twilio/api.test.ts +145 -0
  52. package/src/providers/twilio/api.ts +67 -16
  53. package/src/providers/twilio/twiml-policy.ts +6 -10
  54. package/src/providers/twilio/webhook.ts +1 -1
  55. package/src/providers/twilio.test.ts +425 -25
  56. package/src/providers/twilio.ts +230 -77
  57. package/src/providers/twilio.types.ts +17 -0
  58. package/src/realtime-defaults.ts +3 -0
  59. package/src/realtime-fast-context.test.ts +88 -0
  60. package/src/realtime-fast-context.ts +165 -0
  61. package/src/realtime-transcription.runtime.ts +4 -0
  62. package/src/realtime-voice.runtime.ts +5 -0
  63. package/src/response-generator.test.ts +321 -0
  64. package/src/response-generator.ts +213 -53
  65. package/src/response-model.test.ts +71 -0
  66. package/src/response-model.ts +23 -0
  67. package/src/runtime.test.ts +429 -0
  68. package/src/runtime.ts +270 -24
  69. package/src/telephony-audio.test.ts +61 -0
  70. package/src/telephony-audio.ts +1 -79
  71. package/src/telephony-tts.test.ts +133 -12
  72. package/src/telephony-tts.ts +155 -2
  73. package/src/test-fixtures.ts +28 -7
  74. package/src/tts-provider-voice.test.ts +34 -0
  75. package/src/tts-provider-voice.ts +21 -0
  76. package/src/tunnel.test.ts +166 -0
  77. package/src/tunnel.ts +1 -1
  78. package/src/types.ts +24 -37
  79. package/src/utils.test.ts +17 -0
  80. package/src/voice-mapping.test.ts +34 -0
  81. package/src/voice-mapping.ts +3 -2
  82. package/src/webhook/realtime-handler.test.ts +598 -0
  83. package/src/webhook/realtime-handler.ts +485 -0
  84. package/src/webhook/stale-call-reaper.test.ts +88 -0
  85. package/src/webhook/stale-call-reaper.ts +5 -0
  86. package/src/webhook/tailscale.test.ts +214 -0
  87. package/src/webhook/tailscale.ts +19 -5
  88. package/src/webhook-exposure.test.ts +33 -0
  89. package/src/webhook-exposure.ts +84 -0
  90. package/src/webhook-security.test.ts +172 -21
  91. package/src/webhook-security.ts +43 -29
  92. package/src/webhook.hangup-once.lifecycle.test.ts +135 -0
  93. package/src/webhook.test.ts +1145 -27
  94. package/src/webhook.ts +523 -102
  95. package/src/webhook.types.ts +5 -0
  96. package/src/websocket-test-support.ts +72 -0
  97. package/tsconfig.json +16 -0
  98. package/CHANGELOG.md +0 -121
  99. package/src/providers/index.ts +0 -10
  100. package/src/providers/stt-openai-realtime.test.ts +0 -42
  101. package/src/providers/stt-openai-realtime.ts +0 -311
  102. package/src/providers/tts-openai.test.ts +0 -43
  103. package/src/providers/tts-openai.ts +0 -221
@@ -1,3 +1,4 @@
1
+ import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime";
1
2
  import type { EndReason } from "../../types.js";
2
3
 
3
4
  const TERMINAL_PROVIDER_STATUS_TO_END_REASON: Record<string, EndReason> = {
@@ -9,7 +10,7 @@ const TERMINAL_PROVIDER_STATUS_TO_END_REASON: Record<string, EndReason> = {
9
10
  };
10
11
 
11
12
  export function normalizeProviderStatus(status: string | null | undefined): string {
12
- const normalized = status?.trim().toLowerCase();
13
+ const normalized = normalizeOptionalLowercaseString(status);
13
14
  return normalized && normalized.length > 0 ? normalized : "unknown";
14
15
  }
15
16
 
@@ -0,0 +1,106 @@
1
+ import { beforeEach, 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 { guardedJsonApiRequest } from "./guarded-json-api.js";
12
+
13
+ describe("guardedJsonApiRequest", () => {
14
+ beforeEach(() => {
15
+ vi.clearAllMocks();
16
+ });
17
+
18
+ it("uses the SSRF-guarded fetch and parses json responses", async () => {
19
+ const release = vi.fn(async () => {});
20
+ fetchWithSsrFGuardMock.mockResolvedValue({
21
+ response: new Response(JSON.stringify({ ok: true }), { status: 200 }),
22
+ release,
23
+ });
24
+
25
+ await expect(
26
+ guardedJsonApiRequest({
27
+ url: "https://api.example.com/v1/calls",
28
+ method: "POST",
29
+ headers: { Authorization: "Bearer token" },
30
+ body: { hello: "world" },
31
+ allowedHostnames: ["api.example.com"],
32
+ auditContext: "voice-call:test",
33
+ errorPrefix: "request failed",
34
+ }),
35
+ ).resolves.toEqual({ ok: true });
36
+
37
+ expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith({
38
+ url: "https://api.example.com/v1/calls",
39
+ init: {
40
+ method: "POST",
41
+ headers: { Authorization: "Bearer token" },
42
+ body: JSON.stringify({ hello: "world" }),
43
+ },
44
+ policy: { allowedHostnames: ["api.example.com"] },
45
+ auditContext: "voice-call:test",
46
+ });
47
+ expect(release).toHaveBeenCalledTimes(1);
48
+ });
49
+
50
+ it("returns undefined for empty bodies and allowed 404s", async () => {
51
+ const release = vi.fn(async () => {});
52
+ fetchWithSsrFGuardMock.mockResolvedValueOnce({
53
+ response: new Response(null, { status: 204 }),
54
+ release,
55
+ });
56
+
57
+ await expect(
58
+ guardedJsonApiRequest({
59
+ url: "https://api.example.com/v1/calls/1",
60
+ method: "GET",
61
+ headers: {},
62
+ allowedHostnames: ["api.example.com"],
63
+ auditContext: "voice-call:test",
64
+ errorPrefix: "request failed",
65
+ }),
66
+ ).resolves.toBeUndefined();
67
+
68
+ fetchWithSsrFGuardMock.mockResolvedValueOnce({
69
+ response: new Response("missing", { status: 404 }),
70
+ release,
71
+ });
72
+
73
+ await expect(
74
+ guardedJsonApiRequest({
75
+ url: "https://api.example.com/v1/calls/2",
76
+ method: "GET",
77
+ headers: {},
78
+ allowNotFound: true,
79
+ allowedHostnames: ["api.example.com"],
80
+ auditContext: "voice-call:test",
81
+ errorPrefix: "request failed",
82
+ }),
83
+ ).resolves.toBeUndefined();
84
+ });
85
+
86
+ it("throws prefixed errors and still releases the response handle", async () => {
87
+ const release = vi.fn(async () => {});
88
+ fetchWithSsrFGuardMock.mockResolvedValue({
89
+ response: new Response("boom", { status: 500 }),
90
+ release,
91
+ });
92
+
93
+ await expect(
94
+ guardedJsonApiRequest({
95
+ url: "https://api.example.com/v1/calls/3",
96
+ method: "DELETE",
97
+ headers: {},
98
+ allowedHostnames: ["api.example.com"],
99
+ auditContext: "voice-call:test",
100
+ errorPrefix: "provider error",
101
+ }),
102
+ ).rejects.toThrow("provider error: 500 boom");
103
+
104
+ expect(release).toHaveBeenCalledTimes(1);
105
+ });
106
+ });
@@ -1,4 +1,4 @@
1
- import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/voice-call";
1
+ import { fetchWithSsrFGuard } from "../../../api.js";
2
2
 
3
3
  type GuardedJsonApiRequestParams = {
4
4
  url: string;
@@ -1,8 +1,20 @@
1
1
  import crypto from "node:crypto";
2
- import { describe, expect, it } from "vitest";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
3
  import type { WebhookContext } from "../types.js";
4
4
  import { TelnyxProvider } from "./telnyx.js";
5
5
 
6
+ const apiMocks = vi.hoisted(() => ({
7
+ fetchWithSsrFGuard: vi.fn(),
8
+ }));
9
+
10
+ vi.mock("../../api.js", () => ({
11
+ fetchWithSsrFGuard: apiMocks.fetchWithSsrFGuard,
12
+ }));
13
+
14
+ afterEach(() => {
15
+ apiMocks.fetchWithSsrFGuard.mockReset();
16
+ });
17
+
6
18
  function createCtx(params?: Partial<WebhookContext>): WebhookContext {
7
19
  return {
8
20
  headers: {},
@@ -46,8 +58,25 @@ function expectReplayVerification(
46
58
  ) {
47
59
  expect(results.map((result) => result.ok)).toEqual([true, true]);
48
60
  expect(results.map((result) => Boolean(result.isReplay))).toEqual([false, true]);
49
- expect(results[0]?.verifiedRequestKey).toEqual(expect.any(String));
50
- expect(results[1]?.verifiedRequestKey).toBe(results[0]?.verifiedRequestKey);
61
+ const firstResult = results[0];
62
+ if (!firstResult?.verifiedRequestKey) {
63
+ throw new Error("expected Telnyx verification to produce a request key");
64
+ }
65
+ const secondResult = results[1];
66
+ if (!secondResult?.verifiedRequestKey) {
67
+ throw new Error("expected replayed Telnyx verification to preserve the request key");
68
+ }
69
+ const firstKey = firstResult.verifiedRequestKey;
70
+ const secondKey = secondResult.verifiedRequestKey;
71
+ expect(firstKey.length).toBeGreaterThan(0);
72
+ expect(secondKey).toBe(firstKey);
73
+ }
74
+
75
+ function requireJwkX(jwk: JsonWebKey) {
76
+ if (typeof jwk.x !== "string" || jwk.x.length === 0) {
77
+ throw new Error("expected Ed25519 JWK export to expose x");
78
+ }
79
+ return jwk.x;
51
80
  }
52
81
 
53
82
  function expectWebhookVerificationSucceeds(params: {
@@ -106,9 +135,8 @@ describe("TelnyxProvider.verifyWebhook", () => {
106
135
  const jwk = publicKey.export({ format: "jwk" }) as JsonWebKey;
107
136
  expect(jwk.kty).toBe("OKP");
108
137
  expect(jwk.crv).toBe("Ed25519");
109
- expect(typeof jwk.x).toBe("string");
110
138
 
111
- const rawPublicKey = decodeBase64Url(jwk.x as string);
139
+ const rawPublicKey = decodeBase64Url(requireJwkX(jwk));
112
140
  const rawPublicKeyBase64 = rawPublicKey.toString("base64");
113
141
  expectWebhookVerificationSucceeds({ publicKey: rawPublicKeyBase64, privateKey });
114
142
  });
@@ -163,6 +191,150 @@ describe("TelnyxProvider.parseWebhookEvent", () => {
163
191
  );
164
192
 
165
193
  expect(result.events).toHaveLength(1);
166
- expect(result.events[0]?.dedupeKey).toBe("telnyx:req:abc");
194
+ const event = result.events[0];
195
+ if (!event) {
196
+ throw new Error("expected Telnyx parseWebhookEvent to produce one event");
197
+ }
198
+ expect(event.dedupeKey).toBe("telnyx:req:abc");
199
+ });
200
+
201
+ it("maps call direction and phone numbers from Call Control callbacks", () => {
202
+ const provider = new TelnyxProvider({
203
+ apiKey: "KEY123",
204
+ connectionId: "CONN456",
205
+ publicKey: undefined,
206
+ });
207
+ const result = provider.parseWebhookEvent(
208
+ createCtx({
209
+ rawBody: JSON.stringify({
210
+ data: {
211
+ id: "evt-inbound",
212
+ event_type: "call.initiated",
213
+ payload: {
214
+ call_control_id: "call-1",
215
+ direction: "incoming",
216
+ from: "+15551111111",
217
+ to: "+15550000000",
218
+ },
219
+ },
220
+ }),
221
+ }),
222
+ );
223
+
224
+ expect(result.events).toHaveLength(1);
225
+ expect(result.events[0]).toEqual(
226
+ expect.objectContaining({
227
+ type: "call.initiated",
228
+ direction: "inbound",
229
+ from: "+15551111111",
230
+ to: "+15550000000",
231
+ }),
232
+ );
233
+ });
234
+
235
+ it("reads transcription text from Telnyx transcription_data payloads", () => {
236
+ const provider = new TelnyxProvider({
237
+ apiKey: "KEY123",
238
+ connectionId: "CONN456",
239
+ publicKey: undefined,
240
+ });
241
+ const result = provider.parseWebhookEvent(
242
+ createCtx({
243
+ rawBody: JSON.stringify({
244
+ data: {
245
+ id: "evt-transcription",
246
+ event_type: "call.transcription",
247
+ payload: {
248
+ call_control_id: "call-1",
249
+ transcription_data: {
250
+ transcript: "hello this is a test speech",
251
+ is_final: false,
252
+ confidence: 0.977219,
253
+ },
254
+ },
255
+ },
256
+ }),
257
+ }),
258
+ );
259
+
260
+ expect(result.events).toHaveLength(1);
261
+ expect(result.events[0]).toEqual(
262
+ expect.objectContaining({
263
+ type: "call.speech",
264
+ transcript: "hello this is a test speech",
265
+ isFinal: false,
266
+ confidence: 0.977219,
267
+ }),
268
+ );
269
+ });
270
+ });
271
+
272
+ describe("TelnyxProvider answer control", () => {
273
+ it("answers inbound call-control legs with a deterministic command id", async () => {
274
+ const release = vi.fn(async () => {});
275
+ apiMocks.fetchWithSsrFGuard.mockResolvedValue({
276
+ response: new Response(JSON.stringify({ data: {} }), { status: 200 }),
277
+ release,
278
+ });
279
+ const provider = new TelnyxProvider({
280
+ apiKey: "KEY123",
281
+ connectionId: "CONN456",
282
+ publicKey: undefined,
283
+ });
284
+
285
+ await provider.answerCall({
286
+ callId: "call-1",
287
+ providerCallId: "call-control-1",
288
+ });
289
+
290
+ expect(apiMocks.fetchWithSsrFGuard).toHaveBeenCalledWith(
291
+ expect.objectContaining({
292
+ url: "https://api.telnyx.com/v2/calls/call-control-1/actions/answer",
293
+ auditContext: "voice-call.telnyx.api",
294
+ policy: { allowedHostnames: ["api.telnyx.com"] },
295
+ init: expect.objectContaining({
296
+ method: "POST",
297
+ body: JSON.stringify({ command_id: "openclaw-answer-call-1" }),
298
+ }),
299
+ }),
300
+ );
301
+ expect(release).toHaveBeenCalledTimes(1);
302
+ });
303
+ });
304
+
305
+ describe("TelnyxProvider speak control", () => {
306
+ it("passes custom Telnyx voice ids to the speak action", async () => {
307
+ const release = vi.fn(async () => {});
308
+ apiMocks.fetchWithSsrFGuard.mockResolvedValue({
309
+ response: new Response(JSON.stringify({ data: {} }), { status: 200 }),
310
+ release,
311
+ });
312
+ const provider = new TelnyxProvider({
313
+ apiKey: "KEY123",
314
+ connectionId: "CONN456",
315
+ publicKey: undefined,
316
+ });
317
+
318
+ await provider.playTts({
319
+ callId: "call-1",
320
+ providerCallId: "call-control-1",
321
+ text: "hello",
322
+ voice: "Telnyx.Qwen3TTS.12345678-1234-1234-1234-123456789abc",
323
+ });
324
+
325
+ expect(apiMocks.fetchWithSsrFGuard).toHaveBeenCalledWith(
326
+ expect.objectContaining({
327
+ url: "https://api.telnyx.com/v2/calls/call-control-1/actions/speak",
328
+ auditContext: "voice-call.telnyx.api",
329
+ policy: { allowedHostnames: ["api.telnyx.com"] },
330
+ init: expect.objectContaining({
331
+ method: "POST",
332
+ body: expect.stringContaining(
333
+ '"voice":"Telnyx.Qwen3TTS.12345678-1234-1234-1234-123456789abc"',
334
+ ),
335
+ }),
336
+ }),
337
+ );
338
+ expect(release).toHaveBeenCalledTimes(1);
167
339
  });
168
340
  });
@@ -1,6 +1,7 @@
1
1
  import crypto from "node:crypto";
2
2
  import type { TelnyxConfig } from "../config.js";
3
3
  import type {
4
+ AnswerCallInput,
4
5
  EndReason,
5
6
  GetCallStatusInput,
6
7
  GetCallStatusResult,
@@ -31,6 +32,21 @@ export interface TelnyxProviderOptions {
31
32
  skipVerification?: boolean;
32
33
  }
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
+
34
50
  export class TelnyxProvider implements VoiceCallProvider {
35
51
  readonly name = "telnyx" as const;
36
52
 
@@ -143,6 +159,9 @@ export class TelnyxProvider implements VoiceCallProvider {
143
159
  callId,
144
160
  providerCallId: data.payload?.call_control_id,
145
161
  timestamp: Date.now(),
162
+ direction: normalizeTelnyxDirection(data.payload?.direction),
163
+ from: data.payload?.from,
164
+ to: data.payload?.to,
146
165
  };
147
166
 
148
167
  switch (data.event_type) {
@@ -169,9 +188,10 @@ export class TelnyxProvider implements VoiceCallProvider {
169
188
  return {
170
189
  ...baseEvent,
171
190
  type: "call.speech",
172
- transcript: data.payload?.transcription || "",
173
- isFinal: data.payload?.is_final ?? true,
174
- confidence: data.payload?.confidence,
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,
175
195
  };
176
196
 
177
197
  case "call.hangup":
@@ -261,6 +281,15 @@ export class TelnyxProvider implements VoiceCallProvider {
261
281
  );
262
282
  }
263
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
+
264
293
  /**
265
294
  * Play TTS audio via Telnyx speak action.
266
295
  */
@@ -336,10 +365,18 @@ interface TelnyxEvent {
336
365
  payload?: {
337
366
  call_control_id?: string;
338
367
  client_state?: string;
368
+ direction?: string;
369
+ from?: string;
370
+ to?: string;
339
371
  text?: string;
340
372
  transcription?: string;
341
373
  is_final?: boolean;
342
374
  confidence?: number;
375
+ transcription_data?: {
376
+ transcript?: string;
377
+ is_final?: boolean;
378
+ confidence?: number;
379
+ };
343
380
  hangup_cause?: string;
344
381
  digit?: string;
345
382
  [key: string]: unknown;
@@ -0,0 +1,145 @@
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,3 +1,44 @@
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
+
1
42
  export async function twilioApiRequest<T = unknown>(params: {
2
43
  baseUrl: string;
3
44
  accountSid: string;
@@ -9,7 +50,7 @@ export async function twilioApiRequest<T = unknown>(params: {
9
50
  const bodyParams =
10
51
  params.body instanceof URLSearchParams
11
52
  ? params.body
12
- : Object.entries(params.body).reduce<URLSearchParams>((acc, [key, value]) => {
53
+ : Object.entries(params.body).reduce((acc, [key, value]) => {
13
54
  if (Array.isArray(value)) {
14
55
  for (const entry of value) {
15
56
  acc.append(key, entry);
@@ -20,23 +61,33 @@ export async function twilioApiRequest<T = unknown>(params: {
20
61
  return acc;
21
62
  }, new URLSearchParams());
22
63
 
23
- const response = await fetch(`${params.baseUrl}${params.endpoint}`, {
24
- method: "POST",
25
- headers: {
26
- Authorization: `Basic ${Buffer.from(`${params.accountSid}:${params.authToken}`).toString("base64")}`,
27
- "Content-Type": "application/x-www-form-urlencoded",
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,
28
74
  },
29
- body: bodyParams,
75
+ policy: { allowedHostnames: ["api.twilio.com"] },
76
+ timeoutMs: TWILIO_API_TIMEOUT_MS,
77
+ auditContext: "voice-call.twilio.api",
30
78
  });
31
-
32
- if (!response.ok) {
33
- if (params.allowNotFound && response.status === 404) {
34
- return undefined as T;
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);
35
86
  }
36
- const errorText = await response.text();
37
- throw new Error(`Twilio API error: ${response.status} ${errorText}`);
38
- }
39
87
 
40
- const text = await response.text();
41
- return text ? (JSON.parse(text) as T) : (undefined as T);
88
+ const text = await response.text();
89
+ return text ? (JSON.parse(text) as T) : (undefined as T);
90
+ } finally {
91
+ await release();
92
+ }
42
93
  }