@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/known.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What this bot already knows, as one short string.
|
|
5
|
+
*
|
|
6
|
+
* Sent to Core as ?known= alongside ?wait=, so Core can hold the request open until the
|
|
7
|
+
* answer would actually be different. Before this, Core would only hold a request for a bot
|
|
8
|
+
* that was on no communities at all, which meant every bot already serving one heard about
|
|
9
|
+
* the next one on its own sixty-second timer: half a minute of nothing, on average, after
|
|
10
|
+
* somebody pressed Add.
|
|
11
|
+
*
|
|
12
|
+
* Mirrors core-server/internal/bots/known.go exactly, and the same three vectors are pinned
|
|
13
|
+
* in both test suites. A count would have been easier and wrong: removed from one community
|
|
14
|
+
* and added to another between two polls is the same count, so Core would hold the request
|
|
15
|
+
* while the bot served the wrong place.
|
|
16
|
+
*/
|
|
17
|
+
const VERSION = 'v1';
|
|
18
|
+
|
|
19
|
+
export function serversToken(serverIds) {
|
|
20
|
+
const unique = [...new Set(serverIds ?? [])].map(String).sort();
|
|
21
|
+
const digest = createHash('sha256').update(unique.join('\n')).digest('hex');
|
|
22
|
+
return `${VERSION}:${digest.slice(0, 16)}`;
|
|
23
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The permission bits, by the names the app uses for them.
|
|
3
|
+
*
|
|
4
|
+
* A bot deciding who can see a channel otherwise writes `1024` and hopes. These are the
|
|
5
|
+
* same bits the server checks, so a rule written here is the rule a person would set by
|
|
6
|
+
* hand in the channel's permissions screen.
|
|
7
|
+
*/
|
|
8
|
+
export const Permissions = {
|
|
9
|
+
view_channel: 1 << 0,
|
|
10
|
+
send_messages: 1 << 1,
|
|
11
|
+
manage_messages: 1 << 2,
|
|
12
|
+
manage_channels: 1 << 3,
|
|
13
|
+
manage_roles: 1 << 4,
|
|
14
|
+
manage_members: 1 << 5,
|
|
15
|
+
administrator: 1 << 6,
|
|
16
|
+
connect: 1 << 7,
|
|
17
|
+
speak: 1 << 8,
|
|
18
|
+
mute_members: 1 << 9,
|
|
19
|
+
deafen_members: 1 << 10,
|
|
20
|
+
manage_server: 1 << 11,
|
|
21
|
+
create_invite: 1 << 12,
|
|
22
|
+
attach_files: 1 << 13,
|
|
23
|
+
stream: 1 << 14,
|
|
24
|
+
use_camera: 1 << 15,
|
|
25
|
+
manage_emojis: 1 << 16,
|
|
26
|
+
manage_emoji_policy: 1 << 17,
|
|
27
|
+
timeout_members: 1 << 18,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A number, a name, or a list of names, as one mask.
|
|
32
|
+
*
|
|
33
|
+
* A name this package has never heard of throws rather than contributing nothing: a
|
|
34
|
+
* typo in a deny list would otherwise leave a channel open and say nothing about it.
|
|
35
|
+
*/
|
|
36
|
+
export function toMask(value) {
|
|
37
|
+
if (value === undefined || value === null) return 0;
|
|
38
|
+
if (typeof value === 'number') return value;
|
|
39
|
+
const names = Array.isArray(value) ? value : [value];
|
|
40
|
+
let mask = 0;
|
|
41
|
+
for (const name of names) {
|
|
42
|
+
if (typeof name === 'number') {
|
|
43
|
+
mask |= name;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
const bit = Permissions[String(name).toLowerCase()];
|
|
47
|
+
if (bit === undefined) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`unknown permission "${name}". Known: ${Object.keys(Permissions).join(', ')}`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
mask |= bit;
|
|
53
|
+
}
|
|
54
|
+
return mask;
|
|
55
|
+
}
|
package/src/rest.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Transport, query } from './http.js';
|
|
2
|
+
import { toMask } from './permissions.js';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* One community, and everything a bot can do in it.
|
|
@@ -11,19 +12,43 @@ import { Transport, query } from './http.js';
|
|
|
11
12
|
* What a bot may actually do is decided by its roles in the community, not here. A call
|
|
12
13
|
* the bot lacks permission for comes back as a 403 with a reason, not as silence.
|
|
13
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
|
+
|
|
14
18
|
export class ServerClient {
|
|
15
19
|
#transport;
|
|
16
|
-
|
|
17
|
-
|
|
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
|
+
}) {
|
|
18
34
|
this.serverId = serverId;
|
|
19
35
|
this.apiUrl = String(apiUrl || '').replace(/\/+$/, '');
|
|
20
36
|
this.#transport = new Transport({
|
|
21
37
|
baseUrl: this.apiUrl,
|
|
22
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,
|
|
23
47
|
fetch,
|
|
24
48
|
sleep,
|
|
25
49
|
maxRetries,
|
|
26
50
|
});
|
|
51
|
+
this.#memberTtlMs = memberTtlMs;
|
|
27
52
|
}
|
|
28
53
|
|
|
29
54
|
/**
|
|
@@ -88,6 +113,38 @@ export class ServerClient {
|
|
|
88
113
|
return this.request('POST', `/bot/channels/${enc(channelId)}/typing`, {});
|
|
89
114
|
}
|
|
90
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
|
+
|
|
91
148
|
pin(messageId) {
|
|
92
149
|
return this.request('PUT', `/bot/messages/${enc(messageId)}/pin`);
|
|
93
150
|
}
|
|
@@ -123,6 +180,59 @@ export class ServerClient {
|
|
|
123
180
|
return this.request('DELETE', `/bot/channels/${enc(channelId)}`);
|
|
124
181
|
}
|
|
125
182
|
|
|
183
|
+
// Categories, which are what turns a pile of channels into a server.
|
|
184
|
+
|
|
185
|
+
async categories() {
|
|
186
|
+
const out = await this.request('GET', '/bot/categories');
|
|
187
|
+
return out?.categories ?? [];
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
createCategory(category) {
|
|
191
|
+
return this.request('POST', '/bot/categories', category);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
updateCategory(categoryId, changes) {
|
|
195
|
+
return this.request('PATCH', `/bot/categories/${enc(categoryId)}`, changes);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Removing a category leaves its channels; they come back to the top level. */
|
|
199
|
+
deleteCategory(categoryId) {
|
|
200
|
+
return this.request('DELETE', `/bot/categories/${enc(categoryId)}`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Who can do what in one channel.
|
|
204
|
+
|
|
205
|
+
async channelPermissions(channelId) {
|
|
206
|
+
const out = await this.request('GET', `/bot/channels/${enc(channelId)}/overrides`);
|
|
207
|
+
return out?.overrides ?? [];
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Allow or deny permissions in one channel, for a role or one person.
|
|
212
|
+
*
|
|
213
|
+
* The call a setup bot is mostly made of: deny `view_channel` to `@everyone` and allow
|
|
214
|
+
* it to one role, and the channel is that team's. `allow` and `deny` take permission
|
|
215
|
+
* names as well as numbers.
|
|
216
|
+
*/
|
|
217
|
+
setChannelPermission(channelId, { role, member, allow, deny } = {}) {
|
|
218
|
+
if (!role && !member) {
|
|
219
|
+
return Promise.reject(new Error('a channel permission needs a role or a member to be about'));
|
|
220
|
+
}
|
|
221
|
+
return this.request('PUT', `/bot/channels/${enc(channelId)}/overrides`, {
|
|
222
|
+
target_type: role ? 'role' : 'member',
|
|
223
|
+
target_id: role || member,
|
|
224
|
+
allow: toMask(allow),
|
|
225
|
+
deny: toMask(deny),
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
removeChannelPermission(channelId, overrideId) {
|
|
230
|
+
return this.request(
|
|
231
|
+
'DELETE',
|
|
232
|
+
`/bot/channels/${enc(channelId)}/overrides/${enc(overrideId)}`,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
126
236
|
// Roles.
|
|
127
237
|
|
|
128
238
|
async roles() {
|
|
@@ -149,6 +259,47 @@ export class ServerClient {
|
|
|
149
259
|
return out?.members ?? [];
|
|
150
260
|
}
|
|
151
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
|
+
|
|
152
303
|
addRole(userId, roleId) {
|
|
153
304
|
return this.request('PUT', `/bot/members/${enc(userId)}/roles/${enc(roleId)}`);
|
|
154
305
|
}
|
|
@@ -198,8 +349,22 @@ export class ServerClient {
|
|
|
198
349
|
}
|
|
199
350
|
|
|
200
351
|
/** Replace the command list. Whatever is not in it stops being offered. */
|
|
201
|
-
|
|
202
|
-
|
|
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
|
+
});
|
|
203
368
|
}
|
|
204
369
|
|
|
205
370
|
// Voice.
|
|
@@ -207,12 +372,27 @@ export class ServerClient {
|
|
|
207
372
|
/**
|
|
208
373
|
* Appear in a voice channel.
|
|
209
374
|
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
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).
|
|
213
382
|
*/
|
|
214
|
-
joinVoice(channelId) {
|
|
215
|
-
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
|
+
});
|
|
216
396
|
}
|
|
217
397
|
|
|
218
398
|
leaveVoice(channelId) {
|