@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/LICENSE +21 -21
- package/README.md +439 -285
- package/package.json +33 -33
- package/src/bot.js +615 -350
- package/src/core.js +207 -68
- package/src/dispatch.js +195 -178
- package/src/errors.js +61 -61
- package/src/gateway.js +191 -191
- package/src/http.js +155 -136
- package/src/interaction.js +156 -140
- package/src/known.js +23 -0
- package/src/rest.js +135 -9
package/src/rest.js
CHANGED
|
@@ -12,19 +12,43 @@ import { toMask } from './permissions.js';
|
|
|
12
12
|
* What a bot may actually do is decided by its roles in the community, not here. A call
|
|
13
13
|
* the bot lacks permission for comes back as a 403 with a reason, not as silence.
|
|
14
14
|
*/
|
|
15
|
+
/** How long a member list is good for. Roles do not change between two commands in a breath. */
|
|
16
|
+
const MEMBER_TTL_MS = 60 * 1000;
|
|
17
|
+
|
|
15
18
|
export class ServerClient {
|
|
16
19
|
#transport;
|
|
17
|
-
|
|
18
|
-
|
|
20
|
+
#members = null;
|
|
21
|
+
#membersAt = 0;
|
|
22
|
+
#memberTtlMs;
|
|
23
|
+
|
|
24
|
+
constructor({
|
|
25
|
+
apiUrl,
|
|
26
|
+
token,
|
|
27
|
+
refreshToken,
|
|
28
|
+
serverId = '',
|
|
29
|
+
fetch,
|
|
30
|
+
sleep,
|
|
31
|
+
maxRetries,
|
|
32
|
+
memberTtlMs = MEMBER_TTL_MS,
|
|
33
|
+
}) {
|
|
19
34
|
this.serverId = serverId;
|
|
20
35
|
this.apiUrl = String(apiUrl || '').replace(/\/+$/, '');
|
|
21
36
|
this.#transport = new Transport({
|
|
22
37
|
baseUrl: this.apiUrl,
|
|
23
38
|
authorization: () => `Bearer ${token()}`,
|
|
39
|
+
// Nothing about a token says when it stops working, and asking Core for a fresh one on a
|
|
40
|
+
// timer means guessing at a clock somebody else owns. The refusal is the signal.
|
|
41
|
+
onRefused: refreshToken
|
|
42
|
+
? async () => {
|
|
43
|
+
await refreshToken();
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
: undefined,
|
|
24
47
|
fetch,
|
|
25
48
|
sleep,
|
|
26
49
|
maxRetries,
|
|
27
50
|
});
|
|
51
|
+
this.#memberTtlMs = memberTtlMs;
|
|
28
52
|
}
|
|
29
53
|
|
|
30
54
|
/**
|
|
@@ -89,6 +113,38 @@ export class ServerClient {
|
|
|
89
113
|
return this.request('POST', `/bot/channels/${enc(channelId)}/typing`, {});
|
|
90
114
|
}
|
|
91
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Say "working on it" until the answer is ready. Returns the function that stops it.
|
|
118
|
+
*
|
|
119
|
+
* One POST is not enough. The client drops somebody from the line three seconds after
|
|
120
|
+
* their last one, and a model call runs longer than that, so a bot that posted once
|
|
121
|
+
* showed the dots, lost them, and then answered into a channel that had gone quiet.
|
|
122
|
+
*
|
|
123
|
+
* Nothing here can stop an answer. A community that refuses the indicator, or a request
|
|
124
|
+
* that times out, is swallowed: saying you are working is worth nothing next to the
|
|
125
|
+
* sentence itself, and the caller is in the middle of producing it.
|
|
126
|
+
*
|
|
127
|
+
* const done = bot.rest(serverId).thinking(channelId);
|
|
128
|
+
* try { await say(await model(question)); } finally { done(); }
|
|
129
|
+
*/
|
|
130
|
+
thinking(channelId, { everyMs = 2000, setTimer = setTimeout, clearTimer = clearTimeout } = {}) {
|
|
131
|
+
let stopped = false;
|
|
132
|
+
let timer = null;
|
|
133
|
+
const beat = () => {
|
|
134
|
+
if (stopped) return;
|
|
135
|
+
Promise.resolve(this.typing(channelId)).catch(() => {});
|
|
136
|
+
timer = setTimer(beat, everyMs);
|
|
137
|
+
};
|
|
138
|
+
beat();
|
|
139
|
+
// Idempotent, because the caller stops it from a finally block and an error path will
|
|
140
|
+
// reach that twice often enough.
|
|
141
|
+
return () => {
|
|
142
|
+
if (stopped) return;
|
|
143
|
+
stopped = true;
|
|
144
|
+
if (timer !== null) clearTimer(timer);
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
92
148
|
pin(messageId) {
|
|
93
149
|
return this.request('PUT', `/bot/messages/${enc(messageId)}/pin`);
|
|
94
150
|
}
|
|
@@ -203,6 +259,47 @@ export class ServerClient {
|
|
|
203
259
|
return out?.members ?? [];
|
|
204
260
|
}
|
|
205
261
|
|
|
262
|
+
/**
|
|
263
|
+
* One member, with the roles that say what they may do.
|
|
264
|
+
*
|
|
265
|
+
* There is no route for one member: GET /bot/members is the whole community, and it is
|
|
266
|
+
* rate limited on a box shared by hundreds of them. A bot that checks a permission before
|
|
267
|
+
* every command would otherwise re-read every member each time. So the list is held for a
|
|
268
|
+
* minute, which is shorter than anybody's patience and longer than a burst of commands.
|
|
269
|
+
*
|
|
270
|
+
* **The held copy carries roles, so it carries permissions, and a role taken away a minute
|
|
271
|
+
* ago is still in it.** That cannot grant anybody anything: the community server checks
|
|
272
|
+
* every action against the real roles and refuses it regardless. What it can do is let a
|
|
273
|
+
* bot offer somebody a thing the server will then refuse, or act on its own judgement
|
|
274
|
+
* where the bot is the only judge, which is most of what a moderation bot does. Pass
|
|
275
|
+
* `{ fresh: true }` when the answer is a decision rather than a label, and take the extra
|
|
276
|
+
* call: a fresh read also replaces the held copy, so the next caller is not handed the
|
|
277
|
+
* stale one back.
|
|
278
|
+
*
|
|
279
|
+
* Null for somebody who is not here. A failed read is not held, so a community that could
|
|
280
|
+
* not be read during a deploy is not remembered as empty.
|
|
281
|
+
*/
|
|
282
|
+
async member(userId, { fresh = false } = {}) {
|
|
283
|
+
if (fresh || !this.#members || this.#membersAt <= Date.now() - this.#memberTtlMs) {
|
|
284
|
+
const list = await this.members();
|
|
285
|
+
this.#members = list;
|
|
286
|
+
this.#membersAt = Date.now();
|
|
287
|
+
}
|
|
288
|
+
const id = String(userId ?? '');
|
|
289
|
+
return this.#members.find((m) => m.user_id === id) ?? null;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Forget the held member list.
|
|
294
|
+
*
|
|
295
|
+
* For the one case where a minute of staleness is a bug rather than a saving: a bot that
|
|
296
|
+
* has just granted a role and wants to read the member back.
|
|
297
|
+
*/
|
|
298
|
+
forgetMembers() {
|
|
299
|
+
this.#members = null;
|
|
300
|
+
this.#membersAt = 0;
|
|
301
|
+
}
|
|
302
|
+
|
|
206
303
|
addRole(userId, roleId) {
|
|
207
304
|
return this.request('PUT', `/bot/members/${enc(userId)}/roles/${enc(roleId)}`);
|
|
208
305
|
}
|
|
@@ -252,8 +349,22 @@ export class ServerClient {
|
|
|
252
349
|
}
|
|
253
350
|
|
|
254
351
|
/** Replace the command list. Whatever is not in it stops being offered. */
|
|
255
|
-
|
|
256
|
-
|
|
352
|
+
/**
|
|
353
|
+
* Register this bot's commands with one community, and say what it needs while it is here.
|
|
354
|
+
*
|
|
355
|
+
* `requestedPermissions` is the bot's own declaration, sent again on every connect because
|
|
356
|
+
* this is the one message a bot sends every community every time. A community copies the
|
|
357
|
+
* declaration from Core when somebody adds the bot and never again, so without this a bot
|
|
358
|
+
* that had declared nothing at that moment offered nothing to tick for the life of that
|
|
359
|
+
* install. It changes nothing about what the bot holds: asking is not having.
|
|
360
|
+
*/
|
|
361
|
+
setCommands(commands, { requestedPermissions } = {}) {
|
|
362
|
+
return this.request('PUT', '/bot/commands', {
|
|
363
|
+
commands,
|
|
364
|
+
...(requestedPermissions === undefined || requestedPermissions === null
|
|
365
|
+
? {}
|
|
366
|
+
: { requested_permissions: requestedPermissions }),
|
|
367
|
+
});
|
|
257
368
|
}
|
|
258
369
|
|
|
259
370
|
// Voice.
|
|
@@ -261,12 +372,27 @@ export class ServerClient {
|
|
|
261
372
|
/**
|
|
262
373
|
* Appear in a voice channel.
|
|
263
374
|
*
|
|
264
|
-
*
|
|
265
|
-
*
|
|
266
|
-
*
|
|
375
|
+
* `speaking` is what everybody else is shown. Mic state reaches the server over a
|
|
376
|
+
* person's websocket and a bot has none, so a bot that does not say this is drawn with
|
|
377
|
+
* the crossed-out microphone: right for a bot that only listens, wrong and confusing
|
|
378
|
+
* for one that is about to play something.
|
|
379
|
+
*
|
|
380
|
+
* Presence is this one call. Carrying audio needs a media client as well
|
|
381
|
+
* (`@livekit/rtc-node`, connecting to `voiceToken(channelId)` and publishing a track).
|
|
267
382
|
*/
|
|
268
|
-
joinVoice(channelId) {
|
|
269
|
-
return this.request('POST', '/bot/voice/join', {
|
|
383
|
+
joinVoice(channelId, { speaking = false } = {}) {
|
|
384
|
+
return this.request('POST', '/bot/voice/join', {
|
|
385
|
+
channel_id: channelId,
|
|
386
|
+
mic_enabled: speaking,
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Change what the channel shows: done playing, or about to start. */
|
|
391
|
+
setVoiceState(channelId, { speaking = false } = {}) {
|
|
392
|
+
return this.request('POST', '/bot/voice/state', {
|
|
393
|
+
channel_id: channelId,
|
|
394
|
+
mic_enabled: speaking,
|
|
395
|
+
});
|
|
270
396
|
}
|
|
271
397
|
|
|
272
398
|
leaveVoice(channelId) {
|