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