@tusile/bot 0.1.2 → 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/LICENSE +21 -21
- package/README.md +204 -4
- package/package.json +1 -1
- package/src/bot.js +615 -319
- package/src/core.js +151 -12
- package/src/dispatch.js +195 -178
- package/src/errors.js +61 -49
- package/src/gateway.js +191 -180
- package/src/http.js +155 -136
- package/src/index.js +27 -26
- package/src/interaction.js +144 -140
- package/src/known.js +23 -0
- package/src/permissions.js +55 -0
- package/src/rest.js +189 -9
package/src/bot.js
CHANGED
|
@@ -1,319 +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 {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
this
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
this.#
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
+
}
|