@profullstack/group-call 0.1.0 → 0.2.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,11 @@ 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.
50
55
  - `telnyxSms`, `verifyTelnyx`, `newRoomCode`, `sameSecret` helpers.
51
56
 
52
57
  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;
@@ -228,6 +238,17 @@ export declare class GroupCall {
228
238
  * who else is.
229
239
  */
230
240
  join(leg: string, code: string, welcome?: string): Promise<void>;
241
+ /**
242
+ * A conference's name: findable again after a restart, but not the code itself,
243
+ * which would be a room's whole key sitting in somebody else's dashboard. A
244
+ * hash keyed with the API key, so it cannot be walked back to the code.
245
+ */
246
+ private conferenceName;
247
+ /**
248
+ * Find this room's conference on Telnyx if it is still running, and the legs
249
+ * already in it. True when one was found.
250
+ */
251
+ private recover;
231
252
  private enter;
232
253
  /** The name an agent registered for this number and room, used once. */
233
254
  private claimName;
@@ -264,6 +285,8 @@ export declare class GroupCall {
264
285
  private get maxParticipants();
265
286
  /** One call-control command. True when Telnyx accepted it. */
266
287
  protected command(leg: string, action: string, body: unknown): Promise<boolean>;
288
+ /** A GET from Telnyx, or null if it did not work. */
289
+ protected get(path: string): Promise<unknown | null>;
267
290
  /** A POST to Telnyx, or null if it did not work. */
268
291
  protected request(path: string, body: unknown): Promise<unknown | null>;
269
292
  }
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
  /**
@@ -279,6 +279,17 @@ export class GroupCall {
279
279
  await this.routeCode(leg, code);
280
280
  return;
281
281
  }
282
+ if (type === "call.transcription") {
283
+ const data = (payload["transcription_data"] ?? {});
284
+ const text = typeof data["transcript"] === "string" ? data["transcript"].trim() : "";
285
+ if (!text || data["is_final"] === false)
286
+ return;
287
+ const code = this.legRoom.get(leg);
288
+ const who = code !== undefined ? this.rooms.get(code)?.legs.get(leg) : undefined;
289
+ if (code !== undefined && who)
290
+ this.options.onTranscript?.(code, who, text);
291
+ return;
292
+ }
282
293
  if (type === "conference.participant.left" || type === "call.hangup") {
283
294
  this.release(leg);
284
295
  this.legFrom.delete(leg);
@@ -396,8 +407,19 @@ export class GroupCall {
396
407
  // ended it, or Telnyx did. Fall through and make a new one.
397
408
  room.conferenceId = null;
398
409
  }
410
+ // After a restart our map is empty, but the room's conference may still be
411
+ // running with people in it: find it by name and join that, not a new one.
412
+ if (room.conferenceId === null && (await this.recover(room))) {
413
+ const joined = await this.request(`/conferences/${encodeURIComponent(room.conferenceId)}/actions/join`, { call_control_id: leg, start_conference_on_enter: true });
414
+ if (joined !== null) {
415
+ this.enter(room, leg);
416
+ await this.greet(room, leg, welcome);
417
+ return;
418
+ }
419
+ room.conferenceId = null;
420
+ }
399
421
  const created = await this.request("/conferences", {
400
- name: `${this.options.conferencePrefix ?? "groupcall"}-${this.now()}-${room.legs.size}`,
422
+ name: `${this.conferenceName(code)}${this.now()}`,
401
423
  call_control_id: leg,
402
424
  start_conference_on_create: true,
403
425
  max_participants: this.maxParticipants,
@@ -416,6 +438,41 @@ export class GroupCall {
416
438
  this.enter(room, leg);
417
439
  await this.greet(room, leg, welcome);
418
440
  }
441
+ /**
442
+ * A conference's name: findable again after a restart, but not the code itself,
443
+ * which would be a room's whole key sitting in somebody else's dashboard. A
444
+ * hash keyed with the API key, so it cannot be walked back to the code.
445
+ */
446
+ conferenceName(code) {
447
+ const tag = createHmac("sha256", this.options.apiKey).update(`room:${code}`).digest("hex").slice(0, 12);
448
+ return `${this.options.conferencePrefix ?? "groupcall"}-${tag}-`;
449
+ }
450
+ /**
451
+ * Find this room's conference on Telnyx if it is still running, and the legs
452
+ * already in it. True when one was found.
453
+ */
454
+ async recover(room) {
455
+ const list = await this.get(`/conferences?filter[status]=in_progress&page[size]=250`);
456
+ const found = (list?.data ?? [])
457
+ .filter((c) => typeof c.id === "string" && String(c.name ?? "").startsWith(this.conferenceName(room.code)))
458
+ .sort((a, b) => String(b.created_at ?? "").localeCompare(String(a.created_at ?? "")))[0];
459
+ if (!found?.id)
460
+ return false;
461
+ room.conferenceId = found.id;
462
+ const created = Date.parse(found.created_at ?? "");
463
+ room.startedAt = Number.isFinite(created) ? created : this.now();
464
+ const people = await this.get(`/conferences/${encodeURIComponent(found.id)}/participants?page[size]=100`);
465
+ for (const p of people?.data ?? []) {
466
+ const id = p.call_control_id;
467
+ if (typeof id !== "string" || p.status === "left" || room.legs.has(id))
468
+ continue;
469
+ room.legs.set(id, { leg: id, from: "", name: "", joinedAt: room.startedAt });
470
+ this.legRoom.set(id, room.code);
471
+ }
472
+ room.callers = room.legs.size;
473
+ this.options.onEvent?.(` found room ${room.code}'s conference still running (${room.callers} on the line).`);
474
+ return true;
475
+ }
419
476
  enter(room, leg) {
420
477
  if (room.legs.has(leg))
421
478
  return;
@@ -425,6 +482,16 @@ export class GroupCall {
425
482
  room.callers = room.legs.size;
426
483
  this.options.onEvent?.(` ${who.name || "a caller"} joined a room (${room.callers} on the line).`);
427
484
  this.options.onJoin?.(room.code, who, this.info(room));
485
+ const t = this.options.transcribe;
486
+ if (t) {
487
+ const opts = typeof t === "object" ? t : {};
488
+ // Only what this participant says (their side of the leg), so each line has one speaker.
489
+ void this.command(leg, "transcription_start", {
490
+ language: opts.language ?? "en",
491
+ transcription_engine: opts.engine ?? "Deepgram",
492
+ transcription_tracks: "inbound",
493
+ });
494
+ }
428
495
  }
429
496
  /** The name an agent registered for this number and room, used once. */
430
497
  claimName(code, from) {
@@ -546,6 +613,22 @@ export class GroupCall {
546
613
  const path = `/calls/${encodeURIComponent(leg)}/actions/${action}`;
547
614
  return (await this.request(path, body)) !== null;
548
615
  }
616
+ /** A GET from Telnyx, or null if it did not work. */
617
+ async get(path) {
618
+ try {
619
+ const response = await this.fetch(`${TELNYX_API}${path}`, { headers: { authorization: `Bearer ${this.options.apiKey}` } });
620
+ if (!response.ok) {
621
+ this.options.onEvent?.(` telnyx GET ${path} -> ${response.status}`);
622
+ return null;
623
+ }
624
+ const text = await response.text();
625
+ return text ? JSON.parse(text) : {};
626
+ }
627
+ catch (error) {
628
+ this.options.onEvent?.(` telnyx GET ${path} failed: ${error.message}`);
629
+ return null;
630
+ }
631
+ }
549
632
  /** A POST to Telnyx, or null if it did not work. */
550
633
  async request(path, body) {
551
634
  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.2.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",