@profullstack/group-call 0.2.0 → 0.4.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 +6 -0
- package/dist/index.d.ts +41 -0
- package/dist/index.js +111 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -43,6 +43,9 @@ await line.handle(JSON.parse(rawBody).data);
|
|
|
43
43
|
- **ElevenLabs voices** with `voiceSettings: { api_key_ref }`.
|
|
44
44
|
- **Agents with names.** `line.expect(code, callerId, "riotcoder")` before an agent dials
|
|
45
45
|
in, and the room knows who it is.
|
|
46
|
+
- **Call people in.** `dial(code, phone, { from, connectionId, name?, prompt? })` rings a phone
|
|
47
|
+
from your number. Whoever answers presses 1 and is in the room, so voicemail never joins.
|
|
48
|
+
The room rides in the call's `client_state`, so an answer after a restart still lands right.
|
|
46
49
|
- **Room API.** `say(code, text, voice?)` speaks into a room. Also `participants(code)`,
|
|
47
50
|
`kick(code, leg)`, `list()`, `hasCallers(code)` and `callersIn(code)`.
|
|
48
51
|
- **App hooks.** `onCode` can join, welcome, reject, or take the call over: `call.gather(...)`
|
|
@@ -52,6 +55,9 @@ await line.handle(JSON.parse(rawBody).data);
|
|
|
52
55
|
Telnyx bills this per minute per leg, so it's off unless you ask for it.
|
|
53
56
|
- **Survives restarts.** Conference names carry a keyed hash of the room code. After a restart,
|
|
54
57
|
the first caller into a room joins its running conference, not a new one.
|
|
58
|
+
- **Deploy-proof rooms.** Save `participants(code)` plus each room's conference id from `onJoin`/`onLeave`,
|
|
59
|
+
and pass them to `restore()` at boot. Each conference is checked against Telnyx, and the legs still in
|
|
60
|
+
it come back with their names, so a restart neither splits a room nor forgets who is in it.
|
|
55
61
|
- `telnyxSms`, `verifyTelnyx`, `newRoomCode`, `sameSecret` helpers.
|
|
56
62
|
|
|
57
63
|
MIT
|
package/dist/index.d.ts
CHANGED
|
@@ -176,6 +176,8 @@ export declare class GroupCall {
|
|
|
176
176
|
protected readonly legFrom: Map<string, string>;
|
|
177
177
|
/** Legs answering a question an `onCode` hook asked, and what to do with the answer. */
|
|
178
178
|
private readonly pendingGather;
|
|
179
|
+
/** Phones `dial` is ringing, and the room each is wanted in, until they pick up. */
|
|
180
|
+
private readonly dialled;
|
|
179
181
|
/** How many times each leg has been asked for a code, so it is not forever. */
|
|
180
182
|
private readonly asks;
|
|
181
183
|
/** Legs whose voice failed once, which hear the plain one from then on. */
|
|
@@ -198,6 +200,21 @@ export declare class GroupCall {
|
|
|
198
200
|
callers: number;
|
|
199
201
|
startedAt: number;
|
|
200
202
|
}[];
|
|
203
|
+
/**
|
|
204
|
+
* Put back rooms a previous process knew about: a deploy or a crash must not
|
|
205
|
+
* split a room. Each saved conference is checked against Telnyx, and only the
|
|
206
|
+
* legs still in it come back, with their names. A leg Telnyx has that the save
|
|
207
|
+
* missed comes back unnamed. Returns how many rooms and legs were restored.
|
|
208
|
+
*/
|
|
209
|
+
restore(saved: {
|
|
210
|
+
code: string;
|
|
211
|
+
conferenceId: string;
|
|
212
|
+
startedAt: number;
|
|
213
|
+
participants: Participant[];
|
|
214
|
+
}[]): Promise<{
|
|
215
|
+
rooms: number;
|
|
216
|
+
legs: number;
|
|
217
|
+
}>;
|
|
201
218
|
/** Who is on the line in a room, earliest first. */
|
|
202
219
|
participants(code: string): Participant[];
|
|
203
220
|
/**
|
|
@@ -217,6 +234,30 @@ export declare class GroupCall {
|
|
|
217
234
|
* this cares about five.
|
|
218
235
|
*/
|
|
219
236
|
handle(event: TelnyxEvent): Promise<void>;
|
|
237
|
+
/**
|
|
238
|
+
* Ring a phone and bring whoever answers into the room on `code`, so a person
|
|
239
|
+
* can be called in rather than having to dial in.
|
|
240
|
+
*
|
|
241
|
+
* The call goes out on `connectionId` (the call-control app whose webhooks
|
|
242
|
+
* reach this instance) from `from`, best the room's own dial-in number, so a
|
|
243
|
+
* missed call rings back into the line. Whoever picks up is asked to press 1
|
|
244
|
+
* before joining: a voicemail greeting or a wrong number never lands in the
|
|
245
|
+
* room. The room is carried in the call's client_state too, so an answer
|
|
246
|
+
* that arrives after a restart still knows where it was going. Returns the
|
|
247
|
+
* new leg, or null when Telnyx would not place the call.
|
|
248
|
+
*/
|
|
249
|
+
dial(code: string, to: string, opts: {
|
|
250
|
+
from: string;
|
|
251
|
+
connectionId: string;
|
|
252
|
+
name?: string;
|
|
253
|
+
prompt?: string;
|
|
254
|
+
welcome?: string;
|
|
255
|
+
timeoutSecs?: number;
|
|
256
|
+
}): Promise<{
|
|
257
|
+
leg: string;
|
|
258
|
+
} | null>;
|
|
259
|
+
/** A dialled phone was picked up: press 1 to join, so voicemail never does. */
|
|
260
|
+
private offer;
|
|
220
261
|
/** Hand a well-formed code to the app, and act on what it says the code is. */
|
|
221
262
|
private routeCode;
|
|
222
263
|
/** A caller, as a hook sees them. */
|
package/dist/index.js
CHANGED
|
@@ -61,6 +61,24 @@ const NUMBER_WORDS = ["zero", "one", "two", "three", "four", "five", "six", "sev
|
|
|
61
61
|
export function digitWords(length) {
|
|
62
62
|
return `${NUMBER_WORDS[length] ?? String(length)} digit`;
|
|
63
63
|
}
|
|
64
|
+
/** The room a dialled leg's client_state names, or null when the call is not one `dial` placed. */
|
|
65
|
+
function dialState(raw) {
|
|
66
|
+
if (typeof raw !== "string" || raw === "")
|
|
67
|
+
return null;
|
|
68
|
+
try {
|
|
69
|
+
const s = JSON.parse(Buffer.from(raw, "base64").toString("utf8"));
|
|
70
|
+
if (s["gc"] !== "dial" || typeof s["code"] !== "string" || !/^\d+$/.test(s["code"]))
|
|
71
|
+
return null;
|
|
72
|
+
return {
|
|
73
|
+
code: s["code"],
|
|
74
|
+
...(typeof s["prompt"] === "string" ? { prompt: s["prompt"] } : {}),
|
|
75
|
+
...(typeof s["welcome"] === "string" ? { welcome: s["welcome"] } : {}),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
64
82
|
/**
|
|
65
83
|
* A room code, from whatever the caller keyed.
|
|
66
84
|
*
|
|
@@ -165,6 +183,8 @@ export class GroupCall {
|
|
|
165
183
|
legFrom = new Map();
|
|
166
184
|
/** Legs answering a question an `onCode` hook asked, and what to do with the answer. */
|
|
167
185
|
pendingGather = new Map();
|
|
186
|
+
/** Phones `dial` is ringing, and the room each is wanted in, until they pick up. */
|
|
187
|
+
dialled = new Map();
|
|
168
188
|
/** How many times each leg has been asked for a code, so it is not forever. */
|
|
169
189
|
asks = new Map();
|
|
170
190
|
/** Legs whose voice failed once, which hear the plain one from then on. */
|
|
@@ -199,6 +219,42 @@ export class GroupCall {
|
|
|
199
219
|
.map(({ code, callers, startedAt }) => ({ code, callers, startedAt }))
|
|
200
220
|
.sort((a, b) => b.callers - a.callers || a.startedAt - b.startedAt);
|
|
201
221
|
}
|
|
222
|
+
/**
|
|
223
|
+
* Put back rooms a previous process knew about: a deploy or a crash must not
|
|
224
|
+
* split a room. Each saved conference is checked against Telnyx, and only the
|
|
225
|
+
* legs still in it come back, with their names. A leg Telnyx has that the save
|
|
226
|
+
* missed comes back unnamed. Returns how many rooms and legs were restored.
|
|
227
|
+
*/
|
|
228
|
+
async restore(saved) {
|
|
229
|
+
let rooms = 0;
|
|
230
|
+
let legs = 0;
|
|
231
|
+
for (const r of saved) {
|
|
232
|
+
const conf = (await this.get(`/conferences/${encodeURIComponent(r.conferenceId)}`));
|
|
233
|
+
if (!conf?.data || (conf.data.status && conf.data.status !== "in_progress"))
|
|
234
|
+
continue;
|
|
235
|
+
const list = (await this.get(`/conferences/${encodeURIComponent(r.conferenceId)}/participants?page[size]=100`));
|
|
236
|
+
const present = new Set((list?.data ?? []).filter((p) => p.status !== "left" && typeof p.call_control_id === "string").map((p) => p.call_control_id));
|
|
237
|
+
if (present.size === 0)
|
|
238
|
+
continue;
|
|
239
|
+
const room = this.room(r.code);
|
|
240
|
+
room.conferenceId = r.conferenceId;
|
|
241
|
+
room.startedAt = r.startedAt;
|
|
242
|
+
const known = new Map(r.participants.map((p) => [p.leg, p]));
|
|
243
|
+
for (const leg of present) {
|
|
244
|
+
const who = known.get(leg) ?? { leg, from: "", name: "", joinedAt: r.startedAt };
|
|
245
|
+
room.legs.set(leg, who);
|
|
246
|
+
this.legRoom.set(leg, r.code);
|
|
247
|
+
if (who.from)
|
|
248
|
+
this.legFrom.set(leg, who.from);
|
|
249
|
+
legs += 1;
|
|
250
|
+
}
|
|
251
|
+
room.callers = room.legs.size;
|
|
252
|
+
rooms += 1;
|
|
253
|
+
}
|
|
254
|
+
if (rooms)
|
|
255
|
+
this.options.onEvent?.(` restored ${rooms} room(s) with ${legs} on the line.`);
|
|
256
|
+
return { rooms, legs };
|
|
257
|
+
}
|
|
202
258
|
/** Who is on the line in a room, earliest first. */
|
|
203
259
|
participants(code) {
|
|
204
260
|
const room = this.rooms.get(code);
|
|
@@ -239,9 +295,14 @@ export class GroupCall {
|
|
|
239
295
|
return;
|
|
240
296
|
}
|
|
241
297
|
if (type === "call.answered") {
|
|
242
|
-
// An answered outbound leg is somebody else's business
|
|
243
|
-
|
|
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);
|
|
244
304
|
return;
|
|
305
|
+
}
|
|
245
306
|
await this.ask(leg);
|
|
246
307
|
return;
|
|
247
308
|
}
|
|
@@ -293,12 +354,60 @@ export class GroupCall {
|
|
|
293
354
|
if (type === "conference.participant.left" || type === "call.hangup") {
|
|
294
355
|
this.release(leg);
|
|
295
356
|
this.legFrom.delete(leg);
|
|
357
|
+
this.dialled.delete(leg);
|
|
296
358
|
this.pendingGather.delete(leg);
|
|
297
359
|
this.asks.delete(leg);
|
|
298
360
|
this.plainVoice.delete(leg);
|
|
299
361
|
return;
|
|
300
362
|
}
|
|
301
363
|
}
|
|
364
|
+
/**
|
|
365
|
+
* Ring a phone and bring whoever answers into the room on `code`, so a person
|
|
366
|
+
* can be called in rather than having to dial in.
|
|
367
|
+
*
|
|
368
|
+
* The call goes out on `connectionId` (the call-control app whose webhooks
|
|
369
|
+
* reach this instance) from `from`, best the room's own dial-in number, so a
|
|
370
|
+
* missed call rings back into the line. Whoever picks up is asked to press 1
|
|
371
|
+
* before joining: a voicemail greeting or a wrong number never lands in the
|
|
372
|
+
* room. The room is carried in the call's client_state too, so an answer
|
|
373
|
+
* that arrives after a restart still knows where it was going. Returns the
|
|
374
|
+
* new leg, or null when Telnyx would not place the call.
|
|
375
|
+
*/
|
|
376
|
+
async dial(code, to, opts) {
|
|
377
|
+
const number = normalNumber(to);
|
|
378
|
+
if (!number || !opts.connectionId || !opts.from)
|
|
379
|
+
return null;
|
|
380
|
+
const state = { code, ...(opts.prompt ? { prompt: opts.prompt.slice(0, 300) } : {}), ...(opts.welcome ? { welcome: opts.welcome.slice(0, 200) } : {}) };
|
|
381
|
+
const placed = (await this.request("/calls", {
|
|
382
|
+
connection_id: opts.connectionId,
|
|
383
|
+
to: number,
|
|
384
|
+
from: normalNumber(opts.from),
|
|
385
|
+
timeout_secs: opts.timeoutSecs ?? 30,
|
|
386
|
+
client_state: Buffer.from(JSON.stringify({ gc: "dial", ...state }), "utf8").toString("base64"),
|
|
387
|
+
}));
|
|
388
|
+
const leg = placed?.data?.call_control_id;
|
|
389
|
+
if (typeof leg !== "string")
|
|
390
|
+
return null;
|
|
391
|
+
this.dialled.set(leg, state);
|
|
392
|
+
this.legFrom.set(leg, number);
|
|
393
|
+
if (opts.name)
|
|
394
|
+
this.expect(code, number, opts.name, 5 * 60 * 1000);
|
|
395
|
+
this.options.onEvent?.(` ringing a phone into a room.`);
|
|
396
|
+
return { leg };
|
|
397
|
+
}
|
|
398
|
+
/** A dialled phone was picked up: press 1 to join, so voicemail never does. */
|
|
399
|
+
async offer(leg, dialled, tries = 1) {
|
|
400
|
+
this.dialled.delete(leg);
|
|
401
|
+
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) => {
|
|
403
|
+
if (digits === "1")
|
|
404
|
+
return this.join(leg, dialled.code, dialled.welcome);
|
|
405
|
+
if (tries < 2)
|
|
406
|
+
return this.offer(leg, dialled, tries + 1);
|
|
407
|
+
await this.command(leg, "speak", { payload: "Okay, not joining. Goodbye.", ...this.voiceBody(leg) });
|
|
408
|
+
await this.command(leg, "hangup", {});
|
|
409
|
+
});
|
|
410
|
+
}
|
|
302
411
|
/** Hand a well-formed code to the app, and act on what it says the code is. */
|
|
303
412
|
async routeCode(leg, code) {
|
|
304
413
|
const answer = this.options.onCode ? await this.options.onCode(code, this.callHandle(leg)) : undefined;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@profullstack/group-call",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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",
|