@profullstack/group-call 0.3.0 → 0.4.1
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 +3 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.js +77 -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(...)`
|
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. */
|
|
@@ -232,6 +234,34 @@ export declare class GroupCall {
|
|
|
232
234
|
* this cares about five.
|
|
233
235
|
*/
|
|
234
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
|
+
/**
|
|
260
|
+
* A dialled phone was picked up: press 1 to join, so voicemail never does.
|
|
261
|
+
* Telnyx itself replays the prompt (three tries) and ignores any other key,
|
|
262
|
+
* so an ended gather without a 1 is a no.
|
|
263
|
+
*/
|
|
264
|
+
private offer;
|
|
235
265
|
/** Hand a well-formed code to the app, and act on what it says the code is. */
|
|
236
266
|
private routeCode;
|
|
237
267
|
/** 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. */
|
|
@@ -275,9 +295,14 @@ export class GroupCall {
|
|
|
275
295
|
return;
|
|
276
296
|
}
|
|
277
297
|
if (type === "call.answered") {
|
|
278
|
-
// An answered outbound leg is somebody else's business
|
|
279
|
-
|
|
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);
|
|
280
304
|
return;
|
|
305
|
+
}
|
|
281
306
|
await this.ask(leg);
|
|
282
307
|
return;
|
|
283
308
|
}
|
|
@@ -329,12 +354,62 @@ export class GroupCall {
|
|
|
329
354
|
if (type === "conference.participant.left" || type === "call.hangup") {
|
|
330
355
|
this.release(leg);
|
|
331
356
|
this.legFrom.delete(leg);
|
|
357
|
+
this.dialled.delete(leg);
|
|
332
358
|
this.pendingGather.delete(leg);
|
|
333
359
|
this.asks.delete(leg);
|
|
334
360
|
this.plainVoice.delete(leg);
|
|
335
361
|
return;
|
|
336
362
|
}
|
|
337
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
|
+
/**
|
|
399
|
+
* A dialled phone was picked up: press 1 to join, so voicemail never does.
|
|
400
|
+
* Telnyx itself replays the prompt (three tries) and ignores any other key,
|
|
401
|
+
* so an ended gather without a 1 is a no.
|
|
402
|
+
*/
|
|
403
|
+
async offer(leg, dialled) {
|
|
404
|
+
this.dialled.delete(leg);
|
|
405
|
+
const prompt = dialled.prompt ?? "This is group call, calling you into a room.";
|
|
406
|
+
await this.gather(leg, `${prompt} Press 1 to join.`, { validDigits: "1", maximumDigits: 1, timeoutMillis: 6000 }, async (digits) => {
|
|
407
|
+
if (digits === "1")
|
|
408
|
+
return this.join(leg, dialled.code, dialled.welcome);
|
|
409
|
+
await this.command(leg, "speak", { payload: "Okay, not joining. Goodbye.", ...this.voiceBody(leg) });
|
|
410
|
+
await this.command(leg, "hangup", {});
|
|
411
|
+
});
|
|
412
|
+
}
|
|
338
413
|
/** Hand a well-formed code to the app, and act on what it says the code is. */
|
|
339
414
|
async routeCode(leg, code) {
|
|
340
415
|
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.1",
|
|
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",
|