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/dist/partyline.js CHANGED
@@ -2,101 +2,36 @@
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
- /** Where Telnyx's REST API lives. Injectable so a test never leaves the process. */
36
- const TELNYX_API = "https://api.telnyx.com/v2";
16
+ import { GroupCall, peopleHere, roomCodeFrom as groupRoomCodeFrom, sameSecret, spokenCode, telnyxSms, } from "@profullstack/group-call";
17
+ export { peopleHere, sameSecret, spokenCode, telnyxSms };
37
18
  /**
38
19
  * Telnyx's own voice, so a room prompt costs nothing beyond the call. A
39
20
  * Polly or ElevenLabs voice reads better and bills separately; the name is
40
21
  * configuration rather than a constant for exactly that reason.
41
22
  */
42
23
  const DEFAULT_VOICE = "Telnyx.KokoroTTS.af";
43
- /**
44
- * The voice a leg falls back to when the good one fails.
45
- *
46
- * Telnyx's Kokoro voice answered a prompt with a 500 once (2026-09-09), and
47
- * what the caller got was a gather with no speech in it, ended at once with
48
- * no digits, and asked again -- a line that "just repeats itself", silently,
49
- * every ninety seconds. The plain Telnyx voice is older and worse and has not
50
- * been seen to fail, which is the quality that matters on the second try.
51
- */
52
- const FALLBACK_VOICE = "female";
53
- /**
54
- * How long a caller has between digits before the code is treated as done.
55
- *
56
- * Telnyx's default is five seconds and did not fire: a caller who keyed five
57
- * digits (one was lost in the keypad tone over the prompt) waited ten seconds
58
- * in silence and hung up. Set explicitly so the partial code comes back to us
59
- * quickly and we can say how many digits we got.
60
- */
61
- const INTER_DIGIT_MS = 4000;
62
- /**
63
- * How many times a caller is asked for a code before being let go.
64
- *
65
- * A gather that ends with nothing three times is a caller who cannot or will
66
- * not key a code -- or a voice that is not being heard at all. Asking a
67
- * fourth time is the loop that was reported; saying goodbye is not.
68
- */
69
- const MAX_ASKS = 3;
70
- /** How long a signed webhook stays acceptable. Telnyx's own SDKs use five minutes. */
71
- const SIGNATURE_TOLERANCE_MS = 5 * 60 * 1000;
72
- /** A conference Telnyx will discard on its own, so we stop trusting ours first. */
73
- const CONFERENCE_TTL_MS = 4 * 60 * 60 * 1000;
74
24
  /**
75
25
  * How long a room code is.
76
26
  *
77
27
  * Six digits, because the code has to survive being read down a phone line and
78
- * typed into a URL. The generated ids this replaces were long enough that
79
- * nobody could say one out loud, which is the whole failure being fixed: a
80
- * room code is something you tell somebody, so it has to be short enough to
81
- * hold in your head between hearing it and dialling it.
28
+ * typed into a URL. A room code is something you tell somebody, so it has to
29
+ * be short enough to hold in your head between hearing it and dialling it.
82
30
  */
83
31
  export const CODE_LENGTH = 6;
84
- /**
85
- * A room code, from whatever the caller keyed.
86
- *
87
- * Digits only, and exactly six of them. Five is not a near miss to be
88
- * charitable about -- it is a different room, and guessing which one they
89
- * meant would drop somebody into a stranger's conversation.
90
- *
91
- * Nothing here is case-sensitive because nothing here has a case. That is the
92
- * point of digits over letters: a phone keypad has one way to type a 4, and no
93
- * two people disagree about how to say it.
94
- */
32
+ /** A six-digit room code from whatever the caller keyed, or "". */
95
33
  export function roomCodeFrom(entered) {
96
- if (typeof entered !== "string" && typeof entered !== "number")
97
- return "";
98
- const digits = String(entered).replace(/\D/g, "");
99
- return digits.length === CODE_LENGTH ? digits : "";
34
+ return groupRoomCodeFrom(entered, CODE_LENGTH);
100
35
  }
101
36
  /**
102
37
  * A time as a caller should hear it.
@@ -115,17 +50,12 @@ export function pacificTime(at) {
115
50
  }).format(new Date(at));
116
51
  return `${clock} Pacific`;
117
52
  }
118
- /** How a code is read back: one digit at a time, because 482917 is not a number. */
119
- export function spokenCode(code) {
120
- return code.split("").join(", ");
121
- }
122
53
  /**
123
54
  * A track's name as it should be read out.
124
55
  *
125
56
  * What is playing is a filename more often than a title: "02 - ...And Justice
126
57
  * For All.mp3" read aloud is "zero two dash dot dot dot", and the extension is
127
- * a noise the caller does not need. The track number and extension go; what
128
- * is left is close enough to a title to say.
58
+ * a noise the caller does not need.
129
59
  */
130
60
  export function spokenTitle(nowPlaying) {
131
61
  return nowPlaying
@@ -135,66 +65,10 @@ export function spokenTitle(nowPlaying) {
135
65
  .trim();
136
66
  }
137
67
  /**
138
- * How many people are in a room, said to the one who just walked in.
139
- *
140
- * The count includes them: "there are 3 people in here" is what you say to the
141
- * third person, and "you are the first one here" is what you say to the first,
142
- * who would otherwise be told there is one person in an empty room.
143
- */
144
- export function peopleHere(callers) {
145
- if (callers <= 1)
146
- return "You are the first one here. Say hello when somebody joins.";
147
- if (callers === 2)
148
- return "There is one other person in here. Say hello.";
149
- return `There are ${callers} people in here. Say hello.`;
150
- }
151
- /**
152
- * Telnyx signs `${timestamp}|${body}` with ed25519 and sends both back in
153
- * headers. Node will not take a bare 32-byte key, so it is wrapped in the
154
- * fixed SPKI prefix that says "this is ed25519" and handed over as DER.
68
+ * The party line, as a thing that answers webhooks: a group call whose codes
69
+ * can also be streams.
155
70
  */
156
- const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
157
- function ed25519KeyFrom(base64Key) {
158
- let raw;
159
- try {
160
- raw = Buffer.from(base64Key, "base64");
161
- }
162
- catch {
163
- return null;
164
- }
165
- if (raw.length !== 32)
166
- return null;
167
- try {
168
- return createPublicKey({
169
- key: Buffer.concat([ED25519_SPKI_PREFIX, raw]),
170
- format: "der",
171
- type: "spki",
172
- });
173
- }
174
- catch {
175
- return null;
176
- }
177
- }
178
- /**
179
- * The party line, as a thing that answers webhooks.
180
- *
181
- * It owns no socket and no timer. The server hands it a verified event and it
182
- * issues whatever call-control commands that event calls for, which makes the
183
- * whole state machine testable with a fetch that records what it was asked.
184
- */
185
- export class PartyLine {
186
- options;
187
- rooms = new Map();
188
- /** Which room a leg is heading for, between asking and being answered. */
189
- legRoom = new Map();
190
- /** The number each caller is calling from, for a reminder they ask for. */
191
- legFrom = new Map();
192
- /** Legs that heard "press 1", and which stream they would be reminded about. */
193
- pendingReminder = new Map();
194
- /** How many times each leg has been asked for a code, so it is not forever. */
195
- asks = new Map();
196
- /** Legs whose voice failed once, which hear the plain one from then on. */
197
- plainVoice = new Set();
71
+ export class PartyLine extends GroupCall {
198
72
  /** Who to text when a stream returns, by stream code. */
199
73
  reminders = new Map();
200
74
  /**
@@ -205,6 +79,27 @@ export class PartyLine {
205
79
  * break a promise made to somebody on a telephone.
206
80
  */
207
81
  reminderStore = null;
82
+ party;
83
+ constructor(options) {
84
+ let self;
85
+ super({
86
+ apiKey: options.apiKey,
87
+ publicKey: options.publicKey,
88
+ codeLength: CODE_LENGTH,
89
+ conferencePrefix: "partyline",
90
+ greeting: options.greeting ?? "Welcome to the nixamp party line. Enter the six digit room code.",
91
+ voice: options.voice ?? DEFAULT_VOICE,
92
+ ...(options.maxParticipants !== undefined ? { maxParticipants: options.maxParticipants } : {}),
93
+ ...(options.now ? { now: options.now } : {}),
94
+ ...(options.fetch ? { fetch: options.fetch } : {}),
95
+ ...(options.onEvent ? { onEvent: options.onEvent } : {}),
96
+ // A code that belongs to a stream is answered as a stream. Anything else
97
+ // is an ordinary room, which is what this line was before.
98
+ onCode: (code, call) => self.stream(code, call),
99
+ });
100
+ self = this;
101
+ this.party = options;
102
+ }
208
103
  /** Start echoing reminders somewhere durable, and put back what was there. */
209
104
  persistRemindersTo(store, waiting = new Map()) {
210
105
  this.reminderStore = store;
@@ -215,260 +110,51 @@ export class PartyLine {
215
110
  this.reminders.set(code, set);
216
111
  }
217
112
  }
218
- key;
219
- fetch;
220
- now;
221
- constructor(options) {
222
- this.options = options;
223
- this.key = options.publicKey ? ed25519KeyFrom(options.publicKey) : null;
224
- this.fetch = options.fetch ?? globalThis.fetch;
225
- this.now = options.now ?? Date.now;
226
- }
227
- /** True when this instance can actually check a signature. */
228
- get armed() {
229
- return this.key !== null && this.options.apiKey.length > 0;
230
- }
231
- /**
232
- * Whether a webhook really came from Telnyx.
233
- *
234
- * The body has to be the bytes that arrived. Parsing and reserialising JSON
235
- * changes key order and whitespace, and the signature is over the original.
236
- */
237
- verify(rawBody, signature, timestamp) {
238
- if (this.key === null || !signature || !timestamp)
239
- return false;
240
- const sent = Number(timestamp) * 1000;
241
- if (!Number.isFinite(sent))
242
- return false;
243
- // Both directions: a replayed webhook is old, and a clock-skewed forgery
244
- // from the future is no more trustworthy for being ahead.
245
- if (Math.abs(this.now() - sent) > SIGNATURE_TOLERANCE_MS)
246
- return false;
247
- let sig;
248
- try {
249
- sig = Buffer.from(signature, "base64");
250
- }
251
- catch {
252
- return false;
253
- }
254
- if (sig.length !== 64)
255
- return false;
256
- try {
257
- return verifySignature(null, Buffer.from(`${timestamp}|${rawBody}`, "utf8"), this.key, sig);
258
- }
259
- catch {
260
- return false;
261
- }
262
- }
263
- /**
264
- * The rooms with someone in them, busiest first, with their codes.
265
- *
266
- * The code is published on purpose. An earlier version withheld it on the
267
- * reasoning that a code is the only thing between a stranger and a
268
- * conversation -- true of a private room, and wrong here: this is a public
269
- * call-in line, and a listing you cannot dial is a listing of nothing. The
270
- * code is how you join, so it is what the list is for.
271
- */
272
- list() {
273
- return [...this.rooms.values()]
274
- .filter((room) => room.callers > 0)
275
- .map(({ code, callers, startedAt }) => ({ code, callers, startedAt }))
276
- .sort((a, b) => b.callers - a.callers || a.startedAt - b.startedAt);
277
- }
278
- /**
279
- * Drive one call-control event.
280
- *
281
- * Every branch returns rather than falling through, because an event we do
282
- * not handle is the normal case -- Telnyx sends a dozen kinds per call and
283
- * this cares about four.
284
- */
285
- async handle(event) {
286
- const type = event.event_type ?? "";
287
- const payload = event.payload ?? {};
288
- const leg = typeof payload["call_control_id"] === "string" ? payload["call_control_id"] : "";
289
- if (!leg)
290
- return;
291
- if (type === "call.initiated") {
292
- // Only inbound. An outbound leg we dialled is not somebody calling in,
293
- // and answering it would be answering ourselves.
294
- if (payload["direction"] !== "incoming")
295
- return;
296
- // Kept now because a reminder needs it later, and by the time the caller
297
- // presses 1 the only thing we have is the leg.
298
- if (typeof payload["from"] === "string")
299
- this.legFrom.set(leg, payload["from"]);
300
- await this.command(leg, "answer", {});
301
- return;
302
- }
303
- if (type === "call.answered") {
304
- await this.ask(leg);
305
- return;
306
- }
307
- if (type === "call.speak.failed") {
308
- // The voice, not the caller, failed. Everything this leg hears from now
309
- // on is in the plain voice; the gather this speech belonged to ends on
310
- // its own and is asked again, audibly this time.
311
- this.plainVoice.add(leg);
312
- this.options.onEvent?.(" a prompt could not be spoken; using the plain voice.");
313
- return;
314
- }
315
- if (type === "call.gather.ended") {
316
- const digits = typeof payload["digits"] === "string" ? payload["digits"] : "";
317
- // A gather that ended because the caller hung up is not an answer, and
318
- // anything sent to that leg now is a 422 for the log.
319
- if (payload["status"] === "call_hangup")
320
- return;
321
- // A leg that was just offered a reminder is answering that, not keying a
322
- // room code -- the same event carries both, so the question we asked is
323
- // what decides how to read it.
324
- const offered = this.pendingReminder.get(leg);
325
- if (offered !== undefined) {
326
- this.pendingReminder.delete(leg);
327
- await this.reminder(leg, offered, digits);
328
- return;
329
- }
330
- const code = roomCodeFrom(digits);
331
- if (!code) {
332
- // Re-ask rather than guess. Anything that is not six digits is not a
333
- // room, and picking the nearest one would be picking a stranger's.
334
- // Say what we got: five digits and silence is a caller who thinks
335
- // the line is broken, and a caller who keyed nothing does not need
336
- // telling their nothing was not six digits.
337
- const got = digits.replace(/\D/g, "").length;
338
- await this.ask(leg, got > 0 ? `I only got ${got} digits. ` : "I did not get a code. ");
339
- return;
340
- }
341
- this.asks.delete(leg);
342
- // A code that belongs to a stream is answered as a stream. Anything else
343
- // is an ordinary room, which is what this line was before.
344
- if (await this.stream(leg, code))
345
- return;
346
- await this.join(leg, code);
347
- return;
348
- }
349
- if (type === "conference.participant.left" || type === "call.hangup") {
350
- this.release(leg);
351
- this.legFrom.delete(leg);
352
- this.pendingReminder.delete(leg);
353
- this.asks.delete(leg);
354
- this.plainVoice.delete(leg);
355
- return;
356
- }
357
- }
358
- /**
359
- * Ask for a room code, on the keypad.
360
- *
361
- * Not by voice, which is the one thing here that changed its mind. Speech
362
- * suited a room *name* -- "blue" misheard is still recognisably a word, and
363
- * a person can say it differently the second time. A six-digit code has no
364
- * such slack: one digit misheard is a different room that also exists, and
365
- * the caller lands in a stranger's conversation with nothing to tell them
366
- * they went wrong. A keypad cannot mishear a 4.
367
- *
368
- * Six digits terminates the gather on its own, so the caller does not have
369
- * to press anything after; # is there for the ones who do it anyway.
370
- */
371
- async ask(leg, prefix = "") {
372
- const asked = (this.asks.get(leg) ?? 0) + 1;
373
- this.asks.set(leg, asked);
374
- if (asked > MAX_ASKS) {
375
- // Three gathers with no code in them is not a caller who needs a fourth
376
- // prompt. Whatever is wrong -- their keypad, our voice -- repeating
377
- // ourselves is the failure that was reported, so this ends instead.
378
- await this.command(leg, "speak", {
379
- payload: "I did not get a room code. Goodbye.",
380
- voice: this.voiceFor(leg),
381
- });
382
- await this.command(leg, "hangup", {});
383
- return;
384
- }
385
- // Short, because callers key the code over the prompt and a long one
386
- // costs digits: a tone pressed as the speech starts was not heard. The
387
- // first time gets the welcome; a re-ask has already been welcomed.
388
- const greeting = this.options.greeting ?? "Welcome to the nixamp party line. Enter the six digit room code.";
389
- const payload = asked === 1 ? `${prefix}${greeting}` : `${prefix}Enter the six digit room code.`;
390
- await this.command(leg, "gather_using_speak", {
391
- payload,
392
- voice: this.voiceFor(leg),
393
- valid_digits: "0123456789",
394
- minimum_digits: CODE_LENGTH,
395
- maximum_digits: CODE_LENGTH,
396
- terminating_digit: "#",
397
- timeout_millis: 20000,
398
- inter_digit_timeout_millis: INTER_DIGIT_MS,
399
- });
400
- }
401
113
  /**
402
114
  * Answer a code that belongs to a stream, rather than a room.
403
115
  *
404
- * Returns false when the code is nobody's stream, which is how an ordinary
405
- * room code still works: this line was a party line before it was a way into
406
- * a broadcast, and a code that means nothing to the directory should still
407
- * mean a room.
408
- *
409
116
  * Keying a stream's code puts you in a room with the other people watching
410
- * it. It does not play the stream at you, which is what it used to do: this
411
- * is the phone line beside a broadcast, the way a podcast has an 800 number
412
- * -- the show is on your screen and the phone is where you talk about it.
413
- * Playing the audio down the phone was both the worse half of the idea and
414
- * the one that kept failing, because a share link answers a 302 and a cookie
415
- * rather than an MP3.
117
+ * it. It does not play the stream at you: this is the phone line beside a
118
+ * broadcast, the way a podcast has an 800 number -- the show is on your
119
+ * screen and the phone is where you talk about it.
416
120
  */
417
- async stream(leg, code) {
418
- const streams = this.options.streams;
121
+ async stream(code, call) {
122
+ const streams = this.party.streams;
419
123
  if (streams === undefined)
420
- return false;
124
+ return true;
421
125
  const live = streams.liveByCode(code);
422
126
  if (live !== undefined) {
423
127
  // The welcome is said once they are in the room, not before: a speak
424
- // on the leg followed by the conference join was cut off by the join --
425
- // Telnyx reported it started and ended in the same millisecond -- and
426
- // the caller heard twenty seconds of nothing and hung up.
128
+ // on the leg followed by the conference join was cut off by the join.
427
129
  const title = spokenTitle(live.nowPlaying);
428
130
  const what = title ? `, playing ${title}` : "";
429
- await this.join(leg, code, `Welcome to the live room for ${live.name}${what}. `);
430
- return true;
131
+ return { welcome: `Welcome to the live room for ${live.name}${what}. ` };
431
132
  }
432
133
  const ended = streams.endedByCode(code);
433
134
  if (ended === undefined)
434
- return false;
135
+ return true;
435
136
  const what = ended.nowPlaying ? ` of ${ended.nowPlaying}` : "";
436
- // Set before the prompt, not after: the answer can arrive while we are
437
- // still awaiting the command that asked for it.
438
- this.pendingReminder.set(leg, code);
439
- await this.command(leg, "gather_using_speak", {
440
- payload: `Welcome to ${ended.name}'s live stream${what}. ` +
441
- `The live stream ended at ${pacificTime(ended.endedAt)}. ` +
442
- "Call back later when they stream again. " +
443
- "Press 1 to get a text message when they do.",
444
- voice: this.voiceFor(leg),
445
- valid_digits: "1",
446
- minimum_digits: 1,
447
- maximum_digits: 1,
448
- timeout_millis: 12000,
449
- });
450
- return true;
137
+ await call.gather(`Welcome to ${ended.name}'s live stream${what}. ` +
138
+ `The live stream ended at ${pacificTime(ended.endedAt)}. ` +
139
+ "Call back later when they stream again. " +
140
+ "Press 1 to get a text message when they do.", { validDigits: "1", minimumDigits: 1, maximumDigits: 1, timeoutMillis: 12000 }, (digits) => this.reminder(call, code, digits));
141
+ return "handled";
451
142
  }
452
143
  /** Whether the caller took the reminder that was offered. */
453
- async reminder(leg, code, digits) {
454
- const from = this.legFrom.get(leg) ?? "";
144
+ async reminder(call, code, digits) {
145
+ const from = call.from;
455
146
  if (!digits.includes("1") || !from) {
456
147
  // Not pressing 1 is an answer. So is a call with no caller id, which we
457
148
  // cannot text however willing the caller was.
458
- await this.command(leg, "speak", { payload: "Goodbye.", voice: this.voice });
459
- await this.command(leg, "hangup", {});
149
+ await call.goodbye("Goodbye.");
460
150
  return;
461
151
  }
462
152
  const waiting = this.reminders.get(code) ?? new Set();
463
153
  waiting.add(from);
464
154
  this.reminders.set(code, waiting);
465
155
  this.reminderStore?.add(code, from);
466
- this.options.onEvent?.(` a caller asked to be told when ${code} is live again.`);
467
- await this.command(leg, "speak", {
468
- payload: "Got it. We will text you when they are live again. Goodbye.",
469
- voice: this.voiceFor(leg),
470
- });
471
- await this.command(leg, "hangup", {});
156
+ this.party.onEvent?.(` a caller asked to be told when ${code} is live again.`);
157
+ await call.goodbye("Got it. We will text you when they are live again. Goodbye.");
472
158
  }
473
159
  /**
474
160
  * A stream came back: text whoever asked to be told.
@@ -478,7 +164,7 @@ export class PartyLine {
478
164
  * the week is how a useful message becomes the reason they block the number.
479
165
  */
480
166
  async wentLive(stream) {
481
- const sms = this.options.sms;
167
+ const sms = this.party.sms;
482
168
  // Taken from the store first, and that take is what clears it: a number
483
169
  // put there by a process that has since been replaced is still owed a
484
170
  // text, and this one never heard the call that promised it.
@@ -491,251 +177,29 @@ export class PartyLine {
491
177
  // STOP is not decoration: an automated text to a US number has to say how
492
178
  // to make it stop, and the carriers check.
493
179
  const text = `${stream.name} is live now${what} on nixamp. ` +
494
- `Call ${this.options.callIn ?? "408-357-2326"} and key ${stream.code} to listen. ` +
180
+ `Call ${this.party.callIn ?? "408-357-2326"} and key ${stream.code} to listen. ` +
495
181
  "Reply STOP to opt out.";
496
182
  let sent = 0;
497
183
  for (const to of waiting)
498
184
  if (await sms.send(to, text))
499
185
  sent += 1;
500
- this.options.onEvent?.(` texted ${sent} of ${waiting.size} waiting on ${stream.code}.`);
186
+ this.party.onEvent?.(` texted ${sent} of ${waiting.size} waiting on ${stream.code}.`);
501
187
  return sent;
502
188
  }
503
189
  /** How many numbers are waiting to hear that a code is live. */
504
190
  waitingOn(code) {
505
191
  return this.reminders.get(code)?.size ?? 0;
506
192
  }
507
- /**
508
- * Put a leg into a room, making the conference if it is the first one there.
509
- *
510
- * `welcome` is what the caller hears once they are in, before the count of
511
- * who else is; a stream's room names the stream, an ordinary room reads its
512
- * code back.
513
- */
514
- async join(leg, code, welcome = `Welcome to room ${spokenCode(code)}. `) {
515
- const room = this.room(code);
516
- if (room.callers >= this.maxParticipants) {
517
- await this.command(leg, "speak", {
518
- payload: "That room is full. Goodbye.",
519
- voice: this.voiceFor(leg),
520
- });
521
- await this.command(leg, "hangup", {});
522
- return;
523
- }
524
- this.legRoom.set(leg, code);
525
- // A conference we made more than four hours ago is gone on Telnyx's side
526
- // whatever our map says, so it is remade rather than joined.
527
- const stale = this.now() - room.startedAt > CONFERENCE_TTL_MS;
528
- if (room.conferenceId !== null && !stale) {
529
- const joined = await this.request(`/conferences/${encodeURIComponent(room.conferenceId)}/actions/join`, { call_control_id: leg, start_conference_on_enter: true });
530
- if (joined !== null) {
531
- this.enter(room, leg);
532
- await this.greet(room, leg, welcome);
533
- return;
534
- }
535
- // The id was stale in a way the clock did not predict -- an operator
536
- // ended it, or Telnyx did. Fall through and make a new one.
537
- room.conferenceId = null;
538
- }
539
- const created = await this.request("/conferences", {
540
- name: `partyline-${this.now()}-${room.legs.size}`,
541
- call_control_id: leg,
542
- start_conference_on_create: true,
543
- max_participants: this.maxParticipants,
544
- });
545
- const id = created && typeof created === "object"
546
- ? created["data"]?.["id"]
547
- : undefined;
548
- if (typeof id !== "string") {
549
- await this.command(leg, "speak", {
550
- payload: "Sorry, that room could not be opened. Goodbye.",
551
- voice: this.voiceFor(leg),
552
- });
553
- await this.command(leg, "hangup", {});
554
- this.legRoom.delete(leg);
555
- return;
556
- }
557
- room.conferenceId = id;
558
- room.startedAt = this.now();
559
- this.enter(room, leg);
560
- await this.greet(room, leg, welcome);
561
- }
562
- enter(room, leg) {
563
- if (room.legs.has(leg))
564
- return;
565
- room.legs.add(leg);
566
- room.callers = room.legs.size;
567
- this.options.onEvent?.(` a caller joined a room (${room.callers} on the line).`);
568
- }
569
- /**
570
- * Say hello to somebody who just joined, and only to them.
571
- *
572
- * Spoken into the conference rather than at the leg, because the leg is in
573
- * the conference now and a speak on it is what the join interrupts. Telnyx
574
- * addresses conference speech to particular participants, so the others in
575
- * the room do not hear every arrival welcomed.
576
- */
577
- async greet(room, leg, welcome) {
578
- if (room.conferenceId === null)
579
- return;
580
- await this.request(`/conferences/${encodeURIComponent(room.conferenceId)}/actions/speak`, {
581
- payload: `${welcome}${peopleHere(room.callers)}`,
582
- voice: this.voiceFor(leg),
583
- call_control_ids: [leg],
584
- });
585
- }
586
193
  /**
587
194
  * A line said into a room, to everybody on the phone in it. What the
588
195
  * trollbox becomes on the party line: the handle and the words, in the
589
- * voice of whoever typed them. False when nobody is on the phone there,
590
- * which is most of the time and costs nothing.
196
+ * voice of whoever typed them.
591
197
  */
592
198
  async say(code, text, voice, settings) {
593
- const room = this.rooms.get(code);
594
- if (!room || room.conferenceId === null || room.callers === 0)
595
- return false;
596
- const said = text.replace(/\s+/g, " ").trim().slice(0, 500);
597
- if (said === "")
598
- return false;
599
- const answer = await this.request(`/conferences/${encodeURIComponent(room.conferenceId)}/actions/speak`, {
600
- payload: said,
601
- voice: voice || this.voice,
602
- // An ElevenLabs voice needs the key Telnyx holds for it, by name.
603
- ...(settings ? { voice_settings: settings } : {}),
604
- });
605
- return answer !== null;
606
- }
607
- /** Whether anybody is on the phone in a room, before a line is worth reading. */
608
- hasCallers(code) {
609
- const room = this.rooms.get(code);
610
- return room !== undefined && room.conferenceId !== null && room.callers > 0;
199
+ return super.say(code, text, voice, settings);
611
200
  }
612
- /**
613
- * How many people are on the phone for a stream.
614
- *
615
- * The room's own count, now that a stream's code is a room like any other.
616
- * It used to count legs with an MP3 playing into them, which is a thing that
617
- * no longer happens.
618
- */
201
+ /** How many people are on the phone for a stream: the room's own count. */
619
202
  listenersOn(code) {
620
- return this.rooms.get(code)?.callers ?? 0;
621
- }
622
- /** A leg that hung up or was dropped, wherever it was. */
623
- release(leg) {
624
- const code = this.legRoom.get(leg);
625
- this.legRoom.delete(leg);
626
- if (code === undefined)
627
- return;
628
- const room = this.rooms.get(code);
629
- if (room === undefined)
630
- return;
631
- room.legs.delete(leg);
632
- room.callers = room.legs.size;
633
- if (room.callers === 0) {
634
- // Telnyx ends an empty conference itself; keeping the code would only
635
- // mean handing the next caller a dead id.
636
- this.rooms.delete(code);
637
- this.options.onEvent?.(" a room is empty.");
638
- }
203
+ return this.callersIn(code);
639
204
  }
640
- /**
641
- * The room on this code, made if nobody is using it.
642
- *
643
- * Entering a code nobody is in opens that room rather than failing. The code
644
- * is a rendezvous, not a credential: two people who agree on 482917
645
- * beforehand should both be able to dial in, and neither of them should have
646
- * had to create it first.
647
- */
648
- room(code) {
649
- const existing = this.rooms.get(code);
650
- if (existing !== undefined)
651
- return existing;
652
- const room = {
653
- code,
654
- conferenceId: null,
655
- callers: 0,
656
- startedAt: this.now(),
657
- legs: new Set(),
658
- };
659
- this.rooms.set(code, room);
660
- return room;
661
- }
662
- get voice() {
663
- return this.options.voice ?? DEFAULT_VOICE;
664
- }
665
- /** The voice for this leg: the good one, unless it has already failed them. */
666
- voiceFor(leg) {
667
- return this.plainVoice.has(leg) ? FALLBACK_VOICE : this.voice;
668
- }
669
- get maxParticipants() {
670
- return this.options.maxParticipants ?? 50;
671
- }
672
- /** One call-control command. True when Telnyx accepted it. */
673
- async command(leg, action, body) {
674
- const path = `/calls/${encodeURIComponent(leg)}/actions/${action}`;
675
- return (await this.request(path, body)) !== null;
676
- }
677
- /** A POST to Telnyx, or null if it did not work. */
678
- async request(path, body) {
679
- try {
680
- const response = await this.fetch(`${TELNYX_API}${path}`, {
681
- method: "POST",
682
- headers: {
683
- authorization: `Bearer ${this.options.apiKey}`,
684
- "content-type": "application/json",
685
- },
686
- body: JSON.stringify(body),
687
- });
688
- if (!response.ok) {
689
- // A command against a leg that already hung up is a 422 and is not
690
- // worth a stack trace; it is the ordinary end of a race.
691
- this.options.onEvent?.(` telnyx ${path} -> ${response.status}`);
692
- return null;
693
- }
694
- const text = await response.text();
695
- return text ? JSON.parse(text) : {};
696
- }
697
- catch (error) {
698
- this.options.onEvent?.(` telnyx ${path} failed: ${error.message}`);
699
- return null;
700
- }
701
- }
702
- }
703
- /**
704
- * Texting, over Telnyx.
705
- *
706
- * A separate `from` because it is a different number: the call arrives on the
707
- * toll-free line, but toll-free A2P messaging is filtered by carriers until
708
- * that number is verified and ours is not yet. The long code already carries a
709
- * messaging profile, so it can send today -- and when verification lands, this
710
- * becomes a one-line change rather than a redesign.
711
- */
712
- export function telnyxSms({ apiKey, from, fetch = globalThis.fetch, onEvent }) {
713
- return {
714
- async send(to, text) {
715
- try {
716
- const response = await fetch(`${TELNYX_API}/messages`, {
717
- method: "POST",
718
- headers: { authorization: `Bearer ${apiKey}`, "content-type": "application/json" },
719
- body: JSON.stringify({ from, to, text }),
720
- });
721
- if (!response.ok) {
722
- onEvent?.(` sms to ${to} -> ${response.status}`);
723
- return false;
724
- }
725
- return true;
726
- }
727
- catch (error) {
728
- onEvent?.(` sms to ${to} failed: ${error.message}`);
729
- return false;
730
- }
731
- },
732
- };
733
- }
734
- /** Constant-time compare, for the places a token is checked rather than signed. */
735
- export function sameSecret(a, b) {
736
- const left = Buffer.from(a, "utf8");
737
- const right = Buffer.from(b, "utf8");
738
- if (left.length !== right.length)
739
- return false;
740
- return timingSafeEqual(left, right);
741
205
  }