@tusile/bot 0.2.1 → 0.5.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/src/gateway.js CHANGED
@@ -1,191 +1,191 @@
1
- /**
2
- * The socket a bot keeps open to one community.
3
- *
4
- * A Tusile bot dials out. There is no inbound port and no public url to arrange, which is
5
- * most of why the webhook bots this replaces went unwritten: a hobby bot on a laptop
6
- * cannot be called back. The cost of dialling out is that the connection is now the bot's
7
- * problem, and that is what this file is: the auth frame first, a heartbeat at whatever
8
- * interval the server asks for, and a clear answer to the only question that matters when
9
- * a socket dies, which is whether to try again.
10
- */
11
-
12
- const DEFAULT_HEARTBEAT_SECONDS = 30;
13
-
14
- /** Where the gateway lives, given where the community's api lives. */
15
- export function gatewayUrl(apiUrl) {
16
- return String(apiUrl || '').replace(/\/+$/, '').replace(/^http/, 'ws') + '/bot/gateway';
17
- }
18
-
19
- /**
20
- * How long to wait before trying a dead connection again.
21
- *
22
- * Doubling, and capped: a community that is down for the afternoon should not be asked
23
- * every millisecond, and a bot should not sit out an hour because of one bad minute.
24
- */
25
- export function nextBackoff(current, min, max) {
26
- if (!current) return min;
27
- return Math.min(current * 2, max);
28
- }
29
-
30
- export class Gateway {
31
- #token;
32
- #connect;
33
- #timers;
34
- #onReady;
35
- #onFrame;
36
- #heartbeat = null;
37
- #settle = null;
38
- #closed = null;
39
- #generation = 0;
40
- #wasLive = false;
41
-
42
- constructor({ url, token, connect, timers = globalThis, onReady = () => {}, onFrame = () => {} }) {
43
- this.url = url;
44
- this.socket = null;
45
- this.stopped = false;
46
- this.#token = token;
47
- this.#connect = connect;
48
- this.#timers = timers;
49
- this.#onReady = onReady;
50
- this.#onFrame = onFrame;
51
- }
52
-
53
- /**
54
- * Mint a token and open the socket.
55
- *
56
- * Resolves once the socket exists and is wired up, not once it is authenticated: a
57
- * caller that wants to know the connection worked waits on `closed()`, which is the
58
- * same thing as waiting for it to stop working.
59
- */
60
- async open() {
61
- this.#generation += 1;
62
- const generation = this.#generation;
63
- this.#wasLive = false;
64
- this.#closed = new Promise((resolve) => {
65
- this.#settle = (outcome) => {
66
- if (generation !== this.#generation) return;
67
- this.#stopHeartbeat();
68
- // Whether this connection ever worked is the difference between a community
69
- // that is down and one that drops a socket now and then: the first should be
70
- // asked less and less often, the second should be picked up at once.
71
- resolve({ ...outcome, wasLive: this.#wasLive });
72
- };
73
- });
74
-
75
- try {
76
- // A token is minted per community and lives an hour, so every connect gets a fresh
77
- // one rather than guessing whether the last one is still good.
78
- const token = await this.#token();
79
- if (this.stopped) {
80
- this.#settle({ fatal: true, reason: 'stopped' });
81
- return;
82
- }
83
-
84
- const socket = this.#connect(gatewayUrl(this.url));
85
- this.socket = socket;
86
-
87
- // The auth frame goes first, before anything else is sent or expected.
88
- socket.on('open', () => this.#send({ type: 'auth', token }));
89
- socket.on('message', (raw) => this.#onMessage(raw));
90
- socket.on('close', (code, reason) => {
91
- const why = reason ? reason.toString() : '';
92
- // The server says this distinctly rather than just closing, so that a bot which
93
- // has been removed can stop instead of reconnecting for the rest of its life.
94
- this.#settle({ fatal: this.stopped || why.includes('bot_uninstalled'), reason: why });
95
- });
96
- socket.on('error', (err) => {
97
- this.#settle({ fatal: this.stopped, reason: String(err?.message || err) });
98
- });
99
- } catch (err) {
100
- // Core refusing a token, or a socket that would not even be constructed. Settled as
101
- // well as thrown: a caller waiting on `closed` would otherwise wait for good.
102
- //
103
- // Credentials Core refuses are final. The bot was deleted, or its secret was
104
- // rotated, and every retry from here is a 401 in somebody's log until the process
105
- // is stopped, plus a share of the rate limit the working bots need.
106
- this.#settle({
107
- fatal: err?.isUninstalled === true || err?.isAuthFailure === true,
108
- reason: String(err?.message || err),
109
- });
110
- throw err;
111
- }
112
- }
113
-
114
- /** Resolves when this connection ends, saying whether trying again is worth anything. */
115
- closed() {
116
- return (
117
- this.#closed ?? Promise.resolve({ fatal: this.stopped, reason: 'never opened', wasLive: false })
118
- );
119
- }
120
-
121
- /** Open it and wait for it to end. Never throws: the outcome says what happened. */
122
- async run() {
123
- try {
124
- await this.open();
125
- } catch (err) {
126
- // Core refusing to mint a token, usually. Not a reason to give up on the community.
127
- return {
128
- fatal: err?.isUninstalled === true || err?.isAuthFailure === true,
129
- reason: String(err?.message || err),
130
- wasLive: false,
131
- };
132
- }
133
- return this.closed();
134
- }
135
-
136
- stop() {
137
- this.stopped = true;
138
- this.#stopHeartbeat();
139
- const settle = this.#settle;
140
- // Settled before the close event arrives, so that a bot on its way out does not
141
- // reconnect on the strength of its own goodbye.
142
- if (settle) settle({ fatal: true, reason: 'stopped' });
143
- try {
144
- this.socket?.close();
145
- } catch {
146
- // Closing an already-dead socket is not news.
147
- }
148
- }
149
-
150
- #onMessage(raw) {
151
- let frame;
152
- try {
153
- frame = JSON.parse(raw.toString());
154
- } catch {
155
- // A proxy answering with html, most often. Not worth taking a running bot down for.
156
- return;
157
- }
158
- if (frame?.type === 'ready') {
159
- this.#wasLive = true;
160
- this.#startHeartbeat(frame.heartbeat_interval_seconds);
161
- this.#onReady(frame.bot || {}, frame);
162
- return;
163
- }
164
- this.#onFrame(frame);
165
- }
166
-
167
- /**
168
- * Check in at the interval the server names.
169
- *
170
- * A socket that goes quiet is dropped, and on a phone network or behind an idle proxy
171
- * that is otherwise indistinguishable from one that is working perfectly well.
172
- */
173
- #startHeartbeat(seconds) {
174
- this.#stopHeartbeat();
175
- const ms = (Number(seconds) > 0 ? Number(seconds) : DEFAULT_HEARTBEAT_SECONDS) * 1000;
176
- this.#heartbeat = this.#timers.setInterval(() => this.#send({ type: 'heartbeat' }), ms);
177
- }
178
-
179
- #stopHeartbeat() {
180
- if (this.#heartbeat) this.#timers.clearInterval(this.#heartbeat);
181
- this.#heartbeat = null;
182
- }
183
-
184
- #send(frame) {
185
- try {
186
- this.socket?.send(JSON.stringify(frame));
187
- } catch {
188
- // The socket died between the timer firing and this line. The close handler has it.
189
- }
190
- }
191
- }
1
+ /**
2
+ * The socket a bot keeps open to one community.
3
+ *
4
+ * A Tusile bot dials out. There is no inbound port and no public url to arrange, which is
5
+ * most of why the webhook bots this replaces went unwritten: a hobby bot on a laptop
6
+ * cannot be called back. The cost of dialling out is that the connection is now the bot's
7
+ * problem, and that is what this file is: the auth frame first, a heartbeat at whatever
8
+ * interval the server asks for, and a clear answer to the only question that matters when
9
+ * a socket dies, which is whether to try again.
10
+ */
11
+
12
+ const DEFAULT_HEARTBEAT_SECONDS = 30;
13
+
14
+ /** Where the gateway lives, given where the community's api lives. */
15
+ export function gatewayUrl(apiUrl) {
16
+ return String(apiUrl || '').replace(/\/+$/, '').replace(/^http/, 'ws') + '/bot/gateway';
17
+ }
18
+
19
+ /**
20
+ * How long to wait before trying a dead connection again.
21
+ *
22
+ * Doubling, and capped: a community that is down for the afternoon should not be asked
23
+ * every millisecond, and a bot should not sit out an hour because of one bad minute.
24
+ */
25
+ export function nextBackoff(current, min, max) {
26
+ if (!current) return min;
27
+ return Math.min(current * 2, max);
28
+ }
29
+
30
+ export class Gateway {
31
+ #token;
32
+ #connect;
33
+ #timers;
34
+ #onReady;
35
+ #onFrame;
36
+ #heartbeat = null;
37
+ #settle = null;
38
+ #closed = null;
39
+ #generation = 0;
40
+ #wasLive = false;
41
+
42
+ constructor({ url, token, connect, timers = globalThis, onReady = () => {}, onFrame = () => {} }) {
43
+ this.url = url;
44
+ this.socket = null;
45
+ this.stopped = false;
46
+ this.#token = token;
47
+ this.#connect = connect;
48
+ this.#timers = timers;
49
+ this.#onReady = onReady;
50
+ this.#onFrame = onFrame;
51
+ }
52
+
53
+ /**
54
+ * Mint a token and open the socket.
55
+ *
56
+ * Resolves once the socket exists and is wired up, not once it is authenticated: a
57
+ * caller that wants to know the connection worked waits on `closed()`, which is the
58
+ * same thing as waiting for it to stop working.
59
+ */
60
+ async open() {
61
+ this.#generation += 1;
62
+ const generation = this.#generation;
63
+ this.#wasLive = false;
64
+ this.#closed = new Promise((resolve) => {
65
+ this.#settle = (outcome) => {
66
+ if (generation !== this.#generation) return;
67
+ this.#stopHeartbeat();
68
+ // Whether this connection ever worked is the difference between a community
69
+ // that is down and one that drops a socket now and then: the first should be
70
+ // asked less and less often, the second should be picked up at once.
71
+ resolve({ ...outcome, wasLive: this.#wasLive });
72
+ };
73
+ });
74
+
75
+ try {
76
+ // A token is minted per community and lives an hour, so every connect gets a fresh
77
+ // one rather than guessing whether the last one is still good.
78
+ const token = await this.#token();
79
+ if (this.stopped) {
80
+ this.#settle({ fatal: true, reason: 'stopped' });
81
+ return;
82
+ }
83
+
84
+ const socket = this.#connect(gatewayUrl(this.url));
85
+ this.socket = socket;
86
+
87
+ // The auth frame goes first, before anything else is sent or expected.
88
+ socket.on('open', () => this.#send({ type: 'auth', token }));
89
+ socket.on('message', (raw) => this.#onMessage(raw));
90
+ socket.on('close', (code, reason) => {
91
+ const why = reason ? reason.toString() : '';
92
+ // The server says this distinctly rather than just closing, so that a bot which
93
+ // has been removed can stop instead of reconnecting for the rest of its life.
94
+ this.#settle({ fatal: this.stopped || why.includes('bot_uninstalled'), reason: why });
95
+ });
96
+ socket.on('error', (err) => {
97
+ this.#settle({ fatal: this.stopped, reason: String(err?.message || err) });
98
+ });
99
+ } catch (err) {
100
+ // Core refusing a token, or a socket that would not even be constructed. Settled as
101
+ // well as thrown: a caller waiting on `closed` would otherwise wait for good.
102
+ //
103
+ // Credentials Core refuses are final. The bot was deleted, or its secret was
104
+ // rotated, and every retry from here is a 401 in somebody's log until the process
105
+ // is stopped, plus a share of the rate limit the working bots need.
106
+ this.#settle({
107
+ fatal: err?.isUninstalled === true || err?.isAuthFailure === true,
108
+ reason: String(err?.message || err),
109
+ });
110
+ throw err;
111
+ }
112
+ }
113
+
114
+ /** Resolves when this connection ends, saying whether trying again is worth anything. */
115
+ closed() {
116
+ return (
117
+ this.#closed ?? Promise.resolve({ fatal: this.stopped, reason: 'never opened', wasLive: false })
118
+ );
119
+ }
120
+
121
+ /** Open it and wait for it to end. Never throws: the outcome says what happened. */
122
+ async run() {
123
+ try {
124
+ await this.open();
125
+ } catch (err) {
126
+ // Core refusing to mint a token, usually. Not a reason to give up on the community.
127
+ return {
128
+ fatal: err?.isUninstalled === true || err?.isAuthFailure === true,
129
+ reason: String(err?.message || err),
130
+ wasLive: false,
131
+ };
132
+ }
133
+ return this.closed();
134
+ }
135
+
136
+ stop() {
137
+ this.stopped = true;
138
+ this.#stopHeartbeat();
139
+ const settle = this.#settle;
140
+ // Settled before the close event arrives, so that a bot on its way out does not
141
+ // reconnect on the strength of its own goodbye.
142
+ if (settle) settle({ fatal: true, reason: 'stopped' });
143
+ try {
144
+ this.socket?.close();
145
+ } catch {
146
+ // Closing an already-dead socket is not news.
147
+ }
148
+ }
149
+
150
+ #onMessage(raw) {
151
+ let frame;
152
+ try {
153
+ frame = JSON.parse(raw.toString());
154
+ } catch {
155
+ // A proxy answering with html, most often. Not worth taking a running bot down for.
156
+ return;
157
+ }
158
+ if (frame?.type === 'ready') {
159
+ this.#wasLive = true;
160
+ this.#startHeartbeat(frame.heartbeat_interval_seconds);
161
+ this.#onReady(frame.bot || {}, frame);
162
+ return;
163
+ }
164
+ this.#onFrame(frame);
165
+ }
166
+
167
+ /**
168
+ * Check in at the interval the server names.
169
+ *
170
+ * A socket that goes quiet is dropped, and on a phone network or behind an idle proxy
171
+ * that is otherwise indistinguishable from one that is working perfectly well.
172
+ */
173
+ #startHeartbeat(seconds) {
174
+ this.#stopHeartbeat();
175
+ const ms = (Number(seconds) > 0 ? Number(seconds) : DEFAULT_HEARTBEAT_SECONDS) * 1000;
176
+ this.#heartbeat = this.#timers.setInterval(() => this.#send({ type: 'heartbeat' }), ms);
177
+ }
178
+
179
+ #stopHeartbeat() {
180
+ if (this.#heartbeat) this.#timers.clearInterval(this.#heartbeat);
181
+ this.#heartbeat = null;
182
+ }
183
+
184
+ #send(frame) {
185
+ try {
186
+ this.socket?.send(JSON.stringify(frame));
187
+ } catch {
188
+ // The socket died between the timer firing and this line. The close handler has it.
189
+ }
190
+ }
191
+ }