@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/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
- 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
+ }) {
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
- setCommands(commands) {
256
- 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
+ });
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
- * This is the presence half, which is what most bots want. Carrying audio needs a
265
- * LiveKit client as well (`@livekit/rtc-node`, joining room `channel_<id>` with the
266
- * 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).
267
382
  */
268
- joinVoice(channelId) {
269
- 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
+ });
270
396
  }
271
397
 
272
398
  leaveVoice(channelId) {