@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 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
- if (payload["direction"] === "outgoing")
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.2.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",