@profullstack/group-call 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Profullstack, Inc. (moshcoder)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,52 @@
1
- # Temporary Holding Version
1
+ # @profullstack/group-call
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A group call on a phone number. People dial in, key a room code, and talk with
4
+ everyone else who keyed the same code. AI agents phone in the same way, keying the
5
+ code with DTMF, so a room holds both.
6
+
7
+ It's the engine behind [nixamp](https://nixamp.com)'s live-join party line and
8
+ [group.call.pit.world](https://group.call.pit.world). Audio mixing is Telnyx's. This
9
+ package answers Telnyx Call Control webhooks and runs the state machine: answer, ask
10
+ for the code on the keypad, join or create the conference, welcome the caller, count
11
+ who is there.
12
+
13
+ ```sh
14
+ npm i @profullstack/group-call
15
+ ```
16
+
17
+ ```ts
18
+ import { GroupCall } from "@profullstack/group-call";
19
+
20
+ const line = new GroupCall({
21
+ apiKey: process.env.TELNYX_API_KEY!,
22
+ publicKey: process.env.TELNYX_PUBLIC_KEY!, // the account's ed25519 key, base64
23
+ codeLength: 4,
24
+ greeting: "Welcome to group call. Enter the four digit room code.",
25
+ // Only rooms you know about open. Leave this out and any code is a room.
26
+ onCode: (code) => (rooms.has(code) ? { welcome: `Welcome to ${rooms.get(code)}. ` } : { reject: "That is not a room." }),
27
+ onJoin: (code, who) => console.log(`${who.name || who.from} joined ${code}`),
28
+ onLeave: (code, who) => console.log(`${who.name || who.from} left ${code}`),
29
+ });
30
+
31
+ // Your webhook route. Check the signature against the raw body, then hand over the event.
32
+ if (!line.verify(rawBody, headers["telnyx-signature-ed25519"], headers["telnyx-timestamp"])) return 401;
33
+ await line.handle(JSON.parse(rawBody).data);
34
+ ```
35
+
36
+ ## What it does
37
+
38
+ - **Keypad codes only.** A misheard spoken digit is a different room that also exists.
39
+ It asks three times, says how many digits it got, then says goodbye.
40
+ - **Conferences that outlive Telnyx's 4-hour expiry.** A stale id is remade on the next join.
41
+ - **A fallback voice.** If the main voice fails once, that leg hears the plain voice from
42
+ then on, so a broken TTS voice can't loop silently.
43
+ - **ElevenLabs voices** with `voiceSettings: { api_key_ref }`.
44
+ - **Agents with names.** `line.expect(code, callerId, "riotcoder")` before an agent dials
45
+ in, and the room knows who it is.
46
+ - **Room API.** `say(code, text, voice?)` speaks into a room. Also `participants(code)`,
47
+ `kick(code, leg)`, `list()`, `hasCallers(code)` and `callersIn(code)`.
48
+ - **App hooks.** `onCode` can join, welcome, reject, or take the call over: `call.gather(...)`
49
+ asks another question (nixamp's "press 1 for a text").
50
+ - `telnyxSms`, `verifyTelnyx`, `newRoomCode`, `sameSecret` helpers.
51
+
52
+ MIT
@@ -0,0 +1,287 @@
1
+ /** Where Telnyx's REST API lives. */
2
+ export declare const TELNYX_API = "https://api.telnyx.com/v2";
3
+ /**
4
+ * Telnyx's own voice, so a room prompt costs nothing beyond the call. A
5
+ * Polly or ElevenLabs voice reads better and bills separately; the name is
6
+ * configuration rather than a constant for exactly that reason.
7
+ */
8
+ export declare const DEFAULT_VOICE = "Telnyx.KokoroTTS.af";
9
+ /**
10
+ * The voice a leg falls back to when the good one fails.
11
+ *
12
+ * Telnyx's Kokoro voice answered a prompt with a 500 once (2026-09-09), and
13
+ * what the caller got was a gather with no speech in it, ended at once with
14
+ * no digits, and asked again -- a line that "just repeats itself", silently.
15
+ * The plain Telnyx voice is older and worse and has not been seen to fail,
16
+ * which is the quality that matters on the second try.
17
+ */
18
+ export declare const FALLBACK_VOICE = "female";
19
+ /** "six digit", said rather than written. */
20
+ export declare function digitWords(length: number): string;
21
+ export interface RoomInfo {
22
+ /** The room code, which is the room's whole identity on the phone. */
23
+ code: string;
24
+ /** Telnyx's id for the conference, once a first caller has made one. */
25
+ conferenceId: string | null;
26
+ /** How many legs we have put in, less the ones we have seen leave. */
27
+ callers: number;
28
+ startedAt: number;
29
+ }
30
+ /** Somebody on the line in a room, person or agent. */
31
+ export interface Participant {
32
+ /** The call-control id of their leg. */
33
+ leg: string;
34
+ /** The number they called from, when the network told us. */
35
+ from: string;
36
+ /** A name, when one is known: an agent says who it is before it dials. */
37
+ name: string;
38
+ joinedAt: number;
39
+ }
40
+ interface Room extends RoomInfo {
41
+ /** Legs currently in the room, so a caller is never counted twice. */
42
+ legs: Map<string, Participant>;
43
+ }
44
+ /** Sending a text. Injected because the number that sends is not always the one called. */
45
+ export interface Sms {
46
+ send(to: string, text: string): Promise<boolean>;
47
+ }
48
+ export interface TelnyxEvent {
49
+ event_type?: string;
50
+ payload?: Record<string, unknown>;
51
+ }
52
+ /**
53
+ * One caller, as an `onCode` hook sees them: enough to put them in a room,
54
+ * tell them something, ask them something else, or let them go.
55
+ */
56
+ export interface CallHandle {
57
+ readonly leg: string;
58
+ /** The number they called from, or "" when withheld. */
59
+ readonly from: string;
60
+ /** Put them in the room on `code`. `welcome` is said once they are in. */
61
+ join(code: string, welcome?: string): Promise<void>;
62
+ /** Say something to them alone. */
63
+ speak(text: string): Promise<boolean>;
64
+ /** Ask a keypad question; `answer` gets the digits (or "" for none). */
65
+ gather(prompt: string, options: GatherOptions, answer: (digits: string) => Promise<void>): Promise<void>;
66
+ hangup(): Promise<boolean>;
67
+ /** Say `text`, then hang up. */
68
+ goodbye(text: string): Promise<void>;
69
+ }
70
+ export interface GatherOptions {
71
+ validDigits?: string;
72
+ minimumDigits?: number;
73
+ maximumDigits?: number;
74
+ timeoutMillis?: number;
75
+ terminatingDigit?: string;
76
+ }
77
+ /**
78
+ * What a code turned out to be.
79
+ *
80
+ * - `true` (or nothing): an ordinary room, joined with the default welcome.
81
+ * - `{ welcome }`: an ordinary room, with this said on the way in.
82
+ * - `{ reject }`: not a room; this is said, and the caller may try again.
83
+ * - `"handled"`: the hook already did something with the call itself.
84
+ */
85
+ export type CodeAnswer = void | true | "handled" | {
86
+ welcome: string;
87
+ } | {
88
+ reject: string;
89
+ };
90
+ export interface GroupCallOptions {
91
+ /** A Telnyx API key with call-control rights. */
92
+ apiKey: string;
93
+ /**
94
+ * The account's ed25519 public key, base64, from the portal. Without it
95
+ * every webhook is refused: an unauthenticated call-control webhook lets a
96
+ * stranger drive calls we are paying for.
97
+ */
98
+ publicKey: string;
99
+ /** How many digits a room code has. nixamp uses six; a private room can live with four. */
100
+ codeLength?: number;
101
+ /** What the caller hears first. Defaults to asking for the code. */
102
+ greeting?: string;
103
+ voice?: string;
104
+ /**
105
+ * Voice settings sent with every prompt in the main voice, for voices that
106
+ * need them -- an ElevenLabs voice needs `{ api_key_ref }` naming the key
107
+ * Telnyx holds.
108
+ */
109
+ voiceSettings?: Record<string, unknown>;
110
+ /** A ceiling per room, so one room cannot spend the whole balance. */
111
+ maxParticipants?: number;
112
+ /** Prefix of the conference names we create on Telnyx. */
113
+ conferencePrefix?: string;
114
+ /**
115
+ * What a keyed code means. Without it, any code opens a room -- the code
116
+ * is a rendezvous, not a credential. With it, an app can keep a list of
117
+ * rooms that exist, route some codes elsewhere, or refuse.
118
+ */
119
+ onCode?: (code: string, call: CallHandle) => Promise<CodeAnswer> | CodeAnswer;
120
+ /** A person or agent got into a room. */
121
+ onJoin?: (code: string, who: Participant, room: RoomInfo) => void;
122
+ /** A person or agent left a room. */
123
+ onLeave?: (code: string, who: Participant, room: RoomInfo) => void;
124
+ /** Injected for tests. */
125
+ now?: () => number;
126
+ fetch?: typeof globalThis.fetch;
127
+ onEvent?: (message: string) => void;
128
+ }
129
+ /**
130
+ * A room code, from whatever the caller keyed.
131
+ *
132
+ * Digits only, and exactly `length` of them. One short is not a near miss to
133
+ * be charitable about -- it is a different room, and guessing which one they
134
+ * meant would drop somebody into a stranger's conversation.
135
+ */
136
+ export declare function roomCodeFrom(entered: unknown, length?: number): string;
137
+ /** How a code is read back: one digit at a time, because 4829 is not a number. */
138
+ export declare function spokenCode(code: string): string;
139
+ /**
140
+ * How many people are in a room, said to the one who just walked in.
141
+ *
142
+ * The count includes them: "there are 3 people in here" is what you say to the
143
+ * third person, and "you are the first one here" is what you say to the first,
144
+ * who would otherwise be told there is one person in an empty room.
145
+ */
146
+ export declare function peopleHere(callers: number): string;
147
+ /**
148
+ * Whether a webhook really came from Telnyx.
149
+ *
150
+ * The body has to be the bytes that arrived. Parsing and reserialising JSON
151
+ * changes key order and whitespace, and the signature is over the original.
152
+ */
153
+ export declare function verifyTelnyx(publicKey: string, rawBody: string, signature: string | undefined, timestamp: string | undefined, now?: number): boolean;
154
+ /**
155
+ * The group call, as a thing that answers webhooks.
156
+ *
157
+ * It owns no socket and no timer. The server hands it a verified event and it
158
+ * issues whatever call-control commands that event calls for, which makes the
159
+ * whole state machine testable with a fetch that records what it was asked.
160
+ */
161
+ export declare class GroupCall {
162
+ protected readonly rooms: Map<string, Room>;
163
+ /** Which room a leg is heading for, between asking and being answered. */
164
+ private readonly legRoom;
165
+ /** The number each caller is calling from. */
166
+ protected readonly legFrom: Map<string, string>;
167
+ /** Legs answering a question an `onCode` hook asked, and what to do with the answer. */
168
+ private readonly pendingGather;
169
+ /** How many times each leg has been asked for a code, so it is not forever. */
170
+ private readonly asks;
171
+ /** Legs whose voice failed once, which hear the plain one from then on. */
172
+ private readonly plainVoice;
173
+ /** Names promised for callers about to arrive from a number: agents announce themselves. */
174
+ private readonly expected;
175
+ private readonly key;
176
+ protected readonly fetch: typeof globalThis.fetch;
177
+ protected readonly now: () => number;
178
+ protected readonly options: GroupCallOptions;
179
+ constructor(options: GroupCallOptions);
180
+ /** True when this instance can actually check a signature. */
181
+ get armed(): boolean;
182
+ get codeLength(): number;
183
+ /** Whether a webhook really came from Telnyx. See `verifyTelnyx`. */
184
+ verify(rawBody: string, signature: string | undefined, timestamp: string | undefined): boolean;
185
+ /** The rooms with someone in them, busiest first. */
186
+ list(): {
187
+ code: string;
188
+ callers: number;
189
+ startedAt: number;
190
+ }[];
191
+ /** Who is on the line in a room, earliest first. */
192
+ participants(code: string): Participant[];
193
+ /**
194
+ * Name the next caller from `from` into `code`, for the next two minutes.
195
+ *
196
+ * An agent's call arrives from a shared caller id and keys a code like
197
+ * anybody else; this is how the room learns it is "riotcoder" rather than a
198
+ * number. Matched on number and room, so it cannot rename a stranger in a
199
+ * different room.
200
+ */
201
+ expect(code: string, from: string, name: string, withinMs?: number): void;
202
+ /**
203
+ * Drive one call-control event.
204
+ *
205
+ * Every branch returns rather than falling through, because an event we do
206
+ * not handle is the normal case -- Telnyx sends a dozen kinds per call and
207
+ * this cares about five.
208
+ */
209
+ handle(event: TelnyxEvent): Promise<void>;
210
+ /** Hand a well-formed code to the app, and act on what it says the code is. */
211
+ private routeCode;
212
+ /** A caller, as a hook sees them. */
213
+ protected callHandle(leg: string): CallHandle;
214
+ /** Ask a keypad question whose answer goes to `answer`, not to the room-code path. */
215
+ private gather;
216
+ /**
217
+ * Ask for a room code, on the keypad.
218
+ *
219
+ * Short, because callers key the code over the prompt and a long one costs
220
+ * digits. A full-length code ends the gather on its own, so the caller does
221
+ * not have to press anything after; # is there for the ones who do it anyway.
222
+ */
223
+ private ask;
224
+ /**
225
+ * Put a leg into a room, making the conference if it is the first one there.
226
+ *
227
+ * `welcome` is what the caller hears once they are in, before the count of
228
+ * who else is.
229
+ */
230
+ join(leg: string, code: string, welcome?: string): Promise<void>;
231
+ private enter;
232
+ /** The name an agent registered for this number and room, used once. */
233
+ private claimName;
234
+ /**
235
+ * Say hello to somebody who just joined, and only to them.
236
+ *
237
+ * Spoken into the conference rather than at the leg, because the leg is in
238
+ * the conference now and a speak on it is what the join interrupts. Telnyx
239
+ * addresses conference speech to particular participants, so the others in
240
+ * the room do not hear every arrival welcomed.
241
+ */
242
+ private greet;
243
+ /**
244
+ * A line said into a room, to everybody on the phone in it. False when
245
+ * nobody is on the phone there, which is most of the time and costs nothing.
246
+ */
247
+ say(code: string, text: string, voice?: string, settings?: Record<string, unknown>): Promise<boolean>;
248
+ /** Hang up one participant in a room. */
249
+ kick(code: string, leg: string): Promise<boolean>;
250
+ /** Whether anybody is on the phone in a room. */
251
+ hasCallers(code: string): boolean;
252
+ /** How many people are on the phone in a room. */
253
+ callersIn(code: string): number;
254
+ /** A leg that hung up or was dropped, wherever it was. */
255
+ private release;
256
+ private info;
257
+ /** The room on this code, made if nobody is using it. */
258
+ private room;
259
+ protected get voice(): string;
260
+ /** The voice for this leg: the good one, unless it has already failed them. */
261
+ protected voiceFor(leg: string): string;
262
+ /** `voice`, plus its settings while the good voice is the one in use. */
263
+ protected voiceBody(leg: string): Record<string, unknown>;
264
+ private get maxParticipants();
265
+ /** One call-control command. True when Telnyx accepted it. */
266
+ protected command(leg: string, action: string, body: unknown): Promise<boolean>;
267
+ /** A POST to Telnyx, or null if it did not work. */
268
+ protected request(path: string, body: unknown): Promise<unknown | null>;
269
+ }
270
+ /** +1XXXXXXXXXX from whatever shape a North American number arrived in. */
271
+ export declare function normalNumber(input: string): string;
272
+ /** Texting, over Telnyx, from a number that has a messaging profile. */
273
+ export declare function telnyxSms({ apiKey, from, fetch, onEvent }: {
274
+ apiKey: string;
275
+ from: string;
276
+ fetch?: typeof globalThis.fetch;
277
+ onEvent?: (message: string) => void;
278
+ }): Sms;
279
+ /** Constant-time compare, for the places a token is checked rather than signed. */
280
+ export declare function sameSecret(a: string, b: string): boolean;
281
+ /**
282
+ * A fresh random room code of `length` digits that `taken` says is free.
283
+ * Codes easy to stumble on -- all one digit, a leading zero some people
284
+ * drop, a straight run like 1234 -- are kept for nobody.
285
+ */
286
+ export declare function newRoomCode(length?: number, taken?: (code: string) => boolean, random?: () => number): string;
287
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,631 @@
1
+ /**
2
+ * A group call on a phone number.
3
+ *
4
+ * Somebody dials the number, keys a room code, and is in a conference with
5
+ * everybody else who keyed the same code -- people on their phones and AI
6
+ * agents that phoned in, alike. This is nixamp's party line with the nixamp
7
+ * taken out: the state machine, the keypad, the voices and the conference
8
+ * handling are here; what a code *means* is the app's, through `onCode`.
9
+ *
10
+ * The audio mixing is Telnyx's. A conference is a name on their side too, so
11
+ * this module never touches a byte of audio: it answers a call, asks a
12
+ * question, and puts the leg into a conference. What we keep is the part
13
+ * Telnyx does not -- which keyed digits mean which conference, and who a room
14
+ * is holding.
15
+ *
16
+ * Two things about conferences are worth knowing before reading the state
17
+ * machine. They expire after four hours whether or not anyone is still on
18
+ * them, so a long-lived room's conference id goes stale underneath us and has
19
+ * to be remade on the next join. And the id is only knowable after the first
20
+ * caller creates it, so the first caller and the tenth take different paths
21
+ * through the same function.
22
+ *
23
+ * The code is asked for on the keypad, never by voice: one misheard digit is a
24
+ * different room that also exists, and the caller lands in a stranger's
25
+ * conversation with nothing to tell them they went wrong. An agent keys it
26
+ * too, with DTMF, so a person and an agent arrive the same way.
27
+ */
28
+ import { createPublicKey, verify as verifySignature, timingSafeEqual } from "node:crypto";
29
+ /** Where Telnyx's REST API lives. */
30
+ export const TELNYX_API = "https://api.telnyx.com/v2";
31
+ /**
32
+ * Telnyx's own voice, so a room prompt costs nothing beyond the call. A
33
+ * Polly or ElevenLabs voice reads better and bills separately; the name is
34
+ * configuration rather than a constant for exactly that reason.
35
+ */
36
+ export const DEFAULT_VOICE = "Telnyx.KokoroTTS.af";
37
+ /**
38
+ * The voice a leg falls back to when the good one fails.
39
+ *
40
+ * Telnyx's Kokoro voice answered a prompt with a 500 once (2026-09-09), and
41
+ * what the caller got was a gather with no speech in it, ended at once with
42
+ * no digits, and asked again -- a line that "just repeats itself", silently.
43
+ * The plain Telnyx voice is older and worse and has not been seen to fail,
44
+ * which is the quality that matters on the second try.
45
+ */
46
+ export const FALLBACK_VOICE = "female";
47
+ /**
48
+ * How long a caller has between digits before the code is treated as done.
49
+ * Telnyx's default of five seconds did not fire in practice; set explicitly
50
+ * so a partial code comes back quickly and we can say how many digits we got.
51
+ */
52
+ const INTER_DIGIT_MS = 4000;
53
+ /** How many times a caller is asked for a code before being let go. */
54
+ const MAX_ASKS = 3;
55
+ /** How long a signed webhook stays acceptable. Telnyx's own SDKs use five minutes. */
56
+ const SIGNATURE_TOLERANCE_MS = 5 * 60 * 1000;
57
+ /** A conference Telnyx will discard on its own, so we stop trusting ours first. */
58
+ const CONFERENCE_TTL_MS = 4 * 60 * 60 * 1000;
59
+ const NUMBER_WORDS = ["zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten", "eleven", "twelve"];
60
+ /** "six digit", said rather than written. */
61
+ export function digitWords(length) {
62
+ return `${NUMBER_WORDS[length] ?? String(length)} digit`;
63
+ }
64
+ /**
65
+ * A room code, from whatever the caller keyed.
66
+ *
67
+ * Digits only, and exactly `length` of them. One short is not a near miss to
68
+ * be charitable about -- it is a different room, and guessing which one they
69
+ * meant would drop somebody into a stranger's conversation.
70
+ */
71
+ export function roomCodeFrom(entered, length = 6) {
72
+ if (typeof entered !== "string" && typeof entered !== "number")
73
+ return "";
74
+ const digits = String(entered).replace(/\D/g, "");
75
+ return digits.length === length ? digits : "";
76
+ }
77
+ /** How a code is read back: one digit at a time, because 4829 is not a number. */
78
+ export function spokenCode(code) {
79
+ return code.split("").join(", ");
80
+ }
81
+ /**
82
+ * How many people are in a room, said to the one who just walked in.
83
+ *
84
+ * The count includes them: "there are 3 people in here" is what you say to the
85
+ * third person, and "you are the first one here" is what you say to the first,
86
+ * who would otherwise be told there is one person in an empty room.
87
+ */
88
+ export function peopleHere(callers) {
89
+ if (callers <= 1)
90
+ return "You are the first one here. Say hello when somebody joins.";
91
+ if (callers === 2)
92
+ return "There is one other person in here. Say hello.";
93
+ return `There are ${callers} people in here. Say hello.`;
94
+ }
95
+ /**
96
+ * Telnyx signs `${timestamp}|${body}` with ed25519 and sends both back in
97
+ * headers. Node will not take a bare 32-byte key, so it is wrapped in the
98
+ * fixed SPKI prefix that says "this is ed25519" and handed over as DER.
99
+ */
100
+ const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
101
+ function ed25519KeyFrom(base64Key) {
102
+ let raw;
103
+ try {
104
+ raw = Buffer.from(base64Key, "base64");
105
+ }
106
+ catch {
107
+ return null;
108
+ }
109
+ if (raw.length !== 32)
110
+ return null;
111
+ try {
112
+ return createPublicKey({ key: Buffer.concat([ED25519_SPKI_PREFIX, raw]), format: "der", type: "spki" });
113
+ }
114
+ catch {
115
+ return null;
116
+ }
117
+ }
118
+ /**
119
+ * Whether a webhook really came from Telnyx.
120
+ *
121
+ * The body has to be the bytes that arrived. Parsing and reserialising JSON
122
+ * changes key order and whitespace, and the signature is over the original.
123
+ */
124
+ export function verifyTelnyx(publicKey, rawBody, signature, timestamp, now = Date.now()) {
125
+ return verifyWith(publicKey ? ed25519KeyFrom(publicKey) : null, rawBody, signature, timestamp, now);
126
+ }
127
+ function verifyWith(key, rawBody, signature, timestamp, now) {
128
+ if (key === null || !signature || !timestamp)
129
+ return false;
130
+ const sent = Number(timestamp) * 1000;
131
+ if (!Number.isFinite(sent))
132
+ return false;
133
+ // Both directions: a replayed webhook is old, and a clock-skewed forgery
134
+ // from the future is no more trustworthy for being ahead.
135
+ if (Math.abs(now - sent) > SIGNATURE_TOLERANCE_MS)
136
+ return false;
137
+ let sig;
138
+ try {
139
+ sig = Buffer.from(signature, "base64");
140
+ }
141
+ catch {
142
+ return false;
143
+ }
144
+ if (sig.length !== 64)
145
+ return false;
146
+ try {
147
+ return verifySignature(null, Buffer.from(`${timestamp}|${rawBody}`, "utf8"), key, sig);
148
+ }
149
+ catch {
150
+ return false;
151
+ }
152
+ }
153
+ /**
154
+ * The group call, as a thing that answers webhooks.
155
+ *
156
+ * It owns no socket and no timer. The server hands it a verified event and it
157
+ * issues whatever call-control commands that event calls for, which makes the
158
+ * whole state machine testable with a fetch that records what it was asked.
159
+ */
160
+ export class GroupCall {
161
+ rooms = new Map();
162
+ /** Which room a leg is heading for, between asking and being answered. */
163
+ legRoom = new Map();
164
+ /** The number each caller is calling from. */
165
+ legFrom = new Map();
166
+ /** Legs answering a question an `onCode` hook asked, and what to do with the answer. */
167
+ pendingGather = new Map();
168
+ /** How many times each leg has been asked for a code, so it is not forever. */
169
+ asks = new Map();
170
+ /** Legs whose voice failed once, which hear the plain one from then on. */
171
+ plainVoice = new Set();
172
+ /** Names promised for callers about to arrive from a number: agents announce themselves. */
173
+ expected = [];
174
+ key;
175
+ fetch;
176
+ now;
177
+ options;
178
+ constructor(options) {
179
+ this.options = options;
180
+ this.key = options.publicKey ? ed25519KeyFrom(options.publicKey) : null;
181
+ this.fetch = options.fetch ?? globalThis.fetch;
182
+ this.now = options.now ?? Date.now;
183
+ }
184
+ /** True when this instance can actually check a signature. */
185
+ get armed() {
186
+ return this.key !== null && this.options.apiKey.length > 0;
187
+ }
188
+ get codeLength() {
189
+ return this.options.codeLength ?? 6;
190
+ }
191
+ /** Whether a webhook really came from Telnyx. See `verifyTelnyx`. */
192
+ verify(rawBody, signature, timestamp) {
193
+ return verifyWith(this.key, rawBody, signature, timestamp, this.now());
194
+ }
195
+ /** The rooms with someone in them, busiest first. */
196
+ list() {
197
+ return [...this.rooms.values()]
198
+ .filter((room) => room.callers > 0)
199
+ .map(({ code, callers, startedAt }) => ({ code, callers, startedAt }))
200
+ .sort((a, b) => b.callers - a.callers || a.startedAt - b.startedAt);
201
+ }
202
+ /** Who is on the line in a room, earliest first. */
203
+ participants(code) {
204
+ const room = this.rooms.get(code);
205
+ return room ? [...room.legs.values()].sort((a, b) => a.joinedAt - b.joinedAt) : [];
206
+ }
207
+ /**
208
+ * Name the next caller from `from` into `code`, for the next two minutes.
209
+ *
210
+ * An agent's call arrives from a shared caller id and keys a code like
211
+ * anybody else; this is how the room learns it is "riotcoder" rather than a
212
+ * number. Matched on number and room, so it cannot rename a stranger in a
213
+ * different room.
214
+ */
215
+ expect(code, from, name, withinMs = 2 * 60 * 1000) {
216
+ this.expected.push({ code, from: normalNumber(from), name: name.slice(0, 60), until: this.now() + withinMs });
217
+ }
218
+ /**
219
+ * Drive one call-control event.
220
+ *
221
+ * Every branch returns rather than falling through, because an event we do
222
+ * not handle is the normal case -- Telnyx sends a dozen kinds per call and
223
+ * this cares about five.
224
+ */
225
+ async handle(event) {
226
+ const type = event.event_type ?? "";
227
+ const payload = event.payload ?? {};
228
+ const leg = typeof payload["call_control_id"] === "string" ? payload["call_control_id"] : "";
229
+ if (!leg)
230
+ return;
231
+ if (type === "call.initiated") {
232
+ // Only inbound. An outbound leg we dialled is not somebody calling in,
233
+ // and answering it would be answering ourselves.
234
+ if (payload["direction"] !== "incoming")
235
+ return;
236
+ if (typeof payload["from"] === "string")
237
+ this.legFrom.set(leg, payload["from"]);
238
+ await this.command(leg, "answer", {});
239
+ return;
240
+ }
241
+ if (type === "call.answered") {
242
+ // An answered outbound leg is somebody else's business.
243
+ if (payload["direction"] === "outgoing")
244
+ return;
245
+ await this.ask(leg);
246
+ return;
247
+ }
248
+ if (type === "call.speak.failed") {
249
+ // The voice, not the caller, failed. Everything this leg hears from now
250
+ // on is in the plain voice; the gather this speech belonged to ends on
251
+ // its own and is asked again, audibly this time.
252
+ this.plainVoice.add(leg);
253
+ this.options.onEvent?.(" a prompt could not be spoken; using the plain voice.");
254
+ return;
255
+ }
256
+ if (type === "call.gather.ended") {
257
+ const digits = typeof payload["digits"] === "string" ? payload["digits"] : "";
258
+ // A gather that ended because the caller hung up is not an answer, and
259
+ // anything sent to that leg now is a 422 for the log.
260
+ if (payload["status"] === "call_hangup")
261
+ return;
262
+ // A leg answering something a hook asked is answering that, not keying a
263
+ // room code -- the same event carries both, so the question we asked is
264
+ // what decides how to read it.
265
+ const pending = this.pendingGather.get(leg);
266
+ if (pending !== undefined) {
267
+ this.pendingGather.delete(leg);
268
+ await pending(digits);
269
+ return;
270
+ }
271
+ const code = roomCodeFrom(digits, this.codeLength);
272
+ if (!code) {
273
+ // Re-ask rather than guess, and say what we got: five digits and
274
+ // silence is a caller who thinks the line is broken.
275
+ const got = digits.replace(/\D/g, "").length;
276
+ await this.ask(leg, got > 0 ? `I only got ${got} digits. ` : "I did not get a code. ");
277
+ return;
278
+ }
279
+ await this.routeCode(leg, code);
280
+ return;
281
+ }
282
+ if (type === "conference.participant.left" || type === "call.hangup") {
283
+ this.release(leg);
284
+ this.legFrom.delete(leg);
285
+ this.pendingGather.delete(leg);
286
+ this.asks.delete(leg);
287
+ this.plainVoice.delete(leg);
288
+ return;
289
+ }
290
+ }
291
+ /** Hand a well-formed code to the app, and act on what it says the code is. */
292
+ async routeCode(leg, code) {
293
+ const answer = this.options.onCode ? await this.options.onCode(code, this.callHandle(leg)) : undefined;
294
+ if (answer === "handled") {
295
+ this.asks.delete(leg);
296
+ return;
297
+ }
298
+ if (answer && typeof answer === "object" && "reject" in answer) {
299
+ // Not a room. Asked again, because a typo deserves a second go -- and
300
+ // the ask count still runs, so nobody can sit guessing codes forever.
301
+ await this.ask(leg, `${answer.reject} `);
302
+ return;
303
+ }
304
+ this.asks.delete(leg);
305
+ const welcome = answer && typeof answer === "object" && "welcome" in answer ? answer.welcome : undefined;
306
+ await this.join(leg, code, welcome);
307
+ }
308
+ /** A caller, as a hook sees them. */
309
+ callHandle(leg) {
310
+ return {
311
+ leg,
312
+ from: this.legFrom.get(leg) ?? "",
313
+ join: (code, welcome) => this.join(leg, code, welcome),
314
+ speak: (text) => this.command(leg, "speak", { payload: text, ...this.voiceBody(leg) }),
315
+ gather: (prompt, options, answer) => this.gather(leg, prompt, options, answer),
316
+ hangup: () => this.command(leg, "hangup", {}),
317
+ goodbye: async (text) => {
318
+ await this.command(leg, "speak", { payload: text, ...this.voiceBody(leg) });
319
+ await this.command(leg, "hangup", {});
320
+ },
321
+ };
322
+ }
323
+ /** Ask a keypad question whose answer goes to `answer`, not to the room-code path. */
324
+ async gather(leg, prompt, options, answer) {
325
+ // Set before the prompt, not after: the answer can arrive while we are
326
+ // still awaiting the command that asked for it.
327
+ this.pendingGather.set(leg, answer);
328
+ await this.command(leg, "gather_using_speak", {
329
+ payload: prompt,
330
+ ...this.voiceBody(leg),
331
+ valid_digits: options.validDigits ?? "0123456789",
332
+ minimum_digits: options.minimumDigits ?? 1,
333
+ maximum_digits: options.maximumDigits ?? 1,
334
+ ...(options.terminatingDigit ? { terminating_digit: options.terminatingDigit } : {}),
335
+ timeout_millis: options.timeoutMillis ?? 12000,
336
+ });
337
+ }
338
+ /**
339
+ * Ask for a room code, on the keypad.
340
+ *
341
+ * Short, because callers key the code over the prompt and a long one costs
342
+ * digits. A full-length code ends the gather on its own, so the caller does
343
+ * not have to press anything after; # is there for the ones who do it anyway.
344
+ */
345
+ async ask(leg, prefix = "") {
346
+ const asked = (this.asks.get(leg) ?? 0) + 1;
347
+ this.asks.set(leg, asked);
348
+ if (asked > MAX_ASKS) {
349
+ // Three gathers with no room in them is not a caller who needs a fourth
350
+ // prompt. Whatever is wrong -- their keypad, our voice -- repeating
351
+ // ourselves is the failure that was reported, so this ends instead.
352
+ await this.command(leg, "speak", { payload: "I did not get a room code. Goodbye.", ...this.voiceBody(leg) });
353
+ await this.command(leg, "hangup", {});
354
+ return;
355
+ }
356
+ const length = this.codeLength;
357
+ const askCode = `Enter the ${digitWords(length)} room code.`;
358
+ const greeting = this.options.greeting ?? `Welcome. ${askCode}`;
359
+ const payload = asked === 1 ? `${prefix}${greeting}` : `${prefix}${askCode}`;
360
+ await this.command(leg, "gather_using_speak", {
361
+ payload,
362
+ ...this.voiceBody(leg),
363
+ valid_digits: "0123456789",
364
+ minimum_digits: length,
365
+ maximum_digits: length,
366
+ terminating_digit: "#",
367
+ timeout_millis: 20000,
368
+ inter_digit_timeout_millis: INTER_DIGIT_MS,
369
+ });
370
+ }
371
+ /**
372
+ * Put a leg into a room, making the conference if it is the first one there.
373
+ *
374
+ * `welcome` is what the caller hears once they are in, before the count of
375
+ * who else is.
376
+ */
377
+ async join(leg, code, welcome = `Welcome to room ${spokenCode(code)}. `) {
378
+ const room = this.room(code);
379
+ if (room.callers >= this.maxParticipants) {
380
+ await this.command(leg, "speak", { payload: "That room is full. Goodbye.", ...this.voiceBody(leg) });
381
+ await this.command(leg, "hangup", {});
382
+ return;
383
+ }
384
+ this.legRoom.set(leg, code);
385
+ // A conference we made more than four hours ago is gone on Telnyx's side
386
+ // whatever our map says, so it is remade rather than joined.
387
+ const stale = this.now() - room.startedAt > CONFERENCE_TTL_MS;
388
+ if (room.conferenceId !== null && !stale) {
389
+ const joined = await this.request(`/conferences/${encodeURIComponent(room.conferenceId)}/actions/join`, { call_control_id: leg, start_conference_on_enter: true });
390
+ if (joined !== null) {
391
+ this.enter(room, leg);
392
+ await this.greet(room, leg, welcome);
393
+ return;
394
+ }
395
+ // The id was stale in a way the clock did not predict -- an operator
396
+ // ended it, or Telnyx did. Fall through and make a new one.
397
+ room.conferenceId = null;
398
+ }
399
+ const created = await this.request("/conferences", {
400
+ name: `${this.options.conferencePrefix ?? "groupcall"}-${this.now()}-${room.legs.size}`,
401
+ call_control_id: leg,
402
+ start_conference_on_create: true,
403
+ max_participants: this.maxParticipants,
404
+ });
405
+ const id = created && typeof created === "object"
406
+ ? created["data"]?.["id"]
407
+ : undefined;
408
+ if (typeof id !== "string") {
409
+ await this.command(leg, "speak", { payload: "Sorry, that room could not be opened. Goodbye.", ...this.voiceBody(leg) });
410
+ await this.command(leg, "hangup", {});
411
+ this.legRoom.delete(leg);
412
+ return;
413
+ }
414
+ room.conferenceId = id;
415
+ room.startedAt = this.now();
416
+ this.enter(room, leg);
417
+ await this.greet(room, leg, welcome);
418
+ }
419
+ enter(room, leg) {
420
+ if (room.legs.has(leg))
421
+ return;
422
+ const from = this.legFrom.get(leg) ?? "";
423
+ const who = { leg, from, name: this.claimName(room.code, from), joinedAt: this.now() };
424
+ room.legs.set(leg, who);
425
+ room.callers = room.legs.size;
426
+ this.options.onEvent?.(` ${who.name || "a caller"} joined a room (${room.callers} on the line).`);
427
+ this.options.onJoin?.(room.code, who, this.info(room));
428
+ }
429
+ /** The name an agent registered for this number and room, used once. */
430
+ claimName(code, from) {
431
+ const now = this.now();
432
+ for (let i = this.expected.length - 1; i >= 0; i--)
433
+ if (this.expected[i].until < now)
434
+ this.expected.splice(i, 1);
435
+ const number = normalNumber(from);
436
+ if (!number)
437
+ return "";
438
+ const at = this.expected.findIndex((one) => one.code === code && one.from === number);
439
+ if (at < 0)
440
+ return "";
441
+ const [found] = this.expected.splice(at, 1);
442
+ return found.name;
443
+ }
444
+ /**
445
+ * Say hello to somebody who just joined, and only to them.
446
+ *
447
+ * Spoken into the conference rather than at the leg, because the leg is in
448
+ * the conference now and a speak on it is what the join interrupts. Telnyx
449
+ * addresses conference speech to particular participants, so the others in
450
+ * the room do not hear every arrival welcomed.
451
+ */
452
+ async greet(room, leg, welcome) {
453
+ if (room.conferenceId === null)
454
+ return;
455
+ await this.request(`/conferences/${encodeURIComponent(room.conferenceId)}/actions/speak`, {
456
+ payload: `${welcome}${peopleHere(room.callers)}`,
457
+ ...this.voiceBody(leg),
458
+ call_control_ids: [leg],
459
+ });
460
+ }
461
+ /**
462
+ * A line said into a room, to everybody on the phone in it. False when
463
+ * nobody is on the phone there, which is most of the time and costs nothing.
464
+ */
465
+ async say(code, text, voice, settings) {
466
+ const room = this.rooms.get(code);
467
+ if (!room || room.conferenceId === null || room.callers === 0)
468
+ return false;
469
+ const said = text.replace(/\s+/g, " ").trim().slice(0, 500);
470
+ if (said === "")
471
+ return false;
472
+ const voiceSettings = settings ?? (!voice ? this.options.voiceSettings : undefined);
473
+ const answer = await this.request(`/conferences/${encodeURIComponent(room.conferenceId)}/actions/speak`, {
474
+ payload: said,
475
+ voice: voice || this.voice,
476
+ // An ElevenLabs voice needs the key Telnyx holds for it, by name.
477
+ ...(voiceSettings ? { voice_settings: voiceSettings } : {}),
478
+ });
479
+ return answer !== null;
480
+ }
481
+ /** Hang up one participant in a room. */
482
+ async kick(code, leg) {
483
+ if (!this.rooms.get(code)?.legs.has(leg))
484
+ return false;
485
+ return this.command(leg, "hangup", {});
486
+ }
487
+ /** Whether anybody is on the phone in a room. */
488
+ hasCallers(code) {
489
+ const room = this.rooms.get(code);
490
+ return room !== undefined && room.conferenceId !== null && room.callers > 0;
491
+ }
492
+ /** How many people are on the phone in a room. */
493
+ callersIn(code) {
494
+ return this.rooms.get(code)?.callers ?? 0;
495
+ }
496
+ /** A leg that hung up or was dropped, wherever it was. */
497
+ release(leg) {
498
+ const code = this.legRoom.get(leg);
499
+ this.legRoom.delete(leg);
500
+ if (code === undefined)
501
+ return;
502
+ const room = this.rooms.get(code);
503
+ if (room === undefined)
504
+ return;
505
+ const who = room.legs.get(leg);
506
+ room.legs.delete(leg);
507
+ room.callers = room.legs.size;
508
+ if (who)
509
+ this.options.onLeave?.(code, who, this.info(room));
510
+ if (room.callers === 0) {
511
+ // Telnyx ends an empty conference itself; keeping the code would only
512
+ // mean handing the next caller a dead id.
513
+ this.rooms.delete(code);
514
+ this.options.onEvent?.(" a room is empty.");
515
+ }
516
+ }
517
+ info(room) {
518
+ return { code: room.code, conferenceId: room.conferenceId, callers: room.callers, startedAt: room.startedAt };
519
+ }
520
+ /** The room on this code, made if nobody is using it. */
521
+ room(code) {
522
+ const existing = this.rooms.get(code);
523
+ if (existing !== undefined)
524
+ return existing;
525
+ const room = { code, conferenceId: null, callers: 0, startedAt: this.now(), legs: new Map() };
526
+ this.rooms.set(code, room);
527
+ return room;
528
+ }
529
+ get voice() {
530
+ return this.options.voice ?? DEFAULT_VOICE;
531
+ }
532
+ /** The voice for this leg: the good one, unless it has already failed them. */
533
+ voiceFor(leg) {
534
+ return this.plainVoice.has(leg) ? FALLBACK_VOICE : this.voice;
535
+ }
536
+ /** `voice`, plus its settings while the good voice is the one in use. */
537
+ voiceBody(leg) {
538
+ const voice = this.voiceFor(leg);
539
+ return voice === this.voice && this.options.voiceSettings ? { voice, voice_settings: this.options.voiceSettings } : { voice };
540
+ }
541
+ get maxParticipants() {
542
+ return this.options.maxParticipants ?? 50;
543
+ }
544
+ /** One call-control command. True when Telnyx accepted it. */
545
+ async command(leg, action, body) {
546
+ const path = `/calls/${encodeURIComponent(leg)}/actions/${action}`;
547
+ return (await this.request(path, body)) !== null;
548
+ }
549
+ /** A POST to Telnyx, or null if it did not work. */
550
+ async request(path, body) {
551
+ try {
552
+ const response = await this.fetch(`${TELNYX_API}${path}`, {
553
+ method: "POST",
554
+ headers: { authorization: `Bearer ${this.options.apiKey}`, "content-type": "application/json" },
555
+ body: JSON.stringify(body),
556
+ });
557
+ if (!response.ok) {
558
+ // A command against a leg that already hung up is a 422 and is not
559
+ // worth a stack trace; it is the ordinary end of a race.
560
+ this.options.onEvent?.(` telnyx ${path} -> ${response.status}`);
561
+ return null;
562
+ }
563
+ const text = await response.text();
564
+ return text ? JSON.parse(text) : {};
565
+ }
566
+ catch (error) {
567
+ this.options.onEvent?.(` telnyx ${path} failed: ${error.message}`);
568
+ return null;
569
+ }
570
+ }
571
+ }
572
+ /** +1XXXXXXXXXX from whatever shape a North American number arrived in. */
573
+ export function normalNumber(input) {
574
+ const digits = String(input || "").replace(/[^\d]/g, "");
575
+ if (digits.length === 10)
576
+ return `+1${digits}`;
577
+ return digits ? `+${digits}` : "";
578
+ }
579
+ /** Texting, over Telnyx, from a number that has a messaging profile. */
580
+ export function telnyxSms({ apiKey, from, fetch = globalThis.fetch, onEvent }) {
581
+ return {
582
+ async send(to, text) {
583
+ try {
584
+ const response = await fetch(`${TELNYX_API}/messages`, {
585
+ method: "POST",
586
+ headers: { authorization: `Bearer ${apiKey}`, "content-type": "application/json" },
587
+ body: JSON.stringify({ from, to, text }),
588
+ });
589
+ if (!response.ok) {
590
+ onEvent?.(` sms to ${to} -> ${response.status}`);
591
+ return false;
592
+ }
593
+ return true;
594
+ }
595
+ catch (error) {
596
+ onEvent?.(` sms to ${to} failed: ${error.message}`);
597
+ return false;
598
+ }
599
+ },
600
+ };
601
+ }
602
+ /** Constant-time compare, for the places a token is checked rather than signed. */
603
+ export function sameSecret(a, b) {
604
+ const left = Buffer.from(a, "utf8");
605
+ const right = Buffer.from(b, "utf8");
606
+ if (left.length !== right.length)
607
+ return false;
608
+ return timingSafeEqual(left, right);
609
+ }
610
+ /**
611
+ * A fresh random room code of `length` digits that `taken` says is free.
612
+ * Codes easy to stumble on -- all one digit, a leading zero some people
613
+ * drop, a straight run like 1234 -- are kept for nobody.
614
+ */
615
+ export function newRoomCode(length = 4, taken = () => false, random = cryptoRandom) {
616
+ for (let i = 0; i < 1000; i++) {
617
+ let code = "";
618
+ for (let d = 0; d < length; d++)
619
+ code += String(Math.floor(random() * 10));
620
+ if (/^(\d)\1+$/.test(code) || code.startsWith("0") || "0123456789".includes(code) || "9876543210".includes(code))
621
+ continue;
622
+ if (!taken(code))
623
+ return code;
624
+ }
625
+ throw new Error(`no free ${length}-digit room code`);
626
+ }
627
+ function cryptoRandom() {
628
+ const buf = new Uint32Array(1);
629
+ globalThis.crypto.getRandomValues(buf);
630
+ return buf[0] / 2 ** 32;
631
+ }
package/package.json CHANGED
@@ -1,6 +1,28 @@
1
1
  {
2
2
  "name": "@profullstack/group-call",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
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
+ "license": "MIT",
6
+ "type": "module",
7
+ "repository": { "type": "git", "url": "git+https://github.com/profullstack/group-call.git" },
8
+ "homepage": "https://group.call.pit.world",
9
+ "keywords": ["telnyx", "conference", "party line", "group call", "phone", "agents", "voice"],
10
+ "main": "./dist/index.js",
11
+ "types": "./dist/index.d.ts",
12
+ "exports": {
13
+ ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
14
+ "./package.json": "./package.json"
15
+ },
16
+ "files": ["dist", "README.md", "LICENSE"],
17
+ "engines": { "node": ">=20" },
18
+ "scripts": {
19
+ "build": "rm -rf dist && tsc -p tsconfig.build.json",
20
+ "test": "node --experimental-strip-types --no-warnings --test test/*.test.ts",
21
+ "typecheck": "tsc --noEmit",
22
+ "prepublishOnly": "npm run build"
23
+ },
24
+ "devDependencies": {
25
+ "@types/node": "^22.0.0",
26
+ "typescript": "^5.9.0"
27
+ }
28
+ }