@profullstack/group-call 0.3.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(...)`
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,30 @@ 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
+ /** A dialled phone was picked up: press 1 to join, so voicemail never does. */
260
+ private offer;
235
261
  /** Hand a well-formed code to the app, and act on what it says the code is. */
236
262
  private routeCode;
237
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. */
@@ -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
- 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);
280
304
  return;
305
+ }
281
306
  await this.ask(leg);
282
307
  return;
283
308
  }
@@ -329,12 +354,60 @@ 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
+ /** 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
+ }
338
411
  /** Hand a well-formed code to the app, and act on what it says the code is. */
339
412
  async routeCode(leg, code) {
340
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.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",