nixamp 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +171 -0
- package/dist/accounts.d.ts +54 -0
- package/dist/accounts.js +160 -0
- package/dist/broadcast.d.ts +96 -0
- package/dist/broadcast.js +193 -0
- package/dist/channels.d.ts +94 -0
- package/dist/channels.js +235 -0
- package/dist/connections.d.ts +6 -0
- package/dist/connections.js +13 -0
- package/dist/directory.d.ts +186 -0
- package/dist/directory.js +275 -0
- package/dist/durable.d.ts +70 -0
- package/dist/durable.js +156 -0
- package/dist/follows.d.ts +92 -0
- package/dist/follows.js +248 -0
- package/dist/ingest.d.ts +80 -0
- package/dist/ingest.js +252 -0
- package/dist/main.js +21 -0
- package/dist/manage.js +2 -1
- package/dist/notify.d.ts +83 -0
- package/dist/notify.js +126 -0
- package/dist/optin.d.ts +37 -0
- package/dist/optin.js +122 -0
- package/dist/owner.d.ts +53 -0
- package/dist/owner.js +96 -0
- package/dist/partyline.d.ts +259 -0
- package/dist/partyline.js +616 -0
- package/dist/paywall.d.ts +60 -0
- package/dist/paywall.js +162 -0
- package/dist/playlist.js +5 -0
- package/dist/publish.d.ts +57 -0
- package/dist/publish.js +106 -0
- package/dist/rtmp-in.d.ts +22 -0
- package/dist/rtmp-in.js +79 -0
- package/dist/server.d.ts +94 -0
- package/dist/server.js +1158 -12
- package/dist/session.d.ts +29 -0
- package/dist/session.js +184 -0
- package/dist/share.d.ts +26 -0
- package/dist/share.js +31 -0
- package/package.json +8 -2
- package/src/accounts.ts +193 -0
- package/src/broadcast.ts +264 -0
- package/src/channels.ts +281 -0
- package/src/connections.ts +13 -0
- package/src/directory.ts +362 -0
- package/src/durable.ts +215 -0
- package/src/follows.ts +307 -0
- package/src/ingest.ts +297 -0
- package/src/main.ts +21 -0
- package/src/manage.ts +2 -1
- package/src/notify.ts +217 -0
- package/src/optin.ts +128 -0
- package/src/owner.ts +113 -0
- package/src/partyline.ts +742 -0
- package/src/paywall.ts +198 -0
- package/src/playlist.ts +5 -0
- package/src/publish.ts +137 -0
- package/src/rtmp-in.ts +90 -0
- package/src/server.ts +1304 -12
- package/src/session.ts +209 -0
- package/src/share.ts +40 -0
- package/src/types/auth-system.d.ts +77 -0
- package/web/dist/assets/{index-BGKWWaIx.css → index-DSIDSSPF.css} +1 -1
- package/web/dist/assets/index-qRguFskX.js +1 -0
- package/web/dist/index.html +62 -6
- package/web/dist/install.sh +82 -0
- package/web/dist/sw.js +45 -3
- package/web/dist/assets/index-Dhja5wxB.js +0 -1
|
@@ -0,0 +1,616 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The party line.
|
|
3
|
+
*
|
|
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
|
|
6
|
+
* line with whoever else keyed 482917.
|
|
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.
|
|
33
|
+
*/
|
|
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";
|
|
37
|
+
/**
|
|
38
|
+
* Telnyx's own voice, so a room prompt costs nothing beyond the call. A
|
|
39
|
+
* Polly or ElevenLabs voice reads better and bills separately; the name is
|
|
40
|
+
* configuration rather than a constant for exactly that reason.
|
|
41
|
+
*/
|
|
42
|
+
const DEFAULT_VOICE = "Telnyx.KokoroTTS.af";
|
|
43
|
+
/** How long a signed webhook stays acceptable. Telnyx's own SDKs use five minutes. */
|
|
44
|
+
const SIGNATURE_TOLERANCE_MS = 5 * 60 * 1000;
|
|
45
|
+
/** A conference Telnyx will discard on its own, so we stop trusting ours first. */
|
|
46
|
+
const CONFERENCE_TTL_MS = 4 * 60 * 60 * 1000;
|
|
47
|
+
/**
|
|
48
|
+
* How long a room code is.
|
|
49
|
+
*
|
|
50
|
+
* Six digits, because the code has to survive being read down a phone line and
|
|
51
|
+
* typed into a URL. The generated ids this replaces were long enough that
|
|
52
|
+
* nobody could say one out loud, which is the whole failure being fixed: a
|
|
53
|
+
* room code is something you tell somebody, so it has to be short enough to
|
|
54
|
+
* hold in your head between hearing it and dialling it.
|
|
55
|
+
*/
|
|
56
|
+
export const CODE_LENGTH = 6;
|
|
57
|
+
/**
|
|
58
|
+
* A room code, from whatever the caller keyed.
|
|
59
|
+
*
|
|
60
|
+
* Digits only, and exactly six of them. Five is not a near miss to be
|
|
61
|
+
* charitable about -- it is a different room, and guessing which one they
|
|
62
|
+
* meant would drop somebody into a stranger's conversation.
|
|
63
|
+
*
|
|
64
|
+
* Nothing here is case-sensitive because nothing here has a case. That is the
|
|
65
|
+
* point of digits over letters: a phone keypad has one way to type a 4, and no
|
|
66
|
+
* two people disagree about how to say it.
|
|
67
|
+
*/
|
|
68
|
+
export function roomCodeFrom(entered) {
|
|
69
|
+
if (typeof entered !== "string" && typeof entered !== "number")
|
|
70
|
+
return "";
|
|
71
|
+
const digits = String(entered).replace(/\D/g, "");
|
|
72
|
+
return digits.length === CODE_LENGTH ? digits : "";
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* A time as a caller should hear it.
|
|
76
|
+
*
|
|
77
|
+
* Pacific, spelled out, because that is the clock the streams are announced on
|
|
78
|
+
* and a bare "9:27" down a phone line is a time in somebody's head rather than
|
|
79
|
+
* a time. Built with Intl rather than arithmetic: the offset changes twice a
|
|
80
|
+
* year and hand-rolled zone maths is how you end up an hour out for three
|
|
81
|
+
* weeks every spring.
|
|
82
|
+
*/
|
|
83
|
+
export function pacificTime(at) {
|
|
84
|
+
const clock = new Intl.DateTimeFormat("en-US", {
|
|
85
|
+
hour: "numeric",
|
|
86
|
+
minute: "2-digit",
|
|
87
|
+
timeZone: "America/Los_Angeles",
|
|
88
|
+
}).format(new Date(at));
|
|
89
|
+
return `${clock} Pacific`;
|
|
90
|
+
}
|
|
91
|
+
/** How a code is read back: one digit at a time, because 482917 is not a number. */
|
|
92
|
+
export function spokenCode(code) {
|
|
93
|
+
return code.split("").join(", ");
|
|
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({
|
|
113
|
+
key: Buffer.concat([ED25519_SPKI_PREFIX, raw]),
|
|
114
|
+
format: "der",
|
|
115
|
+
type: "spki",
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* The party line, as a thing that answers webhooks.
|
|
124
|
+
*
|
|
125
|
+
* It owns no socket and no timer. The server hands it a verified event and it
|
|
126
|
+
* issues whatever call-control commands that event calls for, which makes the
|
|
127
|
+
* whole state machine testable with a fetch that records what it was asked.
|
|
128
|
+
*/
|
|
129
|
+
export class PartyLine {
|
|
130
|
+
options;
|
|
131
|
+
rooms = new Map();
|
|
132
|
+
/** Which room a leg is heading for, between asking and being answered. */
|
|
133
|
+
legRoom = new Map();
|
|
134
|
+
/** The number each caller is calling from, for a reminder they ask for. */
|
|
135
|
+
legFrom = new Map();
|
|
136
|
+
/** Legs that heard "press 1", and which stream they would be reminded about. */
|
|
137
|
+
pendingReminder = new Map();
|
|
138
|
+
/** Who to text when a stream returns, by stream code. */
|
|
139
|
+
reminders = new Map();
|
|
140
|
+
/**
|
|
141
|
+
* The same list, somewhere that survives a deploy.
|
|
142
|
+
*
|
|
143
|
+
* A caller who pressed 1 was told they would be texted. Keeping that promise
|
|
144
|
+
* only in a Map meant a restart broke it silently, which is the worst way to
|
|
145
|
+
* break a promise made to somebody on a telephone.
|
|
146
|
+
*/
|
|
147
|
+
reminderStore = null;
|
|
148
|
+
/** Start echoing reminders somewhere durable, and put back what was there. */
|
|
149
|
+
persistRemindersTo(store, waiting = new Map()) {
|
|
150
|
+
this.reminderStore = store;
|
|
151
|
+
for (const [code, phones] of waiting) {
|
|
152
|
+
const set = this.reminders.get(code) ?? new Set();
|
|
153
|
+
for (const phone of phones)
|
|
154
|
+
set.add(phone);
|
|
155
|
+
this.reminders.set(code, set);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Legs listening to a stream, by its code.
|
|
160
|
+
*
|
|
161
|
+
* Separate from the rooms because a stream listener is not in a conference:
|
|
162
|
+
* they are a leg with an MP3 playing into it. Nothing else was counting
|
|
163
|
+
* them, so the directory had no way to say how many people were on the
|
|
164
|
+
* phone for a broadcast.
|
|
165
|
+
*/
|
|
166
|
+
streamLegs = new Map();
|
|
167
|
+
key;
|
|
168
|
+
fetch;
|
|
169
|
+
now;
|
|
170
|
+
constructor(options) {
|
|
171
|
+
this.options = options;
|
|
172
|
+
this.key = options.publicKey ? ed25519KeyFrom(options.publicKey) : null;
|
|
173
|
+
this.fetch = options.fetch ?? globalThis.fetch;
|
|
174
|
+
this.now = options.now ?? Date.now;
|
|
175
|
+
}
|
|
176
|
+
/** True when this instance can actually check a signature. */
|
|
177
|
+
get armed() {
|
|
178
|
+
return this.key !== null && this.options.apiKey.length > 0;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Whether a webhook really came from Telnyx.
|
|
182
|
+
*
|
|
183
|
+
* The body has to be the bytes that arrived. Parsing and reserialising JSON
|
|
184
|
+
* changes key order and whitespace, and the signature is over the original.
|
|
185
|
+
*/
|
|
186
|
+
verify(rawBody, signature, timestamp) {
|
|
187
|
+
if (this.key === null || !signature || !timestamp)
|
|
188
|
+
return false;
|
|
189
|
+
const sent = Number(timestamp) * 1000;
|
|
190
|
+
if (!Number.isFinite(sent))
|
|
191
|
+
return false;
|
|
192
|
+
// Both directions: a replayed webhook is old, and a clock-skewed forgery
|
|
193
|
+
// from the future is no more trustworthy for being ahead.
|
|
194
|
+
if (Math.abs(this.now() - sent) > SIGNATURE_TOLERANCE_MS)
|
|
195
|
+
return false;
|
|
196
|
+
let sig;
|
|
197
|
+
try {
|
|
198
|
+
sig = Buffer.from(signature, "base64");
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
if (sig.length !== 64)
|
|
204
|
+
return false;
|
|
205
|
+
try {
|
|
206
|
+
return verifySignature(null, Buffer.from(`${timestamp}|${rawBody}`, "utf8"), this.key, sig);
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* The rooms with someone in them, busiest first, with their codes.
|
|
214
|
+
*
|
|
215
|
+
* The code is published on purpose. An earlier version withheld it on the
|
|
216
|
+
* reasoning that a code is the only thing between a stranger and a
|
|
217
|
+
* conversation -- true of a private room, and wrong here: this is a public
|
|
218
|
+
* call-in line, and a listing you cannot dial is a listing of nothing. The
|
|
219
|
+
* code is how you join, so it is what the list is for.
|
|
220
|
+
*/
|
|
221
|
+
list() {
|
|
222
|
+
return [...this.rooms.values()]
|
|
223
|
+
.filter((room) => room.callers > 0)
|
|
224
|
+
.map(({ code, callers, startedAt }) => ({ code, callers, startedAt }))
|
|
225
|
+
.sort((a, b) => b.callers - a.callers || a.startedAt - b.startedAt);
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Drive one call-control event.
|
|
229
|
+
*
|
|
230
|
+
* Every branch returns rather than falling through, because an event we do
|
|
231
|
+
* not handle is the normal case -- Telnyx sends a dozen kinds per call and
|
|
232
|
+
* this cares about four.
|
|
233
|
+
*/
|
|
234
|
+
async handle(event) {
|
|
235
|
+
const type = event.event_type ?? "";
|
|
236
|
+
const payload = event.payload ?? {};
|
|
237
|
+
const leg = typeof payload["call_control_id"] === "string" ? payload["call_control_id"] : "";
|
|
238
|
+
if (!leg)
|
|
239
|
+
return;
|
|
240
|
+
if (type === "call.initiated") {
|
|
241
|
+
// Only inbound. An outbound leg we dialled is not somebody calling in,
|
|
242
|
+
// and answering it would be answering ourselves.
|
|
243
|
+
if (payload["direction"] !== "incoming")
|
|
244
|
+
return;
|
|
245
|
+
// Kept now because a reminder needs it later, and by the time the caller
|
|
246
|
+
// presses 1 the only thing we have is the leg.
|
|
247
|
+
if (typeof payload["from"] === "string")
|
|
248
|
+
this.legFrom.set(leg, payload["from"]);
|
|
249
|
+
await this.command(leg, "answer", {});
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
if (type === "call.answered") {
|
|
253
|
+
await this.ask(leg);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (type === "call.gather.ended") {
|
|
257
|
+
const digits = typeof payload["digits"] === "string" ? payload["digits"] : "";
|
|
258
|
+
// A leg that was just offered a reminder is answering that, not keying a
|
|
259
|
+
// room code -- the same event carries both, so the question we asked is
|
|
260
|
+
// what decides how to read it.
|
|
261
|
+
const offered = this.pendingReminder.get(leg);
|
|
262
|
+
if (offered !== undefined) {
|
|
263
|
+
this.pendingReminder.delete(leg);
|
|
264
|
+
await this.reminder(leg, offered, digits);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
const code = roomCodeFrom(digits);
|
|
268
|
+
if (!code) {
|
|
269
|
+
// Re-ask rather than guess. Anything that is not six digits is not a
|
|
270
|
+
// room, and picking the nearest one would be picking a stranger's.
|
|
271
|
+
await this.ask(leg, "That is not a six digit code. ");
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
// A code that belongs to a stream is answered as a stream. Anything else
|
|
275
|
+
// is an ordinary room, which is what this line was before.
|
|
276
|
+
if (await this.stream(leg, code))
|
|
277
|
+
return;
|
|
278
|
+
await this.join(leg, code);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
if (type === "conference.participant.left" || type === "call.hangup") {
|
|
282
|
+
this.release(leg);
|
|
283
|
+
this.legFrom.delete(leg);
|
|
284
|
+
this.pendingReminder.delete(leg);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Ask for a room code, on the keypad.
|
|
290
|
+
*
|
|
291
|
+
* Not by voice, which is the one thing here that changed its mind. Speech
|
|
292
|
+
* suited a room *name* -- "blue" misheard is still recognisably a word, and
|
|
293
|
+
* a person can say it differently the second time. A six-digit code has no
|
|
294
|
+
* such slack: one digit misheard is a different room that also exists, and
|
|
295
|
+
* the caller lands in a stranger's conversation with nothing to tell them
|
|
296
|
+
* they went wrong. A keypad cannot mishear a 4.
|
|
297
|
+
*
|
|
298
|
+
* Six digits terminates the gather on its own, so the caller does not have
|
|
299
|
+
* to press anything after; # is there for the ones who do it anyway.
|
|
300
|
+
*/
|
|
301
|
+
async ask(leg, prefix = "") {
|
|
302
|
+
const greeting = this.options.greeting ??
|
|
303
|
+
"Welcome to the party line. Enter a six digit room code. Anyone who enters the same code will be on the line with you.";
|
|
304
|
+
await this.command(leg, "gather_using_speak", {
|
|
305
|
+
payload: `${prefix}${greeting}`,
|
|
306
|
+
voice: this.voice,
|
|
307
|
+
valid_digits: "0123456789",
|
|
308
|
+
minimum_digits: CODE_LENGTH,
|
|
309
|
+
maximum_digits: CODE_LENGTH,
|
|
310
|
+
terminating_digit: "#",
|
|
311
|
+
timeout_millis: 20000,
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Answer a code that belongs to a stream, rather than a room.
|
|
316
|
+
*
|
|
317
|
+
* Returns false when the code is nobody's stream, which is how an ordinary
|
|
318
|
+
* room code still works: this line was a party line before it was a way into
|
|
319
|
+
* a broadcast, and a code that means nothing to the directory should still
|
|
320
|
+
* mean a room.
|
|
321
|
+
*/
|
|
322
|
+
async stream(leg, code) {
|
|
323
|
+
const streams = this.options.streams;
|
|
324
|
+
if (streams === undefined)
|
|
325
|
+
return false;
|
|
326
|
+
const live = streams.liveByCode(code);
|
|
327
|
+
if (live !== undefined) {
|
|
328
|
+
const what = live.nowPlaying ? ` of ${live.nowPlaying}` : "";
|
|
329
|
+
// The share link is not playable. It answers 302 with a cookie and sends
|
|
330
|
+
// a browser to the player page; Telnyx fetches once with no cookie jar
|
|
331
|
+
// and gets a 401 in JSON. Playing it means a caller who is told "here it
|
|
332
|
+
// is" and then hears nothing at all, which is how this was found. Say
|
|
333
|
+
// what is true instead, and hang up rather than bill for silence.
|
|
334
|
+
if (!live.audio) {
|
|
335
|
+
await this.command(leg, "speak", {
|
|
336
|
+
payload: `${live.name} is live right now${what}, but this stream cannot be played over the phone. ` +
|
|
337
|
+
"You can listen to it at nixamp dot com slash directory. Goodbye.",
|
|
338
|
+
voice: this.voice,
|
|
339
|
+
});
|
|
340
|
+
await this.command(leg, "hangup", {});
|
|
341
|
+
this.options.onEvent?.(` ${code} is live but announced no audio address; nothing to play.`);
|
|
342
|
+
return true;
|
|
343
|
+
}
|
|
344
|
+
await this.command(leg, "speak", {
|
|
345
|
+
payload: `Welcome to ${live.name}'s live stream${what}. It started at ${pacificTime(live.startedAt)}. Here it is.`,
|
|
346
|
+
voice: this.voice,
|
|
347
|
+
});
|
|
348
|
+
// A nixamp stream is an MP3 over HTTP and Telnyx will play a URL into a
|
|
349
|
+
// call, so listening by phone costs no audio handling here at all.
|
|
350
|
+
const playing = await this.command(leg, "playback_start", {
|
|
351
|
+
audio_url: live.audio,
|
|
352
|
+
loop: "infinity",
|
|
353
|
+
});
|
|
354
|
+
// Counted only once the audio is actually going. A leg we failed to
|
|
355
|
+
// start is not somebody listening, and the directory would be saying so.
|
|
356
|
+
if (playing) {
|
|
357
|
+
const legs = this.streamLegs.get(code) ?? new Set();
|
|
358
|
+
legs.add(leg);
|
|
359
|
+
this.streamLegs.set(code, legs);
|
|
360
|
+
this.options.onEvent?.(` a caller is listening to ${code} (${legs.size} on the phone).`);
|
|
361
|
+
}
|
|
362
|
+
return true;
|
|
363
|
+
}
|
|
364
|
+
const ended = streams.endedByCode(code);
|
|
365
|
+
if (ended === undefined)
|
|
366
|
+
return false;
|
|
367
|
+
const what = ended.nowPlaying ? ` of ${ended.nowPlaying}` : "";
|
|
368
|
+
// Set before the prompt, not after: the answer can arrive while we are
|
|
369
|
+
// still awaiting the command that asked for it.
|
|
370
|
+
this.pendingReminder.set(leg, code);
|
|
371
|
+
await this.command(leg, "gather_using_speak", {
|
|
372
|
+
payload: `Welcome to ${ended.name}'s live stream${what}. ` +
|
|
373
|
+
`The live stream ended at ${pacificTime(ended.endedAt)}. ` +
|
|
374
|
+
"Call back later when they stream again. " +
|
|
375
|
+
"Press 1 to get a text message when they do.",
|
|
376
|
+
voice: this.voice,
|
|
377
|
+
valid_digits: "1",
|
|
378
|
+
minimum_digits: 1,
|
|
379
|
+
maximum_digits: 1,
|
|
380
|
+
timeout_millis: 12000,
|
|
381
|
+
});
|
|
382
|
+
return true;
|
|
383
|
+
}
|
|
384
|
+
/** Whether the caller took the reminder that was offered. */
|
|
385
|
+
async reminder(leg, code, digits) {
|
|
386
|
+
const from = this.legFrom.get(leg) ?? "";
|
|
387
|
+
if (!digits.includes("1") || !from) {
|
|
388
|
+
// Not pressing 1 is an answer. So is a call with no caller id, which we
|
|
389
|
+
// cannot text however willing the caller was.
|
|
390
|
+
await this.command(leg, "speak", { payload: "Goodbye.", voice: this.voice });
|
|
391
|
+
await this.command(leg, "hangup", {});
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
const waiting = this.reminders.get(code) ?? new Set();
|
|
395
|
+
waiting.add(from);
|
|
396
|
+
this.reminders.set(code, waiting);
|
|
397
|
+
this.reminderStore?.add(code, from);
|
|
398
|
+
this.options.onEvent?.(` a caller asked to be told when ${code} is live again.`);
|
|
399
|
+
await this.command(leg, "speak", {
|
|
400
|
+
payload: "Got it. We will text you when they are live again. Goodbye.",
|
|
401
|
+
voice: this.voice,
|
|
402
|
+
});
|
|
403
|
+
await this.command(leg, "hangup", {});
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* A stream came back: text whoever asked to be told.
|
|
407
|
+
*
|
|
408
|
+
* The list is cleared as it is sent. A reminder is a thing somebody asked
|
|
409
|
+
* for once, and texting them every time that stream starts for the rest of
|
|
410
|
+
* the week is how a useful message becomes the reason they block the number.
|
|
411
|
+
*/
|
|
412
|
+
async wentLive(stream) {
|
|
413
|
+
const sms = this.options.sms;
|
|
414
|
+
// Taken from the store first, and that take is what clears it: a number
|
|
415
|
+
// put there by a process that has since been replaced is still owed a
|
|
416
|
+
// text, and this one never heard the call that promised it.
|
|
417
|
+
const stored = this.reminderStore ? await this.reminderStore.take(stream.code) : [];
|
|
418
|
+
const waiting = new Set([...(this.reminders.get(stream.code) ?? []), ...stored]);
|
|
419
|
+
if (waiting.size === 0 || sms === undefined)
|
|
420
|
+
return 0;
|
|
421
|
+
this.reminders.delete(stream.code);
|
|
422
|
+
const what = stream.nowPlaying ? ` of ${stream.nowPlaying}` : "";
|
|
423
|
+
// STOP is not decoration: an automated text to a US number has to say how
|
|
424
|
+
// to make it stop, and the carriers check.
|
|
425
|
+
const text = `${stream.name} is live now${what} on nixamp. ` +
|
|
426
|
+
`Call ${this.options.callIn ?? "408-357-2326"} and key ${stream.code} to listen. ` +
|
|
427
|
+
"Reply STOP to opt out.";
|
|
428
|
+
let sent = 0;
|
|
429
|
+
for (const to of waiting)
|
|
430
|
+
if (await sms.send(to, text))
|
|
431
|
+
sent += 1;
|
|
432
|
+
this.options.onEvent?.(` texted ${sent} of ${waiting.size} waiting on ${stream.code}.`);
|
|
433
|
+
return sent;
|
|
434
|
+
}
|
|
435
|
+
/** How many numbers are waiting to hear that a code is live. */
|
|
436
|
+
waitingOn(code) {
|
|
437
|
+
return this.reminders.get(code)?.size ?? 0;
|
|
438
|
+
}
|
|
439
|
+
/** Put a leg into a room, making the conference if it is the first one there. */
|
|
440
|
+
async join(leg, code) {
|
|
441
|
+
const room = this.room(code);
|
|
442
|
+
if (room.callers >= this.maxParticipants) {
|
|
443
|
+
await this.command(leg, "speak", {
|
|
444
|
+
payload: "That room is full. Goodbye.",
|
|
445
|
+
voice: this.voice,
|
|
446
|
+
});
|
|
447
|
+
await this.command(leg, "hangup", {});
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
this.legRoom.set(leg, code);
|
|
451
|
+
// A conference we made more than four hours ago is gone on Telnyx's side
|
|
452
|
+
// whatever our map says, so it is remade rather than joined.
|
|
453
|
+
const stale = this.now() - room.startedAt > CONFERENCE_TTL_MS;
|
|
454
|
+
if (room.conferenceId !== null && !stale) {
|
|
455
|
+
const joined = await this.request(`/conferences/${encodeURIComponent(room.conferenceId)}/actions/join`, { call_control_id: leg, start_conference_on_enter: true });
|
|
456
|
+
if (joined !== null) {
|
|
457
|
+
this.enter(room, leg);
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
// The id was stale in a way the clock did not predict -- an operator
|
|
461
|
+
// ended it, or Telnyx did. Fall through and make a new one.
|
|
462
|
+
room.conferenceId = null;
|
|
463
|
+
}
|
|
464
|
+
const created = await this.request("/conferences", {
|
|
465
|
+
name: `partyline-${this.now()}-${room.legs.size}`,
|
|
466
|
+
call_control_id: leg,
|
|
467
|
+
start_conference_on_create: true,
|
|
468
|
+
max_participants: this.maxParticipants,
|
|
469
|
+
});
|
|
470
|
+
const id = created && typeof created === "object"
|
|
471
|
+
? created["data"]?.["id"]
|
|
472
|
+
: undefined;
|
|
473
|
+
if (typeof id !== "string") {
|
|
474
|
+
await this.command(leg, "speak", {
|
|
475
|
+
payload: "Sorry, that room could not be opened. Goodbye.",
|
|
476
|
+
voice: this.voice,
|
|
477
|
+
});
|
|
478
|
+
await this.command(leg, "hangup", {});
|
|
479
|
+
this.legRoom.delete(leg);
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
room.conferenceId = id;
|
|
483
|
+
room.startedAt = this.now();
|
|
484
|
+
this.enter(room, leg);
|
|
485
|
+
}
|
|
486
|
+
enter(room, leg) {
|
|
487
|
+
if (room.legs.has(leg))
|
|
488
|
+
return;
|
|
489
|
+
room.legs.add(leg);
|
|
490
|
+
room.callers = room.legs.size;
|
|
491
|
+
this.options.onEvent?.(` a caller joined a room (${room.callers} on the line).`);
|
|
492
|
+
}
|
|
493
|
+
/** How many people are listening to a stream by phone. */
|
|
494
|
+
listenersOn(code) {
|
|
495
|
+
return this.streamLegs.get(code)?.size ?? 0;
|
|
496
|
+
}
|
|
497
|
+
/** A leg that hung up or was dropped, wherever it was. */
|
|
498
|
+
release(leg) {
|
|
499
|
+
for (const [code, legs] of this.streamLegs) {
|
|
500
|
+
if (legs.delete(leg) && legs.size === 0)
|
|
501
|
+
this.streamLegs.delete(code);
|
|
502
|
+
}
|
|
503
|
+
const code = this.legRoom.get(leg);
|
|
504
|
+
this.legRoom.delete(leg);
|
|
505
|
+
if (code === undefined)
|
|
506
|
+
return;
|
|
507
|
+
const room = this.rooms.get(code);
|
|
508
|
+
if (room === undefined)
|
|
509
|
+
return;
|
|
510
|
+
room.legs.delete(leg);
|
|
511
|
+
room.callers = room.legs.size;
|
|
512
|
+
if (room.callers === 0) {
|
|
513
|
+
// Telnyx ends an empty conference itself; keeping the code would only
|
|
514
|
+
// mean handing the next caller a dead id.
|
|
515
|
+
this.rooms.delete(code);
|
|
516
|
+
this.options.onEvent?.(" a room is empty.");
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* The room on this code, made if nobody is using it.
|
|
521
|
+
*
|
|
522
|
+
* Entering a code nobody is in opens that room rather than failing. The code
|
|
523
|
+
* is a rendezvous, not a credential: two people who agree on 482917
|
|
524
|
+
* beforehand should both be able to dial in, and neither of them should have
|
|
525
|
+
* had to create it first.
|
|
526
|
+
*/
|
|
527
|
+
room(code) {
|
|
528
|
+
const existing = this.rooms.get(code);
|
|
529
|
+
if (existing !== undefined)
|
|
530
|
+
return existing;
|
|
531
|
+
const room = {
|
|
532
|
+
code,
|
|
533
|
+
conferenceId: null,
|
|
534
|
+
callers: 0,
|
|
535
|
+
startedAt: this.now(),
|
|
536
|
+
legs: new Set(),
|
|
537
|
+
};
|
|
538
|
+
this.rooms.set(code, room);
|
|
539
|
+
return room;
|
|
540
|
+
}
|
|
541
|
+
get voice() {
|
|
542
|
+
return this.options.voice ?? DEFAULT_VOICE;
|
|
543
|
+
}
|
|
544
|
+
get maxParticipants() {
|
|
545
|
+
return this.options.maxParticipants ?? 50;
|
|
546
|
+
}
|
|
547
|
+
/** One call-control command. True when Telnyx accepted it. */
|
|
548
|
+
async command(leg, action, body) {
|
|
549
|
+
const path = `/calls/${encodeURIComponent(leg)}/actions/${action}`;
|
|
550
|
+
return (await this.request(path, body)) !== null;
|
|
551
|
+
}
|
|
552
|
+
/** A POST to Telnyx, or null if it did not work. */
|
|
553
|
+
async request(path, body) {
|
|
554
|
+
try {
|
|
555
|
+
const response = await this.fetch(`${TELNYX_API}${path}`, {
|
|
556
|
+
method: "POST",
|
|
557
|
+
headers: {
|
|
558
|
+
authorization: `Bearer ${this.options.apiKey}`,
|
|
559
|
+
"content-type": "application/json",
|
|
560
|
+
},
|
|
561
|
+
body: JSON.stringify(body),
|
|
562
|
+
});
|
|
563
|
+
if (!response.ok) {
|
|
564
|
+
// A command against a leg that already hung up is a 422 and is not
|
|
565
|
+
// worth a stack trace; it is the ordinary end of a race.
|
|
566
|
+
this.options.onEvent?.(` telnyx ${path} -> ${response.status}`);
|
|
567
|
+
return null;
|
|
568
|
+
}
|
|
569
|
+
const text = await response.text();
|
|
570
|
+
return text ? JSON.parse(text) : {};
|
|
571
|
+
}
|
|
572
|
+
catch (error) {
|
|
573
|
+
this.options.onEvent?.(` telnyx ${path} failed: ${error.message}`);
|
|
574
|
+
return null;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* Texting, over Telnyx.
|
|
580
|
+
*
|
|
581
|
+
* A separate `from` because it is a different number: the call arrives on the
|
|
582
|
+
* toll-free line, but toll-free A2P messaging is filtered by carriers until
|
|
583
|
+
* that number is verified and ours is not yet. The long code already carries a
|
|
584
|
+
* messaging profile, so it can send today -- and when verification lands, this
|
|
585
|
+
* becomes a one-line change rather than a redesign.
|
|
586
|
+
*/
|
|
587
|
+
export function telnyxSms({ apiKey, from, fetch = globalThis.fetch, onEvent }) {
|
|
588
|
+
return {
|
|
589
|
+
async send(to, text) {
|
|
590
|
+
try {
|
|
591
|
+
const response = await fetch(`${TELNYX_API}/messages`, {
|
|
592
|
+
method: "POST",
|
|
593
|
+
headers: { authorization: `Bearer ${apiKey}`, "content-type": "application/json" },
|
|
594
|
+
body: JSON.stringify({ from, to, text }),
|
|
595
|
+
});
|
|
596
|
+
if (!response.ok) {
|
|
597
|
+
onEvent?.(` sms to ${to} -> ${response.status}`);
|
|
598
|
+
return false;
|
|
599
|
+
}
|
|
600
|
+
return true;
|
|
601
|
+
}
|
|
602
|
+
catch (error) {
|
|
603
|
+
onEvent?.(` sms to ${to} failed: ${error.message}`);
|
|
604
|
+
return false;
|
|
605
|
+
}
|
|
606
|
+
},
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
/** Constant-time compare, for the places a token is checked rather than signed. */
|
|
610
|
+
export function sameSecret(a, b) {
|
|
611
|
+
const left = Buffer.from(a, "utf8");
|
|
612
|
+
const right = Buffer.from(b, "utf8");
|
|
613
|
+
if (left.length !== right.length)
|
|
614
|
+
return false;
|
|
615
|
+
return timingSafeEqual(left, right);
|
|
616
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
/** Listeners who get in for nothing. The sixth is the one who pays. */
|
|
3
|
+
export declare const FREE_LISTENERS = 5;
|
|
4
|
+
export interface PaywallConfig {
|
|
5
|
+
enabled: boolean;
|
|
6
|
+
/** EVM address that receives the USDC. Without one there is nothing to pay to. */
|
|
7
|
+
payTo: string;
|
|
8
|
+
/** A scoped CoinPay key with payments:create. */
|
|
9
|
+
coinpayKey: string;
|
|
10
|
+
priceCents: number;
|
|
11
|
+
/** What one price buys. 1440 is a day. */
|
|
12
|
+
passMinutes: number;
|
|
13
|
+
}
|
|
14
|
+
export declare const DEFAULT_PAYWALL: PaywallConfig;
|
|
15
|
+
/** Only the audio is behind the gate. */
|
|
16
|
+
export declare const GATED: string[];
|
|
17
|
+
export declare function isGated(path: string): boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Whether this request should be charged at all. Configuration first, because
|
|
20
|
+
* a disabled paywall must never answer 402; then the path, then how busy the
|
|
21
|
+
* stream is.
|
|
22
|
+
*/
|
|
23
|
+
export declare function shouldCharge(config: PaywallConfig, path: string, liveListeners: number): boolean;
|
|
24
|
+
export interface PaywallOptions {
|
|
25
|
+
config: () => PaywallConfig;
|
|
26
|
+
/** How many listeners are on the audio routes right now. */
|
|
27
|
+
liveListeners: () => number;
|
|
28
|
+
/**
|
|
29
|
+
* The origin a payer is quoted, which has to be one they can reach. A
|
|
30
|
+
* function because the port is not known until the socket is bound.
|
|
31
|
+
*/
|
|
32
|
+
siteUrl: () => string;
|
|
33
|
+
/** True for the operator's own browser, which never pays to hear its own music. */
|
|
34
|
+
exempt: (request: IncomingMessage) => boolean;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* A node http shim over the gateway's Fetch-API handler. Returns true when it
|
|
38
|
+
* answered the request, so the server's own routing can stop.
|
|
39
|
+
*/
|
|
40
|
+
export declare function createPaywall(options: PaywallOptions): (request: IncomingMessage, response: ServerResponse, path: string) => Promise<boolean>;
|
|
41
|
+
/**
|
|
42
|
+
* The gateway sells crawl access, and says so: its 402 tells the caller that
|
|
43
|
+
* "payment is required for training crawlers". Someone trying to hear a song
|
|
44
|
+
* is not a crawler, so the sentence is replaced on the way out. The offer
|
|
45
|
+
* itself is untouched -- only the words are ours.
|
|
46
|
+
*/
|
|
47
|
+
export declare function rewrite(answer: Response, siteUrl: string): Promise<Buffer>;
|
|
48
|
+
/**
|
|
49
|
+
* Enough of a Fetch Request for the gateway: it reads the URL, the method and
|
|
50
|
+
* headers. A body would need streaming, and nothing it gates has one.
|
|
51
|
+
*/
|
|
52
|
+
export declare function toRequest(request: IncomingMessage, siteUrl: string): Request;
|
|
53
|
+
/** Read a paywall out of the environment, for an operator who runs one by hand. */
|
|
54
|
+
export declare function paywallFromEnv(env?: NodeJS.ProcessEnv): PaywallConfig;
|
|
55
|
+
/**
|
|
56
|
+
* The directory's answer may carry a configuration, which is how a server is
|
|
57
|
+
* turned on and off from nixamp.com. Anything missing keeps what it had, and
|
|
58
|
+
* a payTo the operator did not set is never invented here.
|
|
59
|
+
*/
|
|
60
|
+
export declare function applyRemoteConfig(current: PaywallConfig, remote: unknown): PaywallConfig;
|