@profullstack/group-call 0.4.1 → 0.5.0
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 +7 -0
- package/dist/index.d.ts +49 -2
- package/dist/index.js +116 -13
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -46,6 +46,13 @@ await line.handle(JSON.parse(rawBody).data);
|
|
|
46
46
|
- **Call people in.** `dial(code, phone, { from, connectionId, name?, prompt? })` rings a phone
|
|
47
47
|
from your number. Whoever answers presses 1 and is in the room, so voicemail never joins.
|
|
48
48
|
The room rides in the call's `client_state`, so an answer after a restart still lands right.
|
|
49
|
+
- **Call a voice agent in.** `dial(code, "sip:+1555...@sip.rtc.elevenlabs.io", { ..., direct: true, name, headers })`
|
|
50
|
+
joins on answer with no press 1 and no greeting, and sends `headers` (`X-...` only) on the INVITE.
|
|
51
|
+
ElevenLabs Agents expose them as dynamic variables (`X-Otr-Episode` -> `{{sip_otr_episode}}`).
|
|
52
|
+
- **Record a room.** `record(code)` records the conference, everyone mixed, as mp3 (or wav) until it
|
|
53
|
+
ends or `stopRecording(code)`. `onRecording({ code, conferenceId, recordingId, mp3, wav, ... })` fires
|
|
54
|
+
on `conference.recording.saved`; the links are short-lived, so download straight away, or get fresh
|
|
55
|
+
ones with `recordingLinks(recordingId)`.
|
|
49
56
|
- **Room API.** `say(code, text, voice?)` speaks into a room. Also `participants(code)`,
|
|
50
57
|
`kick(code, leg)`, `list()`, `hasCallers(code)` and `callersIn(code)`.
|
|
51
58
|
- **App hooks.** `onCode` can join, welcome, reject, or take the call over: `call.gather(...)`
|
package/dist/index.d.ts
CHANGED
|
@@ -40,6 +40,20 @@ export interface Participant {
|
|
|
40
40
|
interface Room extends RoomInfo {
|
|
41
41
|
/** Legs currently in the room, so a caller is never counted twice. */
|
|
42
42
|
legs: Map<string, Participant>;
|
|
43
|
+
/** The conference a recording was started on, or null; a remade conference is not being recorded. */
|
|
44
|
+
recordingConf?: string | null;
|
|
45
|
+
}
|
|
46
|
+
/** A finished recording of a room's conference, as Telnyx saved it. */
|
|
47
|
+
export interface Recording {
|
|
48
|
+
/** The room, when this process started the recording; null after a restart lost track. */
|
|
49
|
+
code: string | null;
|
|
50
|
+
conferenceId: string;
|
|
51
|
+
recordingId: string;
|
|
52
|
+
/** Short-lived signed links; fetch the file promptly, or ask Telnyx for /recordings/{id} again. */
|
|
53
|
+
mp3: string | null;
|
|
54
|
+
wav: string | null;
|
|
55
|
+
startedAt: string | null;
|
|
56
|
+
endedAt: string | null;
|
|
43
57
|
}
|
|
44
58
|
/** Sending a text. Injected because the number that sends is not always the one called. */
|
|
45
59
|
export interface Sms {
|
|
@@ -131,6 +145,8 @@ export interface GroupCallOptions {
|
|
|
131
145
|
};
|
|
132
146
|
/** A finished line somebody said in a room (needs `transcribe`). */
|
|
133
147
|
onTranscript?: (code: string, who: Participant, text: string) => void;
|
|
148
|
+
/** A room recording (see `record`) is saved on Telnyx and ready to download. */
|
|
149
|
+
onRecording?: (recording: Recording) => void;
|
|
134
150
|
/** Injected for tests. */
|
|
135
151
|
now?: () => number;
|
|
136
152
|
fetch?: typeof globalThis.fetch;
|
|
@@ -180,6 +196,10 @@ export declare class GroupCall {
|
|
|
180
196
|
private readonly dialled;
|
|
181
197
|
/** How many times each leg has been asked for a code, so it is not forever. */
|
|
182
198
|
private readonly asks;
|
|
199
|
+
/** Names for legs `dial` placed with one, used when they enter. */
|
|
200
|
+
private readonly legName;
|
|
201
|
+
/** Which room each conference we are recording belongs to, for the saved webhook. */
|
|
202
|
+
private readonly recordingRoom;
|
|
183
203
|
/** Legs whose voice failed once, which hear the plain one from then on. */
|
|
184
204
|
private readonly plainVoice;
|
|
185
205
|
/** Names promised for callers about to arrive from a number: agents announce themselves. */
|
|
@@ -245,6 +265,12 @@ export declare class GroupCall {
|
|
|
245
265
|
* room. The room is carried in the call's client_state too, so an answer
|
|
246
266
|
* that arrives after a restart still knows where it was going. Returns the
|
|
247
267
|
* new leg, or null when Telnyx would not place the call.
|
|
268
|
+
*
|
|
269
|
+
* `direct` skips the press-1 question and joins on answer, silently: for a
|
|
270
|
+
* machine that will not press anything, like a voice agent. `to` may then be
|
|
271
|
+
* a SIP URI (`sip:+1555...@sip.example.com`), and `headers` are sent on the
|
|
272
|
+
* INVITE as custom X- headers, which is how such an agent is told what call
|
|
273
|
+
* this is.
|
|
248
274
|
*/
|
|
249
275
|
dial(code: string, to: string, opts: {
|
|
250
276
|
from: string;
|
|
@@ -253,6 +279,8 @@ export declare class GroupCall {
|
|
|
253
279
|
prompt?: string;
|
|
254
280
|
welcome?: string;
|
|
255
281
|
timeoutSecs?: number;
|
|
282
|
+
direct?: boolean;
|
|
283
|
+
headers?: Record<string, string>;
|
|
256
284
|
}): Promise<{
|
|
257
285
|
leg: string;
|
|
258
286
|
} | null>;
|
|
@@ -280,9 +308,9 @@ export declare class GroupCall {
|
|
|
280
308
|
* Put a leg into a room, making the conference if it is the first one there.
|
|
281
309
|
*
|
|
282
310
|
* `welcome` is what the caller hears once they are in, before the count of
|
|
283
|
-
* who else is.
|
|
311
|
+
* who else is; null joins them without a word.
|
|
284
312
|
*/
|
|
285
|
-
join(leg: string, code: string, welcome?: string): Promise<void>;
|
|
313
|
+
join(leg: string, code: string, welcome?: string | null): Promise<void>;
|
|
286
314
|
/**
|
|
287
315
|
* A conference's name: findable again after a restart, but not the code itself,
|
|
288
316
|
* which would be a room's whole key sitting in somebody else's dashboard. A
|
|
@@ -311,6 +339,25 @@ export declare class GroupCall {
|
|
|
311
339
|
* nobody is on the phone there, which is most of the time and costs nothing.
|
|
312
340
|
*/
|
|
313
341
|
say(code: string, text: string, voice?: string, settings?: Record<string, unknown>): Promise<boolean>;
|
|
342
|
+
/**
|
|
343
|
+
* Record the room's conference, everyone mixed into one file. Runs until the
|
|
344
|
+
* conference ends (the last caller leaves) or `stopRecording`; `onRecording`
|
|
345
|
+
* hears when the file is saved. False when nobody is on the line yet, since
|
|
346
|
+
* there is no conference to record until somebody is.
|
|
347
|
+
*/
|
|
348
|
+
record(code: string, opts?: {
|
|
349
|
+
format?: "mp3" | "wav";
|
|
350
|
+
beep?: boolean;
|
|
351
|
+
}): Promise<boolean>;
|
|
352
|
+
/** Whether this process is recording the room's current conference. */
|
|
353
|
+
recording(code: string): boolean;
|
|
354
|
+
/** Stop recording; the file is saved and `onRecording` follows. */
|
|
355
|
+
stopRecording(code: string): Promise<boolean>;
|
|
356
|
+
/** A saved recording's current download links, for when the webhook's have expired. */
|
|
357
|
+
recordingLinks(recordingId: string): Promise<{
|
|
358
|
+
mp3: string | null;
|
|
359
|
+
wav: string | null;
|
|
360
|
+
} | null>;
|
|
314
361
|
/** Hang up one participant in a room. */
|
|
315
362
|
kick(code: string, leg: string): Promise<boolean>;
|
|
316
363
|
/** Whether anybody is on the phone in a room. */
|
package/dist/index.js
CHANGED
|
@@ -73,6 +73,8 @@ function dialState(raw) {
|
|
|
73
73
|
code: s["code"],
|
|
74
74
|
...(typeof s["prompt"] === "string" ? { prompt: s["prompt"] } : {}),
|
|
75
75
|
...(typeof s["welcome"] === "string" ? { welcome: s["welcome"] } : {}),
|
|
76
|
+
...(s["direct"] === true ? { direct: true } : {}),
|
|
77
|
+
...(typeof s["name"] === "string" ? { name: s["name"] } : {}),
|
|
76
78
|
};
|
|
77
79
|
}
|
|
78
80
|
catch {
|
|
@@ -187,6 +189,10 @@ export class GroupCall {
|
|
|
187
189
|
dialled = new Map();
|
|
188
190
|
/** How many times each leg has been asked for a code, so it is not forever. */
|
|
189
191
|
asks = new Map();
|
|
192
|
+
/** Names for legs `dial` placed with one, used when they enter. */
|
|
193
|
+
legName = new Map();
|
|
194
|
+
/** Which room each conference we are recording belongs to, for the saved webhook. */
|
|
195
|
+
recordingRoom = new Map();
|
|
190
196
|
/** Legs whose voice failed once, which hear the plain one from then on. */
|
|
191
197
|
plainVoice = new Set();
|
|
192
198
|
/** Names promised for callers about to arrive from a number: agents announce themselves. */
|
|
@@ -281,13 +287,34 @@ export class GroupCall {
|
|
|
281
287
|
async handle(event) {
|
|
282
288
|
const type = event.event_type ?? "";
|
|
283
289
|
const payload = event.payload ?? {};
|
|
290
|
+
// A conference event, not a leg's: it may carry no call_control_id at all.
|
|
291
|
+
if (type === "conference.recording.saved") {
|
|
292
|
+
const conferenceId = typeof payload["conference_id"] === "string" ? payload["conference_id"] : "";
|
|
293
|
+
const recordingId = typeof payload["recording_id"] === "string" ? payload["recording_id"] : "";
|
|
294
|
+
if (!conferenceId || !recordingId)
|
|
295
|
+
return;
|
|
296
|
+
const urls = (payload["recording_urls"] ?? payload["public_recording_urls"] ?? {});
|
|
297
|
+
const str = (v) => (typeof v === "string" && v !== "" ? v : null);
|
|
298
|
+
const code = this.recordingRoom.get(conferenceId) ?? null;
|
|
299
|
+
this.recordingRoom.delete(conferenceId);
|
|
300
|
+
this.options.onRecording?.({
|
|
301
|
+
code,
|
|
302
|
+
conferenceId,
|
|
303
|
+
recordingId,
|
|
304
|
+
mp3: str(urls["mp3"]),
|
|
305
|
+
wav: str(urls["wav"]),
|
|
306
|
+
startedAt: str(payload["recording_started_at"]),
|
|
307
|
+
endedAt: str(payload["recording_ended_at"]),
|
|
308
|
+
});
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
284
311
|
const leg = typeof payload["call_control_id"] === "string" ? payload["call_control_id"] : "";
|
|
285
312
|
if (!leg)
|
|
286
313
|
return;
|
|
287
314
|
if (type === "call.initiated") {
|
|
288
315
|
// Only inbound. An outbound leg we dialled is not somebody calling in,
|
|
289
316
|
// and answering it would be answering ourselves.
|
|
290
|
-
if (payload["direction"] !== "incoming")
|
|
317
|
+
if (payload["direction"] !== "incoming" || this.dialled.has(leg))
|
|
291
318
|
return;
|
|
292
319
|
if (typeof payload["from"] === "string")
|
|
293
320
|
this.legFrom.set(leg, payload["from"]);
|
|
@@ -295,14 +322,23 @@ export class GroupCall {
|
|
|
295
322
|
return;
|
|
296
323
|
}
|
|
297
324
|
if (type === "call.answered") {
|
|
298
|
-
//
|
|
299
|
-
//
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
325
|
+
// A leg `dial` rang is somebody picking up, to be asked in -- checked before
|
|
326
|
+
// the direction, because a call to a SIP URI does not always say "outgoing".
|
|
327
|
+
// Any other answered outbound leg is somebody else's business.
|
|
328
|
+
const dialled = this.dialled.get(leg) ?? dialState(payload["client_state"]);
|
|
329
|
+
if (dialled?.direct) {
|
|
330
|
+
this.dialled.delete(leg);
|
|
331
|
+
if (dialled.name)
|
|
332
|
+
this.legName.set(leg, dialled.name);
|
|
333
|
+
await this.join(leg, dialled.code, null);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
if (dialled) {
|
|
337
|
+
await this.offer(leg, dialled);
|
|
304
338
|
return;
|
|
305
339
|
}
|
|
340
|
+
if (payload["direction"] === "outgoing")
|
|
341
|
+
return;
|
|
306
342
|
await this.ask(leg);
|
|
307
343
|
return;
|
|
308
344
|
}
|
|
@@ -355,6 +391,7 @@ export class GroupCall {
|
|
|
355
391
|
this.release(leg);
|
|
356
392
|
this.legFrom.delete(leg);
|
|
357
393
|
this.dialled.delete(leg);
|
|
394
|
+
this.legName.delete(leg);
|
|
358
395
|
this.pendingGather.delete(leg);
|
|
359
396
|
this.asks.delete(leg);
|
|
360
397
|
this.plainVoice.delete(leg);
|
|
@@ -372,25 +409,44 @@ export class GroupCall {
|
|
|
372
409
|
* room. The room is carried in the call's client_state too, so an answer
|
|
373
410
|
* that arrives after a restart still knows where it was going. Returns the
|
|
374
411
|
* new leg, or null when Telnyx would not place the call.
|
|
412
|
+
*
|
|
413
|
+
* `direct` skips the press-1 question and joins on answer, silently: for a
|
|
414
|
+
* machine that will not press anything, like a voice agent. `to` may then be
|
|
415
|
+
* a SIP URI (`sip:+1555...@sip.example.com`), and `headers` are sent on the
|
|
416
|
+
* INVITE as custom X- headers, which is how such an agent is told what call
|
|
417
|
+
* this is.
|
|
375
418
|
*/
|
|
376
419
|
async dial(code, to, opts) {
|
|
377
|
-
const
|
|
420
|
+
const sip = opts.direct === true && /^sips?:[^\s@]+@[^\s@]+$/i.test(to.trim());
|
|
421
|
+
const number = sip ? to.trim() : normalNumber(to);
|
|
378
422
|
if (!number || !opts.connectionId || !opts.from)
|
|
379
423
|
return null;
|
|
380
|
-
const state = {
|
|
424
|
+
const state = {
|
|
425
|
+
code,
|
|
426
|
+
...(opts.prompt ? { prompt: opts.prompt.slice(0, 300) } : {}),
|
|
427
|
+
...(opts.welcome ? { welcome: opts.welcome.slice(0, 200) } : {}),
|
|
428
|
+
...(opts.direct ? { direct: true } : {}),
|
|
429
|
+
...(opts.direct && opts.name ? { name: opts.name.slice(0, 60) } : {}),
|
|
430
|
+
};
|
|
431
|
+
const headers = Object.entries(opts.headers ?? {})
|
|
432
|
+
.filter(([name]) => /^X-[A-Za-z0-9-]{1,60}$/.test(name))
|
|
433
|
+
.map(([name, value]) => ({ name, value: String(value).slice(0, 200) }));
|
|
381
434
|
const placed = (await this.request("/calls", {
|
|
382
435
|
connection_id: opts.connectionId,
|
|
383
436
|
to: number,
|
|
384
437
|
from: normalNumber(opts.from),
|
|
385
438
|
timeout_secs: opts.timeoutSecs ?? 30,
|
|
386
439
|
client_state: Buffer.from(JSON.stringify({ gc: "dial", ...state }), "utf8").toString("base64"),
|
|
440
|
+
...(headers.length ? { custom_headers: headers } : {}),
|
|
387
441
|
}));
|
|
388
442
|
const leg = placed?.data?.call_control_id;
|
|
389
443
|
if (typeof leg !== "string")
|
|
390
444
|
return null;
|
|
391
445
|
this.dialled.set(leg, state);
|
|
392
446
|
this.legFrom.set(leg, number);
|
|
393
|
-
if (opts.name)
|
|
447
|
+
if (opts.name && opts.direct)
|
|
448
|
+
this.legName.set(leg, opts.name.slice(0, 60));
|
|
449
|
+
else if (opts.name)
|
|
394
450
|
this.expect(code, number, opts.name, 5 * 60 * 1000);
|
|
395
451
|
this.options.onEvent?.(` ringing a phone into a room.`);
|
|
396
452
|
return { leg };
|
|
@@ -494,7 +550,7 @@ export class GroupCall {
|
|
|
494
550
|
* Put a leg into a room, making the conference if it is the first one there.
|
|
495
551
|
*
|
|
496
552
|
* `welcome` is what the caller hears once they are in, before the count of
|
|
497
|
-
* who else is.
|
|
553
|
+
* who else is; null joins them without a word.
|
|
498
554
|
*/
|
|
499
555
|
async join(leg, code, welcome = `Welcome to room ${spokenCode(code)}. `) {
|
|
500
556
|
const room = this.room(code);
|
|
@@ -588,7 +644,7 @@ export class GroupCall {
|
|
|
588
644
|
if (room.legs.has(leg))
|
|
589
645
|
return;
|
|
590
646
|
const from = this.legFrom.get(leg) ?? "";
|
|
591
|
-
const who = { leg, from, name: this.claimName(room.code, from), joinedAt: this.now() };
|
|
647
|
+
const who = { leg, from, name: this.legName.get(leg) || this.claimName(room.code, from), joinedAt: this.now() };
|
|
592
648
|
room.legs.set(leg, who);
|
|
593
649
|
room.callers = room.legs.size;
|
|
594
650
|
this.options.onEvent?.(` ${who.name || "a caller"} joined a room (${room.callers} on the line).`);
|
|
@@ -628,7 +684,7 @@ export class GroupCall {
|
|
|
628
684
|
* the room do not hear every arrival welcomed.
|
|
629
685
|
*/
|
|
630
686
|
async greet(room, leg, welcome) {
|
|
631
|
-
if (room.conferenceId === null)
|
|
687
|
+
if (room.conferenceId === null || welcome === null)
|
|
632
688
|
return;
|
|
633
689
|
await this.request(`/conferences/${encodeURIComponent(room.conferenceId)}/actions/speak`, {
|
|
634
690
|
payload: `${welcome}${peopleHere(room.callers)}`,
|
|
@@ -656,6 +712,53 @@ export class GroupCall {
|
|
|
656
712
|
});
|
|
657
713
|
return answer !== null;
|
|
658
714
|
}
|
|
715
|
+
/**
|
|
716
|
+
* Record the room's conference, everyone mixed into one file. Runs until the
|
|
717
|
+
* conference ends (the last caller leaves) or `stopRecording`; `onRecording`
|
|
718
|
+
* hears when the file is saved. False when nobody is on the line yet, since
|
|
719
|
+
* there is no conference to record until somebody is.
|
|
720
|
+
*/
|
|
721
|
+
async record(code, opts = {}) {
|
|
722
|
+
const room = this.rooms.get(code);
|
|
723
|
+
if (!room || room.conferenceId === null || room.callers === 0)
|
|
724
|
+
return false;
|
|
725
|
+
if (room.recordingConf === room.conferenceId)
|
|
726
|
+
return true;
|
|
727
|
+
const started = await this.request(`/conferences/${encodeURIComponent(room.conferenceId)}/actions/record_start`, {
|
|
728
|
+
format: opts.format ?? "mp3",
|
|
729
|
+
play_beep: opts.beep ?? false,
|
|
730
|
+
});
|
|
731
|
+
if (started === null)
|
|
732
|
+
return false;
|
|
733
|
+
room.recordingConf = room.conferenceId;
|
|
734
|
+
this.recordingRoom.set(room.conferenceId, code);
|
|
735
|
+
this.options.onEvent?.(` recording a room.`);
|
|
736
|
+
return true;
|
|
737
|
+
}
|
|
738
|
+
/** Whether this process is recording the room's current conference. */
|
|
739
|
+
recording(code) {
|
|
740
|
+
const room = this.rooms.get(code);
|
|
741
|
+
return Boolean(room && room.conferenceId !== null && room.recordingConf === room.conferenceId);
|
|
742
|
+
}
|
|
743
|
+
/** Stop recording; the file is saved and `onRecording` follows. */
|
|
744
|
+
async stopRecording(code) {
|
|
745
|
+
const room = this.rooms.get(code);
|
|
746
|
+
if (!room || room.conferenceId === null || room.recordingConf !== room.conferenceId)
|
|
747
|
+
return false;
|
|
748
|
+
const stopped = await this.request(`/conferences/${encodeURIComponent(room.conferenceId)}/actions/record_stop`, {});
|
|
749
|
+
if (stopped !== null)
|
|
750
|
+
room.recordingConf = null;
|
|
751
|
+
return stopped !== null;
|
|
752
|
+
}
|
|
753
|
+
/** A saved recording's current download links, for when the webhook's have expired. */
|
|
754
|
+
async recordingLinks(recordingId) {
|
|
755
|
+
const got = (await this.get(`/recordings/${encodeURIComponent(recordingId)}`));
|
|
756
|
+
const urls = got?.data?.download_urls;
|
|
757
|
+
if (!urls)
|
|
758
|
+
return null;
|
|
759
|
+
const str = (v) => (typeof v === "string" && v !== "" ? v : null);
|
|
760
|
+
return { mp3: str(urls["mp3"]), wav: str(urls["wav"]) };
|
|
761
|
+
}
|
|
659
762
|
/** Hang up one participant in a room. */
|
|
660
763
|
async kick(code, leg) {
|
|
661
764
|
if (!this.rooms.get(code)?.legs.has(leg))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@profullstack/group-call",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "A group call on a phone number: dial in, key a room code, talk with everyone else who did, people and AI agents alike. Telnyx Call Control state machine extracted from nixamp's party line; the engine behind nixamp live joins and group.call.pit.world.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|