@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 +5 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +85 -2
- package/package.json +1 -1
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.
|
|
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.
|
|
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",
|