@crewhaus/call-session 0.1.4 → 0.1.5

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.
@@ -1,433 +0,0 @@
1
- /**
2
- * Section 30 — telephony adapter contract tests.
3
- */
4
- import { describe, expect, test } from "bun:test";
5
- import { CallSessionError } from "../index";
6
- import { createLiveKitSipAdapter } from "./livekit-sip";
7
- import { createTwilioTelephonyAdapter } from "./twilio";
8
-
9
- describe("twilio telephony adapter", () => {
10
- test("dial POSTs to Calls.json with from/to params", async () => {
11
- let observedUrl = "";
12
- let observedAuth = "";
13
- let observedBody = "";
14
- const fetchImpl = (async (url: string, init?: RequestInit) => {
15
- observedUrl = url;
16
- observedAuth = (init?.headers as Record<string, string>)?.Authorization ?? "";
17
- observedBody = init?.body as string;
18
- return new Response(JSON.stringify({ sid: "CA123" }), { status: 200 });
19
- }) as unknown as typeof fetch;
20
- const adapter = createTwilioTelephonyAdapter({
21
- accountSid: "AC123",
22
- authToken: "tok",
23
- fromNumber: "+15551234567",
24
- fetchImpl,
25
- });
26
- await adapter.dial("+15559876543");
27
- expect(observedUrl).toContain("/Accounts/AC123/Calls.json");
28
- expect(observedAuth.startsWith("Basic ")).toBe(true);
29
- expect(observedBody).toContain("From=%2B15551234567");
30
- expect(observedBody).toContain("To=%2B15559876543");
31
- });
32
-
33
- test("hold/resume update the active call", async () => {
34
- let updates = 0;
35
- const fetchImpl = (async (_url: string, init?: RequestInit) => {
36
- if (init?.method === "POST" && (init.body as string).includes("Status=")) updates++;
37
- return new Response(JSON.stringify({ sid: "CA123" }), { status: 200 });
38
- }) as unknown as typeof fetch;
39
- const adapter = createTwilioTelephonyAdapter({
40
- accountSid: "A",
41
- authToken: "t",
42
- fromNumber: "+1",
43
- fetchImpl,
44
- });
45
- await adapter.dial("+2");
46
- await adapter.hold();
47
- await adapter.resume();
48
- expect(updates).toBe(2);
49
- });
50
-
51
- test("missing config throws", () => {
52
- expect(() =>
53
- createTwilioTelephonyAdapter({
54
- accountSid: "",
55
- authToken: "t",
56
- fromNumber: "+1",
57
- }),
58
- ).toThrow(CallSessionError);
59
- });
60
-
61
- test("missing authToken throws", () => {
62
- expect(() =>
63
- createTwilioTelephonyAdapter({ accountSid: "A", authToken: "", fromNumber: "+1" }),
64
- ).toThrow(/requires authToken/);
65
- });
66
-
67
- test("missing fromNumber throws", () => {
68
- expect(() =>
69
- createTwilioTelephonyAdapter({ accountSid: "A", authToken: "t", fromNumber: "" }),
70
- ).toThrow(/requires fromNumber/);
71
- });
72
-
73
- test("dial includes a TwiML Url and a basic auth header derived from sid:token", async () => {
74
- let observedAuth = "";
75
- let observedBody = "";
76
- const fetchImpl = (async (_url: string, init?: RequestInit) => {
77
- observedAuth = (init?.headers as Record<string, string>)?.Authorization ?? "";
78
- observedBody = init?.body as string;
79
- return new Response(JSON.stringify({ sid: "CA999" }), { status: 200 });
80
- }) as unknown as typeof fetch;
81
- const adapter = createTwilioTelephonyAdapter({
82
- accountSid: "AC123",
83
- authToken: "tok",
84
- fromNumber: "+1",
85
- fetchImpl,
86
- });
87
- await adapter.dial("+2");
88
- expect(observedBody).toContain("Url=https%3A%2F%2Fdemo.twilio.com");
89
- // Base64 of "AC123:tok".
90
- expect(observedAuth).toBe(`Basic ${Buffer.from("AC123:tok").toString("base64")}`);
91
- });
92
-
93
- test("answer is an adapter-level no-op", async () => {
94
- const fetchImpl = (async () => {
95
- throw new Error("answer must not hit the network");
96
- }) as unknown as typeof fetch;
97
- const adapter = createTwilioTelephonyAdapter({
98
- accountSid: "A",
99
- authToken: "t",
100
- fromNumber: "+1",
101
- fetchImpl,
102
- });
103
- await expect(adapter.answer()).resolves.toBeUndefined();
104
- });
105
-
106
- test("dial throws CallSessionError with status + body on a non-ok response", async () => {
107
- const fetchImpl = (async () =>
108
- new Response("nope", { status: 422 })) as unknown as typeof fetch;
109
- const adapter = createTwilioTelephonyAdapter({
110
- accountSid: "A",
111
- authToken: "t",
112
- fromNumber: "+1",
113
- fetchImpl,
114
- });
115
- await expect(adapter.dial("+2")).rejects.toThrow(/Calls\.create returned 422: nope/);
116
- });
117
-
118
- test("callsUpdate throws CallSessionError on a non-ok response (via hold)", async () => {
119
- let firstCall = true;
120
- const fetchImpl = (async () => {
121
- if (firstCall) {
122
- firstCall = false;
123
- return new Response(JSON.stringify({ sid: "CA1" }), { status: 200 });
124
- }
125
- return new Response("denied", { status: 500 });
126
- }) as unknown as typeof fetch;
127
- const adapter = createTwilioTelephonyAdapter({
128
- accountSid: "A",
129
- authToken: "t",
130
- fromNumber: "+1",
131
- fetchImpl,
132
- });
133
- await adapter.dial("+2");
134
- await expect(adapter.hold()).rejects.toThrow(/Calls\.update returned 500: denied/);
135
- });
136
-
137
- test("hold/resume/transfer/end without an active call behave correctly", async () => {
138
- const fetchImpl = (async () =>
139
- new Response(JSON.stringify({ sid: "x" }), { status: 200 })) as unknown as typeof fetch;
140
- const adapter = createTwilioTelephonyAdapter({
141
- accountSid: "A",
142
- authToken: "t",
143
- fromNumber: "+1",
144
- fetchImpl,
145
- });
146
- await expect(adapter.hold()).rejects.toThrow(/no active call to hold/);
147
- await expect(adapter.resume()).rejects.toThrow(/no active call to resume/);
148
- await expect(adapter.transfer("+2")).rejects.toThrow(/no active call to transfer/);
149
- // end() with no active call is a silent no-op.
150
- await expect(adapter.end("bye")).resolves.toBeUndefined();
151
- });
152
-
153
- test("transfer posts a redirect Url with the encoded destination", async () => {
154
- let transferBody = "";
155
- let calls = 0;
156
- const fetchImpl = (async (_url: string, init?: RequestInit) => {
157
- calls += 1;
158
- if (calls >= 2) transferBody = init?.body as string;
159
- return new Response(JSON.stringify({ sid: "CA1" }), { status: 200 });
160
- }) as unknown as typeof fetch;
161
- const adapter = createTwilioTelephonyAdapter({
162
- accountSid: "A",
163
- authToken: "t",
164
- fromNumber: "+1",
165
- fetchImpl,
166
- });
167
- await adapter.dial("+2");
168
- await adapter.transfer("sip:agent@x.com");
169
- // The destination is encodeURIComponent-ed into the Url query string, then
170
- // the whole Url is form-urlencoded again as a POST body param — so the
171
- // caller-supplied URI ends up double-encoded. Decoding twice recovers it.
172
- const params = new URLSearchParams(transferBody);
173
- const url = new URL(params.get("Url") ?? "");
174
- expect(url.searchParams.get("to")).toBe("sip:agent@x.com");
175
- expect(transferBody).toContain("Method=POST");
176
- });
177
-
178
- test("end completes the active call then clears it (next end is a no-op)", async () => {
179
- const seen: string[] = [];
180
- const fetchImpl = (async (url: string, init?: RequestInit) => {
181
- seen.push((init?.body as string) ?? "");
182
- void url;
183
- return new Response(JSON.stringify({ sid: "CA1" }), { status: 200 });
184
- }) as unknown as typeof fetch;
185
- const adapter = createTwilioTelephonyAdapter({
186
- accountSid: "A",
187
- authToken: "t",
188
- fromNumber: "+1",
189
- fetchImpl,
190
- });
191
- await adapter.dial("+2");
192
- await adapter.end("done");
193
- expect(seen.some((b) => b.includes("Status=completed"))).toBe(true);
194
- const before = seen.length;
195
- await adapter.end("again"); // no active call → no network
196
- expect(seen.length).toBe(before);
197
- });
198
-
199
- test("kind is 'twilio'", () => {
200
- const adapter = createTwilioTelephonyAdapter({
201
- accountSid: "A",
202
- authToken: "t",
203
- fromNumber: "+1",
204
- fetchImpl: (async () => new Response("{}")) as unknown as typeof fetch,
205
- });
206
- expect(adapter.kind).toBe("twilio");
207
- });
208
- });
209
-
210
- describe("livekit-sip telephony adapter", () => {
211
- test("dial POSTs to CreateSIPParticipant", async () => {
212
- let observedUrl = "";
213
- let observedBody = "";
214
- const fetchImpl = (async (url: string, init?: RequestInit) => {
215
- observedUrl = url;
216
- observedBody = init?.body as string;
217
- return new Response(JSON.stringify({ participant_id: "p1" }), { status: 200 });
218
- }) as unknown as typeof fetch;
219
- const adapter = createLiveKitSipAdapter({
220
- url: "http://lk",
221
- apiKey: "k",
222
- apiSecret: "s",
223
- fromNumber: "+1",
224
- fetchImpl,
225
- });
226
- await adapter.dial("sip:test@example.com");
227
- expect(observedUrl).toContain("/twirp/livekit.SIP/CreateSIPParticipant");
228
- const parsed = JSON.parse(observedBody) as { sip_call_to: string };
229
- expect(parsed.sip_call_to).toBe("sip:test@example.com");
230
- });
231
-
232
- test("transfer requires active call", async () => {
233
- const fetchImpl = (async () => new Response("{}", { status: 200 })) as unknown as typeof fetch;
234
- const adapter = createLiveKitSipAdapter({
235
- url: "http://lk",
236
- apiKey: "k",
237
- apiSecret: "s",
238
- fromNumber: "+1",
239
- fetchImpl,
240
- });
241
- await expect(adapter.transfer("sip:other@x.com")).rejects.toBeInstanceOf(CallSessionError);
242
- });
243
-
244
- test("missing config throws", () => {
245
- expect(() =>
246
- createLiveKitSipAdapter({
247
- url: "",
248
- apiKey: "k",
249
- apiSecret: "s",
250
- fromNumber: "+1",
251
- }),
252
- ).toThrow(CallSessionError);
253
- });
254
-
255
- test("each required field is validated independently", () => {
256
- expect(() =>
257
- createLiveKitSipAdapter({ url: "u", apiKey: "", apiSecret: "s", fromNumber: "+1" }),
258
- ).toThrow(/requires apiKey/);
259
- expect(() =>
260
- createLiveKitSipAdapter({ url: "u", apiKey: "k", apiSecret: "", fromNumber: "+1" }),
261
- ).toThrow(/requires apiSecret/);
262
- expect(() =>
263
- createLiveKitSipAdapter({ url: "u", apiKey: "k", apiSecret: "s", fromNumber: "" }),
264
- ).toThrow(/requires fromNumber/);
265
- });
266
-
267
- test("dial sends a bearer auth header and records the participant id", async () => {
268
- let observedAuth = "";
269
- const fetchImpl = (async (_url: string, init?: RequestInit) => {
270
- observedAuth = (init?.headers as Record<string, string>)?.Authorization ?? "";
271
- return new Response(JSON.stringify({ participant_id: "p42" }), { status: 200 });
272
- }) as unknown as typeof fetch;
273
- const adapter = createLiveKitSipAdapter({
274
- url: "http://lk",
275
- apiKey: "k",
276
- apiSecret: "s",
277
- fromNumber: "+1",
278
- fetchImpl,
279
- });
280
- await adapter.dial("sip:a@x.com");
281
- expect(observedAuth).toBe("Bearer k:s");
282
- });
283
-
284
- test("dial without participant_id in the response falls back to 'unknown' but stays active", async () => {
285
- // Response omits participant_id → activeParticipantId becomes "unknown"
286
- // (truthy), so a subsequent hold proceeds to the RPC rather than throwing.
287
- let holdHit = false;
288
- const fetchImpl = (async (url: string) => {
289
- if (String(url).includes("UpdateSIPParticipant")) holdHit = true;
290
- return new Response(JSON.stringify({}), { status: 200 });
291
- }) as unknown as typeof fetch;
292
- const adapter = createLiveKitSipAdapter({
293
- url: "http://lk",
294
- apiKey: "k",
295
- apiSecret: "s",
296
- fromNumber: "+1",
297
- fetchImpl,
298
- });
299
- await adapter.dial("sip:a@x.com");
300
- await adapter.hold();
301
- expect(holdHit).toBe(true);
302
- });
303
-
304
- test("answer is an adapter-level no-op", async () => {
305
- const fetchImpl = (async () => {
306
- throw new Error("answer must not hit the network");
307
- }) as unknown as typeof fetch;
308
- const adapter = createLiveKitSipAdapter({
309
- url: "http://lk",
310
- apiKey: "k",
311
- apiSecret: "s",
312
- fromNumber: "+1",
313
- fetchImpl,
314
- });
315
- await expect(adapter.answer()).resolves.toBeUndefined();
316
- });
317
-
318
- test("rpc throws CallSessionError with method, status and body on a non-ok response", async () => {
319
- const fetchImpl = (async () =>
320
- new Response("upstream boom", { status: 503 })) as unknown as typeof fetch;
321
- const adapter = createLiveKitSipAdapter({
322
- url: "http://lk",
323
- apiKey: "k",
324
- apiSecret: "s",
325
- fromNumber: "+1",
326
- fetchImpl,
327
- });
328
- await expect(adapter.dial("sip:a@x.com")).rejects.toThrow(
329
- /CreateSIPParticipant returned 503: upstream boom/,
330
- );
331
- });
332
-
333
- test("hold and resume mute/unmute the active participant", async () => {
334
- const bodies: Array<Record<string, unknown>> = [];
335
- const fetchImpl = (async (url: string, init?: RequestInit) => {
336
- if (String(url).includes("UpdateSIPParticipant")) {
337
- bodies.push(JSON.parse(init?.body as string));
338
- }
339
- return new Response(JSON.stringify({ participant_id: "p1" }), { status: 200 });
340
- }) as unknown as typeof fetch;
341
- const adapter = createLiveKitSipAdapter({
342
- url: "http://lk",
343
- apiKey: "k",
344
- apiSecret: "s",
345
- fromNumber: "+1",
346
- fetchImpl,
347
- });
348
- await adapter.dial("sip:a@x.com");
349
- await adapter.hold();
350
- await adapter.resume();
351
- expect(bodies).toEqual([
352
- { participant_id: "p1", muted: true },
353
- { participant_id: "p1", muted: false },
354
- ]);
355
- });
356
-
357
- test("hold/resume without an active call throw", async () => {
358
- const fetchImpl = (async () => new Response("{}", { status: 200 })) as unknown as typeof fetch;
359
- const adapter = createLiveKitSipAdapter({
360
- url: "http://lk",
361
- apiKey: "k",
362
- apiSecret: "s",
363
- fromNumber: "+1",
364
- fetchImpl,
365
- });
366
- await expect(adapter.hold()).rejects.toThrow(/no active call to hold/);
367
- await expect(adapter.resume()).rejects.toThrow(/no active call to resume/);
368
- });
369
-
370
- test("transfer forwards transfer_to for the active participant", async () => {
371
- let transferBody: Record<string, unknown> = {};
372
- const fetchImpl = (async (url: string, init?: RequestInit) => {
373
- if (String(url).includes("TransferSIPParticipant")) {
374
- transferBody = JSON.parse(init?.body as string);
375
- }
376
- return new Response(JSON.stringify({ participant_id: "p1" }), { status: 200 });
377
- }) as unknown as typeof fetch;
378
- const adapter = createLiveKitSipAdapter({
379
- url: "http://lk",
380
- apiKey: "k",
381
- apiSecret: "s",
382
- fromNumber: "+1",
383
- fetchImpl,
384
- });
385
- await adapter.dial("sip:a@x.com");
386
- await adapter.transfer("sip:b@x.com");
387
- expect(transferBody).toEqual({ participant_id: "p1", transfer_to: "sip:b@x.com" });
388
- });
389
-
390
- test("end deletes the active participant then clears it (next end is a no-op)", async () => {
391
- let deletes = 0;
392
- const fetchImpl = (async (url: string) => {
393
- if (String(url).includes("DeleteSIPParticipant")) deletes += 1;
394
- return new Response(JSON.stringify({ participant_id: "p1" }), { status: 200 });
395
- }) as unknown as typeof fetch;
396
- const adapter = createLiveKitSipAdapter({
397
- url: "http://lk",
398
- apiKey: "k",
399
- apiSecret: "s",
400
- fromNumber: "+1",
401
- fetchImpl,
402
- });
403
- await adapter.dial("sip:a@x.com");
404
- await adapter.end("done");
405
- await adapter.end("again"); // no active participant → no extra delete
406
- expect(deletes).toBe(1);
407
- });
408
-
409
- test("end with no active call is a silent no-op", async () => {
410
- const fetchImpl = (async () => {
411
- throw new Error("end must not hit the network when idle");
412
- }) as unknown as typeof fetch;
413
- const adapter = createLiveKitSipAdapter({
414
- url: "http://lk",
415
- apiKey: "k",
416
- apiSecret: "s",
417
- fromNumber: "+1",
418
- fetchImpl,
419
- });
420
- await expect(adapter.end("never")).resolves.toBeUndefined();
421
- });
422
-
423
- test("kind is 'livekit-sip'", () => {
424
- const adapter = createLiveKitSipAdapter({
425
- url: "http://lk",
426
- apiKey: "k",
427
- apiSecret: "s",
428
- fromNumber: "+1",
429
- fetchImpl: (async () => new Response("{}")) as unknown as typeof fetch,
430
- });
431
- expect(adapter.kind).toBe("livekit-sip");
432
- });
433
- });
@@ -1,81 +0,0 @@
1
- /**
2
- * Section 30 — LiveKit SIP telephony adapter for `@crewhaus/call-session`.
3
- * LiveKit Cloud's SIP gateway provides outbound + inbound SIP trunks; this
4
- * adapter targets their REST/RPC API for call control.
5
- */
6
- import { CallSessionError, type TelephonyAdapter } from "../index";
7
-
8
- export type LiveKitSipAdapterOptions = {
9
- readonly url: string;
10
- readonly apiKey: string;
11
- readonly apiSecret: string;
12
- readonly fromNumber: string;
13
- readonly fetchImpl?: typeof fetch;
14
- };
15
-
16
- export function createLiveKitSipAdapter(opts: LiveKitSipAdapterOptions): TelephonyAdapter {
17
- if (!opts.url) throw new CallSessionError("livekit-sip adapter requires url");
18
- if (!opts.apiKey) throw new CallSessionError("livekit-sip adapter requires apiKey");
19
- if (!opts.apiSecret) throw new CallSessionError("livekit-sip adapter requires apiSecret");
20
- if (!opts.fromNumber) throw new CallSessionError("livekit-sip adapter requires fromNumber");
21
- const fetchImpl = opts.fetchImpl ?? fetch;
22
- let activeParticipantId: string | undefined;
23
-
24
- async function rpc(method: string, body: Record<string, unknown>): Promise<unknown> {
25
- const res = await fetchImpl(`${opts.url}/twirp/livekit.SIP/${method}`, {
26
- method: "POST",
27
- headers: {
28
- Authorization: `Bearer ${opts.apiKey}:${opts.apiSecret}`,
29
- "Content-Type": "application/json",
30
- },
31
- body: JSON.stringify(body),
32
- });
33
- if (!res.ok) {
34
- throw new CallSessionError(
35
- `livekit-sip ${method} returned ${res.status}: ${await res.text()}`,
36
- );
37
- }
38
- return await res.json();
39
- }
40
-
41
- return {
42
- kind: "livekit-sip",
43
- async dial(toUri: string): Promise<void> {
44
- const result = (await rpc("CreateSIPParticipant", {
45
- sip_call_to: toUri,
46
- sip_number: opts.fromNumber,
47
- })) as { participant_id?: string };
48
- activeParticipantId = result.participant_id ?? "unknown";
49
- },
50
- async answer(): Promise<void> {
51
- // LiveKit dispatches answer via the room agent — adapter-level no-op.
52
- },
53
- async hold(): Promise<void> {
54
- if (!activeParticipantId) throw new CallSessionError("livekit-sip: no active call to hold");
55
- await rpc("UpdateSIPParticipant", {
56
- participant_id: activeParticipantId,
57
- muted: true,
58
- });
59
- },
60
- async resume(): Promise<void> {
61
- if (!activeParticipantId) throw new CallSessionError("livekit-sip: no active call to resume");
62
- await rpc("UpdateSIPParticipant", {
63
- participant_id: activeParticipantId,
64
- muted: false,
65
- });
66
- },
67
- async transfer(toUri: string): Promise<void> {
68
- if (!activeParticipantId)
69
- throw new CallSessionError("livekit-sip: no active call to transfer");
70
- await rpc("TransferSIPParticipant", {
71
- participant_id: activeParticipantId,
72
- transfer_to: toUri,
73
- });
74
- },
75
- async end(_reason: string): Promise<void> {
76
- if (!activeParticipantId) return;
77
- await rpc("DeleteSIPParticipant", { participant_id: activeParticipantId });
78
- activeParticipantId = undefined;
79
- },
80
- };
81
- }
@@ -1,104 +0,0 @@
1
- /**
2
- * Section 30 — Twilio telephony adapter for `@crewhaus/call-session`.
3
- * Routes through the Twilio REST API (via fetch — no SDK dependency).
4
- * Webhook signature verification lives outside this module (the daemon's
5
- * inbound HTTP handler does that with `X-Twilio-Signature`).
6
- */
7
- import { CallSessionError, type TelephonyAdapter } from "../index";
8
-
9
- export type TwilioAdapterOptions = {
10
- readonly accountSid: string;
11
- readonly authToken: string;
12
- readonly fromNumber: string;
13
- /** Test override: inject a fake fetch. */
14
- readonly fetchImpl?: typeof fetch;
15
- /** Override base URL (defaults to https://api.twilio.com/2010-04-01). */
16
- readonly baseUrl?: string;
17
- };
18
-
19
- export function createTwilioTelephonyAdapter(opts: TwilioAdapterOptions): TelephonyAdapter {
20
- if (!opts.accountSid) throw new CallSessionError("twilio adapter requires accountSid");
21
- if (!opts.authToken) throw new CallSessionError("twilio adapter requires authToken");
22
- if (!opts.fromNumber) throw new CallSessionError("twilio adapter requires fromNumber");
23
- const fetchImpl = opts.fetchImpl ?? fetch;
24
- const baseUrl = opts.baseUrl ?? "https://api.twilio.com/2010-04-01";
25
- let activeCallSid: string | undefined;
26
-
27
- function authHeader(): string {
28
- const token = Buffer.from(`${opts.accountSid}:${opts.authToken}`).toString("base64");
29
- return `Basic ${token}`;
30
- }
31
-
32
- async function callsPost(body: Record<string, string>): Promise<{ sid: string }> {
33
- const params = new URLSearchParams(body).toString();
34
- const res = await fetchImpl(`${baseUrl}/Accounts/${opts.accountSid}/Calls.json`, {
35
- method: "POST",
36
- headers: {
37
- Authorization: authHeader(),
38
- "Content-Type": "application/x-www-form-urlencoded",
39
- },
40
- body: params,
41
- });
42
- if (!res.ok) {
43
- throw new CallSessionError(`twilio Calls.create returned ${res.status}: ${await res.text()}`);
44
- }
45
- return (await res.json()) as { sid: string };
46
- }
47
-
48
- async function callsUpdate(callSid: string, body: Record<string, string>): Promise<void> {
49
- const params = new URLSearchParams(body).toString();
50
- const res = await fetchImpl(`${baseUrl}/Accounts/${opts.accountSid}/Calls/${callSid}.json`, {
51
- method: "POST",
52
- headers: {
53
- Authorization: authHeader(),
54
- "Content-Type": "application/x-www-form-urlencoded",
55
- },
56
- body: params,
57
- });
58
- if (!res.ok) {
59
- throw new CallSessionError(`twilio Calls.update returned ${res.status}: ${await res.text()}`);
60
- }
61
- }
62
-
63
- return {
64
- kind: "twilio",
65
- async dial(toUri: string): Promise<void> {
66
- const result = await callsPost({
67
- From: opts.fromNumber,
68
- To: toUri,
69
- // Twilio requires a TwiML URL or instructions; v0 expects callers
70
- // to wire the actual TwiML elsewhere. The empty Url makes the call
71
- // fail loudly rather than silently dial.
72
- Url: "https://demo.twilio.com/docs/voice.xml",
73
- });
74
- activeCallSid = result.sid;
75
- },
76
- async answer(): Promise<void> {
77
- // Inbound calls answer via TwiML <Say>/<Connect> on the webhook —
78
- // adapter-level answer is a no-op.
79
- },
80
- async hold(): Promise<void> {
81
- if (!activeCallSid) throw new CallSessionError("twilio: no active call to hold");
82
- await callsUpdate(activeCallSid, { Status: "queued" });
83
- },
84
- async resume(): Promise<void> {
85
- if (!activeCallSid) throw new CallSessionError("twilio: no active call to resume");
86
- await callsUpdate(activeCallSid, { Status: "in-progress" });
87
- },
88
- async transfer(toUri: string): Promise<void> {
89
- if (!activeCallSid) throw new CallSessionError("twilio: no active call to transfer");
90
- // Twilio transfer = update the active call to redirect to a TwiML
91
- // <Dial> for the new endpoint. Caller's TwiML supplies the verb;
92
- // here we POST the redirect URL.
93
- await callsUpdate(activeCallSid, {
94
- Url: `https://demo.twilio.com/docs/voice.xml?to=${encodeURIComponent(toUri)}`,
95
- Method: "POST",
96
- });
97
- },
98
- async end(_reason: string): Promise<void> {
99
- if (!activeCallSid) return;
100
- await callsUpdate(activeCallSid, { Status: "completed" });
101
- activeCallSid = undefined;
102
- },
103
- };
104
- }