nixamp 0.77.0 → 0.78.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.
Files changed (86) hide show
  1. package/backtoschool/dist/assets/{ar-Cj5WpObc.js → ar-CYu0byMf.js} +1 -1
  2. package/backtoschool/dist/assets/{cs-DOYgXvWh.js → cs-BTTM7DeB.js} +1 -1
  3. package/backtoschool/dist/assets/{da-CmgimT0P.js → da-iixezQtd.js} +1 -1
  4. package/backtoschool/dist/assets/{de-D_AUWuE3.js → de-Sx3acevR.js} +1 -1
  5. package/backtoschool/dist/assets/{es-B4QfiG7X.js → es-ByCnw8ja.js} +1 -1
  6. package/backtoschool/dist/assets/{fi-Clf9SZn5.js → fi-BCeFoeH5.js} +1 -1
  7. package/backtoschool/dist/assets/{fr-DpLIxvgd.js → fr-Nz5hMzAE.js} +1 -1
  8. package/backtoschool/dist/assets/{hi-a9HR1j7t.js → hi-x5-IG1rl.js} +1 -1
  9. package/backtoschool/dist/assets/hls-CydbS6hn.js +42 -0
  10. package/backtoschool/dist/assets/hls-OR5FY6YP-O3qhTXm4.js +1 -0
  11. package/backtoschool/dist/assets/{hu-CXw_ATKN.js → hu-QqIkGcMe.js} +1 -1
  12. package/backtoschool/dist/assets/{id-B8GHWT99.js → id-OVwO1Uoi.js} +1 -1
  13. package/backtoschool/dist/assets/index-Dgqs6yM_.js +112 -0
  14. package/backtoschool/dist/assets/{it-D8K-Cjn9.js → it-CmssY5Ut.js} +1 -1
  15. package/backtoschool/dist/assets/mpegts-RQlLEU1A.js +3 -0
  16. package/backtoschool/dist/assets/mpegts-Y2FARLFE-y49QH7a5.js +1 -0
  17. package/backtoschool/dist/assets/{nl-BTHM_tIM.js → nl-VkQxlPku.js} +1 -1
  18. package/backtoschool/dist/assets/{ru-cwqVc7z_.js → ru-Dk8FAbDx.js} +1 -1
  19. package/backtoschool/dist/assets/{sv-De7KSFg-.js → sv-CNs1UySx.js} +1 -1
  20. package/backtoschool/dist/assets/{uk-COyId2km.js → uk-BDB1YayY.js} +1 -1
  21. package/backtoschool/dist/assets/{vi-3djg7AGj.js → vi-DnEIKzy7.js} +1 -1
  22. package/backtoschool/dist/assets/{zh-DNAab67I.js → zh-B_atHA4S.js} +1 -1
  23. package/backtoschool/dist/index.html +1 -1
  24. package/dist/ai-label.d.ts +3 -0
  25. package/dist/ai-label.js +392 -10
  26. package/dist/partyline.d.ts +41 -201
  27. package/dist/partyline.js +65 -601
  28. package/dist/veo-studio.d.ts +22 -0
  29. package/dist/veo-studio.js +64 -3
  30. package/package.json +13 -12
  31. package/src/ai-label.ts +391 -9
  32. package/src/partyline.ts +88 -671
  33. package/src/veo-studio.ts +68 -4
  34. package/web/dist/assets/{ar-DvGjcQ2J.js → ar-Dt4ftL7_.js} +1 -1
  35. package/web/dist/assets/background.worker-F49brs4A.js +547 -0
  36. package/web/dist/assets/chunk-55GBNX64-BDcCEJUP.js +1 -0
  37. package/web/dist/assets/client-D7waX8NA.js +1 -0
  38. package/web/dist/assets/{cs-VkeGFdfN.js → cs-D7U7SQMd.js} +1 -1
  39. package/web/dist/assets/{da-BcpK9125.js → da-BcJX_2kR.js} +1 -1
  40. package/web/dist/assets/{de-DMUTObgP.js → de-rl_x2VZN.js} +1 -1
  41. package/web/dist/assets/{es-Cgv5mJmC.js → es-BGrhKZQz.js} +1 -1
  42. package/web/dist/assets/{fi-CZw6yFV4.js → fi-CY2dfbr_.js} +1 -1
  43. package/web/dist/assets/{fr-Cashl2VH.js → fr-BJkwpR_P.js} +1 -1
  44. package/web/dist/assets/{hi-nRs3DZ0Q.js → hi-BrJHlcBX.js} +1 -1
  45. package/web/dist/assets/hls-CydbS6hn.js +42 -0
  46. package/web/dist/assets/hls-OR5FY6YP-_WOdANsC.js +1 -0
  47. package/web/dist/assets/{hu-D6H6n2Li.js → hu-BULQmmjT.js} +1 -1
  48. package/web/dist/assets/{id-DB89Twrj.js → id-Dzh3ujB1.js} +1 -1
  49. package/web/dist/assets/{it-CH9caHnD.js → it-CexE1NXI.js} +1 -1
  50. package/web/dist/assets/main-CsEDH18x.js +26 -0
  51. package/web/dist/assets/mpegts-CL9RC5Ls.js +3 -0
  52. package/web/dist/assets/mpegts-Y2FARLFE-C91SD8Pl.js +1 -0
  53. package/web/dist/assets/music-ByEeUk4Z.js +1 -0
  54. package/web/dist/assets/{nl-vTvT2DVb.js → nl-BPpsDvjT.js} +1 -1
  55. package/web/dist/assets/radio-BnQ78a-O.js +1 -0
  56. package/web/dist/assets/{ru-dpgYvvQ7.js → ru-CmYwwWDR.js} +1 -1
  57. package/web/dist/assets/{station-UibBpdBh.js → station-C_6L4CDU.js} +2 -2
  58. package/web/dist/assets/{sv-gp9SyEIA.js → sv-DnY0WiF0.js} +1 -1
  59. package/web/dist/assets/{uk-DicgOcIK.js → uk-Cm0pAuQa.js} +1 -1
  60. package/web/dist/assets/{vi-m4X3bO3f.js → vi-CgKu2IlV.js} +1 -1
  61. package/web/dist/assets/{vod-CkR6hzN7.js → vod-CbSC5NFA.js} +1 -1
  62. package/web/dist/assets/{watch-BO-wMY8T.js → watch-D-ZRxfJ_.js} +1 -1
  63. package/web/dist/assets/{zh-DPnaJjYS.js → zh-DOa77AtL.js} +1 -1
  64. package/web/dist/index.html +5 -5
  65. package/web/dist/music.html +2 -2
  66. package/web/dist/radio.html +3 -3
  67. package/web/dist/station.html +3 -3
  68. package/web/dist/sw.js +32 -32
  69. package/web/dist/vod.html +3 -3
  70. package/web/dist/watch.html +3 -3
  71. package/backtoschool/dist/assets/hls-OR5FY6YP-nOjNFW0f.js +0 -1
  72. package/backtoschool/dist/assets/hls-n74Cnh8A.js +0 -42
  73. package/backtoschool/dist/assets/index-BPJoeY65.js +0 -112
  74. package/backtoschool/dist/assets/mpegts-7CN75AI2-cUtR703j.js +0 -1
  75. package/backtoschool/dist/assets/mpegts-CixmcUbS.js +0 -3
  76. package/web/dist/assets/background.worker-Hz7oaQ8X.js +0 -547
  77. package/web/dist/assets/chunk-Q2XQMA4B-OBCD3Aev.js +0 -1
  78. package/web/dist/assets/client-B_mfdrFh.js +0 -1
  79. package/web/dist/assets/hls-OR5FY6YP-B2RNEz5K.js +0 -1
  80. package/web/dist/assets/hls-n74Cnh8A.js +0 -42
  81. package/web/dist/assets/main-BS8YvXOs.js +0 -26
  82. package/web/dist/assets/mpegts-7CN75AI2-1Qj6py7N.js +0 -1
  83. package/web/dist/assets/mpegts-BcF3gJrf.js +0 -3
  84. package/web/dist/assets/music-Day-DtFd.js +0 -1
  85. package/web/dist/assets/radio-Dx44o-ug.js +0 -1
  86. /package/web/dist/assets/{chunk-Q2XQMA4B-DR1XNWCJ.css → chunk-55GBNX64-DR1XNWCJ.css} +0 -0
package/src/partyline.ts CHANGED
@@ -2,39 +2,33 @@
2
2
  * The party line.
3
3
  *
4
4
  * A phone number, a six-digit code, and everybody who keyed the same code
5
- * talking to each other. You call 888-ROOM-818, key 482917, and you are on the
5
+ * talking to each other. You call 408-357-2326, key 482917, and you are on the
6
6
  * line with whoever else keyed 482917.
7
7
  *
8
- * Six digits, and digits rather than letters, for one reason: the code is a
9
- * thing you say to somebody. The generated ids this replaces were long enough
10
- * that nobody could read one down a phone line, and letters would have brought
11
- * case and spelling with them -- was that a capital B, was it "blue" or "blu".
12
- * A keypad has one way to type a 4 and nobody disagrees about how to say it.
13
- *
14
- * The rooms are not configured anywhere. Keying a code nobody is using opens
15
- * it, and the last person to hang up closes it -- the same shape as a channel,
16
- * where a name is just where a stream happens to be rather than a record
17
- * somebody created first. The code is a rendezvous, not a credential: two
18
- * people who agree on 482917 beforehand both dial in, and neither had to
19
- * create it first.
20
- *
21
- * The audio mixing is Telnyx's. A conference is a name on their side too, so
22
- * this module never touches a byte of audio: it answers a call, asks a
23
- * question, and puts the leg into a conference. What we keep is the part
24
- * Telnyx does not -- which spoken words mean which conference, and how many
25
- * people a room is holding.
26
- *
27
- * Two things about conferences are worth knowing before reading the state
28
- * machine. They expire after four hours whether or not anyone is still on
29
- * them, so a long-lived room's conference id goes stale underneath us and has
30
- * to be remade on the next join. And the id is only knowable after the first
31
- * caller creates it, so the first caller and the tenth take different paths
32
- * through the same function.
8
+ * The phone half -- answering, asking for the code on the keypad, the voices,
9
+ * Telnyx conferences and their four-hour expiry, who is in which room -- is
10
+ * @profullstack/group-call, which started life as this file and is shared with
11
+ * group.call.pit.world. What stays here is what a code means to nixamp: a code
12
+ * that belongs to a live stream is that stream's room, a code whose stream has
13
+ * ended offers a text for when it is back, and anything else is an ordinary
14
+ * room nobody had to create first.
33
15
  */
34
- import { createPublicKey, verify as verifySignature, timingSafeEqual } from "node:crypto";
35
-
36
- /** Where Telnyx's REST API lives. Injectable so a test never leaves the process. */
37
- const TELNYX_API = "https://api.telnyx.com/v2";
16
+ import {
17
+ GroupCall,
18
+ peopleHere,
19
+ roomCodeFrom as groupRoomCodeFrom,
20
+ sameSecret,
21
+ spokenCode,
22
+ telnyxSms,
23
+ type CallHandle,
24
+ type CodeAnswer,
25
+ type RoomInfo,
26
+ type Sms,
27
+ type TelnyxEvent,
28
+ } from "@profullstack/group-call";
29
+
30
+ export { peopleHere, sameSecret, spokenCode, telnyxSms };
31
+ export type { RoomInfo, Sms, TelnyxEvent };
38
32
 
39
33
  /**
40
34
  * Telnyx's own voice, so a room prompt costs nothing beyond the call. A
@@ -44,54 +38,17 @@ const TELNYX_API = "https://api.telnyx.com/v2";
44
38
  const DEFAULT_VOICE = "Telnyx.KokoroTTS.af";
45
39
 
46
40
  /**
47
- * The voice a leg falls back to when the good one fails.
48
- *
49
- * Telnyx's Kokoro voice answered a prompt with a 500 once (2026-09-09), and
50
- * what the caller got was a gather with no speech in it, ended at once with
51
- * no digits, and asked again -- a line that "just repeats itself", silently,
52
- * every ninety seconds. The plain Telnyx voice is older and worse and has not
53
- * been seen to fail, which is the quality that matters on the second try.
54
- */
55
- const FALLBACK_VOICE = "female";
56
-
57
- /**
58
- * How long a caller has between digits before the code is treated as done.
59
- *
60
- * Telnyx's default is five seconds and did not fire: a caller who keyed five
61
- * digits (one was lost in the keypad tone over the prompt) waited ten seconds
62
- * in silence and hung up. Set explicitly so the partial code comes back to us
63
- * quickly and we can say how many digits we got.
64
- */
65
- const INTER_DIGIT_MS = 4000;
66
-
67
- /**
68
- * How many times a caller is asked for a code before being let go.
41
+ * How long a room code is.
69
42
  *
70
- * A gather that ends with nothing three times is a caller who cannot or will
71
- * not key a code -- or a voice that is not being heard at all. Asking a
72
- * fourth time is the loop that was reported; saying goodbye is not.
43
+ * Six digits, because the code has to survive being read down a phone line and
44
+ * typed into a URL. A room code is something you tell somebody, so it has to
45
+ * be short enough to hold in your head between hearing it and dialling it.
73
46
  */
74
- const MAX_ASKS = 3;
75
-
76
- /** How long a signed webhook stays acceptable. Telnyx's own SDKs use five minutes. */
77
- const SIGNATURE_TOLERANCE_MS = 5 * 60 * 1000;
78
-
79
- /** A conference Telnyx will discard on its own, so we stop trusting ours first. */
80
- const CONFERENCE_TTL_MS = 4 * 60 * 60 * 1000;
81
-
82
- export interface RoomInfo {
83
- /** The six-digit code, which is the room's whole identity. */
84
- code: string;
85
- /** Telnyx's id for the conference, once a first caller has made one. */
86
- conferenceId: string | null;
87
- /** How many legs we have put in, less the ones we have seen leave. */
88
- callers: number;
89
- startedAt: number;
90
- }
47
+ export const CODE_LENGTH = 6;
91
48
 
92
- interface Room extends RoomInfo {
93
- /** Legs currently in the room, so a caller is never counted twice. */
94
- legs: Set<string>;
49
+ /** A six-digit room code from whatever the caller keyed, or "". */
50
+ export function roomCodeFrom(entered: unknown): string {
51
+ return groupRoomCodeFrom(entered, CODE_LENGTH);
95
52
  }
96
53
 
97
54
  /**
@@ -108,11 +65,6 @@ export interface StreamLookup {
108
65
  endedByCode(code: string): { name: string; nowPlaying: string; startedAt: number; endedAt: number } | undefined;
109
66
  }
110
67
 
111
- /** Sending a text. Injected because the number that sends is not this one. */
112
- export interface Sms {
113
- send(to: string, text: string): Promise<boolean>;
114
- }
115
-
116
68
  export interface PartyLineOptions {
117
69
  /** A Telnyx API key with call-control rights. */
118
70
  apiKey: string;
@@ -123,15 +75,10 @@ export interface PartyLineOptions {
123
75
  *
124
76
  * Not from the toll-free number the call arrived on: toll-free A2P messaging
125
77
  * is filtered by carriers until the number is verified, and ours is not. A
126
- * long code that already has a messaging profile sends today, so reminders
127
- * go out from there and the verification can land whenever it lands.
78
+ * long code that already has a messaging profile sends today.
128
79
  */
129
80
  sms?: Sms;
130
- /**
131
- * The account's ed25519 public key, base64, from the portal. Without it
132
- * every webhook is refused: an unauthenticated call-control webhook lets a
133
- * stranger drive calls we are paying for.
134
- */
81
+ /** The account's ed25519 public key, base64, from the portal. */
135
82
  publicKey: string;
136
83
  /** What the caller hears before being asked for a room. */
137
84
  greeting?: string;
@@ -146,34 +93,6 @@ export interface PartyLineOptions {
146
93
  onEvent?: (message: string) => void;
147
94
  }
148
95
 
149
- /**
150
- * How long a room code is.
151
- *
152
- * Six digits, because the code has to survive being read down a phone line and
153
- * typed into a URL. The generated ids this replaces were long enough that
154
- * nobody could say one out loud, which is the whole failure being fixed: a
155
- * room code is something you tell somebody, so it has to be short enough to
156
- * hold in your head between hearing it and dialling it.
157
- */
158
- export const CODE_LENGTH = 6;
159
-
160
- /**
161
- * A room code, from whatever the caller keyed.
162
- *
163
- * Digits only, and exactly six of them. Five is not a near miss to be
164
- * charitable about -- it is a different room, and guessing which one they
165
- * meant would drop somebody into a stranger's conversation.
166
- *
167
- * Nothing here is case-sensitive because nothing here has a case. That is the
168
- * point of digits over letters: a phone keypad has one way to type a 4, and no
169
- * two people disagree about how to say it.
170
- */
171
- export function roomCodeFrom(entered: unknown): string {
172
- if (typeof entered !== "string" && typeof entered !== "number") return "";
173
- const digits = String(entered).replace(/\D/g, "");
174
- return digits.length === CODE_LENGTH ? digits : "";
175
- }
176
-
177
96
  /**
178
97
  * A time as a caller should hear it.
179
98
  *
@@ -192,18 +111,12 @@ export function pacificTime(at: number): string {
192
111
  return `${clock} Pacific`;
193
112
  }
194
113
 
195
- /** How a code is read back: one digit at a time, because 482917 is not a number. */
196
- export function spokenCode(code: string): string {
197
- return code.split("").join(", ");
198
- }
199
-
200
114
  /**
201
115
  * A track's name as it should be read out.
202
116
  *
203
117
  * What is playing is a filename more often than a title: "02 - ...And Justice
204
118
  * For All.mp3" read aloud is "zero two dash dot dot dot", and the extension is
205
- * a noise the caller does not need. The track number and extension go; what
206
- * is left is close enough to a title to say.
119
+ * a noise the caller does not need.
207
120
  */
208
121
  export function spokenTitle(nowPlaying: string): string {
209
122
  return nowPlaying
@@ -214,68 +127,10 @@ export function spokenTitle(nowPlaying: string): string {
214
127
  }
215
128
 
216
129
  /**
217
- * How many people are in a room, said to the one who just walked in.
218
- *
219
- * The count includes them: "there are 3 people in here" is what you say to the
220
- * third person, and "you are the first one here" is what you say to the first,
221
- * who would otherwise be told there is one person in an empty room.
222
- */
223
- export function peopleHere(callers: number): string {
224
- if (callers <= 1) return "You are the first one here. Say hello when somebody joins.";
225
- if (callers === 2) return "There is one other person in here. Say hello.";
226
- return `There are ${callers} people in here. Say hello.`;
227
- }
228
-
229
- /**
230
- * Telnyx signs `${timestamp}|${body}` with ed25519 and sends both back in
231
- * headers. Node will not take a bare 32-byte key, so it is wrapped in the
232
- * fixed SPKI prefix that says "this is ed25519" and handed over as DER.
233
- */
234
- const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
235
-
236
- function ed25519KeyFrom(base64Key: string): ReturnType<typeof createPublicKey> | null {
237
- let raw: Buffer;
238
- try {
239
- raw = Buffer.from(base64Key, "base64");
240
- } catch {
241
- return null;
242
- }
243
- if (raw.length !== 32) return null;
244
- try {
245
- return createPublicKey({
246
- key: Buffer.concat([ED25519_SPKI_PREFIX, raw]),
247
- format: "der",
248
- type: "spki",
249
- });
250
- } catch {
251
- return null;
252
- }
253
- }
254
-
255
- export interface TelnyxEvent {
256
- event_type?: string;
257
- payload?: Record<string, unknown>;
258
- }
259
-
260
- /**
261
- * The party line, as a thing that answers webhooks.
262
- *
263
- * It owns no socket and no timer. The server hands it a verified event and it
264
- * issues whatever call-control commands that event calls for, which makes the
265
- * whole state machine testable with a fetch that records what it was asked.
130
+ * The party line, as a thing that answers webhooks: a group call whose codes
131
+ * can also be streams.
266
132
  */
267
- export class PartyLine {
268
- private readonly rooms = new Map<string, Room>();
269
- /** Which room a leg is heading for, between asking and being answered. */
270
- private readonly legRoom = new Map<string, string>();
271
- /** The number each caller is calling from, for a reminder they ask for. */
272
- private readonly legFrom = new Map<string, string>();
273
- /** Legs that heard "press 1", and which stream they would be reminded about. */
274
- private readonly pendingReminder = new Map<string, string>();
275
- /** How many times each leg has been asked for a code, so it is not forever. */
276
- private readonly asks = new Map<string, number>();
277
- /** Legs whose voice failed once, which hear the plain one from then on. */
278
- private readonly plainVoice = new Set<string>();
133
+ export class PartyLine extends GroupCall {
279
134
  /** Who to text when a stream returns, by stream code. */
280
135
  private readonly reminders = new Map<string, Set<string>>();
281
136
  /**
@@ -289,6 +144,28 @@ export class PartyLine {
289
144
  add: (code: string, phone: string) => void;
290
145
  take: (code: string) => Promise<string[]>;
291
146
  } | null = null;
147
+ private readonly party: PartyLineOptions;
148
+
149
+ constructor(options: PartyLineOptions) {
150
+ let self: PartyLine | undefined;
151
+ super({
152
+ apiKey: options.apiKey,
153
+ publicKey: options.publicKey,
154
+ codeLength: CODE_LENGTH,
155
+ conferencePrefix: "partyline",
156
+ greeting: options.greeting ?? "Welcome to the nixamp party line. Enter the six digit room code.",
157
+ voice: options.voice ?? DEFAULT_VOICE,
158
+ ...(options.maxParticipants !== undefined ? { maxParticipants: options.maxParticipants } : {}),
159
+ ...(options.now ? { now: options.now } : {}),
160
+ ...(options.fetch ? { fetch: options.fetch } : {}),
161
+ ...(options.onEvent ? { onEvent: options.onEvent } : {}),
162
+ // A code that belongs to a stream is answered as a stream. Anything else
163
+ // is an ordinary room, which is what this line was before.
164
+ onCode: (code, call) => self!.stream(code, call),
165
+ });
166
+ self = this;
167
+ this.party = options;
168
+ }
292
169
 
293
170
  /** Start echoing reminders somewhere durable, and put back what was there. */
294
171
  persistRemindersTo(
@@ -302,264 +179,50 @@ export class PartyLine {
302
179
  this.reminders.set(code, set);
303
180
  }
304
181
  }
305
- private readonly key: ReturnType<typeof createPublicKey> | null;
306
- private readonly fetch: typeof globalThis.fetch;
307
- private readonly now: () => number;
308
-
309
- constructor(private readonly options: PartyLineOptions) {
310
- this.key = options.publicKey ? ed25519KeyFrom(options.publicKey) : null;
311
- this.fetch = options.fetch ?? globalThis.fetch;
312
- this.now = options.now ?? Date.now;
313
- }
314
-
315
- /** True when this instance can actually check a signature. */
316
- get armed(): boolean {
317
- return this.key !== null && this.options.apiKey.length > 0;
318
- }
319
-
320
- /**
321
- * Whether a webhook really came from Telnyx.
322
- *
323
- * The body has to be the bytes that arrived. Parsing and reserialising JSON
324
- * changes key order and whitespace, and the signature is over the original.
325
- */
326
- verify(rawBody: string, signature: string | undefined, timestamp: string | undefined): boolean {
327
- if (this.key === null || !signature || !timestamp) return false;
328
-
329
- const sent = Number(timestamp) * 1000;
330
- if (!Number.isFinite(sent)) return false;
331
- // Both directions: a replayed webhook is old, and a clock-skewed forgery
332
- // from the future is no more trustworthy for being ahead.
333
- if (Math.abs(this.now() - sent) > SIGNATURE_TOLERANCE_MS) return false;
334
-
335
- let sig: Buffer;
336
- try {
337
- sig = Buffer.from(signature, "base64");
338
- } catch {
339
- return false;
340
- }
341
- if (sig.length !== 64) return false;
342
-
343
- try {
344
- return verifySignature(
345
- null,
346
- Buffer.from(`${timestamp}|${rawBody}`, "utf8"),
347
- this.key,
348
- sig,
349
- );
350
- } catch {
351
- return false;
352
- }
353
- }
354
-
355
- /**
356
- * The rooms with someone in them, busiest first, with their codes.
357
- *
358
- * The code is published on purpose. An earlier version withheld it on the
359
- * reasoning that a code is the only thing between a stranger and a
360
- * conversation -- true of a private room, and wrong here: this is a public
361
- * call-in line, and a listing you cannot dial is a listing of nothing. The
362
- * code is how you join, so it is what the list is for.
363
- */
364
- list(): { code: string; callers: number; startedAt: number }[] {
365
- return [...this.rooms.values()]
366
- .filter((room) => room.callers > 0)
367
- .map(({ code, callers, startedAt }) => ({ code, callers, startedAt }))
368
- .sort((a, b) => b.callers - a.callers || a.startedAt - b.startedAt);
369
- }
370
-
371
- /**
372
- * Drive one call-control event.
373
- *
374
- * Every branch returns rather than falling through, because an event we do
375
- * not handle is the normal case -- Telnyx sends a dozen kinds per call and
376
- * this cares about four.
377
- */
378
- async handle(event: TelnyxEvent): Promise<void> {
379
- const type = event.event_type ?? "";
380
- const payload = event.payload ?? {};
381
- const leg = typeof payload["call_control_id"] === "string" ? payload["call_control_id"] : "";
382
- if (!leg) return;
383
-
384
- if (type === "call.initiated") {
385
- // Only inbound. An outbound leg we dialled is not somebody calling in,
386
- // and answering it would be answering ourselves.
387
- if (payload["direction"] !== "incoming") return;
388
- // Kept now because a reminder needs it later, and by the time the caller
389
- // presses 1 the only thing we have is the leg.
390
- if (typeof payload["from"] === "string") this.legFrom.set(leg, payload["from"]);
391
- await this.command(leg, "answer", {});
392
- return;
393
- }
394
-
395
- if (type === "call.answered") {
396
- await this.ask(leg);
397
- return;
398
- }
399
-
400
- if (type === "call.speak.failed") {
401
- // The voice, not the caller, failed. Everything this leg hears from now
402
- // on is in the plain voice; the gather this speech belonged to ends on
403
- // its own and is asked again, audibly this time.
404
- this.plainVoice.add(leg);
405
- this.options.onEvent?.(" a prompt could not be spoken; using the plain voice.");
406
- return;
407
- }
408
-
409
- if (type === "call.gather.ended") {
410
- const digits = typeof payload["digits"] === "string" ? payload["digits"] : "";
411
-
412
- // A gather that ended because the caller hung up is not an answer, and
413
- // anything sent to that leg now is a 422 for the log.
414
- if (payload["status"] === "call_hangup") return;
415
-
416
- // A leg that was just offered a reminder is answering that, not keying a
417
- // room code -- the same event carries both, so the question we asked is
418
- // what decides how to read it.
419
- const offered = this.pendingReminder.get(leg);
420
- if (offered !== undefined) {
421
- this.pendingReminder.delete(leg);
422
- await this.reminder(leg, offered, digits);
423
- return;
424
- }
425
-
426
- const code = roomCodeFrom(digits);
427
- if (!code) {
428
- // Re-ask rather than guess. Anything that is not six digits is not a
429
- // room, and picking the nearest one would be picking a stranger's.
430
- // Say what we got: five digits and silence is a caller who thinks
431
- // the line is broken, and a caller who keyed nothing does not need
432
- // telling their nothing was not six digits.
433
- const got = digits.replace(/\D/g, "").length;
434
- await this.ask(leg, got > 0 ? `I only got ${got} digits. ` : "I did not get a code. ");
435
- return;
436
- }
437
- this.asks.delete(leg);
438
- // A code that belongs to a stream is answered as a stream. Anything else
439
- // is an ordinary room, which is what this line was before.
440
- if (await this.stream(leg, code)) return;
441
- await this.join(leg, code);
442
- return;
443
- }
444
-
445
- if (type === "conference.participant.left" || type === "call.hangup") {
446
- this.release(leg);
447
- this.legFrom.delete(leg);
448
- this.pendingReminder.delete(leg);
449
- this.asks.delete(leg);
450
- this.plainVoice.delete(leg);
451
- return;
452
- }
453
- }
454
-
455
- /**
456
- * Ask for a room code, on the keypad.
457
- *
458
- * Not by voice, which is the one thing here that changed its mind. Speech
459
- * suited a room *name* -- "blue" misheard is still recognisably a word, and
460
- * a person can say it differently the second time. A six-digit code has no
461
- * such slack: one digit misheard is a different room that also exists, and
462
- * the caller lands in a stranger's conversation with nothing to tell them
463
- * they went wrong. A keypad cannot mishear a 4.
464
- *
465
- * Six digits terminates the gather on its own, so the caller does not have
466
- * to press anything after; # is there for the ones who do it anyway.
467
- */
468
- private async ask(leg: string, prefix = ""): Promise<void> {
469
- const asked = (this.asks.get(leg) ?? 0) + 1;
470
- this.asks.set(leg, asked);
471
- if (asked > MAX_ASKS) {
472
- // Three gathers with no code in them is not a caller who needs a fourth
473
- // prompt. Whatever is wrong -- their keypad, our voice -- repeating
474
- // ourselves is the failure that was reported, so this ends instead.
475
- await this.command(leg, "speak", {
476
- payload: "I did not get a room code. Goodbye.",
477
- voice: this.voiceFor(leg),
478
- });
479
- await this.command(leg, "hangup", {});
480
- return;
481
- }
482
-
483
- // Short, because callers key the code over the prompt and a long one
484
- // costs digits: a tone pressed as the speech starts was not heard. The
485
- // first time gets the welcome; a re-ask has already been welcomed.
486
- const greeting = this.options.greeting ?? "Welcome to the nixamp party line. Enter the six digit room code.";
487
- const payload = asked === 1 ? `${prefix}${greeting}` : `${prefix}Enter the six digit room code.`;
488
-
489
- await this.command(leg, "gather_using_speak", {
490
- payload,
491
- voice: this.voiceFor(leg),
492
- valid_digits: "0123456789",
493
- minimum_digits: CODE_LENGTH,
494
- maximum_digits: CODE_LENGTH,
495
- terminating_digit: "#",
496
- timeout_millis: 20000,
497
- inter_digit_timeout_millis: INTER_DIGIT_MS,
498
- });
499
- }
500
182
 
501
183
  /**
502
184
  * Answer a code that belongs to a stream, rather than a room.
503
185
  *
504
- * Returns false when the code is nobody's stream, which is how an ordinary
505
- * room code still works: this line was a party line before it was a way into
506
- * a broadcast, and a code that means nothing to the directory should still
507
- * mean a room.
508
- *
509
186
  * Keying a stream's code puts you in a room with the other people watching
510
- * it. It does not play the stream at you, which is what it used to do: this
511
- * is the phone line beside a broadcast, the way a podcast has an 800 number
512
- * -- the show is on your screen and the phone is where you talk about it.
513
- * Playing the audio down the phone was both the worse half of the idea and
514
- * the one that kept failing, because a share link answers a 302 and a cookie
515
- * rather than an MP3.
187
+ * it. It does not play the stream at you: this is the phone line beside a
188
+ * broadcast, the way a podcast has an 800 number -- the show is on your
189
+ * screen and the phone is where you talk about it.
516
190
  */
517
- private async stream(leg: string, code: string): Promise<boolean> {
518
- const streams = this.options.streams;
519
- if (streams === undefined) return false;
191
+ private async stream(code: string, call: CallHandle): Promise<CodeAnswer> {
192
+ const streams = this.party.streams;
193
+ if (streams === undefined) return true;
520
194
 
521
195
  const live = streams.liveByCode(code);
522
196
  if (live !== undefined) {
523
197
  // The welcome is said once they are in the room, not before: a speak
524
- // on the leg followed by the conference join was cut off by the join --
525
- // Telnyx reported it started and ended in the same millisecond -- and
526
- // the caller heard twenty seconds of nothing and hung up.
198
+ // on the leg followed by the conference join was cut off by the join.
527
199
  const title = spokenTitle(live.nowPlaying);
528
200
  const what = title ? `, playing ${title}` : "";
529
- await this.join(leg, code, `Welcome to the live room for ${live.name}${what}. `);
530
- return true;
201
+ return { welcome: `Welcome to the live room for ${live.name}${what}. ` };
531
202
  }
532
203
 
533
204
  const ended = streams.endedByCode(code);
534
- if (ended === undefined) return false;
205
+ if (ended === undefined) return true;
535
206
 
536
207
  const what = ended.nowPlaying ? ` of ${ended.nowPlaying}` : "";
537
- // Set before the prompt, not after: the answer can arrive while we are
538
- // still awaiting the command that asked for it.
539
- this.pendingReminder.set(leg, code);
540
- await this.command(leg, "gather_using_speak", {
541
- payload:
542
- `Welcome to ${ended.name}'s live stream${what}. ` +
208
+ await call.gather(
209
+ `Welcome to ${ended.name}'s live stream${what}. ` +
543
210
  `The live stream ended at ${pacificTime(ended.endedAt)}. ` +
544
211
  "Call back later when they stream again. " +
545
212
  "Press 1 to get a text message when they do.",
546
- voice: this.voiceFor(leg),
547
- valid_digits: "1",
548
- minimum_digits: 1,
549
- maximum_digits: 1,
550
- timeout_millis: 12000,
551
- });
552
- return true;
213
+ { validDigits: "1", minimumDigits: 1, maximumDigits: 1, timeoutMillis: 12000 },
214
+ (digits) => this.reminder(call, code, digits),
215
+ );
216
+ return "handled";
553
217
  }
554
218
 
555
219
  /** Whether the caller took the reminder that was offered. */
556
- private async reminder(leg: string, code: string, digits: string): Promise<void> {
557
- const from = this.legFrom.get(leg) ?? "";
220
+ private async reminder(call: CallHandle, code: string, digits: string): Promise<void> {
221
+ const from = call.from;
558
222
  if (!digits.includes("1") || !from) {
559
223
  // Not pressing 1 is an answer. So is a call with no caller id, which we
560
224
  // cannot text however willing the caller was.
561
- await this.command(leg, "speak", { payload: "Goodbye.", voice: this.voice });
562
- await this.command(leg, "hangup", {});
225
+ await call.goodbye("Goodbye.");
563
226
  return;
564
227
  }
565
228
 
@@ -567,13 +230,9 @@ export class PartyLine {
567
230
  waiting.add(from);
568
231
  this.reminders.set(code, waiting);
569
232
  this.reminderStore?.add(code, from);
570
- this.options.onEvent?.(` a caller asked to be told when ${code} is live again.`);
233
+ this.party.onEvent?.(` a caller asked to be told when ${code} is live again.`);
571
234
 
572
- await this.command(leg, "speak", {
573
- payload: "Got it. We will text you when they are live again. Goodbye.",
574
- voice: this.voiceFor(leg),
575
- });
576
- await this.command(leg, "hangup", {});
235
+ await call.goodbye("Got it. We will text you when they are live again. Goodbye.");
577
236
  }
578
237
 
579
238
  /**
@@ -584,7 +243,7 @@ export class PartyLine {
584
243
  * the week is how a useful message becomes the reason they block the number.
585
244
  */
586
245
  async wentLive(stream: { code: string; name: string; nowPlaying: string }): Promise<number> {
587
- const sms = this.options.sms;
246
+ const sms = this.party.sms;
588
247
  // Taken from the store first, and that take is what clears it: a number
589
248
  // put there by a process that has since been replaced is still owed a
590
249
  // text, and this one never heard the call that promised it.
@@ -598,12 +257,12 @@ export class PartyLine {
598
257
  // to make it stop, and the carriers check.
599
258
  const text =
600
259
  `${stream.name} is live now${what} on nixamp. ` +
601
- `Call ${this.options.callIn ?? "408-357-2326"} and key ${stream.code} to listen. ` +
260
+ `Call ${this.party.callIn ?? "408-357-2326"} and key ${stream.code} to listen. ` +
602
261
  "Reply STOP to opt out.";
603
262
 
604
263
  let sent = 0;
605
264
  for (const to of waiting) if (await sms.send(to, text)) sent += 1;
606
- this.options.onEvent?.(` texted ${sent} of ${waiting.size} waiting on ${stream.code}.`);
265
+ this.party.onEvent?.(` texted ${sent} of ${waiting.size} waiting on ${stream.code}.`);
607
266
  return sent;
608
267
  }
609
268
 
@@ -612,259 +271,17 @@ export class PartyLine {
612
271
  return this.reminders.get(code)?.size ?? 0;
613
272
  }
614
273
 
615
- /**
616
- * Put a leg into a room, making the conference if it is the first one there.
617
- *
618
- * `welcome` is what the caller hears once they are in, before the count of
619
- * who else is; a stream's room names the stream, an ordinary room reads its
620
- * code back.
621
- */
622
- private async join(leg: string, code: string, welcome = `Welcome to room ${spokenCode(code)}. `): Promise<void> {
623
- const room = this.room(code);
624
-
625
- if (room.callers >= this.maxParticipants) {
626
- await this.command(leg, "speak", {
627
- payload: "That room is full. Goodbye.",
628
- voice: this.voiceFor(leg),
629
- });
630
- await this.command(leg, "hangup", {});
631
- return;
632
- }
633
-
634
- this.legRoom.set(leg, code);
635
-
636
- // A conference we made more than four hours ago is gone on Telnyx's side
637
- // whatever our map says, so it is remade rather than joined.
638
- const stale = this.now() - room.startedAt > CONFERENCE_TTL_MS;
639
- if (room.conferenceId !== null && !stale) {
640
- const joined = await this.request(
641
- `/conferences/${encodeURIComponent(room.conferenceId)}/actions/join`,
642
- { call_control_id: leg, start_conference_on_enter: true },
643
- );
644
- if (joined !== null) {
645
- this.enter(room, leg);
646
- await this.greet(room, leg, welcome);
647
- return;
648
- }
649
- // The id was stale in a way the clock did not predict -- an operator
650
- // ended it, or Telnyx did. Fall through and make a new one.
651
- room.conferenceId = null;
652
- }
653
-
654
- const created = await this.request("/conferences", {
655
- name: `partyline-${this.now()}-${room.legs.size}`,
656
- call_control_id: leg,
657
- start_conference_on_create: true,
658
- max_participants: this.maxParticipants,
659
- });
660
-
661
- const id = created && typeof created === "object"
662
- ? ((created as Record<string, unknown>)["data"] as Record<string, unknown> | undefined)?.["id"]
663
- : undefined;
664
-
665
- if (typeof id !== "string") {
666
- await this.command(leg, "speak", {
667
- payload: "Sorry, that room could not be opened. Goodbye.",
668
- voice: this.voiceFor(leg),
669
- });
670
- await this.command(leg, "hangup", {});
671
- this.legRoom.delete(leg);
672
- return;
673
- }
674
-
675
- room.conferenceId = id;
676
- room.startedAt = this.now();
677
- this.enter(room, leg);
678
- await this.greet(room, leg, welcome);
679
- }
680
-
681
- private enter(room: Room, leg: string): void {
682
- if (room.legs.has(leg)) return;
683
- room.legs.add(leg);
684
- room.callers = room.legs.size;
685
- this.options.onEvent?.(` a caller joined a room (${room.callers} on the line).`);
686
- }
687
-
688
- /**
689
- * Say hello to somebody who just joined, and only to them.
690
- *
691
- * Spoken into the conference rather than at the leg, because the leg is in
692
- * the conference now and a speak on it is what the join interrupts. Telnyx
693
- * addresses conference speech to particular participants, so the others in
694
- * the room do not hear every arrival welcomed.
695
- */
696
- private async greet(room: Room, leg: string, welcome: string): Promise<void> {
697
- if (room.conferenceId === null) return;
698
- await this.request(`/conferences/${encodeURIComponent(room.conferenceId)}/actions/speak`, {
699
- payload: `${welcome}${peopleHere(room.callers)}`,
700
- voice: this.voiceFor(leg),
701
- call_control_ids: [leg],
702
- });
703
- }
704
-
705
274
  /**
706
275
  * A line said into a room, to everybody on the phone in it. What the
707
276
  * trollbox becomes on the party line: the handle and the words, in the
708
- * voice of whoever typed them. False when nobody is on the phone there,
709
- * which is most of the time and costs nothing.
277
+ * voice of whoever typed them.
710
278
  */
711
- async say(code: string, text: string, voice: string, settings?: { api_key_ref: string }): Promise<boolean> {
712
- const room = this.rooms.get(code);
713
- if (!room || room.conferenceId === null || room.callers === 0) return false;
714
- const said = text.replace(/\s+/g, " ").trim().slice(0, 500);
715
- if (said === "") return false;
716
- const answer = await this.request(`/conferences/${encodeURIComponent(room.conferenceId)}/actions/speak`, {
717
- payload: said,
718
- voice: voice || this.voice,
719
- // An ElevenLabs voice needs the key Telnyx holds for it, by name.
720
- ...(settings ? { voice_settings: settings } : {}),
721
- });
722
- return answer !== null;
723
- }
724
-
725
- /** Whether anybody is on the phone in a room, before a line is worth reading. */
726
- hasCallers(code: string): boolean {
727
- const room = this.rooms.get(code);
728
- return room !== undefined && room.conferenceId !== null && room.callers > 0;
279
+ override async say(code: string, text: string, voice: string, settings?: { api_key_ref: string }): Promise<boolean> {
280
+ return super.say(code, text, voice, settings);
729
281
  }
730
282
 
731
- /**
732
- * How many people are on the phone for a stream.
733
- *
734
- * The room's own count, now that a stream's code is a room like any other.
735
- * It used to count legs with an MP3 playing into them, which is a thing that
736
- * no longer happens.
737
- */
283
+ /** How many people are on the phone for a stream: the room's own count. */
738
284
  listenersOn(code: string): number {
739
- return this.rooms.get(code)?.callers ?? 0;
740
- }
741
-
742
- /** A leg that hung up or was dropped, wherever it was. */
743
- private release(leg: string): void {
744
- const code = this.legRoom.get(leg);
745
- this.legRoom.delete(leg);
746
- if (code === undefined) return;
747
- const room = this.rooms.get(code);
748
- if (room === undefined) return;
749
- room.legs.delete(leg);
750
- room.callers = room.legs.size;
751
- if (room.callers === 0) {
752
- // Telnyx ends an empty conference itself; keeping the code would only
753
- // mean handing the next caller a dead id.
754
- this.rooms.delete(code);
755
- this.options.onEvent?.(" a room is empty.");
756
- }
757
- }
758
-
759
- /**
760
- * The room on this code, made if nobody is using it.
761
- *
762
- * Entering a code nobody is in opens that room rather than failing. The code
763
- * is a rendezvous, not a credential: two people who agree on 482917
764
- * beforehand should both be able to dial in, and neither of them should have
765
- * had to create it first.
766
- */
767
- private room(code: string): Room {
768
- const existing = this.rooms.get(code);
769
- if (existing !== undefined) return existing;
770
- const room: Room = {
771
- code,
772
- conferenceId: null,
773
- callers: 0,
774
- startedAt: this.now(),
775
- legs: new Set<string>(),
776
- };
777
- this.rooms.set(code, room);
778
- return room;
779
- }
780
-
781
- private get voice(): string {
782
- return this.options.voice ?? DEFAULT_VOICE;
783
- }
784
-
785
- /** The voice for this leg: the good one, unless it has already failed them. */
786
- private voiceFor(leg: string): string {
787
- return this.plainVoice.has(leg) ? FALLBACK_VOICE : this.voice;
788
- }
789
-
790
- private get maxParticipants(): number {
791
- return this.options.maxParticipants ?? 50;
792
- }
793
-
794
- /** One call-control command. True when Telnyx accepted it. */
795
- private async command(leg: string, action: string, body: unknown): Promise<boolean> {
796
- const path = `/calls/${encodeURIComponent(leg)}/actions/${action}`;
797
- return (await this.request(path, body)) !== null;
798
- }
799
-
800
- /** A POST to Telnyx, or null if it did not work. */
801
- private async request(path: string, body: unknown): Promise<unknown | null> {
802
- try {
803
- const response = await this.fetch(`${TELNYX_API}${path}`, {
804
- method: "POST",
805
- headers: {
806
- authorization: `Bearer ${this.options.apiKey}`,
807
- "content-type": "application/json",
808
- },
809
- body: JSON.stringify(body),
810
- });
811
- if (!response.ok) {
812
- // A command against a leg that already hung up is a 422 and is not
813
- // worth a stack trace; it is the ordinary end of a race.
814
- this.options.onEvent?.(` telnyx ${path} -> ${response.status}`);
815
- return null;
816
- }
817
- const text = await response.text();
818
- return text ? (JSON.parse(text) as unknown) : {};
819
- } catch (error) {
820
- this.options.onEvent?.(` telnyx ${path} failed: ${(error as Error).message}`);
821
- return null;
822
- }
285
+ return this.callersIn(code);
823
286
  }
824
287
  }
825
-
826
- /**
827
- * Texting, over Telnyx.
828
- *
829
- * A separate `from` because it is a different number: the call arrives on the
830
- * toll-free line, but toll-free A2P messaging is filtered by carriers until
831
- * that number is verified and ours is not yet. The long code already carries a
832
- * messaging profile, so it can send today -- and when verification lands, this
833
- * becomes a one-line change rather than a redesign.
834
- */
835
- export function telnyxSms(
836
- { apiKey, from, fetch = globalThis.fetch, onEvent }: {
837
- apiKey: string;
838
- from: string;
839
- fetch?: typeof globalThis.fetch;
840
- onEvent?: (message: string) => void;
841
- },
842
- ): Sms {
843
- return {
844
- async send(to: string, text: string): Promise<boolean> {
845
- try {
846
- const response = await fetch(`${TELNYX_API}/messages`, {
847
- method: "POST",
848
- headers: { authorization: `Bearer ${apiKey}`, "content-type": "application/json" },
849
- body: JSON.stringify({ from, to, text }),
850
- });
851
- if (!response.ok) {
852
- onEvent?.(` sms to ${to} -> ${response.status}`);
853
- return false;
854
- }
855
- return true;
856
- } catch (error) {
857
- onEvent?.(` sms to ${to} failed: ${(error as Error).message}`);
858
- return false;
859
- }
860
- },
861
- };
862
- }
863
-
864
- /** Constant-time compare, for the places a token is checked rather than signed. */
865
- export function sameSecret(a: string, b: string): boolean {
866
- const left = Buffer.from(a, "utf8");
867
- const right = Buffer.from(b, "utf8");
868
- if (left.length !== right.length) return false;
869
- return timingSafeEqual(left, right);
870
- }