@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/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
- constructor({ apiUrl, token, serverId = '', fetch, sleep, maxRetries }) {
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
- setCommands(commands) {
202
- return this.request('PUT', '/bot/commands', { commands });
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
- * This is the presence half, which is what most bots want. Carrying audio needs a
211
- * LiveKit client as well (`@livekit/rtc-node`, joining room `channel_<id>` with the
212
- * token from `voiceToken`).
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', { channel_id: channelId });
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) {