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.
- package/backtoschool/dist/assets/{ar-Cj5WpObc.js → ar-CYu0byMf.js} +1 -1
- package/backtoschool/dist/assets/{cs-DOYgXvWh.js → cs-BTTM7DeB.js} +1 -1
- package/backtoschool/dist/assets/{da-CmgimT0P.js → da-iixezQtd.js} +1 -1
- package/backtoschool/dist/assets/{de-D_AUWuE3.js → de-Sx3acevR.js} +1 -1
- package/backtoschool/dist/assets/{es-B4QfiG7X.js → es-ByCnw8ja.js} +1 -1
- package/backtoschool/dist/assets/{fi-Clf9SZn5.js → fi-BCeFoeH5.js} +1 -1
- package/backtoschool/dist/assets/{fr-DpLIxvgd.js → fr-Nz5hMzAE.js} +1 -1
- package/backtoschool/dist/assets/{hi-a9HR1j7t.js → hi-x5-IG1rl.js} +1 -1
- package/backtoschool/dist/assets/hls-CydbS6hn.js +42 -0
- package/backtoschool/dist/assets/hls-OR5FY6YP-O3qhTXm4.js +1 -0
- package/backtoschool/dist/assets/{hu-CXw_ATKN.js → hu-QqIkGcMe.js} +1 -1
- package/backtoschool/dist/assets/{id-B8GHWT99.js → id-OVwO1Uoi.js} +1 -1
- package/backtoschool/dist/assets/index-Dgqs6yM_.js +112 -0
- package/backtoschool/dist/assets/{it-D8K-Cjn9.js → it-CmssY5Ut.js} +1 -1
- package/backtoschool/dist/assets/mpegts-RQlLEU1A.js +3 -0
- package/backtoschool/dist/assets/mpegts-Y2FARLFE-y49QH7a5.js +1 -0
- package/backtoschool/dist/assets/{nl-BTHM_tIM.js → nl-VkQxlPku.js} +1 -1
- package/backtoschool/dist/assets/{ru-cwqVc7z_.js → ru-Dk8FAbDx.js} +1 -1
- package/backtoschool/dist/assets/{sv-De7KSFg-.js → sv-CNs1UySx.js} +1 -1
- package/backtoschool/dist/assets/{uk-COyId2km.js → uk-BDB1YayY.js} +1 -1
- package/backtoschool/dist/assets/{vi-3djg7AGj.js → vi-DnEIKzy7.js} +1 -1
- package/backtoschool/dist/assets/{zh-DNAab67I.js → zh-B_atHA4S.js} +1 -1
- package/backtoschool/dist/index.html +1 -1
- package/dist/ai-label.d.ts +3 -0
- package/dist/ai-label.js +392 -10
- package/dist/partyline.d.ts +41 -201
- package/dist/partyline.js +65 -601
- package/dist/veo-studio.d.ts +22 -0
- package/dist/veo-studio.js +64 -3
- package/package.json +13 -12
- package/src/ai-label.ts +391 -9
- package/src/partyline.ts +88 -671
- package/src/veo-studio.ts +68 -4
- package/web/dist/assets/{ar-DvGjcQ2J.js → ar-Dt4ftL7_.js} +1 -1
- package/web/dist/assets/background.worker-F49brs4A.js +547 -0
- package/web/dist/assets/chunk-55GBNX64-BDcCEJUP.js +1 -0
- package/web/dist/assets/client-D7waX8NA.js +1 -0
- package/web/dist/assets/{cs-VkeGFdfN.js → cs-D7U7SQMd.js} +1 -1
- package/web/dist/assets/{da-BcpK9125.js → da-BcJX_2kR.js} +1 -1
- package/web/dist/assets/{de-DMUTObgP.js → de-rl_x2VZN.js} +1 -1
- package/web/dist/assets/{es-Cgv5mJmC.js → es-BGrhKZQz.js} +1 -1
- package/web/dist/assets/{fi-CZw6yFV4.js → fi-CY2dfbr_.js} +1 -1
- package/web/dist/assets/{fr-Cashl2VH.js → fr-BJkwpR_P.js} +1 -1
- package/web/dist/assets/{hi-nRs3DZ0Q.js → hi-BrJHlcBX.js} +1 -1
- package/web/dist/assets/hls-CydbS6hn.js +42 -0
- package/web/dist/assets/hls-OR5FY6YP-_WOdANsC.js +1 -0
- package/web/dist/assets/{hu-D6H6n2Li.js → hu-BULQmmjT.js} +1 -1
- package/web/dist/assets/{id-DB89Twrj.js → id-Dzh3ujB1.js} +1 -1
- package/web/dist/assets/{it-CH9caHnD.js → it-CexE1NXI.js} +1 -1
- package/web/dist/assets/main-CsEDH18x.js +26 -0
- package/web/dist/assets/mpegts-CL9RC5Ls.js +3 -0
- package/web/dist/assets/mpegts-Y2FARLFE-C91SD8Pl.js +1 -0
- package/web/dist/assets/music-ByEeUk4Z.js +1 -0
- package/web/dist/assets/{nl-vTvT2DVb.js → nl-BPpsDvjT.js} +1 -1
- package/web/dist/assets/radio-BnQ78a-O.js +1 -0
- package/web/dist/assets/{ru-dpgYvvQ7.js → ru-CmYwwWDR.js} +1 -1
- package/web/dist/assets/{station-UibBpdBh.js → station-C_6L4CDU.js} +2 -2
- package/web/dist/assets/{sv-gp9SyEIA.js → sv-DnY0WiF0.js} +1 -1
- package/web/dist/assets/{uk-DicgOcIK.js → uk-Cm0pAuQa.js} +1 -1
- package/web/dist/assets/{vi-m4X3bO3f.js → vi-CgKu2IlV.js} +1 -1
- package/web/dist/assets/{vod-CkR6hzN7.js → vod-CbSC5NFA.js} +1 -1
- package/web/dist/assets/{watch-BO-wMY8T.js → watch-D-ZRxfJ_.js} +1 -1
- package/web/dist/assets/{zh-DPnaJjYS.js → zh-DOa77AtL.js} +1 -1
- package/web/dist/index.html +5 -5
- package/web/dist/music.html +2 -2
- package/web/dist/radio.html +3 -3
- package/web/dist/station.html +3 -3
- package/web/dist/sw.js +32 -32
- package/web/dist/vod.html +3 -3
- package/web/dist/watch.html +3 -3
- package/backtoschool/dist/assets/hls-OR5FY6YP-nOjNFW0f.js +0 -1
- package/backtoschool/dist/assets/hls-n74Cnh8A.js +0 -42
- package/backtoschool/dist/assets/index-BPJoeY65.js +0 -112
- package/backtoschool/dist/assets/mpegts-7CN75AI2-cUtR703j.js +0 -1
- package/backtoschool/dist/assets/mpegts-CixmcUbS.js +0 -3
- package/web/dist/assets/background.worker-Hz7oaQ8X.js +0 -547
- package/web/dist/assets/chunk-Q2XQMA4B-OBCD3Aev.js +0 -1
- package/web/dist/assets/client-B_mfdrFh.js +0 -1
- package/web/dist/assets/hls-OR5FY6YP-B2RNEz5K.js +0 -1
- package/web/dist/assets/hls-n74Cnh8A.js +0 -42
- package/web/dist/assets/main-BS8YvXOs.js +0 -26
- package/web/dist/assets/mpegts-7CN75AI2-1Qj6py7N.js +0 -1
- package/web/dist/assets/mpegts-BcF3gJrf.js +0 -3
- package/web/dist/assets/music-Day-DtFd.js +0 -1
- package/web/dist/assets/radio-Dx44o-ug.js +0 -1
- /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
|
|
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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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 {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
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
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
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.
|
|
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
|
-
*
|
|
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
|
|
511
|
-
*
|
|
512
|
-
*
|
|
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(
|
|
518
|
-
const streams = this.
|
|
519
|
-
if (streams === undefined) return
|
|
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
|
-
|
|
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
|
|
205
|
+
if (ended === undefined) return true;
|
|
535
206
|
|
|
536
207
|
const what = ended.nowPlaying ? ` of ${ended.nowPlaying}` : "";
|
|
537
|
-
|
|
538
|
-
|
|
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
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
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(
|
|
557
|
-
const from =
|
|
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
|
|
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.
|
|
233
|
+
this.party.onEvent?.(` a caller asked to be told when ${code} is live again.`);
|
|
571
234
|
|
|
572
|
-
await
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
}
|