nixamp 0.3.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/dist/directory.d.ts +126 -3
- package/dist/directory.js +171 -7
- 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/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/partyline.d.ts +259 -0
- package/dist/partyline.js +616 -0
- package/dist/paywall.js +1 -1
- package/dist/playlist.js +5 -0
- package/dist/publish.d.ts +21 -0
- package/dist/publish.js +18 -2
- package/dist/server.d.ts +15 -0
- package/dist/server.js +558 -11
- package/dist/share.d.ts +10 -0
- package/dist/share.js +12 -0
- package/package.json +5 -2
- package/src/directory.ts +232 -5
- package/src/durable.ts +215 -0
- package/src/follows.ts +307 -0
- package/src/notify.ts +217 -0
- package/src/optin.ts +128 -0
- package/src/partyline.ts +742 -0
- package/src/paywall.ts +1 -1
- package/src/playlist.ts +5 -0
- package/src/publish.ts +38 -2
- package/src/server.ts +610 -10
- package/src/share.ts +13 -0
- package/web/dist/assets/{index-0wAv50Ay.css → index-DSIDSSPF.css} +1 -1
- package/web/dist/assets/index-qRguFskX.js +1 -0
- package/web/dist/index.html +27 -2
- package/web/dist/install.sh +82 -0
- package/web/dist/sw.js +45 -3
- package/web/dist/assets/index-WYJ6R4uF.js +0 -1
package/dist/optin.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The page that explains the text messages.
|
|
3
|
+
*
|
|
4
|
+
* Not decoration and not marketing. A carrier reviewing a toll-free number for
|
|
5
|
+
* A2P messaging asks to see where the consent comes from, and answers "a
|
|
6
|
+
* screenshot" -- which is awkward when the consent is somebody pressing 1 on a
|
|
7
|
+
* telephone and there is no screen to shoot. This page is that evidence: the
|
|
8
|
+
* exact prompt the caller hears, what they get, how often, and how to stop.
|
|
9
|
+
*
|
|
10
|
+
* It is also the honest thing to publish regardless of who is asking. Anyone
|
|
11
|
+
* who gets a text from us can find out here why, and stop it, without having
|
|
12
|
+
* to reply to a number they do not recognise.
|
|
13
|
+
*
|
|
14
|
+
* Served as a page of its own rather than a route in the app, because it has
|
|
15
|
+
* to be readable by someone with no JavaScript and no account -- a reviewer,
|
|
16
|
+
* or a person holding a phone that just buzzed.
|
|
17
|
+
*/
|
|
18
|
+
export const OPT_IN_PATH = "/sms";
|
|
19
|
+
/**
|
|
20
|
+
* The number a caller dials.
|
|
21
|
+
*
|
|
22
|
+
* Local, not the toll-free one, and the reason is billing rather than taste.
|
|
23
|
+
* Only standard DIDs are eligible for channel billing -- a flat fee for
|
|
24
|
+
* unlimited inbound minutes -- while toll-free is pay-per-minute forever, at
|
|
25
|
+
* roughly five times the rate. On a line people stay on for hours that is the
|
|
26
|
+
* whole cost of the product, so the number we print is the cheap one.
|
|
27
|
+
*
|
|
28
|
+
* 888-ROOM-818 still answers, for anyone who has it. It is a vanity alias, not
|
|
29
|
+
* the number to publish, and it cannot reach the cheap tier at any volume.
|
|
30
|
+
*/
|
|
31
|
+
export const CALL_IN_NUMBER = "408-357-2326";
|
|
32
|
+
/** The number a reminder is sent from. Not the one above; see partyline.ts. */
|
|
33
|
+
export const SMS_FROM_NUMBER = "408-426-9127";
|
|
34
|
+
export function optInPage({ callIn = CALL_IN_NUMBER, smsFrom = SMS_FROM_NUMBER } = {}) {
|
|
35
|
+
return `<!doctype html>
|
|
36
|
+
<html lang="en">
|
|
37
|
+
<meta charset="utf-8">
|
|
38
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
39
|
+
<title>Stream reminders by text — nixamp</title>
|
|
40
|
+
<style>
|
|
41
|
+
:root { color-scheme: light dark; }
|
|
42
|
+
body {
|
|
43
|
+
margin: 0 auto; padding: 2rem 1.25rem 4rem; max-width: 42rem;
|
|
44
|
+
font: 16px/1.65 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
45
|
+
}
|
|
46
|
+
h1 { font-size: 1.5rem; margin: 0 0 .25rem; }
|
|
47
|
+
h2 { font-size: 1.05rem; margin: 2rem 0 .5rem; }
|
|
48
|
+
.sub { opacity: .7; margin: 0 0 2rem; }
|
|
49
|
+
dt { font-weight: 600; margin-top: .9rem; }
|
|
50
|
+
dd { margin: .15rem 0 0; }
|
|
51
|
+
ol { padding-left: 1.25rem; }
|
|
52
|
+
li { margin: .4rem 0; }
|
|
53
|
+
code, .n { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
54
|
+
blockquote {
|
|
55
|
+
margin: .75rem 0; padding: .75rem 1rem; border-left: 3px solid currentColor;
|
|
56
|
+
opacity: .85; font-style: italic;
|
|
57
|
+
}
|
|
58
|
+
footer { margin-top: 3rem; font-size: .9rem; opacity: .7; }
|
|
59
|
+
</style>
|
|
60
|
+
|
|
61
|
+
<h1>Stream reminders by text</h1>
|
|
62
|
+
<p class="sub">What these messages are, and how to stop them.</p>
|
|
63
|
+
|
|
64
|
+
<h2>How you sign up</h2>
|
|
65
|
+
<p>
|
|
66
|
+
There is one way, and it happens on the phone. Call
|
|
67
|
+
<strong class="n">${callIn}</strong> and key the six-digit code of a stream.
|
|
68
|
+
If that stream has finished, you hear this:
|
|
69
|
+
</p>
|
|
70
|
+
<blockquote>
|
|
71
|
+
Welcome to <name>’s live stream of <what they were playing>.
|
|
72
|
+
The live stream ended at <time> Pacific. Call back later when they
|
|
73
|
+
stream again. Press 1 to get a text message when they do.
|
|
74
|
+
</blockquote>
|
|
75
|
+
<ol>
|
|
76
|
+
<li>You press <strong>1</strong>.</li>
|
|
77
|
+
<li>We keep the number you called from, and nothing else.</li>
|
|
78
|
+
<li>When that stream goes live again, you get one text.</li>
|
|
79
|
+
</ol>
|
|
80
|
+
<p>
|
|
81
|
+
Pressing anything else, or hanging up, signs you up for nothing. We never add
|
|
82
|
+
a number that did not press 1 on that prompt.
|
|
83
|
+
</p>
|
|
84
|
+
|
|
85
|
+
<h2>What you get</h2>
|
|
86
|
+
<dl>
|
|
87
|
+
<dt>Message</dt>
|
|
88
|
+
<dd class="n"><name> is live now of <what> on nixamp. Call ${callIn} and key <code> to listen. Reply STOP to opt out.</dd>
|
|
89
|
+
|
|
90
|
+
<dt>How often</dt>
|
|
91
|
+
<dd>
|
|
92
|
+
Once per stream you asked about. Asking is a one-time thing: after that
|
|
93
|
+
text is sent you are off the list, and you would have to call and press 1
|
|
94
|
+
again to be told about the next one. There is no schedule and no marketing.
|
|
95
|
+
</dd>
|
|
96
|
+
|
|
97
|
+
<dt>Sent from</dt>
|
|
98
|
+
<dd class="n">${smsFrom}</dd>
|
|
99
|
+
</dl>
|
|
100
|
+
|
|
101
|
+
<h2>How to stop</h2>
|
|
102
|
+
<p>
|
|
103
|
+
Reply <strong>STOP</strong> to any message and you will get no more.
|
|
104
|
+
Reply <strong>HELP</strong> for help. You can also simply never press 1.
|
|
105
|
+
</p>
|
|
106
|
+
<p>Message and data rates may apply.</p>
|
|
107
|
+
|
|
108
|
+
<h2>What we keep</h2>
|
|
109
|
+
<p>
|
|
110
|
+
The phone number you called from, tied to the stream you asked about, until
|
|
111
|
+
that text is sent — then it is deleted. Nothing is sold, and nothing is
|
|
112
|
+
shared with anyone but the carrier that has to deliver the message.
|
|
113
|
+
</p>
|
|
114
|
+
|
|
115
|
+
<footer>
|
|
116
|
+
ProFullStack, Inc. ·
|
|
117
|
+
<a href="mailto:anthony@profullstack.com">anthony@profullstack.com</a> ·
|
|
118
|
+
<a href="/">nixamp</a>
|
|
119
|
+
</footer>
|
|
120
|
+
</html>
|
|
121
|
+
`;
|
|
122
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
export interface RoomInfo {
|
|
2
|
+
/** The six-digit code, which is the room's whole identity. */
|
|
3
|
+
code: string;
|
|
4
|
+
/** Telnyx's id for the conference, once a first caller has made one. */
|
|
5
|
+
conferenceId: string | null;
|
|
6
|
+
/** How many legs we have put in, less the ones we have seen leave. */
|
|
7
|
+
callers: number;
|
|
8
|
+
startedAt: number;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* What the party line needs to know about a stream.
|
|
12
|
+
*
|
|
13
|
+
* A narrow view of the Directory rather than the Directory itself, so this
|
|
14
|
+
* module stays a function of its inputs and a test can describe a stream
|
|
15
|
+
* without standing one up.
|
|
16
|
+
*/
|
|
17
|
+
export interface StreamLookup {
|
|
18
|
+
liveByCode(code: string): {
|
|
19
|
+
name: string;
|
|
20
|
+
url: string;
|
|
21
|
+
audio: string;
|
|
22
|
+
nowPlaying: string;
|
|
23
|
+
startedAt: number;
|
|
24
|
+
} | undefined;
|
|
25
|
+
endedByCode(code: string): {
|
|
26
|
+
name: string;
|
|
27
|
+
nowPlaying: string;
|
|
28
|
+
startedAt: number;
|
|
29
|
+
endedAt: number;
|
|
30
|
+
} | undefined;
|
|
31
|
+
}
|
|
32
|
+
/** Sending a text. Injected because the number that sends is not this one. */
|
|
33
|
+
export interface Sms {
|
|
34
|
+
send(to: string, text: string): Promise<boolean>;
|
|
35
|
+
}
|
|
36
|
+
export interface PartyLineOptions {
|
|
37
|
+
/** A Telnyx API key with call-control rights. */
|
|
38
|
+
apiKey: string;
|
|
39
|
+
/** The directory, on the instance that hosts one. */
|
|
40
|
+
streams?: StreamLookup;
|
|
41
|
+
/**
|
|
42
|
+
* How to text somebody when a stream comes back.
|
|
43
|
+
*
|
|
44
|
+
* Not from the toll-free number the call arrived on: toll-free A2P messaging
|
|
45
|
+
* is filtered by carriers until the number is verified, and ours is not. A
|
|
46
|
+
* long code that already has a messaging profile sends today, so reminders
|
|
47
|
+
* go out from there and the verification can land whenever it lands.
|
|
48
|
+
*/
|
|
49
|
+
sms?: Sms;
|
|
50
|
+
/**
|
|
51
|
+
* The account's ed25519 public key, base64, from the portal. Without it
|
|
52
|
+
* every webhook is refused: an unauthenticated call-control webhook lets a
|
|
53
|
+
* stranger drive calls we are paying for.
|
|
54
|
+
*/
|
|
55
|
+
publicKey: string;
|
|
56
|
+
/** What the caller hears before being asked for a room. */
|
|
57
|
+
greeting?: string;
|
|
58
|
+
voice?: string;
|
|
59
|
+
/** A ceiling per room, so one room cannot spend the whole balance. */
|
|
60
|
+
maxParticipants?: number;
|
|
61
|
+
/** The number to tell people to call back on. Injected, not hardcoded. */
|
|
62
|
+
callIn?: string;
|
|
63
|
+
/** Injected for tests. */
|
|
64
|
+
now?: () => number;
|
|
65
|
+
fetch?: typeof globalThis.fetch;
|
|
66
|
+
onEvent?: (message: string) => void;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* How long a room code is.
|
|
70
|
+
*
|
|
71
|
+
* Six digits, because the code has to survive being read down a phone line and
|
|
72
|
+
* typed into a URL. The generated ids this replaces were long enough that
|
|
73
|
+
* nobody could say one out loud, which is the whole failure being fixed: a
|
|
74
|
+
* room code is something you tell somebody, so it has to be short enough to
|
|
75
|
+
* hold in your head between hearing it and dialling it.
|
|
76
|
+
*/
|
|
77
|
+
export declare const CODE_LENGTH = 6;
|
|
78
|
+
/**
|
|
79
|
+
* A room code, from whatever the caller keyed.
|
|
80
|
+
*
|
|
81
|
+
* Digits only, and exactly six of them. Five is not a near miss to be
|
|
82
|
+
* charitable about -- it is a different room, and guessing which one they
|
|
83
|
+
* meant would drop somebody into a stranger's conversation.
|
|
84
|
+
*
|
|
85
|
+
* Nothing here is case-sensitive because nothing here has a case. That is the
|
|
86
|
+
* point of digits over letters: a phone keypad has one way to type a 4, and no
|
|
87
|
+
* two people disagree about how to say it.
|
|
88
|
+
*/
|
|
89
|
+
export declare function roomCodeFrom(entered: unknown): string;
|
|
90
|
+
/**
|
|
91
|
+
* A time as a caller should hear it.
|
|
92
|
+
*
|
|
93
|
+
* Pacific, spelled out, because that is the clock the streams are announced on
|
|
94
|
+
* and a bare "9:27" down a phone line is a time in somebody's head rather than
|
|
95
|
+
* a time. Built with Intl rather than arithmetic: the offset changes twice a
|
|
96
|
+
* year and hand-rolled zone maths is how you end up an hour out for three
|
|
97
|
+
* weeks every spring.
|
|
98
|
+
*/
|
|
99
|
+
export declare function pacificTime(at: number): string;
|
|
100
|
+
/** How a code is read back: one digit at a time, because 482917 is not a number. */
|
|
101
|
+
export declare function spokenCode(code: string): string;
|
|
102
|
+
export interface TelnyxEvent {
|
|
103
|
+
event_type?: string;
|
|
104
|
+
payload?: Record<string, unknown>;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* The party line, as a thing that answers webhooks.
|
|
108
|
+
*
|
|
109
|
+
* It owns no socket and no timer. The server hands it a verified event and it
|
|
110
|
+
* issues whatever call-control commands that event calls for, which makes the
|
|
111
|
+
* whole state machine testable with a fetch that records what it was asked.
|
|
112
|
+
*/
|
|
113
|
+
export declare class PartyLine {
|
|
114
|
+
private readonly options;
|
|
115
|
+
private readonly rooms;
|
|
116
|
+
/** Which room a leg is heading for, between asking and being answered. */
|
|
117
|
+
private readonly legRoom;
|
|
118
|
+
/** The number each caller is calling from, for a reminder they ask for. */
|
|
119
|
+
private readonly legFrom;
|
|
120
|
+
/** Legs that heard "press 1", and which stream they would be reminded about. */
|
|
121
|
+
private readonly pendingReminder;
|
|
122
|
+
/** Who to text when a stream returns, by stream code. */
|
|
123
|
+
private readonly reminders;
|
|
124
|
+
/**
|
|
125
|
+
* The same list, somewhere that survives a deploy.
|
|
126
|
+
*
|
|
127
|
+
* A caller who pressed 1 was told they would be texted. Keeping that promise
|
|
128
|
+
* only in a Map meant a restart broke it silently, which is the worst way to
|
|
129
|
+
* break a promise made to somebody on a telephone.
|
|
130
|
+
*/
|
|
131
|
+
private reminderStore;
|
|
132
|
+
/** Start echoing reminders somewhere durable, and put back what was there. */
|
|
133
|
+
persistRemindersTo(store: {
|
|
134
|
+
add: (code: string, phone: string) => void;
|
|
135
|
+
take: (code: string) => Promise<string[]>;
|
|
136
|
+
}, waiting?: ReadonlyMap<string, ReadonlySet<string>>): void;
|
|
137
|
+
/**
|
|
138
|
+
* Legs listening to a stream, by its code.
|
|
139
|
+
*
|
|
140
|
+
* Separate from the rooms because a stream listener is not in a conference:
|
|
141
|
+
* they are a leg with an MP3 playing into it. Nothing else was counting
|
|
142
|
+
* them, so the directory had no way to say how many people were on the
|
|
143
|
+
* phone for a broadcast.
|
|
144
|
+
*/
|
|
145
|
+
private readonly streamLegs;
|
|
146
|
+
private readonly key;
|
|
147
|
+
private readonly fetch;
|
|
148
|
+
private readonly now;
|
|
149
|
+
constructor(options: PartyLineOptions);
|
|
150
|
+
/** True when this instance can actually check a signature. */
|
|
151
|
+
get armed(): boolean;
|
|
152
|
+
/**
|
|
153
|
+
* Whether a webhook really came from Telnyx.
|
|
154
|
+
*
|
|
155
|
+
* The body has to be the bytes that arrived. Parsing and reserialising JSON
|
|
156
|
+
* changes key order and whitespace, and the signature is over the original.
|
|
157
|
+
*/
|
|
158
|
+
verify(rawBody: string, signature: string | undefined, timestamp: string | undefined): boolean;
|
|
159
|
+
/**
|
|
160
|
+
* The rooms with someone in them, busiest first, with their codes.
|
|
161
|
+
*
|
|
162
|
+
* The code is published on purpose. An earlier version withheld it on the
|
|
163
|
+
* reasoning that a code is the only thing between a stranger and a
|
|
164
|
+
* conversation -- true of a private room, and wrong here: this is a public
|
|
165
|
+
* call-in line, and a listing you cannot dial is a listing of nothing. The
|
|
166
|
+
* code is how you join, so it is what the list is for.
|
|
167
|
+
*/
|
|
168
|
+
list(): {
|
|
169
|
+
code: string;
|
|
170
|
+
callers: number;
|
|
171
|
+
startedAt: number;
|
|
172
|
+
}[];
|
|
173
|
+
/**
|
|
174
|
+
* Drive one call-control event.
|
|
175
|
+
*
|
|
176
|
+
* Every branch returns rather than falling through, because an event we do
|
|
177
|
+
* not handle is the normal case -- Telnyx sends a dozen kinds per call and
|
|
178
|
+
* this cares about four.
|
|
179
|
+
*/
|
|
180
|
+
handle(event: TelnyxEvent): Promise<void>;
|
|
181
|
+
/**
|
|
182
|
+
* Ask for a room code, on the keypad.
|
|
183
|
+
*
|
|
184
|
+
* Not by voice, which is the one thing here that changed its mind. Speech
|
|
185
|
+
* suited a room *name* -- "blue" misheard is still recognisably a word, and
|
|
186
|
+
* a person can say it differently the second time. A six-digit code has no
|
|
187
|
+
* such slack: one digit misheard is a different room that also exists, and
|
|
188
|
+
* the caller lands in a stranger's conversation with nothing to tell them
|
|
189
|
+
* they went wrong. A keypad cannot mishear a 4.
|
|
190
|
+
*
|
|
191
|
+
* Six digits terminates the gather on its own, so the caller does not have
|
|
192
|
+
* to press anything after; # is there for the ones who do it anyway.
|
|
193
|
+
*/
|
|
194
|
+
private ask;
|
|
195
|
+
/**
|
|
196
|
+
* Answer a code that belongs to a stream, rather than a room.
|
|
197
|
+
*
|
|
198
|
+
* Returns false when the code is nobody's stream, which is how an ordinary
|
|
199
|
+
* room code still works: this line was a party line before it was a way into
|
|
200
|
+
* a broadcast, and a code that means nothing to the directory should still
|
|
201
|
+
* mean a room.
|
|
202
|
+
*/
|
|
203
|
+
private stream;
|
|
204
|
+
/** Whether the caller took the reminder that was offered. */
|
|
205
|
+
private reminder;
|
|
206
|
+
/**
|
|
207
|
+
* A stream came back: text whoever asked to be told.
|
|
208
|
+
*
|
|
209
|
+
* The list is cleared as it is sent. A reminder is a thing somebody asked
|
|
210
|
+
* for once, and texting them every time that stream starts for the rest of
|
|
211
|
+
* the week is how a useful message becomes the reason they block the number.
|
|
212
|
+
*/
|
|
213
|
+
wentLive(stream: {
|
|
214
|
+
code: string;
|
|
215
|
+
name: string;
|
|
216
|
+
nowPlaying: string;
|
|
217
|
+
}): Promise<number>;
|
|
218
|
+
/** How many numbers are waiting to hear that a code is live. */
|
|
219
|
+
waitingOn(code: string): number;
|
|
220
|
+
/** Put a leg into a room, making the conference if it is the first one there. */
|
|
221
|
+
private join;
|
|
222
|
+
private enter;
|
|
223
|
+
/** How many people are listening to a stream by phone. */
|
|
224
|
+
listenersOn(code: string): number;
|
|
225
|
+
/** A leg that hung up or was dropped, wherever it was. */
|
|
226
|
+
private release;
|
|
227
|
+
/**
|
|
228
|
+
* The room on this code, made if nobody is using it.
|
|
229
|
+
*
|
|
230
|
+
* Entering a code nobody is in opens that room rather than failing. The code
|
|
231
|
+
* is a rendezvous, not a credential: two people who agree on 482917
|
|
232
|
+
* beforehand should both be able to dial in, and neither of them should have
|
|
233
|
+
* had to create it first.
|
|
234
|
+
*/
|
|
235
|
+
private room;
|
|
236
|
+
private get voice();
|
|
237
|
+
private get maxParticipants();
|
|
238
|
+
/** One call-control command. True when Telnyx accepted it. */
|
|
239
|
+
private command;
|
|
240
|
+
/** A POST to Telnyx, or null if it did not work. */
|
|
241
|
+
private request;
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Texting, over Telnyx.
|
|
245
|
+
*
|
|
246
|
+
* A separate `from` because it is a different number: the call arrives on the
|
|
247
|
+
* toll-free line, but toll-free A2P messaging is filtered by carriers until
|
|
248
|
+
* that number is verified and ours is not yet. The long code already carries a
|
|
249
|
+
* messaging profile, so it can send today -- and when verification lands, this
|
|
250
|
+
* becomes a one-line change rather than a redesign.
|
|
251
|
+
*/
|
|
252
|
+
export declare function telnyxSms({ apiKey, from, fetch, onEvent }: {
|
|
253
|
+
apiKey: string;
|
|
254
|
+
from: string;
|
|
255
|
+
fetch?: typeof globalThis.fetch;
|
|
256
|
+
onEvent?: (message: string) => void;
|
|
257
|
+
}): Sms;
|
|
258
|
+
/** Constant-time compare, for the places a token is checked rather than signed. */
|
|
259
|
+
export declare function sameSecret(a: string, b: string): boolean;
|