@profullstack/group-call 0.1.0 → 0.3.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
@@ -47,6 +47,14 @@ await line.handle(JSON.parse(rawBody).data);
47
47
  `kick(code, leg)`, `list()`, `hasCallers(code)` and `callersIn(code)`.
48
48
  - **App hooks.** `onCode` can join, welcome, reject, or take the call over: `call.gather(...)`
49
49
  asks another question (nixamp's "press 1 for a text").
50
+ - **Live transcripts.** With `transcribe: true`, each participant's own leg is transcribed, and
51
+ `onTranscript(code, who, text)` gets every finished line with its speaker, agents included.
52
+ Telnyx bills this per minute per leg, so it's off unless you ask for it.
53
+ - **Survives restarts.** Conference names carry a keyed hash of the room code. After a restart,
54
+ the first caller into a room joins its running conference, not a new one.
55
+ - **Deploy-proof rooms.** Save `participants(code)` plus each room's conference id from `onJoin`/`onLeave`,
56
+ and pass them to `restore()` at boot. Each conference is checked against Telnyx, and the legs still in
57
+ it come back with their names, so a restart neither splits a room nor forgets who is in it.
50
58
  - `telnyxSms`, `verifyTelnyx`, `newRoomCode`, `sameSecret` helpers.
51
59
 
52
60
  MIT
package/dist/index.d.ts CHANGED
@@ -121,6 +121,16 @@ export interface GroupCallOptions {
121
121
  onJoin?: (code: string, who: Participant, room: RoomInfo) => void;
122
122
  /** A person or agent left a room. */
123
123
  onLeave?: (code: string, who: Participant, room: RoomInfo) => void;
124
+ /**
125
+ * Transcribe what each participant says, on their own leg, so every line has a
126
+ * speaker. Telnyx bills it per minute per leg; off unless asked for.
127
+ */
128
+ transcribe?: boolean | {
129
+ engine?: string;
130
+ language?: string;
131
+ };
132
+ /** A finished line somebody said in a room (needs `transcribe`). */
133
+ onTranscript?: (code: string, who: Participant, text: string) => void;
124
134
  /** Injected for tests. */
125
135
  now?: () => number;
126
136
  fetch?: typeof globalThis.fetch;
@@ -188,6 +198,21 @@ export declare class GroupCall {
188
198
  callers: number;
189
199
  startedAt: number;
190
200
  }[];
201
+ /**
202
+ * Put back rooms a previous process knew about: a deploy or a crash must not
203
+ * split a room. Each saved conference is checked against Telnyx, and only the
204
+ * legs still in it come back, with their names. A leg Telnyx has that the save
205
+ * missed comes back unnamed. Returns how many rooms and legs were restored.
206
+ */
207
+ restore(saved: {
208
+ code: string;
209
+ conferenceId: string;
210
+ startedAt: number;
211
+ participants: Participant[];
212
+ }[]): Promise<{
213
+ rooms: number;
214
+ legs: number;
215
+ }>;
191
216
  /** Who is on the line in a room, earliest first. */
192
217
  participants(code: string): Participant[];
193
218
  /**
@@ -228,6 +253,17 @@ export declare class GroupCall {
228
253
  * who else is.
229
254
  */
230
255
  join(leg: string, code: string, welcome?: string): Promise<void>;
256
+ /**
257
+ * A conference's name: findable again after a restart, but not the code itself,
258
+ * which would be a room's whole key sitting in somebody else's dashboard. A
259
+ * hash keyed with the API key, so it cannot be walked back to the code.
260
+ */
261
+ private conferenceName;
262
+ /**
263
+ * Find this room's conference on Telnyx if it is still running, and the legs
264
+ * already in it. True when one was found.
265
+ */
266
+ private recover;
231
267
  private enter;
232
268
  /** The name an agent registered for this number and room, used once. */
233
269
  private claimName;
@@ -264,6 +300,8 @@ export declare class GroupCall {
264
300
  private get maxParticipants();
265
301
  /** One call-control command. True when Telnyx accepted it. */
266
302
  protected command(leg: string, action: string, body: unknown): Promise<boolean>;
303
+ /** A GET from Telnyx, or null if it did not work. */
304
+ protected get(path: string): Promise<unknown | null>;
267
305
  /** A POST to Telnyx, or null if it did not work. */
268
306
  protected request(path: string, body: unknown): Promise<unknown | null>;
269
307
  }
package/dist/index.js CHANGED
@@ -25,7 +25,7 @@
25
25
  * conversation with nothing to tell them they went wrong. An agent keys it
26
26
  * too, with DTMF, so a person and an agent arrive the same way.
27
27
  */
28
- import { createPublicKey, verify as verifySignature, timingSafeEqual } from "node:crypto";
28
+ import { createHmac, createPublicKey, verify as verifySignature, timingSafeEqual } from "node:crypto";
29
29
  /** Where Telnyx's REST API lives. */
30
30
  export const TELNYX_API = "https://api.telnyx.com/v2";
31
31
  /**
@@ -199,6 +199,42 @@ export class GroupCall {
199
199
  .map(({ code, callers, startedAt }) => ({ code, callers, startedAt }))
200
200
  .sort((a, b) => b.callers - a.callers || a.startedAt - b.startedAt);
201
201
  }
202
+ /**
203
+ * Put back rooms a previous process knew about: a deploy or a crash must not
204
+ * split a room. Each saved conference is checked against Telnyx, and only the
205
+ * legs still in it come back, with their names. A leg Telnyx has that the save
206
+ * missed comes back unnamed. Returns how many rooms and legs were restored.
207
+ */
208
+ async restore(saved) {
209
+ let rooms = 0;
210
+ let legs = 0;
211
+ for (const r of saved) {
212
+ const conf = (await this.get(`/conferences/${encodeURIComponent(r.conferenceId)}`));
213
+ if (!conf?.data || (conf.data.status && conf.data.status !== "in_progress"))
214
+ continue;
215
+ const list = (await this.get(`/conferences/${encodeURIComponent(r.conferenceId)}/participants?page[size]=100`));
216
+ const present = new Set((list?.data ?? []).filter((p) => p.status !== "left" && typeof p.call_control_id === "string").map((p) => p.call_control_id));
217
+ if (present.size === 0)
218
+ continue;
219
+ const room = this.room(r.code);
220
+ room.conferenceId = r.conferenceId;
221
+ room.startedAt = r.startedAt;
222
+ const known = new Map(r.participants.map((p) => [p.leg, p]));
223
+ for (const leg of present) {
224
+ const who = known.get(leg) ?? { leg, from: "", name: "", joinedAt: r.startedAt };
225
+ room.legs.set(leg, who);
226
+ this.legRoom.set(leg, r.code);
227
+ if (who.from)
228
+ this.legFrom.set(leg, who.from);
229
+ legs += 1;
230
+ }
231
+ room.callers = room.legs.size;
232
+ rooms += 1;
233
+ }
234
+ if (rooms)
235
+ this.options.onEvent?.(` restored ${rooms} room(s) with ${legs} on the line.`);
236
+ return { rooms, legs };
237
+ }
202
238
  /** Who is on the line in a room, earliest first. */
203
239
  participants(code) {
204
240
  const room = this.rooms.get(code);
@@ -279,6 +315,17 @@ export class GroupCall {
279
315
  await this.routeCode(leg, code);
280
316
  return;
281
317
  }
318
+ if (type === "call.transcription") {
319
+ const data = (payload["transcription_data"] ?? {});
320
+ const text = typeof data["transcript"] === "string" ? data["transcript"].trim() : "";
321
+ if (!text || data["is_final"] === false)
322
+ return;
323
+ const code = this.legRoom.get(leg);
324
+ const who = code !== undefined ? this.rooms.get(code)?.legs.get(leg) : undefined;
325
+ if (code !== undefined && who)
326
+ this.options.onTranscript?.(code, who, text);
327
+ return;
328
+ }
282
329
  if (type === "conference.participant.left" || type === "call.hangup") {
283
330
  this.release(leg);
284
331
  this.legFrom.delete(leg);
@@ -396,8 +443,19 @@ export class GroupCall {
396
443
  // ended it, or Telnyx did. Fall through and make a new one.
397
444
  room.conferenceId = null;
398
445
  }
446
+ // After a restart our map is empty, but the room's conference may still be
447
+ // running with people in it: find it by name and join that, not a new one.
448
+ if (room.conferenceId === null && (await this.recover(room))) {
449
+ const joined = await this.request(`/conferences/${encodeURIComponent(room.conferenceId)}/actions/join`, { call_control_id: leg, start_conference_on_enter: true });
450
+ if (joined !== null) {
451
+ this.enter(room, leg);
452
+ await this.greet(room, leg, welcome);
453
+ return;
454
+ }
455
+ room.conferenceId = null;
456
+ }
399
457
  const created = await this.request("/conferences", {
400
- name: `${this.options.conferencePrefix ?? "groupcall"}-${this.now()}-${room.legs.size}`,
458
+ name: `${this.conferenceName(code)}${this.now()}`,
401
459
  call_control_id: leg,
402
460
  start_conference_on_create: true,
403
461
  max_participants: this.maxParticipants,
@@ -416,6 +474,41 @@ export class GroupCall {
416
474
  this.enter(room, leg);
417
475
  await this.greet(room, leg, welcome);
418
476
  }
477
+ /**
478
+ * A conference's name: findable again after a restart, but not the code itself,
479
+ * which would be a room's whole key sitting in somebody else's dashboard. A
480
+ * hash keyed with the API key, so it cannot be walked back to the code.
481
+ */
482
+ conferenceName(code) {
483
+ const tag = createHmac("sha256", this.options.apiKey).update(`room:${code}`).digest("hex").slice(0, 12);
484
+ return `${this.options.conferencePrefix ?? "groupcall"}-${tag}-`;
485
+ }
486
+ /**
487
+ * Find this room's conference on Telnyx if it is still running, and the legs
488
+ * already in it. True when one was found.
489
+ */
490
+ async recover(room) {
491
+ const list = await this.get(`/conferences?filter[status]=in_progress&page[size]=250`);
492
+ const found = (list?.data ?? [])
493
+ .filter((c) => typeof c.id === "string" && String(c.name ?? "").startsWith(this.conferenceName(room.code)))
494
+ .sort((a, b) => String(b.created_at ?? "").localeCompare(String(a.created_at ?? "")))[0];
495
+ if (!found?.id)
496
+ return false;
497
+ room.conferenceId = found.id;
498
+ const created = Date.parse(found.created_at ?? "");
499
+ room.startedAt = Number.isFinite(created) ? created : this.now();
500
+ const people = await this.get(`/conferences/${encodeURIComponent(found.id)}/participants?page[size]=100`);
501
+ for (const p of people?.data ?? []) {
502
+ const id = p.call_control_id;
503
+ if (typeof id !== "string" || p.status === "left" || room.legs.has(id))
504
+ continue;
505
+ room.legs.set(id, { leg: id, from: "", name: "", joinedAt: room.startedAt });
506
+ this.legRoom.set(id, room.code);
507
+ }
508
+ room.callers = room.legs.size;
509
+ this.options.onEvent?.(` found room ${room.code}'s conference still running (${room.callers} on the line).`);
510
+ return true;
511
+ }
419
512
  enter(room, leg) {
420
513
  if (room.legs.has(leg))
421
514
  return;
@@ -425,6 +518,16 @@ export class GroupCall {
425
518
  room.callers = room.legs.size;
426
519
  this.options.onEvent?.(` ${who.name || "a caller"} joined a room (${room.callers} on the line).`);
427
520
  this.options.onJoin?.(room.code, who, this.info(room));
521
+ const t = this.options.transcribe;
522
+ if (t) {
523
+ const opts = typeof t === "object" ? t : {};
524
+ // Only what this participant says (their side of the leg), so each line has one speaker.
525
+ void this.command(leg, "transcription_start", {
526
+ language: opts.language ?? "en",
527
+ transcription_engine: opts.engine ?? "Deepgram",
528
+ transcription_tracks: "inbound",
529
+ });
530
+ }
428
531
  }
429
532
  /** The name an agent registered for this number and room, used once. */
430
533
  claimName(code, from) {
@@ -546,6 +649,22 @@ export class GroupCall {
546
649
  const path = `/calls/${encodeURIComponent(leg)}/actions/${action}`;
547
650
  return (await this.request(path, body)) !== null;
548
651
  }
652
+ /** A GET from Telnyx, or null if it did not work. */
653
+ async get(path) {
654
+ try {
655
+ const response = await this.fetch(`${TELNYX_API}${path}`, { headers: { authorization: `Bearer ${this.options.apiKey}` } });
656
+ if (!response.ok) {
657
+ this.options.onEvent?.(` telnyx GET ${path} -> ${response.status}`);
658
+ return null;
659
+ }
660
+ const text = await response.text();
661
+ return text ? JSON.parse(text) : {};
662
+ }
663
+ catch (error) {
664
+ this.options.onEvent?.(` telnyx GET ${path} failed: ${error.message}`);
665
+ return null;
666
+ }
667
+ }
549
668
  /** A POST to Telnyx, or null if it did not work. */
550
669
  async request(path, body) {
551
670
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@profullstack/group-call",
3
- "version": "0.1.0",
3
+ "version": "0.3.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",