@tusile/bot 0.2.1 → 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/src/bot.js CHANGED
@@ -1,350 +1,615 @@
1
- import { CoreClient } from './core.js';
2
- import { Dispatcher } from './dispatch.js';
3
- import { Gateway, nextBackoff } from './gateway.js';
4
- import { Interaction } from './interaction.js';
5
- import { ServerClient } from './rest.js';
6
-
7
- /**
8
- * A bot, on every community that has added it.
9
- *
10
- * The shape of the problem: one identity held by Core, one short-lived token per
11
- * community, one socket per community, and a list of communities that changes while the
12
- * bot runs. None of that is a bot author's business, and all of it used to be. What is
13
- * left is `command`, `component`, `modal`, and `start`.
14
- */
15
-
16
- const DEFAULT_POLL_MS = 60 * 1000;
17
-
18
- /** How long to let Core hold a request open while the bot is on no communities at all. */
19
- const JOIN_WAIT_SECONDS = 25;
20
-
21
- const RECONNECT_MIN_MS = 1000;
22
- const RECONNECT_MAX_MS = 30 * 1000;
23
-
24
- export class Bot {
25
- #core;
26
- #dispatcher;
27
- #links = new Map();
28
- #listeners = new Map();
29
- #connect;
30
- #timers;
31
- #sleep;
32
- #fetch;
33
- #pollMs;
34
- #poll = null;
35
- #stopped = false;
36
-
37
- constructor({
38
- coreUrl = process.env?.CORE_SERVER_URL,
39
- key = process.env?.BOT_KEY,
40
- secret = process.env?.BOT_SECRET,
41
- fetch,
42
- sleep,
43
- connect,
44
- timers = globalThis,
45
- onError,
46
- autoDeferMs,
47
- pollMs = DEFAULT_POLL_MS,
48
- } = {}) {
49
- if (!coreUrl) throw new Error('no coreUrl: set CORE_SERVER_URL to the Tusile core server');
50
- if (!key || !secret) {
51
- // Said here rather than at the first request, because the first request happens
52
- // inside a deploy where nobody is watching, and this happens at startup.
53
- throw new Error(
54
- 'no bot credentials: set BOT_KEY and BOT_SECRET. Create a bot in the Tusile app, ' +
55
- 'under Settings, Bots, and copy them there.',
56
- );
57
- }
58
-
59
- this.#core = new CoreClient({ url: coreUrl, key, secret, fetch, sleep });
60
- this.#fetch = fetch;
61
- this.#sleep = sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
62
- this.#connect = connect || null;
63
- this.#timers = timers;
64
- this.#pollMs = pollMs;
65
- this.#dispatcher = new Dispatcher({
66
- autoDeferMs,
67
- onError: (err, interaction) => this.#report(err, interaction),
68
- });
69
- if (onError) this.on('error', onError);
70
-
71
- /** This bot's own account, once it has asked. Its id is how it recognises itself. */
72
- this.me = null;
73
- /** Every community it is connected to, by id. */
74
- this.servers = new Map();
75
- }
76
-
77
- // What the bot does.
78
-
79
- command(definition, handler) {
80
- this.#dispatcher.command(definition, handler);
81
- return this;
82
- }
83
-
84
- component(customId, handler) {
85
- this.#dispatcher.component(customId, handler);
86
- return this;
87
- }
88
-
89
- modal(customId, handler) {
90
- this.#dispatcher.modal(customId, handler);
91
- return this;
92
- }
93
-
94
- /**
95
- * ready (bot, server) connected to a community and registered there
96
- * message (message, server) something said in a channel the bot can see
97
- * joined (server) added to a community
98
- * left (serverId) removed from one
99
- * error (error, context) anything that went wrong, including in a handler
100
- */
101
- on(event, listener) {
102
- if (!this.#listeners.has(event)) this.#listeners.set(event, []);
103
- this.#listeners.get(event).push(listener);
104
- return this;
105
- }
106
-
107
- /**
108
- * Connect, and stay connected.
109
- *
110
- * Resolves once every community known right now has a socket, so that a caller can
111
- * reasonably look at `servers` afterwards. Keeps running until `stop`.
112
- */
113
- async start() {
114
- // `ws` is loaded only if nobody supplied a socket, so a runtime with its own
115
- // WebSocket, or a test, never pays for it.
116
- if (!this.#connect) {
117
- const { default: WebSocket } = await import('ws');
118
- this.#connect = (url) => new WebSocket(url);
119
- }
120
- this.me = await this.#core.me();
121
- await this.sync();
122
- // Being added to a community is a change Core cannot push to a bot, so this is a
123
- // poll. It is a slow one, because the empty case, which is the one somebody is
124
- // watching, is handled by asking Core to hold the request open instead.
125
- this.#poll = this.#timers.setInterval(() => {
126
- this.sync().catch((err) => this.#report(err));
127
- }, this.#pollMs);
128
- return this;
129
- }
130
-
131
- /**
132
- * Bring the set of connections in line with the set of communities.
133
- *
134
- * Called on a timer, and worth calling by hand after installing the bot somewhere if a
135
- * caller happens to know that has just happened.
136
- */
137
- async sync() {
138
- if (this.#stopped) return;
139
- const waitSeconds = this.servers.size === 0 ? JOIN_WAIT_SECONDS : undefined;
140
- let servers;
141
- try {
142
- servers = await this.#core.servers({ waitSeconds });
143
- } catch (err) {
144
- // Credentials Core refuses will be refused next minute too. Stopping is the honest
145
- // answer: a bot that cannot authenticate is not running, and saying so once beats
146
- // a 401 a minute for as long as nobody notices.
147
- if (err?.isAuthFailure) {
148
- this.#report(err);
149
- await this.stop();
150
- }
151
- throw err;
152
- }
153
- if (this.#stopped) return;
154
-
155
- const seen = new Set();
156
- for (const server of servers) {
157
- seen.add(server.serverId);
158
- if (this.#links.has(server.serverId)) continue;
159
- await this.#join(server);
160
- }
161
- // A community that removed the bot stops being listed, so its socket goes with it.
162
- for (const serverId of [...this.#links.keys()]) {
163
- if (!seen.has(serverId)) this.#leave(serverId);
164
- }
165
- }
166
-
167
- /** Close every socket and stop looking for new communities. */
168
- async stop() {
169
- this.#stopped = true;
170
- if (this.#poll) this.#timers.clearInterval(this.#poll);
171
- this.#poll = null;
172
- for (const serverId of [...this.#links.keys()]) this.#leave(serverId);
173
- }
174
-
175
- async #join({ serverId, apiUrl, raw }) {
176
- const link = new Link({
177
- serverId,
178
- apiUrl,
179
- raw,
180
- core: this.#core,
181
- fetch: this.#fetch,
182
- sleep: this.#sleep,
183
- connect: this.#connect,
184
- timers: this.#timers,
185
- onReady: (bot, client) => {
186
- // Registered on every connect rather than once at startup: a bot redeployed with
187
- // a changed command set, or added to a community while it was down, would
188
- // otherwise go on offering whatever it registered last time.
189
- client
190
- .setCommands(this.#dispatcher.definitions())
191
- .catch((err) => this.#report(err, { serverId }));
192
- this.#emit('ready', bot, client);
193
- },
194
- onFrame: (frame, client) => this.#onFrame(frame, client),
195
- onError: (err) => this.#report(err, { serverId }),
196
- // A connection that has ended for good stops being this bot's connection to that
197
- // community. Leaving it in place would make a community that removed the bot and
198
- // added it again unreachable until the process was restarted: the next sync would
199
- // see a link already there and skip it, and the bot would sit offline with nothing
200
- // in any log to say why.
201
- onFinished: () => {
202
- if (this.#links.get(serverId) === link) this.#leave(serverId);
203
- },
204
- });
205
-
206
- this.#links.set(serverId, link);
207
- this.servers.set(serverId, link.client);
208
- await link.start();
209
- this.#emit('joined', link.client);
210
- }
211
-
212
- #leave(serverId) {
213
- this.#links.get(serverId)?.stop();
214
- this.#links.delete(serverId);
215
- this.servers.delete(serverId);
216
- this.#emit('left', serverId);
217
- }
218
-
219
- #onFrame(frame, client) {
220
- if (frame?.type === 'interaction_create') {
221
- const interaction = new Interaction(frame.data || {}, client);
222
- this.#dispatcher.handle(interaction).catch((err) => this.#report(err, interaction));
223
- return;
224
- }
225
- if (frame?.type === 'message_create') {
226
- this.#emit('message', frame.data || {}, client);
227
- }
228
- }
229
-
230
- #emit(event, ...args) {
231
- for (const listener of this.#listeners.get(event) ?? []) {
232
- try {
233
- listener(...args);
234
- } catch (err) {
235
- // A throwing listener must not take down the socket that fed it.
236
- if (event !== 'error') this.#report(err);
237
- }
238
- }
239
- }
240
-
241
- #report(err, context) {
242
- const listeners = this.#listeners.get('error') ?? [];
243
- if (!listeners.length) {
244
- // Better a line on stderr than a bot that fails in total silence because nobody
245
- // wired up an error handler.
246
- console.error('[tusile-bot]', err);
247
- return;
248
- }
249
- this.#emit('error', err, context);
250
- }
251
- }
252
-
253
- /**
254
- * One community: its socket, its token, and its reconnects.
255
- *
256
- * Kept separate from the bot because everything here is per community. One community being
257
- * down, or having removed the bot, must leave every other connection alone.
258
- */
259
- class Link {
260
- #gateway;
261
- #core;
262
- #sleep;
263
- #onError;
264
- #onReady;
265
- #onFrame;
266
- #onFinished;
267
- #token = '';
268
- #backoff = 0;
269
- #stopped = false;
270
-
271
- constructor({ serverId, apiUrl, raw, core, fetch, sleep, connect, timers, onReady, onFrame,
272
- onError, onFinished }) {
273
- this.serverId = serverId;
274
- this.apiUrl = apiUrl;
275
- this.raw = raw;
276
- this.#core = core;
277
- this.#sleep = sleep;
278
- this.#onError = onError;
279
- this.#onReady = onReady;
280
- this.#onFrame = onFrame;
281
- this.#onFinished = onFinished || (() => {});
282
-
283
- this.client = new ServerClient({
284
- apiUrl,
285
- serverId,
286
- // Read per request, so a reconnect that mints a new token does not leave the rest
287
- // client holding an expired one.
288
- token: () => this.#token,
289
- fetch,
290
- sleep,
291
- });
292
-
293
- this.#gateway = new Gateway({
294
- url: apiUrl,
295
- token: async () => {
296
- this.#token = await this.#core.token(serverId);
297
- return this.#token;
298
- },
299
- connect,
300
- timers,
301
- onReady: (bot) => this.#onReady(bot, this.client),
302
- onFrame: (frame) => this.#onFrame(frame, this.client),
303
- });
304
- }
305
-
306
- /** Open the socket, then keep it open in the background. */
307
- async start() {
308
- await this.#open();
309
- this.#keepAlive().catch((err) => this.#onError(err));
310
- }
311
-
312
- stop() {
313
- this.#stopped = true;
314
- this.#gateway.stop();
315
- }
316
-
317
- async #open() {
318
- try {
319
- await this.#gateway.open();
320
- } catch (err) {
321
- // Core refusing a token, most likely. Swallowed here because the failure is also
322
- // settled on the connection, and the loop below reports it from there: what
323
- // matters is that it does not take the bot's other communities with it.
324
- }
325
- }
326
-
327
- async #keepAlive() {
328
- while (!this.#stopped) {
329
- const { fatal, reason, wasLive } = await this.#gateway.closed();
330
- if (reason) this.#onError(new Error(`[${this.serverId}] ${reason}`));
331
- if (fatal || this.#stopped) return this.#finish();
332
- // A connection that was up and then dropped is worth picking straight back up.
333
- // Carrying the backoff over would leave a bot that reconnects cleanly once an hour
334
- // eventually waiting half a minute every time, for nothing.
335
- if (wasLive) this.#backoff = 0;
336
- this.#backoff = nextBackoff(this.#backoff, RECONNECT_MIN_MS, RECONNECT_MAX_MS);
337
- await this.#sleep(this.#backoff);
338
- if (this.#stopped) return this.#finish();
339
- await this.#open();
340
- }
341
- this.#finish();
342
- }
343
-
344
- /** Say, once, that this connection is over and will not come back by itself. */
345
- #finish() {
346
- const done = this.#onFinished;
347
- this.#onFinished = () => {};
348
- done();
349
- }
350
- }
1
+ import { CoreClient } from './core.js';
2
+ import { Dispatcher } from './dispatch.js';
3
+ import { Gateway, nextBackoff } from './gateway.js';
4
+ import { Interaction } from './interaction.js';
5
+ import { serversToken } from './known.js';
6
+ import { toMask } from './permissions.js';
7
+ import { ServerClient } from './rest.js';
8
+
9
+ /**
10
+ * A bot, on every community that has added it.
11
+ *
12
+ * The shape of the problem: one identity held by Core, one short-lived token per
13
+ * community, one socket per community, and a list of communities that changes while the
14
+ * bot runs. None of that is a bot author's business, and all of it used to be. What is
15
+ * left is `command`, `component`, `modal`, and `start`.
16
+ */
17
+
18
+ /**
19
+ * The fallback, for a Core that will not hold a request open: an older one, or one that
20
+ * answered and said nothing about watching. Not the mechanism any more.
21
+ */
22
+ const DEFAULT_POLL_MS = 60 * 1000;
23
+
24
+ /** How long Core may hold one request. Its own ceiling is the same number. */
25
+ const WATCH_WAIT_SECONDS = 25;
26
+
27
+ /**
28
+ * How much longer than that to wait before giving up on the request entirely.
29
+ *
30
+ * A hold that is never answered and never closed is the one way this loop can be worse than
31
+ * the timer it replaced: the loop that would ask again is waiting on that very request. So
32
+ * every held request carries a deadline of its own.
33
+ */
34
+ const HOLD_PADDING_MS = 10 * 1000;
35
+
36
+ /**
37
+ * The floor under asking again immediately. A Core that answers a held request honestly and
38
+ * instantly, over and over, must not turn into a request every few milliseconds: each one
39
+ * costs a bcrypt on Core.
40
+ */
41
+ const MIN_POLL_GAP_MS = 1000;
42
+
43
+ /** Spread, so a fleet redeployed together does not ask in lockstep. */
44
+ const POLL_JITTER = 0.3;
45
+
46
+ const POLL_RETRY_MIN_MS = 1000;
47
+ const POLL_RETRY_MAX_MS = 60 * 1000;
48
+
49
+ const RECONNECT_MIN_MS = 1000;
50
+ const RECONNECT_MAX_MS = 30 * 1000;
51
+
52
+ /**
53
+ * How many times a community that refuses the bot as uninstalled is tried again, but only
54
+ * when the connection was never live.
55
+ *
56
+ * Installing a bot writes rows in two places, and for a moment the community has recorded the
57
+ * installation without having written the member row the gateway checks. A bot that arrives in
58
+ * that moment is refused with the same reason as a bot that was removed. Telling them apart is
59
+ * not possible from the reason, but it is from the history: a connection that was never up has
60
+ * just arrived, and one that was up and is now refused has been removed.
61
+ */
62
+ const UNINSTALL_RETRIES = 3;
63
+
64
+ /**
65
+ * How many times to try telling Core what this bot needs.
66
+ *
67
+ * Not once, because a bot deployed alongside its Core can reach the route before it exists.
68
+ * Not forever, because a value Core refuses will be refused the same way at midnight, and a
69
+ * request a second for the life of the process is a strange way to give up.
70
+ */
71
+ const DECLARE_TRIES = 60;
72
+
73
+ function jitter(ms) {
74
+ return Math.round(ms * (1 - POLL_JITTER / 2 + Math.random() * POLL_JITTER));
75
+ }
76
+
77
+ /**
78
+ * Whether two community addresses are the same place.
79
+ *
80
+ * Compared after trimming trailing slashes, so a Core that starts or stops sending one does
81
+ * not look like a community that moved and set every bot reconnecting for nothing.
82
+ */
83
+ function sameAddress(a, b) {
84
+ const tidy = (u) => String(u || '').replace(/\/+$/, '');
85
+ return tidy(a) === tidy(b);
86
+ }
87
+
88
+ export class Bot {
89
+ #core;
90
+ #dispatcher;
91
+ #links = new Map();
92
+ #listeners = new Map();
93
+ #connect;
94
+ #timers;
95
+ #sleep;
96
+ #fetch;
97
+ #pollMs;
98
+ #stopped = false;
99
+ #declared;
100
+ #declaredOk = false;
101
+ #declareTries = 0;
102
+ #watching = null;
103
+ #pollBackoff = 0;
104
+ #pendingDelay = null;
105
+ #releaseDelay = null;
106
+ // Reconciling one at a time. The check for a link that already exists sits before an await,
107
+ // so two overlapping syncs would both pass it and both dial the same community.
108
+ #reconciling = Promise.resolve();
109
+
110
+ constructor({
111
+ coreUrl = process.env?.CORE_SERVER_URL,
112
+ key = process.env?.BOT_KEY,
113
+ secret = process.env?.BOT_SECRET,
114
+ fetch,
115
+ sleep,
116
+ connect,
117
+ timers = globalThis,
118
+ onError,
119
+ autoDeferMs,
120
+ pollMs = DEFAULT_POLL_MS,
121
+ permissions,
122
+ } = {}) {
123
+ if (!coreUrl) throw new Error('no coreUrl: set CORE_SERVER_URL to the Tusile core server');
124
+ if (!key || !secret) {
125
+ // Said here rather than at the first request, because the first request happens
126
+ // inside a deploy where nobody is watching, and this happens at startup.
127
+ throw new Error(
128
+ 'no bot credentials: set BOT_KEY and BOT_SECRET. Create a bot in the Tusile app, ' +
129
+ 'under Settings, Bots, and copy them there.',
130
+ );
131
+ }
132
+
133
+ this.#core = new CoreClient({ url: coreUrl, key, secret, fetch, sleep });
134
+ this.#fetch = fetch;
135
+ this.#sleep = sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
136
+ this.#connect = connect || null;
137
+ this.#timers = timers;
138
+ this.#pollMs = pollMs;
139
+ // Names, or a number, or nothing. Converted here so a typo is a stack trace at startup
140
+ // rather than a permission silently missing from the dialog somebody sees later.
141
+ this.#declared = permissions === undefined || permissions === null ? null : toMask(permissions);
142
+ this.#dispatcher = new Dispatcher({
143
+ autoDeferMs,
144
+ onError: (err, interaction) => this.#report(err, interaction),
145
+ });
146
+ if (onError) this.on('error', onError);
147
+
148
+ /** This bot's own account, once it has asked. Its id is how it recognises itself. */
149
+ this.me = null;
150
+ /** Every community it is connected to, by id. */
151
+ this.servers = new Map();
152
+ /**
153
+ * Core itself, for the facts no community holds.
154
+ *
155
+ * When somebody was last here, whether they are a guest, who owns a community: a
156
+ * community's members table knows when somebody joined and nothing else, so a bot that
157
+ * decides anything about a person has to ask Core. Without a way through, a bot writes
158
+ * its own fetch helper beside the client this package already built it, and then owns
159
+ * the timeouts and the retries twice.
160
+ */
161
+ this.core = this.#core;
162
+ }
163
+
164
+ // What the bot does.
165
+
166
+ command(definition, handler) {
167
+ this.#dispatcher.command(definition, handler);
168
+ return this;
169
+ }
170
+
171
+ component(customId, handler) {
172
+ this.#dispatcher.component(customId, handler);
173
+ return this;
174
+ }
175
+
176
+ modal(customId, handler) {
177
+ this.#dispatcher.modal(customId, handler);
178
+ return this;
179
+ }
180
+
181
+ /**
182
+ * ready (bot, server) connected to a community and registered there
183
+ * message (message, server) something said in a channel the bot can see
184
+ * joined (server) added to a community
185
+ * left (serverId) removed from one
186
+ * error (error, context) anything that went wrong, including in a handler
187
+ */
188
+ on(event, listener) {
189
+ if (!this.#listeners.has(event)) this.#listeners.set(event, []);
190
+ this.#listeners.get(event).push(listener);
191
+ return this;
192
+ }
193
+
194
+ /**
195
+ * Connect, and stay connected.
196
+ *
197
+ * Resolves once every community known right now has a socket, so that a caller can
198
+ * reasonably look at `servers` afterwards. Keeps running until `stop`.
199
+ */
200
+ async start() {
201
+ // `ws` is loaded only if nobody supplied a socket, so a runtime with its own
202
+ // WebSocket, or a test, never pays for it.
203
+ if (!this.#connect) {
204
+ const { default: WebSocket } = await import('ws');
205
+ this.#connect = (url) => new WebSocket(url);
206
+ }
207
+ this.me = await this.#core.me();
208
+ await this.#declare();
209
+ // An eager pass that answers at once, so this resolves with every community known right
210
+ // now already connected, which is what a caller looking at `servers` afterwards expects.
211
+ // Then the loop, which spends its life inside a held request rather than waiting on a
212
+ // timer between them.
213
+ //
214
+ // The loop starts whether or not that first pass worked. Core being briefly unreachable at
215
+ // boot used to mean the timer was never registered at all, so the bot sat there having
216
+ // failed to start and never asked again: the caller was told, which is right, but a caller
217
+ // that decided to carry on had a bot that would never connect to anything.
218
+ const firstPass = this.sync();
219
+ const startLoop = () => {
220
+ this.#watching = this.#watchLoop();
221
+ };
222
+ firstPass.then(startLoop, startLoop);
223
+ await firstPass;
224
+ return this;
225
+ }
226
+
227
+ /**
228
+ * Tell Core what this bot needs, once it sticks.
229
+ *
230
+ * Retried on every pass of the loop until it lands, which matters more than it sounds: a bot
231
+ * deployed in the same breath as its Core can be up first, and a Core that does not yet know
232
+ * the route answers 404. Declaring once at startup and logging the failure left the bot
233
+ * running normally with an empty list, so the install dialog had nothing to show and whoever
234
+ * added it granted it nothing. Never fatal: this is a list for a dialog the bot is not part
235
+ * of, and refusing to run without it would be worse than running without it.
236
+ */
237
+ async #declare() {
238
+ // Nothing to say, or already said. A bot that declares nothing must not overwrite what its
239
+ // author ticked in the app with an empty list.
240
+ if (this.#declared === null || this.#declaredOk) return;
241
+ if (this.#declareTries >= DECLARE_TRIES) return;
242
+ this.#declareTries += 1;
243
+ try {
244
+ await this.#core.declarePermissions(this.#declared);
245
+ this.#declaredOk = true;
246
+ } catch (err) {
247
+ this.#report(err);
248
+ if (this.#declareTries >= DECLARE_TRIES) {
249
+ this.#report(new Error(
250
+ 'giving up on declaring what this bot needs: whoever adds it will be shown nothing to allow',
251
+ ));
252
+ }
253
+ }
254
+ }
255
+
256
+ /**
257
+ * Keep asking Core, with the request held open while there is nothing to say.
258
+ *
259
+ * This replaced an interval. An interval ticks independently of the request, so the twenty
260
+ * five seconds Core would hold one left thirty five seconds of every minute uncovered, and
261
+ * an install landing in that gap waited for the next tick. Here the next request starts the
262
+ * moment the last one returns, so there is no gap to land in.
263
+ */
264
+ async #watchLoop() {
265
+ while (!this.#stopped) {
266
+ await this.#declare();
267
+ let outcome;
268
+ try {
269
+ outcome = await this.#syncWatching();
270
+ } catch (err) {
271
+ if (this.#stopped) return;
272
+ this.#report(err);
273
+ outcome = { watch: false, failed: true };
274
+ }
275
+ if (this.#stopped) return;
276
+ await this.#delay(this.#nextDelay(outcome));
277
+ }
278
+ }
279
+
280
+ /** One held request, plus whatever it says about the communities. */
281
+ async #syncWatching() {
282
+ const known = serversToken([...this.servers.keys()]);
283
+ const { servers, watch } = await this.#listServers({
284
+ waitSeconds: WATCH_WAIT_SECONDS,
285
+ known,
286
+ signal: this.#holdDeadline(),
287
+ });
288
+ if (this.#stopped) return { watch, failed: false };
289
+ await this.#reconcile(servers);
290
+ return { watch, failed: false };
291
+ }
292
+
293
+ /**
294
+ * How long to wait before asking again.
295
+ *
296
+ * The three cases are the whole design: Core is watching, so ask again now and spend the
297
+ * time inside the request; Core is not watching, so this is the old timer and it has to stay
298
+ * slow or an older Core is polled in a tight loop; or the request failed, in which case back
299
+ * off, because a Core that is down does not need every bot on the network arriving every
300
+ * second.
301
+ */
302
+ #nextDelay({ watch, failed }) {
303
+ if (failed) {
304
+ this.#pollBackoff = nextBackoff(this.#pollBackoff, POLL_RETRY_MIN_MS, POLL_RETRY_MAX_MS);
305
+ return jitter(this.#pollBackoff);
306
+ }
307
+ this.#pollBackoff = 0;
308
+ return watch ? MIN_POLL_GAP_MS : jitter(this.#pollMs);
309
+ }
310
+
311
+ #holdDeadline() {
312
+ if (typeof AbortSignal === 'undefined' || typeof AbortSignal.timeout !== 'function') {
313
+ return undefined;
314
+ }
315
+ return AbortSignal.timeout(WATCH_WAIT_SECONDS * 1000 + HOLD_PADDING_MS);
316
+ }
317
+
318
+ #delay(ms) {
319
+ return new Promise((resolve) => {
320
+ this.#releaseDelay = resolve;
321
+ this.#pendingDelay = this.#timers.setTimeout(() => {
322
+ this.#pendingDelay = null;
323
+ this.#releaseDelay = null;
324
+ resolve();
325
+ }, ms);
326
+ });
327
+ }
328
+
329
+ /**
330
+ * Bring the set of connections in line with the set of communities.
331
+ *
332
+ * Called on a timer, and worth calling by hand after installing the bot somewhere if a
333
+ * caller happens to know that has just happened.
334
+ */
335
+ async sync() {
336
+ if (this.#stopped) return;
337
+ // No wait. A bot author calling this wants the current answer, not to be held for
338
+ // twenty-five seconds; the loop is what waits.
339
+ const { servers } = await this.#listServers({});
340
+ if (this.#stopped) return;
341
+ await this.#reconcile(servers);
342
+ }
343
+
344
+ async #listServers(options) {
345
+ try {
346
+ return await this.#core.watchServers(options);
347
+ } catch (err) {
348
+ // Credentials Core refuses will be refused next minute too. Stopping is the honest
349
+ // answer: a bot that cannot authenticate is not running, and saying so once beats
350
+ // a 401 a minute for as long as nobody notices.
351
+ if (err?.isAuthFailure) {
352
+ this.#report(err);
353
+ await this.stop();
354
+ }
355
+ throw err;
356
+ }
357
+ }
358
+
359
+ /**
360
+ * Bring the connections in line with one answer from Core, one answer at a time.
361
+ *
362
+ * Serialised because the check for a link that already exists sits before an await: two
363
+ * overlapping calls would both find nothing and both dial.
364
+ */
365
+ #reconcile(servers) {
366
+ const run = () => this.#applyServers(servers);
367
+ const next = this.#reconciling.then(run, run);
368
+ this.#reconciling = next.then(
369
+ () => {},
370
+ () => {},
371
+ );
372
+ return next;
373
+ }
374
+
375
+ async #applyServers(servers) {
376
+ if (this.#stopped) return;
377
+ const seen = new Set();
378
+ for (const server of servers) {
379
+ seen.add(server.serverId);
380
+ const link = this.#links.get(server.serverId);
381
+ if (link) {
382
+ // A community can move: off a shared box onto its own, or onto a domain of its
383
+ // own. Core answers with the new address, and a loop that keys on the id alone
384
+ // never notices, because the old address usually goes on answering. The bot then
385
+ // holds a socket to a server nobody is looking at and reads as offline where it
386
+ // was added, until somebody restarts it. Found that way on prod 2026-10-07.
387
+ if (sameAddress(link.apiUrl, server.apiUrl)) continue;
388
+ this.#leave(server.serverId);
389
+ }
390
+ await this.#join(server);
391
+ }
392
+ // A community that removed the bot stops being listed, so its socket goes with it.
393
+ for (const serverId of [...this.#links.keys()]) {
394
+ if (!seen.has(serverId)) this.#leave(serverId);
395
+ }
396
+ }
397
+
398
+ /** Close every socket and stop looking for new communities. */
399
+ async stop() {
400
+ this.#stopped = true;
401
+ if (this.#pendingDelay !== null) {
402
+ // A pending timer keeps Node alive, so a bot that stops has to let go of it.
403
+ this.#timers.clearTimeout?.(this.#pendingDelay);
404
+ this.#pendingDelay = null;
405
+ }
406
+ if (this.#releaseDelay) {
407
+ const release = this.#releaseDelay;
408
+ this.#releaseDelay = null;
409
+ release();
410
+ }
411
+ for (const serverId of [...this.#links.keys()]) this.#leave(serverId);
412
+ }
413
+
414
+ async #join({ serverId, apiUrl, raw }) {
415
+ const link = new Link({
416
+ serverId,
417
+ apiUrl,
418
+ raw,
419
+ core: this.#core,
420
+ fetch: this.#fetch,
421
+ sleep: this.#sleep,
422
+ connect: this.#connect,
423
+ timers: this.#timers,
424
+ onReady: (bot, client) => {
425
+ // Registered on every connect rather than once at startup: a bot redeployed with
426
+ // a changed command set, or added to a community while it was down, would
427
+ // otherwise go on offering whatever it registered last time.
428
+ client
429
+ .setCommands(this.#dispatcher.definitions(), { requestedPermissions: this.#declared })
430
+ .catch((err) => this.#report(err, { serverId }));
431
+ this.#emit('ready', bot, client);
432
+ },
433
+ onFrame: (frame, client) => this.#onFrame(frame, client),
434
+ onError: (err) => this.#report(err, { serverId }),
435
+ // A connection that has ended for good stops being this bot's connection to that
436
+ // community. Leaving it in place would make a community that removed the bot and
437
+ // added it again unreachable until the process was restarted: the next sync would
438
+ // see a link already there and skip it, and the bot would sit offline with nothing
439
+ // in any log to say why.
440
+ onFinished: () => {
441
+ if (this.#links.get(serverId) === link) this.#leave(serverId);
442
+ },
443
+ });
444
+
445
+ this.#links.set(serverId, link);
446
+ this.servers.set(serverId, link.client);
447
+ await link.start();
448
+ this.#emit('joined', link.client);
449
+ }
450
+
451
+ #leave(serverId) {
452
+ this.#links.get(serverId)?.stop();
453
+ this.#links.delete(serverId);
454
+ this.servers.delete(serverId);
455
+ this.#emit('left', serverId);
456
+ }
457
+
458
+ #onFrame(frame, client) {
459
+ if (frame?.type === 'interaction_create') {
460
+ const interaction = new Interaction(frame.data || {}, client);
461
+ this.#dispatcher.handle(interaction).catch((err) => this.#report(err, interaction));
462
+ return;
463
+ }
464
+ if (frame?.type === 'message_create') {
465
+ this.#emit('message', frame.data || {}, client);
466
+ }
467
+ }
468
+
469
+ #emit(event, ...args) {
470
+ for (const listener of this.#listeners.get(event) ?? []) {
471
+ try {
472
+ listener(...args);
473
+ } catch (err) {
474
+ // A throwing listener must not take down the socket that fed it.
475
+ if (event !== 'error') this.#report(err);
476
+ }
477
+ }
478
+ }
479
+
480
+ #report(err, context) {
481
+ const listeners = this.#listeners.get('error') ?? [];
482
+ if (!listeners.length) {
483
+ // Better a line on stderr than a bot that fails in total silence because nobody
484
+ // wired up an error handler.
485
+ console.error('[tusile-bot]', err);
486
+ return;
487
+ }
488
+ this.#emit('error', err, context);
489
+ }
490
+ }
491
+
492
+ /**
493
+ * One community: its socket, its token, and its reconnects.
494
+ *
495
+ * Kept separate from the bot because everything here is per community. One community being
496
+ * down, or having removed the bot, must leave every other connection alone.
497
+ */
498
+ class Link {
499
+ #gateway;
500
+ #core;
501
+ #sleep;
502
+ #onError;
503
+ #onReady;
504
+ #onFrame;
505
+ #onFinished;
506
+ #token = '';
507
+ #backoff = 0;
508
+ #stopped = false;
509
+ #refusals = 0;
510
+
511
+ constructor({ serverId, apiUrl, raw, core, fetch, sleep, connect, timers, onReady, onFrame,
512
+ onError, onFinished }) {
513
+ this.serverId = serverId;
514
+ this.apiUrl = apiUrl;
515
+ this.raw = raw;
516
+ this.#core = core;
517
+ this.#sleep = sleep;
518
+ this.#onError = onError;
519
+ this.#onReady = onReady;
520
+ this.#onFrame = onFrame;
521
+ this.#onFinished = onFinished || (() => {});
522
+
523
+ this.client = new ServerClient({
524
+ apiUrl,
525
+ serverId,
526
+ // Read per request, so a reconnect that mints a new token does not leave the rest
527
+ // client holding an expired one.
528
+ token: () => this.#token,
529
+ // And minted again when this community says the one it has is no longer good. A token
530
+ // lasts an hour; the socket it was minted for stays up for days, and nothing closes it
531
+ // when the token expires. Without this a bot that had been running since yesterday
532
+ // heard every command and could answer none of them.
533
+ refreshToken: async () => {
534
+ this.#token = await this.#core.token(serverId);
535
+ return this.#token;
536
+ },
537
+ fetch,
538
+ sleep,
539
+ });
540
+
541
+ this.#gateway = new Gateway({
542
+ url: apiUrl,
543
+ token: async () => {
544
+ this.#token = await this.#core.token(serverId);
545
+ return this.#token;
546
+ },
547
+ connect,
548
+ timers,
549
+ onReady: (bot) => this.#onReady(bot, this.client),
550
+ onFrame: (frame) => this.#onFrame(frame, this.client),
551
+ });
552
+ }
553
+
554
+ /** Open the socket, then keep it open in the background. */
555
+ async start() {
556
+ await this.#open();
557
+ this.#keepAlive().catch((err) => this.#onError(err));
558
+ }
559
+
560
+ stop() {
561
+ this.#stopped = true;
562
+ this.#gateway.stop();
563
+ }
564
+
565
+ async #open() {
566
+ try {
567
+ await this.#gateway.open();
568
+ } catch (err) {
569
+ // Core refusing a token, most likely. Swallowed here because the failure is also
570
+ // settled on the connection, and the loop below reports it from there: what
571
+ // matters is that it does not take the bot's other communities with it.
572
+ }
573
+ }
574
+
575
+ async #keepAlive() {
576
+ while (!this.#stopped) {
577
+ const { fatal, reason, wasLive } = await this.#gateway.closed();
578
+ if (reason) this.#onError(new Error(`[${this.serverId}] ${reason}`));
579
+ // A community that says this bot is not installed, on a connection that was never live,
580
+ // is an install that has not finished writing its rows rather than a removal. The two
581
+ // are indistinguishable from the reason and obvious from the history. Treating the first
582
+ // as final cost the bot a whole poll interval on an install that had in fact worked.
583
+ const stillSettling =
584
+ fatal &&
585
+ !wasLive &&
586
+ !this.#stopped &&
587
+ /bot_uninstalled/.test(reason || '') &&
588
+ this.#refusals < UNINSTALL_RETRIES;
589
+ if (stillSettling) {
590
+ this.#refusals++;
591
+ } else if (fatal || this.#stopped) {
592
+ return this.#finish();
593
+ }
594
+ // A connection that was up and then dropped is worth picking straight back up.
595
+ // Carrying the backoff over would leave a bot that reconnects cleanly once an hour
596
+ // eventually waiting half a minute every time, for nothing.
597
+ if (wasLive) {
598
+ this.#backoff = 0;
599
+ this.#refusals = 0;
600
+ }
601
+ this.#backoff = nextBackoff(this.#backoff, RECONNECT_MIN_MS, RECONNECT_MAX_MS);
602
+ await this.#sleep(this.#backoff);
603
+ if (this.#stopped) return this.#finish();
604
+ await this.#open();
605
+ }
606
+ this.#finish();
607
+ }
608
+
609
+ /** Say, once, that this connection is over and will not come back by itself. */
610
+ #finish() {
611
+ const done = this.#onFinished;
612
+ this.#onFinished = () => {};
613
+ done();
614
+ }
615
+ }