@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.
- package/README.md +27 -5
- package/api.ts +16 -0
- package/cli-metadata.ts +10 -0
- package/config-api.ts +12 -0
- package/index.test.ts +943 -0
- package/index.ts +379 -149
- package/openclaw.plugin.json +384 -157
- package/package.json +35 -5
- 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/cli.ts +533 -68
- package/src/config-compat.test.ts +120 -0
- package/src/config-compat.ts +227 -0
- package/src/config.test.ts +273 -12
- package/src/config.ts +355 -72
- package/src/core-bridge.ts +2 -147
- package/src/deep-merge.test.ts +40 -0
- package/src/gateway-continue-operation.ts +200 -0
- package/src/http-headers.ts +6 -3
- package/src/manager/context.ts +6 -5
- package/src/manager/events.test.ts +243 -19
- package/src/manager/events.ts +61 -31
- package/src/manager/lifecycle.ts +53 -0
- package/src/manager/lookup.test.ts +52 -0
- package/src/manager/outbound.test.ts +528 -0
- package/src/manager/outbound.ts +163 -57
- package/src/manager/store.ts +18 -6
- package/src/manager/timers.test.ts +129 -0
- package/src/manager/timers.ts +4 -3
- package/src/manager/twiml.test.ts +13 -0
- package/src/manager/twiml.ts +8 -0
- package/src/manager.closed-loop.test.ts +30 -12
- package/src/manager.inbound-allowlist.test.ts +77 -10
- package/src/manager.notify.test.ts +344 -20
- package/src/manager.restore.test.ts +95 -8
- package/src/manager.test-harness.ts +8 -6
- package/src/manager.ts +79 -5
- package/src/media-stream.test.ts +578 -81
- package/src/media-stream.ts +235 -54
- package/src/providers/base.ts +19 -0
- package/src/providers/mock.ts +7 -1
- package/src/providers/plivo.test.ts +50 -6
- package/src/providers/plivo.ts +14 -6
- package/src/providers/shared/call-status.ts +2 -1
- package/src/providers/shared/guarded-json-api.test.ts +106 -0
- package/src/providers/shared/guarded-json-api.ts +1 -1
- package/src/providers/telnyx.test.ts +178 -6
- package/src/providers/telnyx.ts +40 -3
- package/src/providers/twilio/api.test.ts +145 -0
- package/src/providers/twilio/api.ts +67 -16
- package/src/providers/twilio/twiml-policy.ts +6 -10
- package/src/providers/twilio/webhook.ts +1 -1
- package/src/providers/twilio.test.ts +425 -25
- package/src/providers/twilio.ts +230 -77
- package/src/providers/twilio.types.ts +17 -0
- package/src/realtime-defaults.ts +3 -0
- package/src/realtime-fast-context.test.ts +88 -0
- package/src/realtime-fast-context.ts +165 -0
- package/src/realtime-transcription.runtime.ts +4 -0
- package/src/realtime-voice.runtime.ts +5 -0
- package/src/response-generator.test.ts +321 -0
- package/src/response-generator.ts +213 -53
- package/src/response-model.test.ts +71 -0
- package/src/response-model.ts +23 -0
- package/src/runtime.test.ts +429 -0
- package/src/runtime.ts +270 -24
- package/src/telephony-audio.test.ts +61 -0
- package/src/telephony-audio.ts +1 -79
- package/src/telephony-tts.test.ts +133 -12
- package/src/telephony-tts.ts +155 -2
- package/src/test-fixtures.ts +28 -7
- package/src/tts-provider-voice.test.ts +34 -0
- package/src/tts-provider-voice.ts +21 -0
- package/src/tunnel.test.ts +166 -0
- package/src/tunnel.ts +1 -1
- package/src/types.ts +24 -37
- package/src/utils.test.ts +17 -0
- package/src/voice-mapping.test.ts +34 -0
- package/src/voice-mapping.ts +3 -2
- package/src/webhook/realtime-handler.test.ts +598 -0
- package/src/webhook/realtime-handler.ts +485 -0
- package/src/webhook/stale-call-reaper.test.ts +88 -0
- package/src/webhook/stale-call-reaper.ts +5 -0
- package/src/webhook/tailscale.test.ts +214 -0
- package/src/webhook/tailscale.ts +19 -5
- package/src/webhook-exposure.test.ts +33 -0
- package/src/webhook-exposure.ts +84 -0
- package/src/webhook-security.test.ts +172 -21
- package/src/webhook-security.ts +43 -29
- package/src/webhook.hangup-once.lifecycle.test.ts +135 -0
- package/src/webhook.test.ts +1145 -27
- package/src/webhook.ts +523 -102
- package/src/webhook.types.ts +5 -0
- package/src/websocket-test-support.ts +72 -0
- package/tsconfig.json +16 -0
- package/CHANGELOG.md +0 -121
- package/src/providers/index.ts +0 -10
- package/src/providers/stt-openai-realtime.test.ts +0 -42
- package/src/providers/stt-openai-realtime.ts +0 -311
- package/src/providers/tts-openai.test.ts +0 -43
- 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
|
|
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,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
|
-
|
|
50
|
-
|
|
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
|
|
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
|
-
|
|
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
|
});
|
package/src/providers/telnyx.ts
CHANGED
|
@@ -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:
|
|
173
|
-
|
|
174
|
-
|
|
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
|
|
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
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
75
|
+
policy: { allowedHostnames: ["api.twilio.com"] },
|
|
76
|
+
timeoutMs: TWILIO_API_TIMEOUT_MS,
|
|
77
|
+
auditContext: "voice-call.twilio.api",
|
|
30
78
|
});
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
41
|
-
|
|
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
|
}
|