nixamp 0.9.7 → 0.9.8
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/dist/partyline.d.ts +39 -1
- package/dist/partyline.js +143 -18
- package/package.json +1 -1
- package/src/partyline.ts +149 -20
- package/web/dist/sw.js +1 -1
package/dist/partyline.d.ts
CHANGED
|
@@ -99,6 +99,23 @@ export declare function roomCodeFrom(entered: unknown): string;
|
|
|
99
99
|
export declare function pacificTime(at: number): string;
|
|
100
100
|
/** How a code is read back: one digit at a time, because 482917 is not a number. */
|
|
101
101
|
export declare function spokenCode(code: string): string;
|
|
102
|
+
/**
|
|
103
|
+
* A track's name as it should be read out.
|
|
104
|
+
*
|
|
105
|
+
* What is playing is a filename more often than a title: "02 - ...And Justice
|
|
106
|
+
* For All.mp3" read aloud is "zero two dash dot dot dot", and the extension is
|
|
107
|
+
* a noise the caller does not need. The track number and extension go; what
|
|
108
|
+
* is left is close enough to a title to say.
|
|
109
|
+
*/
|
|
110
|
+
export declare function spokenTitle(nowPlaying: string): string;
|
|
111
|
+
/**
|
|
112
|
+
* How many people are in a room, said to the one who just walked in.
|
|
113
|
+
*
|
|
114
|
+
* The count includes them: "there are 3 people in here" is what you say to the
|
|
115
|
+
* third person, and "you are the first one here" is what you say to the first,
|
|
116
|
+
* who would otherwise be told there is one person in an empty room.
|
|
117
|
+
*/
|
|
118
|
+
export declare function peopleHere(callers: number): string;
|
|
102
119
|
export interface TelnyxEvent {
|
|
103
120
|
event_type?: string;
|
|
104
121
|
payload?: Record<string, unknown>;
|
|
@@ -119,6 +136,10 @@ export declare class PartyLine {
|
|
|
119
136
|
private readonly legFrom;
|
|
120
137
|
/** Legs that heard "press 1", and which stream they would be reminded about. */
|
|
121
138
|
private readonly pendingReminder;
|
|
139
|
+
/** How many times each leg has been asked for a code, so it is not forever. */
|
|
140
|
+
private readonly asks;
|
|
141
|
+
/** Legs whose voice failed once, which hear the plain one from then on. */
|
|
142
|
+
private readonly plainVoice;
|
|
122
143
|
/** Who to text when a stream returns, by stream code. */
|
|
123
144
|
private readonly reminders;
|
|
124
145
|
/**
|
|
@@ -216,9 +237,24 @@ export declare class PartyLine {
|
|
|
216
237
|
}): Promise<number>;
|
|
217
238
|
/** How many numbers are waiting to hear that a code is live. */
|
|
218
239
|
waitingOn(code: string): number;
|
|
219
|
-
/**
|
|
240
|
+
/**
|
|
241
|
+
* Put a leg into a room, making the conference if it is the first one there.
|
|
242
|
+
*
|
|
243
|
+
* `welcome` is what the caller hears once they are in, before the count of
|
|
244
|
+
* who else is; a stream's room names the stream, an ordinary room reads its
|
|
245
|
+
* code back.
|
|
246
|
+
*/
|
|
220
247
|
private join;
|
|
221
248
|
private enter;
|
|
249
|
+
/**
|
|
250
|
+
* Say hello to somebody who just joined, and only to them.
|
|
251
|
+
*
|
|
252
|
+
* Spoken into the conference rather than at the leg, because the leg is in
|
|
253
|
+
* the conference now and a speak on it is what the join interrupts. Telnyx
|
|
254
|
+
* addresses conference speech to particular participants, so the others in
|
|
255
|
+
* the room do not hear every arrival welcomed.
|
|
256
|
+
*/
|
|
257
|
+
private greet;
|
|
222
258
|
/**
|
|
223
259
|
* How many people are on the phone for a stream.
|
|
224
260
|
*
|
|
@@ -239,6 +275,8 @@ export declare class PartyLine {
|
|
|
239
275
|
*/
|
|
240
276
|
private room;
|
|
241
277
|
private get voice();
|
|
278
|
+
/** The voice for this leg: the good one, unless it has already failed them. */
|
|
279
|
+
private voiceFor;
|
|
242
280
|
private get maxParticipants();
|
|
243
281
|
/** One call-control command. True when Telnyx accepted it. */
|
|
244
282
|
private command;
|
package/dist/partyline.js
CHANGED
|
@@ -40,6 +40,33 @@ const TELNYX_API = "https://api.telnyx.com/v2";
|
|
|
40
40
|
* configuration rather than a constant for exactly that reason.
|
|
41
41
|
*/
|
|
42
42
|
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;
|
|
43
70
|
/** How long a signed webhook stays acceptable. Telnyx's own SDKs use five minutes. */
|
|
44
71
|
const SIGNATURE_TOLERANCE_MS = 5 * 60 * 1000;
|
|
45
72
|
/** A conference Telnyx will discard on its own, so we stop trusting ours first. */
|
|
@@ -92,6 +119,35 @@ export function pacificTime(at) {
|
|
|
92
119
|
export function spokenCode(code) {
|
|
93
120
|
return code.split("").join(", ");
|
|
94
121
|
}
|
|
122
|
+
/**
|
|
123
|
+
* A track's name as it should be read out.
|
|
124
|
+
*
|
|
125
|
+
* What is playing is a filename more often than a title: "02 - ...And Justice
|
|
126
|
+
* 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.
|
|
129
|
+
*/
|
|
130
|
+
export function spokenTitle(nowPlaying) {
|
|
131
|
+
return nowPlaying
|
|
132
|
+
.replace(/\.[a-z0-9]{2,4}$/i, "")
|
|
133
|
+
.replace(/^\s*\d{1,3}\s*[-._]\s*/, "")
|
|
134
|
+
.replace(/^[\s.]+/, "")
|
|
135
|
+
.trim();
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
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
|
+
}
|
|
95
151
|
/**
|
|
96
152
|
* Telnyx signs `${timestamp}|${body}` with ed25519 and sends both back in
|
|
97
153
|
* headers. Node will not take a bare 32-byte key, so it is wrapped in the
|
|
@@ -135,6 +191,10 @@ export class PartyLine {
|
|
|
135
191
|
legFrom = new Map();
|
|
136
192
|
/** Legs that heard "press 1", and which stream they would be reminded about. */
|
|
137
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();
|
|
138
198
|
/** Who to text when a stream returns, by stream code. */
|
|
139
199
|
reminders = new Map();
|
|
140
200
|
/**
|
|
@@ -244,8 +304,20 @@ export class PartyLine {
|
|
|
244
304
|
await this.ask(leg);
|
|
245
305
|
return;
|
|
246
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
|
+
}
|
|
247
315
|
if (type === "call.gather.ended") {
|
|
248
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;
|
|
249
321
|
// A leg that was just offered a reminder is answering that, not keying a
|
|
250
322
|
// room code -- the same event carries both, so the question we asked is
|
|
251
323
|
// what decides how to read it.
|
|
@@ -259,9 +331,14 @@ export class PartyLine {
|
|
|
259
331
|
if (!code) {
|
|
260
332
|
// Re-ask rather than guess. Anything that is not six digits is not a
|
|
261
333
|
// room, and picking the nearest one would be picking a stranger's.
|
|
262
|
-
|
|
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. ");
|
|
263
339
|
return;
|
|
264
340
|
}
|
|
341
|
+
this.asks.delete(leg);
|
|
265
342
|
// A code that belongs to a stream is answered as a stream. Anything else
|
|
266
343
|
// is an ordinary room, which is what this line was before.
|
|
267
344
|
if (await this.stream(leg, code))
|
|
@@ -273,6 +350,8 @@ export class PartyLine {
|
|
|
273
350
|
this.release(leg);
|
|
274
351
|
this.legFrom.delete(leg);
|
|
275
352
|
this.pendingReminder.delete(leg);
|
|
353
|
+
this.asks.delete(leg);
|
|
354
|
+
this.plainVoice.delete(leg);
|
|
276
355
|
return;
|
|
277
356
|
}
|
|
278
357
|
}
|
|
@@ -290,16 +369,33 @@ export class PartyLine {
|
|
|
290
369
|
* to press anything after; # is there for the ones who do it anyway.
|
|
291
370
|
*/
|
|
292
371
|
async ask(leg, prefix = "") {
|
|
293
|
-
const
|
|
294
|
-
|
|
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.`;
|
|
295
390
|
await this.command(leg, "gather_using_speak", {
|
|
296
|
-
payload
|
|
297
|
-
voice: this.
|
|
391
|
+
payload,
|
|
392
|
+
voice: this.voiceFor(leg),
|
|
298
393
|
valid_digits: "0123456789",
|
|
299
394
|
minimum_digits: CODE_LENGTH,
|
|
300
395
|
maximum_digits: CODE_LENGTH,
|
|
301
396
|
terminating_digit: "#",
|
|
302
397
|
timeout_millis: 20000,
|
|
398
|
+
inter_digit_timeout_millis: INTER_DIGIT_MS,
|
|
303
399
|
});
|
|
304
400
|
}
|
|
305
401
|
/**
|
|
@@ -324,13 +420,13 @@ export class PartyLine {
|
|
|
324
420
|
return false;
|
|
325
421
|
const live = streams.liveByCode(code);
|
|
326
422
|
if (live !== undefined) {
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
}
|
|
333
|
-
await this.join(leg, code);
|
|
423
|
+
// 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.
|
|
427
|
+
const title = spokenTitle(live.nowPlaying);
|
|
428
|
+
const what = title ? `, playing ${title}` : "";
|
|
429
|
+
await this.join(leg, code, `Welcome to the live room for ${live.name}${what}. `);
|
|
334
430
|
return true;
|
|
335
431
|
}
|
|
336
432
|
const ended = streams.endedByCode(code);
|
|
@@ -345,7 +441,7 @@ export class PartyLine {
|
|
|
345
441
|
`The live stream ended at ${pacificTime(ended.endedAt)}. ` +
|
|
346
442
|
"Call back later when they stream again. " +
|
|
347
443
|
"Press 1 to get a text message when they do.",
|
|
348
|
-
voice: this.
|
|
444
|
+
voice: this.voiceFor(leg),
|
|
349
445
|
valid_digits: "1",
|
|
350
446
|
minimum_digits: 1,
|
|
351
447
|
maximum_digits: 1,
|
|
@@ -370,7 +466,7 @@ export class PartyLine {
|
|
|
370
466
|
this.options.onEvent?.(` a caller asked to be told when ${code} is live again.`);
|
|
371
467
|
await this.command(leg, "speak", {
|
|
372
468
|
payload: "Got it. We will text you when they are live again. Goodbye.",
|
|
373
|
-
voice: this.
|
|
469
|
+
voice: this.voiceFor(leg),
|
|
374
470
|
});
|
|
375
471
|
await this.command(leg, "hangup", {});
|
|
376
472
|
}
|
|
@@ -408,13 +504,19 @@ export class PartyLine {
|
|
|
408
504
|
waitingOn(code) {
|
|
409
505
|
return this.reminders.get(code)?.size ?? 0;
|
|
410
506
|
}
|
|
411
|
-
/**
|
|
412
|
-
|
|
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)}. `) {
|
|
413
515
|
const room = this.room(code);
|
|
414
516
|
if (room.callers >= this.maxParticipants) {
|
|
415
517
|
await this.command(leg, "speak", {
|
|
416
518
|
payload: "That room is full. Goodbye.",
|
|
417
|
-
voice: this.
|
|
519
|
+
voice: this.voiceFor(leg),
|
|
418
520
|
});
|
|
419
521
|
await this.command(leg, "hangup", {});
|
|
420
522
|
return;
|
|
@@ -427,6 +529,7 @@ export class PartyLine {
|
|
|
427
529
|
const joined = await this.request(`/conferences/${encodeURIComponent(room.conferenceId)}/actions/join`, { call_control_id: leg, start_conference_on_enter: true });
|
|
428
530
|
if (joined !== null) {
|
|
429
531
|
this.enter(room, leg);
|
|
532
|
+
await this.greet(room, leg, welcome);
|
|
430
533
|
return;
|
|
431
534
|
}
|
|
432
535
|
// The id was stale in a way the clock did not predict -- an operator
|
|
@@ -445,7 +548,7 @@ export class PartyLine {
|
|
|
445
548
|
if (typeof id !== "string") {
|
|
446
549
|
await this.command(leg, "speak", {
|
|
447
550
|
payload: "Sorry, that room could not be opened. Goodbye.",
|
|
448
|
-
voice: this.
|
|
551
|
+
voice: this.voiceFor(leg),
|
|
449
552
|
});
|
|
450
553
|
await this.command(leg, "hangup", {});
|
|
451
554
|
this.legRoom.delete(leg);
|
|
@@ -454,6 +557,7 @@ export class PartyLine {
|
|
|
454
557
|
room.conferenceId = id;
|
|
455
558
|
room.startedAt = this.now();
|
|
456
559
|
this.enter(room, leg);
|
|
560
|
+
await this.greet(room, leg, welcome);
|
|
457
561
|
}
|
|
458
562
|
enter(room, leg) {
|
|
459
563
|
if (room.legs.has(leg))
|
|
@@ -462,6 +566,23 @@ export class PartyLine {
|
|
|
462
566
|
room.callers = room.legs.size;
|
|
463
567
|
this.options.onEvent?.(` a caller joined a room (${room.callers} on the line).`);
|
|
464
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
|
+
}
|
|
465
586
|
/**
|
|
466
587
|
* How many people are on the phone for a stream.
|
|
467
588
|
*
|
|
@@ -515,6 +636,10 @@ export class PartyLine {
|
|
|
515
636
|
get voice() {
|
|
516
637
|
return this.options.voice ?? DEFAULT_VOICE;
|
|
517
638
|
}
|
|
639
|
+
/** The voice for this leg: the good one, unless it has already failed them. */
|
|
640
|
+
voiceFor(leg) {
|
|
641
|
+
return this.plainVoice.has(leg) ? FALLBACK_VOICE : this.voice;
|
|
642
|
+
}
|
|
518
643
|
get maxParticipants() {
|
|
519
644
|
return this.options.maxParticipants ?? 50;
|
|
520
645
|
}
|
package/package.json
CHANGED
package/src/partyline.ts
CHANGED
|
@@ -43,6 +43,36 @@ const TELNYX_API = "https://api.telnyx.com/v2";
|
|
|
43
43
|
*/
|
|
44
44
|
const DEFAULT_VOICE = "Telnyx.KokoroTTS.af";
|
|
45
45
|
|
|
46
|
+
/**
|
|
47
|
+
* The voice a leg falls back to when the good one fails.
|
|
48
|
+
*
|
|
49
|
+
* Telnyx's Kokoro voice answered a prompt with a 500 once (2026-09-09), and
|
|
50
|
+
* what the caller got was a gather with no speech in it, ended at once with
|
|
51
|
+
* no digits, and asked again -- a line that "just repeats itself", silently,
|
|
52
|
+
* every ninety seconds. The plain Telnyx voice is older and worse and has not
|
|
53
|
+
* been seen to fail, which is the quality that matters on the second try.
|
|
54
|
+
*/
|
|
55
|
+
const FALLBACK_VOICE = "female";
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* How long a caller has between digits before the code is treated as done.
|
|
59
|
+
*
|
|
60
|
+
* Telnyx's default is five seconds and did not fire: a caller who keyed five
|
|
61
|
+
* digits (one was lost in the keypad tone over the prompt) waited ten seconds
|
|
62
|
+
* in silence and hung up. Set explicitly so the partial code comes back to us
|
|
63
|
+
* quickly and we can say how many digits we got.
|
|
64
|
+
*/
|
|
65
|
+
const INTER_DIGIT_MS = 4000;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* How many times a caller is asked for a code before being let go.
|
|
69
|
+
*
|
|
70
|
+
* A gather that ends with nothing three times is a caller who cannot or will
|
|
71
|
+
* not key a code -- or a voice that is not being heard at all. Asking a
|
|
72
|
+
* fourth time is the loop that was reported; saying goodbye is not.
|
|
73
|
+
*/
|
|
74
|
+
const MAX_ASKS = 3;
|
|
75
|
+
|
|
46
76
|
/** How long a signed webhook stays acceptable. Telnyx's own SDKs use five minutes. */
|
|
47
77
|
const SIGNATURE_TOLERANCE_MS = 5 * 60 * 1000;
|
|
48
78
|
|
|
@@ -167,6 +197,35 @@ export function spokenCode(code: string): string {
|
|
|
167
197
|
return code.split("").join(", ");
|
|
168
198
|
}
|
|
169
199
|
|
|
200
|
+
/**
|
|
201
|
+
* A track's name as it should be read out.
|
|
202
|
+
*
|
|
203
|
+
* What is playing is a filename more often than a title: "02 - ...And Justice
|
|
204
|
+
* For All.mp3" read aloud is "zero two dash dot dot dot", and the extension is
|
|
205
|
+
* a noise the caller does not need. The track number and extension go; what
|
|
206
|
+
* is left is close enough to a title to say.
|
|
207
|
+
*/
|
|
208
|
+
export function spokenTitle(nowPlaying: string): string {
|
|
209
|
+
return nowPlaying
|
|
210
|
+
.replace(/\.[a-z0-9]{2,4}$/i, "")
|
|
211
|
+
.replace(/^\s*\d{1,3}\s*[-._]\s*/, "")
|
|
212
|
+
.replace(/^[\s.]+/, "")
|
|
213
|
+
.trim();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* How many people are in a room, said to the one who just walked in.
|
|
218
|
+
*
|
|
219
|
+
* The count includes them: "there are 3 people in here" is what you say to the
|
|
220
|
+
* third person, and "you are the first one here" is what you say to the first,
|
|
221
|
+
* who would otherwise be told there is one person in an empty room.
|
|
222
|
+
*/
|
|
223
|
+
export function peopleHere(callers: number): string {
|
|
224
|
+
if (callers <= 1) return "You are the first one here. Say hello when somebody joins.";
|
|
225
|
+
if (callers === 2) return "There is one other person in here. Say hello.";
|
|
226
|
+
return `There are ${callers} people in here. Say hello.`;
|
|
227
|
+
}
|
|
228
|
+
|
|
170
229
|
/**
|
|
171
230
|
* Telnyx signs `${timestamp}|${body}` with ed25519 and sends both back in
|
|
172
231
|
* headers. Node will not take a bare 32-byte key, so it is wrapped in the
|
|
@@ -213,6 +272,10 @@ export class PartyLine {
|
|
|
213
272
|
private readonly legFrom = new Map<string, string>();
|
|
214
273
|
/** Legs that heard "press 1", and which stream they would be reminded about. */
|
|
215
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>();
|
|
216
279
|
/** Who to text when a stream returns, by stream code. */
|
|
217
280
|
private readonly reminders = new Map<string, Set<string>>();
|
|
218
281
|
/**
|
|
@@ -334,9 +397,22 @@ export class PartyLine {
|
|
|
334
397
|
return;
|
|
335
398
|
}
|
|
336
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
|
+
|
|
337
409
|
if (type === "call.gather.ended") {
|
|
338
410
|
const digits = typeof payload["digits"] === "string" ? payload["digits"] : "";
|
|
339
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
|
+
|
|
340
416
|
// A leg that was just offered a reminder is answering that, not keying a
|
|
341
417
|
// room code -- the same event carries both, so the question we asked is
|
|
342
418
|
// what decides how to read it.
|
|
@@ -351,9 +427,14 @@ export class PartyLine {
|
|
|
351
427
|
if (!code) {
|
|
352
428
|
// Re-ask rather than guess. Anything that is not six digits is not a
|
|
353
429
|
// room, and picking the nearest one would be picking a stranger's.
|
|
354
|
-
|
|
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. ");
|
|
355
435
|
return;
|
|
356
436
|
}
|
|
437
|
+
this.asks.delete(leg);
|
|
357
438
|
// A code that belongs to a stream is answered as a stream. Anything else
|
|
358
439
|
// is an ordinary room, which is what this line was before.
|
|
359
440
|
if (await this.stream(leg, code)) return;
|
|
@@ -365,6 +446,8 @@ export class PartyLine {
|
|
|
365
446
|
this.release(leg);
|
|
366
447
|
this.legFrom.delete(leg);
|
|
367
448
|
this.pendingReminder.delete(leg);
|
|
449
|
+
this.asks.delete(leg);
|
|
450
|
+
this.plainVoice.delete(leg);
|
|
368
451
|
return;
|
|
369
452
|
}
|
|
370
453
|
}
|
|
@@ -383,18 +466,35 @@ export class PartyLine {
|
|
|
383
466
|
* to press anything after; # is there for the ones who do it anyway.
|
|
384
467
|
*/
|
|
385
468
|
private async ask(leg: string, prefix = ""): Promise<void> {
|
|
386
|
-
const
|
|
387
|
-
|
|
388
|
-
|
|
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.`;
|
|
389
488
|
|
|
390
489
|
await this.command(leg, "gather_using_speak", {
|
|
391
|
-
payload
|
|
392
|
-
voice: this.
|
|
490
|
+
payload,
|
|
491
|
+
voice: this.voiceFor(leg),
|
|
393
492
|
valid_digits: "0123456789",
|
|
394
493
|
minimum_digits: CODE_LENGTH,
|
|
395
494
|
maximum_digits: CODE_LENGTH,
|
|
396
495
|
terminating_digit: "#",
|
|
397
496
|
timeout_millis: 20000,
|
|
497
|
+
inter_digit_timeout_millis: INTER_DIGIT_MS,
|
|
398
498
|
});
|
|
399
499
|
}
|
|
400
500
|
|
|
@@ -420,14 +520,13 @@ export class PartyLine {
|
|
|
420
520
|
|
|
421
521
|
const live = streams.liveByCode(code);
|
|
422
522
|
if (live !== undefined) {
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
});
|
|
430
|
-
await this.join(leg, code);
|
|
523
|
+
// 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.
|
|
527
|
+
const title = spokenTitle(live.nowPlaying);
|
|
528
|
+
const what = title ? `, playing ${title}` : "";
|
|
529
|
+
await this.join(leg, code, `Welcome to the live room for ${live.name}${what}. `);
|
|
431
530
|
return true;
|
|
432
531
|
}
|
|
433
532
|
|
|
@@ -444,7 +543,7 @@ export class PartyLine {
|
|
|
444
543
|
`The live stream ended at ${pacificTime(ended.endedAt)}. ` +
|
|
445
544
|
"Call back later when they stream again. " +
|
|
446
545
|
"Press 1 to get a text message when they do.",
|
|
447
|
-
voice: this.
|
|
546
|
+
voice: this.voiceFor(leg),
|
|
448
547
|
valid_digits: "1",
|
|
449
548
|
minimum_digits: 1,
|
|
450
549
|
maximum_digits: 1,
|
|
@@ -472,7 +571,7 @@ export class PartyLine {
|
|
|
472
571
|
|
|
473
572
|
await this.command(leg, "speak", {
|
|
474
573
|
payload: "Got it. We will text you when they are live again. Goodbye.",
|
|
475
|
-
voice: this.
|
|
574
|
+
voice: this.voiceFor(leg),
|
|
476
575
|
});
|
|
477
576
|
await this.command(leg, "hangup", {});
|
|
478
577
|
}
|
|
@@ -513,14 +612,20 @@ export class PartyLine {
|
|
|
513
612
|
return this.reminders.get(code)?.size ?? 0;
|
|
514
613
|
}
|
|
515
614
|
|
|
516
|
-
/**
|
|
517
|
-
|
|
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> {
|
|
518
623
|
const room = this.room(code);
|
|
519
624
|
|
|
520
625
|
if (room.callers >= this.maxParticipants) {
|
|
521
626
|
await this.command(leg, "speak", {
|
|
522
627
|
payload: "That room is full. Goodbye.",
|
|
523
|
-
voice: this.
|
|
628
|
+
voice: this.voiceFor(leg),
|
|
524
629
|
});
|
|
525
630
|
await this.command(leg, "hangup", {});
|
|
526
631
|
return;
|
|
@@ -538,6 +643,7 @@ export class PartyLine {
|
|
|
538
643
|
);
|
|
539
644
|
if (joined !== null) {
|
|
540
645
|
this.enter(room, leg);
|
|
646
|
+
await this.greet(room, leg, welcome);
|
|
541
647
|
return;
|
|
542
648
|
}
|
|
543
649
|
// The id was stale in a way the clock did not predict -- an operator
|
|
@@ -559,7 +665,7 @@ export class PartyLine {
|
|
|
559
665
|
if (typeof id !== "string") {
|
|
560
666
|
await this.command(leg, "speak", {
|
|
561
667
|
payload: "Sorry, that room could not be opened. Goodbye.",
|
|
562
|
-
voice: this.
|
|
668
|
+
voice: this.voiceFor(leg),
|
|
563
669
|
});
|
|
564
670
|
await this.command(leg, "hangup", {});
|
|
565
671
|
this.legRoom.delete(leg);
|
|
@@ -569,6 +675,7 @@ export class PartyLine {
|
|
|
569
675
|
room.conferenceId = id;
|
|
570
676
|
room.startedAt = this.now();
|
|
571
677
|
this.enter(room, leg);
|
|
678
|
+
await this.greet(room, leg, welcome);
|
|
572
679
|
}
|
|
573
680
|
|
|
574
681
|
private enter(room: Room, leg: string): void {
|
|
@@ -578,6 +685,23 @@ export class PartyLine {
|
|
|
578
685
|
this.options.onEvent?.(` a caller joined a room (${room.callers} on the line).`);
|
|
579
686
|
}
|
|
580
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
|
+
|
|
581
705
|
/**
|
|
582
706
|
* How many people are on the phone for a stream.
|
|
583
707
|
*
|
|
@@ -632,6 +756,11 @@ export class PartyLine {
|
|
|
632
756
|
return this.options.voice ?? DEFAULT_VOICE;
|
|
633
757
|
}
|
|
634
758
|
|
|
759
|
+
/** The voice for this leg: the good one, unless it has already failed them. */
|
|
760
|
+
private voiceFor(leg: string): string {
|
|
761
|
+
return this.plainVoice.has(leg) ? FALLBACK_VOICE : this.voice;
|
|
762
|
+
}
|
|
763
|
+
|
|
635
764
|
private get maxParticipants(): number {
|
|
636
765
|
return this.options.maxParticipants ?? 50;
|
|
637
766
|
}
|
package/web/dist/sw.js
CHANGED