@tusile/bot 0.4.0 → 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/README.md +439 -439
- package/package.json +33 -33
- package/src/core.js +207 -207
- package/src/interaction.js +12 -0
- package/src/rest.js +414 -414
package/src/rest.js
CHANGED
|
@@ -1,414 +1,414 @@
|
|
|
1
|
-
import { Transport, query } from './http.js';
|
|
2
|
-
import { toMask } from './permissions.js';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* One community, and everything a bot can do in it.
|
|
6
|
-
*
|
|
7
|
-
* Every method here is a line and a half over `request`, which is deliberate: the wrapper
|
|
8
|
-
* exists so that a bot author does not have to know that history comes back wrapped in an
|
|
9
|
-
* envelope or that a reaction has to be url-encoded, and `request` stays public so that an
|
|
10
|
-
* endpoint added next month needs no new release of this package.
|
|
11
|
-
*
|
|
12
|
-
* What a bot may actually do is decided by its roles in the community, not here. A call
|
|
13
|
-
* the bot lacks permission for comes back as a 403 with a reason, not as silence.
|
|
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
|
-
|
|
18
|
-
export class ServerClient {
|
|
19
|
-
#transport;
|
|
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
|
-
}) {
|
|
34
|
-
this.serverId = serverId;
|
|
35
|
-
this.apiUrl = String(apiUrl || '').replace(/\/+$/, '');
|
|
36
|
-
this.#transport = new Transport({
|
|
37
|
-
baseUrl: this.apiUrl,
|
|
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,
|
|
47
|
-
fetch,
|
|
48
|
-
sleep,
|
|
49
|
-
maxRetries,
|
|
50
|
-
});
|
|
51
|
-
this.#memberTtlMs = memberTtlMs;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Anything at all, with this community's token.
|
|
56
|
-
*
|
|
57
|
-
* The escape hatch, and not a second-class one: everything below is written in terms of
|
|
58
|
-
* it, so a bot reaching an endpoint this package has never heard of is in exactly the
|
|
59
|
-
* same position as one calling a named method.
|
|
60
|
-
*/
|
|
61
|
-
request(method, path, body) {
|
|
62
|
-
return this.#transport.request(method, path, body);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/** What the budget looked like a moment ago, for a bot that would rather not find out. */
|
|
66
|
-
get rateLimit() {
|
|
67
|
-
return this.#transport.rateLimit;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// The community itself.
|
|
71
|
-
|
|
72
|
-
serverInfo() {
|
|
73
|
-
return this.request('GET', '/bot/server');
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// Messages.
|
|
77
|
-
|
|
78
|
-
/** Say something. A string is the common case; an object carries embeds and controls. */
|
|
79
|
-
send(channelId, message) {
|
|
80
|
-
return this.request('POST', `/bot/channels/${enc(channelId)}/messages`, contentOf(message));
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/** Recent messages, newest first. `before` is a message id, for walking backwards. */
|
|
84
|
-
async history(channelId, { limit, before } = {}) {
|
|
85
|
-
const page = await this.request(
|
|
86
|
-
'GET',
|
|
87
|
-
`/bot/channels/${enc(channelId)}/messages${query({ limit, before })}`,
|
|
88
|
-
);
|
|
89
|
-
return page?.messages ?? [];
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
message(messageId) {
|
|
93
|
-
return this.request('GET', `/bot/messages/${enc(messageId)}`);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
editMessage(messageId, message) {
|
|
97
|
-
return this.request('PATCH', `/bot/messages/${enc(messageId)}`, contentOf(message));
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
deleteMessage(messageId) {
|
|
101
|
-
return this.request('DELETE', `/bot/messages/${enc(messageId)}`);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/** Clear several at once, which the server does as one act rather than a flood. */
|
|
105
|
-
deleteMessages(channelId, messageIds) {
|
|
106
|
-
return this.request('POST', `/bot/channels/${enc(channelId)}/messages/bulk-delete`, {
|
|
107
|
-
message_ids: messageIds,
|
|
108
|
-
});
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/** The typing dots, for a bot about to take a second or two over a plain message. */
|
|
112
|
-
typing(channelId) {
|
|
113
|
-
return this.request('POST', `/bot/channels/${enc(channelId)}/typing`, {});
|
|
114
|
-
}
|
|
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
|
-
|
|
148
|
-
pin(messageId) {
|
|
149
|
-
return this.request('PUT', `/bot/messages/${enc(messageId)}/pin`);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
unpin(messageId) {
|
|
153
|
-
return this.request('DELETE', `/bot/messages/${enc(messageId)}/pin`);
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
react(messageId, emoji) {
|
|
157
|
-
return this.request('PUT', `/bot/messages/${enc(messageId)}/reactions/${enc(emoji)}`);
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
unreact(messageId, emoji) {
|
|
161
|
-
return this.request('DELETE', `/bot/messages/${enc(messageId)}/reactions/${enc(emoji)}`);
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
// Channels.
|
|
165
|
-
|
|
166
|
-
async channels() {
|
|
167
|
-
const out = await this.request('GET', '/bot/channels');
|
|
168
|
-
return out?.channels ?? [];
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
createChannel(channel) {
|
|
172
|
-
return this.request('POST', '/bot/channels', channel);
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
updateChannel(channelId, changes) {
|
|
176
|
-
return this.request('PATCH', `/bot/channels/${enc(channelId)}`, changes);
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
deleteChannel(channelId) {
|
|
180
|
-
return this.request('DELETE', `/bot/channels/${enc(channelId)}`);
|
|
181
|
-
}
|
|
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
|
-
|
|
236
|
-
// Roles.
|
|
237
|
-
|
|
238
|
-
async roles() {
|
|
239
|
-
const out = await this.request('GET', '/bot/roles');
|
|
240
|
-
return out?.roles ?? [];
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
createRole(role) {
|
|
244
|
-
return this.request('POST', '/bot/roles', role);
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
updateRole(roleId, changes) {
|
|
248
|
-
return this.request('PATCH', `/bot/roles/${enc(roleId)}`, changes);
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
deleteRole(roleId) {
|
|
252
|
-
return this.request('DELETE', `/bot/roles/${enc(roleId)}`);
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
// Members.
|
|
256
|
-
|
|
257
|
-
async members() {
|
|
258
|
-
const out = await this.request('GET', '/bot/members');
|
|
259
|
-
return out?.members ?? [];
|
|
260
|
-
}
|
|
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
|
-
|
|
303
|
-
addRole(userId, roleId) {
|
|
304
|
-
return this.request('PUT', `/bot/members/${enc(userId)}/roles/${enc(roleId)}`);
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
removeRole(userId, roleId) {
|
|
308
|
-
return this.request('DELETE', `/bot/members/${enc(userId)}/roles/${enc(roleId)}`);
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
/** A nickname, or null to put somebody back to their own name. `@me` renames the bot. */
|
|
312
|
-
setNickname(userId, nickname) {
|
|
313
|
-
return this.request('PATCH', `/bot/members/${enc(userId)}`, { nickname });
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
// Moderation.
|
|
317
|
-
|
|
318
|
-
kick(userId) {
|
|
319
|
-
return this.request('DELETE', `/bot/members/${enc(userId)}`);
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
/** Silence somebody for a while. Seconds, because "until" invites timezone bugs. */
|
|
323
|
-
timeout(userId, seconds, reason = '') {
|
|
324
|
-
return this.request('POST', `/bot/members/${enc(userId)}/timeout`, { seconds, reason });
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
clearTimeout(userId) {
|
|
328
|
-
return this.request('DELETE', `/bot/members/${enc(userId)}/timeout`);
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
ban(userId, reason = '') {
|
|
332
|
-
return this.request('POST', '/bot/bans', { user_id: userId, reason });
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
unban(userId) {
|
|
336
|
-
return this.request('DELETE', `/bot/bans/${enc(userId)}`);
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
async bans() {
|
|
340
|
-
const out = await this.request('GET', '/bot/bans');
|
|
341
|
-
return out?.bans ?? [];
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
// Commands.
|
|
345
|
-
|
|
346
|
-
async commands() {
|
|
347
|
-
const out = await this.request('GET', '/bot/commands');
|
|
348
|
-
return out?.commands ?? [];
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
/** Replace the command list. Whatever is not in it stops being offered. */
|
|
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
|
-
});
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
// Voice.
|
|
371
|
-
|
|
372
|
-
/**
|
|
373
|
-
* Appear in a voice channel.
|
|
374
|
-
*
|
|
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).
|
|
382
|
-
*/
|
|
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
|
-
});
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
leaveVoice(channelId) {
|
|
399
|
-
return this.request('POST', '/bot/voice/leave', { channel_id: channelId });
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
voiceToken(channelId) {
|
|
403
|
-
return this.request('POST', '/bot/livekit/token', { room: `channel_${channelId}` });
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
/** A string is content; anything else is already a body. */
|
|
408
|
-
function contentOf(message) {
|
|
409
|
-
return typeof message === 'string' ? { content: message } : message;
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
function enc(value) {
|
|
413
|
-
return encodeURIComponent(String(value ?? ''));
|
|
414
|
-
}
|
|
1
|
+
import { Transport, query } from './http.js';
|
|
2
|
+
import { toMask } from './permissions.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* One community, and everything a bot can do in it.
|
|
6
|
+
*
|
|
7
|
+
* Every method here is a line and a half over `request`, which is deliberate: the wrapper
|
|
8
|
+
* exists so that a bot author does not have to know that history comes back wrapped in an
|
|
9
|
+
* envelope or that a reaction has to be url-encoded, and `request` stays public so that an
|
|
10
|
+
* endpoint added next month needs no new release of this package.
|
|
11
|
+
*
|
|
12
|
+
* What a bot may actually do is decided by its roles in the community, not here. A call
|
|
13
|
+
* the bot lacks permission for comes back as a 403 with a reason, not as silence.
|
|
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
|
+
|
|
18
|
+
export class ServerClient {
|
|
19
|
+
#transport;
|
|
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
|
+
}) {
|
|
34
|
+
this.serverId = serverId;
|
|
35
|
+
this.apiUrl = String(apiUrl || '').replace(/\/+$/, '');
|
|
36
|
+
this.#transport = new Transport({
|
|
37
|
+
baseUrl: this.apiUrl,
|
|
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,
|
|
47
|
+
fetch,
|
|
48
|
+
sleep,
|
|
49
|
+
maxRetries,
|
|
50
|
+
});
|
|
51
|
+
this.#memberTtlMs = memberTtlMs;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Anything at all, with this community's token.
|
|
56
|
+
*
|
|
57
|
+
* The escape hatch, and not a second-class one: everything below is written in terms of
|
|
58
|
+
* it, so a bot reaching an endpoint this package has never heard of is in exactly the
|
|
59
|
+
* same position as one calling a named method.
|
|
60
|
+
*/
|
|
61
|
+
request(method, path, body) {
|
|
62
|
+
return this.#transport.request(method, path, body);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** What the budget looked like a moment ago, for a bot that would rather not find out. */
|
|
66
|
+
get rateLimit() {
|
|
67
|
+
return this.#transport.rateLimit;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// The community itself.
|
|
71
|
+
|
|
72
|
+
serverInfo() {
|
|
73
|
+
return this.request('GET', '/bot/server');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Messages.
|
|
77
|
+
|
|
78
|
+
/** Say something. A string is the common case; an object carries embeds and controls. */
|
|
79
|
+
send(channelId, message) {
|
|
80
|
+
return this.request('POST', `/bot/channels/${enc(channelId)}/messages`, contentOf(message));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Recent messages, newest first. `before` is a message id, for walking backwards. */
|
|
84
|
+
async history(channelId, { limit, before } = {}) {
|
|
85
|
+
const page = await this.request(
|
|
86
|
+
'GET',
|
|
87
|
+
`/bot/channels/${enc(channelId)}/messages${query({ limit, before })}`,
|
|
88
|
+
);
|
|
89
|
+
return page?.messages ?? [];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
message(messageId) {
|
|
93
|
+
return this.request('GET', `/bot/messages/${enc(messageId)}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
editMessage(messageId, message) {
|
|
97
|
+
return this.request('PATCH', `/bot/messages/${enc(messageId)}`, contentOf(message));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
deleteMessage(messageId) {
|
|
101
|
+
return this.request('DELETE', `/bot/messages/${enc(messageId)}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Clear several at once, which the server does as one act rather than a flood. */
|
|
105
|
+
deleteMessages(channelId, messageIds) {
|
|
106
|
+
return this.request('POST', `/bot/channels/${enc(channelId)}/messages/bulk-delete`, {
|
|
107
|
+
message_ids: messageIds,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The typing dots, for a bot about to take a second or two over a plain message. */
|
|
112
|
+
typing(channelId) {
|
|
113
|
+
return this.request('POST', `/bot/channels/${enc(channelId)}/typing`, {});
|
|
114
|
+
}
|
|
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
|
+
|
|
148
|
+
pin(messageId) {
|
|
149
|
+
return this.request('PUT', `/bot/messages/${enc(messageId)}/pin`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
unpin(messageId) {
|
|
153
|
+
return this.request('DELETE', `/bot/messages/${enc(messageId)}/pin`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
react(messageId, emoji) {
|
|
157
|
+
return this.request('PUT', `/bot/messages/${enc(messageId)}/reactions/${enc(emoji)}`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
unreact(messageId, emoji) {
|
|
161
|
+
return this.request('DELETE', `/bot/messages/${enc(messageId)}/reactions/${enc(emoji)}`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Channels.
|
|
165
|
+
|
|
166
|
+
async channels() {
|
|
167
|
+
const out = await this.request('GET', '/bot/channels');
|
|
168
|
+
return out?.channels ?? [];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
createChannel(channel) {
|
|
172
|
+
return this.request('POST', '/bot/channels', channel);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
updateChannel(channelId, changes) {
|
|
176
|
+
return this.request('PATCH', `/bot/channels/${enc(channelId)}`, changes);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
deleteChannel(channelId) {
|
|
180
|
+
return this.request('DELETE', `/bot/channels/${enc(channelId)}`);
|
|
181
|
+
}
|
|
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
|
+
|
|
236
|
+
// Roles.
|
|
237
|
+
|
|
238
|
+
async roles() {
|
|
239
|
+
const out = await this.request('GET', '/bot/roles');
|
|
240
|
+
return out?.roles ?? [];
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
createRole(role) {
|
|
244
|
+
return this.request('POST', '/bot/roles', role);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
updateRole(roleId, changes) {
|
|
248
|
+
return this.request('PATCH', `/bot/roles/${enc(roleId)}`, changes);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
deleteRole(roleId) {
|
|
252
|
+
return this.request('DELETE', `/bot/roles/${enc(roleId)}`);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Members.
|
|
256
|
+
|
|
257
|
+
async members() {
|
|
258
|
+
const out = await this.request('GET', '/bot/members');
|
|
259
|
+
return out?.members ?? [];
|
|
260
|
+
}
|
|
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
|
+
|
|
303
|
+
addRole(userId, roleId) {
|
|
304
|
+
return this.request('PUT', `/bot/members/${enc(userId)}/roles/${enc(roleId)}`);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
removeRole(userId, roleId) {
|
|
308
|
+
return this.request('DELETE', `/bot/members/${enc(userId)}/roles/${enc(roleId)}`);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** A nickname, or null to put somebody back to their own name. `@me` renames the bot. */
|
|
312
|
+
setNickname(userId, nickname) {
|
|
313
|
+
return this.request('PATCH', `/bot/members/${enc(userId)}`, { nickname });
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Moderation.
|
|
317
|
+
|
|
318
|
+
kick(userId) {
|
|
319
|
+
return this.request('DELETE', `/bot/members/${enc(userId)}`);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Silence somebody for a while. Seconds, because "until" invites timezone bugs. */
|
|
323
|
+
timeout(userId, seconds, reason = '') {
|
|
324
|
+
return this.request('POST', `/bot/members/${enc(userId)}/timeout`, { seconds, reason });
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
clearTimeout(userId) {
|
|
328
|
+
return this.request('DELETE', `/bot/members/${enc(userId)}/timeout`);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
ban(userId, reason = '') {
|
|
332
|
+
return this.request('POST', '/bot/bans', { user_id: userId, reason });
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
unban(userId) {
|
|
336
|
+
return this.request('DELETE', `/bot/bans/${enc(userId)}`);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async bans() {
|
|
340
|
+
const out = await this.request('GET', '/bot/bans');
|
|
341
|
+
return out?.bans ?? [];
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Commands.
|
|
345
|
+
|
|
346
|
+
async commands() {
|
|
347
|
+
const out = await this.request('GET', '/bot/commands');
|
|
348
|
+
return out?.commands ?? [];
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Replace the command list. Whatever is not in it stops being offered. */
|
|
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
|
+
});
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Voice.
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Appear in a voice channel.
|
|
374
|
+
*
|
|
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).
|
|
382
|
+
*/
|
|
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
|
+
});
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
leaveVoice(channelId) {
|
|
399
|
+
return this.request('POST', '/bot/voice/leave', { channel_id: channelId });
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
voiceToken(channelId) {
|
|
403
|
+
return this.request('POST', '/bot/livekit/token', { room: `channel_${channelId}` });
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** A string is content; anything else is already a body. */
|
|
408
|
+
function contentOf(message) {
|
|
409
|
+
return typeof message === 'string' ? { content: message } : message;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function enc(value) {
|
|
413
|
+
return encodeURIComponent(String(value ?? ''));
|
|
414
|
+
}
|