@profullstack/group-call 0.4.0 → 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 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,10 +279,16 @@ 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>;
259
- /** A dialled phone was picked up: press 1 to join, so voicemail never does. */
287
+ /**
288
+ * A dialled phone was picked up: press 1 to join, so voicemail never does.
289
+ * Telnyx itself replays the prompt (three tries) and ignores any other key,
290
+ * so an ended gather without a 1 is a no.
291
+ */
260
292
  private offer;
261
293
  /** Hand a well-formed code to the app, and act on what it says the code is. */
262
294
  private routeCode;
@@ -276,9 +308,9 @@ export declare class GroupCall {
276
308
  * Put a leg into a room, making the conference if it is the first one there.
277
309
  *
278
310
  * `welcome` is what the caller hears once they are in, before the count of
279
- * who else is.
311
+ * who else is; null joins them without a word.
280
312
  */
281
- join(leg: string, code: string, welcome?: string): Promise<void>;
313
+ join(leg: string, code: string, welcome?: string | null): Promise<void>;
282
314
  /**
283
315
  * A conference's name: findable again after a restart, but not the code itself,
284
316
  * which would be a room's whole key sitting in somebody else's dashboard. A
@@ -307,6 +339,25 @@ export declare class GroupCall {
307
339
  * nobody is on the phone there, which is most of the time and costs nothing.
308
340
  */
309
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>;
310
361
  /** Hang up one participant in a room. */
311
362
  kick(code: string, leg: string): Promise<boolean>;
312
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
- // An answered outbound leg is somebody else's business, unless it is one
299
- // `dial` rang: then it is a person picking up, to be asked in.
300
- if (payload["direction"] === "outgoing") {
301
- const dialled = this.dialled.get(leg) ?? dialState(payload["client_state"]);
302
- if (dialled)
303
- await this.offer(leg, dialled);
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);
304
334
  return;
305
335
  }
336
+ if (dialled) {
337
+ await this.offer(leg, dialled);
338
+ return;
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,38 +409,59 @@ 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 number = normalNumber(to);
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 = { code, ...(opts.prompt ? { prompt: opts.prompt.slice(0, 300) } : {}), ...(opts.welcome ? { welcome: opts.welcome.slice(0, 200) } : {}) };
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 };
397
453
  }
398
- /** A dialled phone was picked up: press 1 to join, so voicemail never does. */
399
- async offer(leg, dialled, tries = 1) {
454
+ /**
455
+ * A dialled phone was picked up: press 1 to join, so voicemail never does.
456
+ * Telnyx itself replays the prompt (three tries) and ignores any other key,
457
+ * so an ended gather without a 1 is a no.
458
+ */
459
+ async offer(leg, dialled) {
400
460
  this.dialled.delete(leg);
401
461
  const prompt = dialled.prompt ?? "This is group call, calling you into a room.";
402
- await this.gather(leg, `${tries > 1 ? "" : `${prompt} `}Press 1 to join.`, { validDigits: "0123456789", maximumDigits: 1, timeoutMillis: 10000 }, async (digits) => {
462
+ await this.gather(leg, `${prompt} Press 1 to join.`, { validDigits: "1", maximumDigits: 1, timeoutMillis: 6000 }, async (digits) => {
403
463
  if (digits === "1")
404
464
  return this.join(leg, dialled.code, dialled.welcome);
405
- if (tries < 2)
406
- return this.offer(leg, dialled, tries + 1);
407
465
  await this.command(leg, "speak", { payload: "Okay, not joining. Goodbye.", ...this.voiceBody(leg) });
408
466
  await this.command(leg, "hangup", {});
409
467
  });
@@ -492,7 +550,7 @@ export class GroupCall {
492
550
  * Put a leg into a room, making the conference if it is the first one there.
493
551
  *
494
552
  * `welcome` is what the caller hears once they are in, before the count of
495
- * who else is.
553
+ * who else is; null joins them without a word.
496
554
  */
497
555
  async join(leg, code, welcome = `Welcome to room ${spokenCode(code)}. `) {
498
556
  const room = this.room(code);
@@ -586,7 +644,7 @@ export class GroupCall {
586
644
  if (room.legs.has(leg))
587
645
  return;
588
646
  const from = this.legFrom.get(leg) ?? "";
589
- 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() };
590
648
  room.legs.set(leg, who);
591
649
  room.callers = room.legs.size;
592
650
  this.options.onEvent?.(` ${who.name || "a caller"} joined a room (${room.callers} on the line).`);
@@ -626,7 +684,7 @@ export class GroupCall {
626
684
  * the room do not hear every arrival welcomed.
627
685
  */
628
686
  async greet(room, leg, welcome) {
629
- if (room.conferenceId === null)
687
+ if (room.conferenceId === null || welcome === null)
630
688
  return;
631
689
  await this.request(`/conferences/${encodeURIComponent(room.conferenceId)}/actions/speak`, {
632
690
  payload: `${welcome}${peopleHere(room.callers)}`,
@@ -654,6 +712,53 @@ export class GroupCall {
654
712
  });
655
713
  return answer !== null;
656
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
+ }
657
762
  /** Hang up one participant in a room. */
658
763
  async kick(code, leg) {
659
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.4.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",