@tusile/bot 0.1.1 → 0.2.1
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 +51 -4
- package/package.json +2 -2
- package/src/bot.js +35 -4
- package/src/errors.js +12 -0
- package/src/gateway.js +13 -2
- package/src/index.js +27 -26
- package/src/permissions.js +55 -0
- package/src/rest.js +288 -234
package/README.md
CHANGED
|
@@ -119,7 +119,7 @@ await i.reply({
|
|
|
119
119
|
components: [
|
|
120
120
|
{ type: 'button', style: 'primary', label: 'Press me', custom_id: 'vote:yes' },
|
|
121
121
|
{ type: 'button', style: 'danger', label: 'No', custom_id: 'vote:no' },
|
|
122
|
-
{ type: 'button', style: 'link', label: 'Docs', url: 'https://tusile.com/bots
|
|
122
|
+
{ type: 'button', style: 'link', label: 'Docs', url: 'https://tusile.com/bots' },
|
|
123
123
|
],
|
|
124
124
|
}],
|
|
125
125
|
});
|
|
@@ -133,8 +133,9 @@ whatever state you need in it: `page:3`, `vote:close:99`. An exactly registered
|
|
|
133
133
|
a prefix, and the longest matching prefix wins, so a general handler cannot swallow presses
|
|
134
134
|
meant for a specific one.
|
|
135
135
|
|
|
136
|
-
Button styles are `primary`, `secondary`, `
|
|
137
|
-
|
|
136
|
+
Button styles are `primary`, `secondary`, `danger` and `link` (which takes a `url` instead
|
|
137
|
+
of a `custom_id`). Anything else draws as a plain button rather than not at all. A row can
|
|
138
|
+
hold five buttons, or one select.
|
|
138
139
|
|
|
139
140
|
## Forms
|
|
140
141
|
|
|
@@ -173,6 +174,9 @@ bot.command({ name: 'recap', description: 'What was said here' }, async (i) => {
|
|
|
173
174
|
| `react(id, emoji)`, `unreact(id, emoji)` | reactions |
|
|
174
175
|
| `pin(id)`, `unpin(id)`, `typing(channelId)` | |
|
|
175
176
|
| `channels()`, `createChannel(c)`, `updateChannel(id, c)`, `deleteChannel(id)` | |
|
|
177
|
+
| `categories()`, `createCategory(c)`, `updateCategory(id, c)`, `deleteCategory(id)` | |
|
|
178
|
+
| `channelPermissions(channelId)` | who can do what in one channel |
|
|
179
|
+
| `setChannelPermission(channelId, rule)`, `removeChannelPermission(channelId, id)` | |
|
|
176
180
|
| `roles()`, `createRole(r)`, `updateRole(id, r)`, `deleteRole(id)` | |
|
|
177
181
|
| `members()`, `addRole(userId, roleId)`, `removeRole(userId, roleId)` | |
|
|
178
182
|
| `setNickname(userId, name)` | `'@me'` renames the bot itself |
|
|
@@ -188,6 +192,46 @@ endpoint newer than your copy of this package is one line away.
|
|
|
188
192
|
A call the bot lacks permission for comes back as a `TusileApiError` with `status: 403` and
|
|
189
193
|
a reason, never as silence.
|
|
190
194
|
|
|
195
|
+
## A bot that sets a server up
|
|
196
|
+
|
|
197
|
+
Everything a server is made of is reachable, so the first bot most people want to write is
|
|
198
|
+
the one that builds the whole thing from a template:
|
|
199
|
+
|
|
200
|
+
```js
|
|
201
|
+
import { Bot, Permissions } from '@tusile/bot';
|
|
202
|
+
|
|
203
|
+
bot.command({ name: 'setup', description: 'Build the standard channels' }, async (i) => {
|
|
204
|
+
await i.defer();
|
|
205
|
+
const s = i.server;
|
|
206
|
+
|
|
207
|
+
const staff = await s.createRole({ name: 'Staff', permissions: Permissions.view_channel });
|
|
208
|
+
const team = await s.createCategory({ name: 'Team' });
|
|
209
|
+
|
|
210
|
+
const general = await s.createChannel({ name: 'general', type: 'text', category_id: team.id });
|
|
211
|
+
const private_ = await s.createChannel({ name: 'staff-only', type: 'text', category_id: team.id });
|
|
212
|
+
|
|
213
|
+
// The part that makes it a server rather than a pile of channels.
|
|
214
|
+
const [everyone] = (await s.roles()).filter((r) => r.is_default);
|
|
215
|
+
await s.setChannelPermission(private_.id, { role: everyone.id, deny: 'view_channel' });
|
|
216
|
+
await s.setChannelPermission(private_.id, { role: staff.id, allow: 'view_channel' });
|
|
217
|
+
|
|
218
|
+
await s.addRole(i.userId, staff.id);
|
|
219
|
+
await s.send(general.id, 'Set up. #staff-only is for the Staff role.');
|
|
220
|
+
await i.editReply('Done.');
|
|
221
|
+
});
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
`allow` and `deny` take a permission name, a list of names, or a number:
|
|
225
|
+
`view_channel`, `send_messages`, `manage_messages`, `manage_channels`, `manage_roles`,
|
|
226
|
+
`manage_members`, `administrator`, `connect`, `speak`, `mute_members`, `deafen_members`,
|
|
227
|
+
`manage_server`, `create_invite`, `attach_files`, `stream`, `use_camera`, `manage_emojis`,
|
|
228
|
+
`manage_emoji_policy`, `timeout_members`. A name this package does not know throws, rather
|
|
229
|
+
than quietly denying nothing.
|
|
230
|
+
|
|
231
|
+
Two rules the server keeps, whatever the bot asks for: it cannot create a role above its
|
|
232
|
+
own, and it cannot allow, in a channel, a permission it does not hold itself. Denying is
|
|
233
|
+
not capped, so a bot locking a channel down does not need every permission it switches off.
|
|
234
|
+
|
|
191
235
|
## Events
|
|
192
236
|
|
|
193
237
|
```js
|
|
@@ -209,6 +253,9 @@ unreachable, so a redeploy does not read as a lost message. A bad request is not
|
|
|
209
253
|
`server.rateLimit` says what was left on the last call.
|
|
210
254
|
|
|
211
255
|
A bot removed from a community stops reconnecting to it and keeps every other connection.
|
|
256
|
+
Credentials Core refuses stop it entirely, rather than being retried: a deleted bot or a
|
|
257
|
+
rotated secret will not come right on its own, and a bot asking every thirty seconds until
|
|
258
|
+
somebody notices fills a log and spends rate limit the working bots need.
|
|
212
259
|
|
|
213
260
|
## Running it without the Bot class
|
|
214
261
|
|
|
@@ -233,6 +280,6 @@ commands, a custom status line, gateway resume after a drop (it reconnects and r
|
|
|
233
280
|
instead), and outbound webhooks.
|
|
234
281
|
|
|
235
282
|
Full reference, including every field of an embed, a component and a command option:
|
|
236
|
-
**https://tusile.com/bots
|
|
283
|
+
**https://tusile.com/bots**
|
|
237
284
|
|
|
238
285
|
MIT licensed.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tusile/bot",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Write a Tusile bot: slash commands, buttons, forms and voice, over an outbound WebSocket.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"chat",
|
|
28
28
|
"slash-commands"
|
|
29
29
|
],
|
|
30
|
-
"homepage": "https://tusile.com/bots
|
|
30
|
+
"homepage": "https://tusile.com/bots",
|
|
31
31
|
"license": "MIT",
|
|
32
32
|
"author": "Tusile"
|
|
33
33
|
}
|
package/src/bot.js
CHANGED
|
@@ -137,7 +137,19 @@ export class Bot {
|
|
|
137
137
|
async sync() {
|
|
138
138
|
if (this.#stopped) return;
|
|
139
139
|
const waitSeconds = this.servers.size === 0 ? JOIN_WAIT_SECONDS : undefined;
|
|
140
|
-
|
|
140
|
+
let servers;
|
|
141
|
+
try {
|
|
142
|
+
servers = await this.#core.servers({ waitSeconds });
|
|
143
|
+
} catch (err) {
|
|
144
|
+
// Credentials Core refuses will be refused next minute too. Stopping is the honest
|
|
145
|
+
// answer: a bot that cannot authenticate is not running, and saying so once beats
|
|
146
|
+
// a 401 a minute for as long as nobody notices.
|
|
147
|
+
if (err?.isAuthFailure) {
|
|
148
|
+
this.#report(err);
|
|
149
|
+
await this.stop();
|
|
150
|
+
}
|
|
151
|
+
throw err;
|
|
152
|
+
}
|
|
141
153
|
if (this.#stopped) return;
|
|
142
154
|
|
|
143
155
|
const seen = new Set();
|
|
@@ -181,6 +193,14 @@ export class Bot {
|
|
|
181
193
|
},
|
|
182
194
|
onFrame: (frame, client) => this.#onFrame(frame, client),
|
|
183
195
|
onError: (err) => this.#report(err, { serverId }),
|
|
196
|
+
// A connection that has ended for good stops being this bot's connection to that
|
|
197
|
+
// community. Leaving it in place would make a community that removed the bot and
|
|
198
|
+
// added it again unreachable until the process was restarted: the next sync would
|
|
199
|
+
// see a link already there and skip it, and the bot would sit offline with nothing
|
|
200
|
+
// in any log to say why.
|
|
201
|
+
onFinished: () => {
|
|
202
|
+
if (this.#links.get(serverId) === link) this.#leave(serverId);
|
|
203
|
+
},
|
|
184
204
|
});
|
|
185
205
|
|
|
186
206
|
this.#links.set(serverId, link);
|
|
@@ -243,11 +263,13 @@ class Link {
|
|
|
243
263
|
#onError;
|
|
244
264
|
#onReady;
|
|
245
265
|
#onFrame;
|
|
266
|
+
#onFinished;
|
|
246
267
|
#token = '';
|
|
247
268
|
#backoff = 0;
|
|
248
269
|
#stopped = false;
|
|
249
270
|
|
|
250
|
-
constructor({ serverId, apiUrl, raw, core, fetch, sleep, connect, timers, onReady, onFrame,
|
|
271
|
+
constructor({ serverId, apiUrl, raw, core, fetch, sleep, connect, timers, onReady, onFrame,
|
|
272
|
+
onError, onFinished }) {
|
|
251
273
|
this.serverId = serverId;
|
|
252
274
|
this.apiUrl = apiUrl;
|
|
253
275
|
this.raw = raw;
|
|
@@ -256,6 +278,7 @@ class Link {
|
|
|
256
278
|
this.#onError = onError;
|
|
257
279
|
this.#onReady = onReady;
|
|
258
280
|
this.#onFrame = onFrame;
|
|
281
|
+
this.#onFinished = onFinished || (() => {});
|
|
259
282
|
|
|
260
283
|
this.client = new ServerClient({
|
|
261
284
|
apiUrl,
|
|
@@ -305,15 +328,23 @@ class Link {
|
|
|
305
328
|
while (!this.#stopped) {
|
|
306
329
|
const { fatal, reason, wasLive } = await this.#gateway.closed();
|
|
307
330
|
if (reason) this.#onError(new Error(`[${this.serverId}] ${reason}`));
|
|
308
|
-
if (fatal || this.#stopped) return;
|
|
331
|
+
if (fatal || this.#stopped) return this.#finish();
|
|
309
332
|
// A connection that was up and then dropped is worth picking straight back up.
|
|
310
333
|
// Carrying the backoff over would leave a bot that reconnects cleanly once an hour
|
|
311
334
|
// eventually waiting half a minute every time, for nothing.
|
|
312
335
|
if (wasLive) this.#backoff = 0;
|
|
313
336
|
this.#backoff = nextBackoff(this.#backoff, RECONNECT_MIN_MS, RECONNECT_MAX_MS);
|
|
314
337
|
await this.#sleep(this.#backoff);
|
|
315
|
-
if (this.#stopped) return;
|
|
338
|
+
if (this.#stopped) return this.#finish();
|
|
316
339
|
await this.#open();
|
|
317
340
|
}
|
|
341
|
+
this.#finish();
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Say, once, that this connection is over and will not come back by itself. */
|
|
345
|
+
#finish() {
|
|
346
|
+
const done = this.#onFinished;
|
|
347
|
+
this.#onFinished = () => {};
|
|
348
|
+
done();
|
|
318
349
|
}
|
|
319
350
|
}
|
package/src/errors.js
CHANGED
|
@@ -25,6 +25,18 @@ export class ApiError extends Error {
|
|
|
25
25
|
return this.code === 'bot_uninstalled';
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* The credentials are wrong, or gone.
|
|
30
|
+
*
|
|
31
|
+
* Not something that comes right on its own: a bot deleted while it was running, or a
|
|
32
|
+
* secret somebody rotated, answers this to every request for the rest of the process's
|
|
33
|
+
* life. Retrying it on a timer is how one dead bot fills a log and then trips the rate
|
|
34
|
+
* limiter for the live ones.
|
|
35
|
+
*/
|
|
36
|
+
get isAuthFailure() {
|
|
37
|
+
return this.status === 401 || this.status === 403;
|
|
38
|
+
}
|
|
39
|
+
|
|
28
40
|
get isRateLimited() {
|
|
29
41
|
return this.status === 429;
|
|
30
42
|
}
|
package/src/gateway.js
CHANGED
|
@@ -99,7 +99,14 @@ export class Gateway {
|
|
|
99
99
|
} catch (err) {
|
|
100
100
|
// Core refusing a token, or a socket that would not even be constructed. Settled as
|
|
101
101
|
// well as thrown: a caller waiting on `closed` would otherwise wait for good.
|
|
102
|
-
|
|
102
|
+
//
|
|
103
|
+
// Credentials Core refuses are final. The bot was deleted, or its secret was
|
|
104
|
+
// rotated, and every retry from here is a 401 in somebody's log until the process
|
|
105
|
+
// is stopped, plus a share of the rate limit the working bots need.
|
|
106
|
+
this.#settle({
|
|
107
|
+
fatal: err?.isUninstalled === true || err?.isAuthFailure === true,
|
|
108
|
+
reason: String(err?.message || err),
|
|
109
|
+
});
|
|
103
110
|
throw err;
|
|
104
111
|
}
|
|
105
112
|
}
|
|
@@ -117,7 +124,11 @@ export class Gateway {
|
|
|
117
124
|
await this.open();
|
|
118
125
|
} catch (err) {
|
|
119
126
|
// Core refusing to mint a token, usually. Not a reason to give up on the community.
|
|
120
|
-
return {
|
|
127
|
+
return {
|
|
128
|
+
fatal: err?.isUninstalled === true || err?.isAuthFailure === true,
|
|
129
|
+
reason: String(err?.message || err),
|
|
130
|
+
wasLive: false,
|
|
131
|
+
};
|
|
121
132
|
}
|
|
122
133
|
return this.closed();
|
|
123
134
|
}
|
package/src/index.js
CHANGED
|
@@ -1,26 +1,27 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @tusile/bot
|
|
3
|
-
*
|
|
4
|
-
* Everything a Tusile bot needs, in one place. The short version:
|
|
5
|
-
*
|
|
6
|
-
* import { Bot } from '@tusile/bot';
|
|
7
|
-
*
|
|
8
|
-
* const bot = new Bot(); // reads CORE_SERVER_URL, BOT_KEY, BOT_SECRET
|
|
9
|
-
* bot.command({ name: 'ping', description: 'Check the bot is awake' },
|
|
10
|
-
* (i) => i.reply('pong'));
|
|
11
|
-
* await bot.start();
|
|
12
|
-
*
|
|
13
|
-
* That is a working bot on every community that has added it. No inbound port, no public
|
|
14
|
-
* url, no webhook to register: it dials out.
|
|
15
|
-
*
|
|
16
|
-
* The pieces are exported as well, for a bot that wants one without the rest: a script
|
|
17
|
-
* that only posts a message needs a CoreClient and a ServerClient and no gateway at all.
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
export { Bot } from './bot.js';
|
|
21
|
-
export { CoreClient } from './core.js';
|
|
22
|
-
export { ServerClient } from './rest.js';
|
|
23
|
-
export { Gateway, gatewayUrl, nextBackoff } from './gateway.js';
|
|
24
|
-
export { Dispatcher } from './dispatch.js';
|
|
25
|
-
export { Interaction, ANSWER_WINDOW_MS } from './interaction.js';
|
|
26
|
-
export { ApiError } from './errors.js';
|
|
1
|
+
/**
|
|
2
|
+
* @tusile/bot
|
|
3
|
+
*
|
|
4
|
+
* Everything a Tusile bot needs, in one place. The short version:
|
|
5
|
+
*
|
|
6
|
+
* import { Bot } from '@tusile/bot';
|
|
7
|
+
*
|
|
8
|
+
* const bot = new Bot(); // reads CORE_SERVER_URL, BOT_KEY, BOT_SECRET
|
|
9
|
+
* bot.command({ name: 'ping', description: 'Check the bot is awake' },
|
|
10
|
+
* (i) => i.reply('pong'));
|
|
11
|
+
* await bot.start();
|
|
12
|
+
*
|
|
13
|
+
* That is a working bot on every community that has added it. No inbound port, no public
|
|
14
|
+
* url, no webhook to register: it dials out.
|
|
15
|
+
*
|
|
16
|
+
* The pieces are exported as well, for a bot that wants one without the rest: a script
|
|
17
|
+
* that only posts a message needs a CoreClient and a ServerClient and no gateway at all.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export { Bot } from './bot.js';
|
|
21
|
+
export { CoreClient } from './core.js';
|
|
22
|
+
export { ServerClient } from './rest.js';
|
|
23
|
+
export { Gateway, gatewayUrl, nextBackoff } from './gateway.js';
|
|
24
|
+
export { Dispatcher } from './dispatch.js';
|
|
25
|
+
export { Interaction, ANSWER_WINDOW_MS } from './interaction.js';
|
|
26
|
+
export { ApiError } from './errors.js';
|
|
27
|
+
export { Permissions, toMask } from './permissions.js';
|
|
@@ -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,234 +1,288 @@
|
|
|
1
|
-
import { Transport, query } from './http.js';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
this.
|
|
20
|
-
this
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
typing
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
+
export class ServerClient {
|
|
16
|
+
#transport;
|
|
17
|
+
|
|
18
|
+
constructor({ apiUrl, token, serverId = '', fetch, sleep, maxRetries }) {
|
|
19
|
+
this.serverId = serverId;
|
|
20
|
+
this.apiUrl = String(apiUrl || '').replace(/\/+$/, '');
|
|
21
|
+
this.#transport = new Transport({
|
|
22
|
+
baseUrl: this.apiUrl,
|
|
23
|
+
authorization: () => `Bearer ${token()}`,
|
|
24
|
+
fetch,
|
|
25
|
+
sleep,
|
|
26
|
+
maxRetries,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Anything at all, with this community's token.
|
|
32
|
+
*
|
|
33
|
+
* The escape hatch, and not a second-class one: everything below is written in terms of
|
|
34
|
+
* it, so a bot reaching an endpoint this package has never heard of is in exactly the
|
|
35
|
+
* same position as one calling a named method.
|
|
36
|
+
*/
|
|
37
|
+
request(method, path, body) {
|
|
38
|
+
return this.#transport.request(method, path, body);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** What the budget looked like a moment ago, for a bot that would rather not find out. */
|
|
42
|
+
get rateLimit() {
|
|
43
|
+
return this.#transport.rateLimit;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// The community itself.
|
|
47
|
+
|
|
48
|
+
serverInfo() {
|
|
49
|
+
return this.request('GET', '/bot/server');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Messages.
|
|
53
|
+
|
|
54
|
+
/** Say something. A string is the common case; an object carries embeds and controls. */
|
|
55
|
+
send(channelId, message) {
|
|
56
|
+
return this.request('POST', `/bot/channels/${enc(channelId)}/messages`, contentOf(message));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Recent messages, newest first. `before` is a message id, for walking backwards. */
|
|
60
|
+
async history(channelId, { limit, before } = {}) {
|
|
61
|
+
const page = await this.request(
|
|
62
|
+
'GET',
|
|
63
|
+
`/bot/channels/${enc(channelId)}/messages${query({ limit, before })}`,
|
|
64
|
+
);
|
|
65
|
+
return page?.messages ?? [];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
message(messageId) {
|
|
69
|
+
return this.request('GET', `/bot/messages/${enc(messageId)}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
editMessage(messageId, message) {
|
|
73
|
+
return this.request('PATCH', `/bot/messages/${enc(messageId)}`, contentOf(message));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
deleteMessage(messageId) {
|
|
77
|
+
return this.request('DELETE', `/bot/messages/${enc(messageId)}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Clear several at once, which the server does as one act rather than a flood. */
|
|
81
|
+
deleteMessages(channelId, messageIds) {
|
|
82
|
+
return this.request('POST', `/bot/channels/${enc(channelId)}/messages/bulk-delete`, {
|
|
83
|
+
message_ids: messageIds,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The typing dots, for a bot about to take a second or two over a plain message. */
|
|
88
|
+
typing(channelId) {
|
|
89
|
+
return this.request('POST', `/bot/channels/${enc(channelId)}/typing`, {});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
pin(messageId) {
|
|
93
|
+
return this.request('PUT', `/bot/messages/${enc(messageId)}/pin`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
unpin(messageId) {
|
|
97
|
+
return this.request('DELETE', `/bot/messages/${enc(messageId)}/pin`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
react(messageId, emoji) {
|
|
101
|
+
return this.request('PUT', `/bot/messages/${enc(messageId)}/reactions/${enc(emoji)}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
unreact(messageId, emoji) {
|
|
105
|
+
return this.request('DELETE', `/bot/messages/${enc(messageId)}/reactions/${enc(emoji)}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Channels.
|
|
109
|
+
|
|
110
|
+
async channels() {
|
|
111
|
+
const out = await this.request('GET', '/bot/channels');
|
|
112
|
+
return out?.channels ?? [];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
createChannel(channel) {
|
|
116
|
+
return this.request('POST', '/bot/channels', channel);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
updateChannel(channelId, changes) {
|
|
120
|
+
return this.request('PATCH', `/bot/channels/${enc(channelId)}`, changes);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
deleteChannel(channelId) {
|
|
124
|
+
return this.request('DELETE', `/bot/channels/${enc(channelId)}`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Categories, which are what turns a pile of channels into a server.
|
|
128
|
+
|
|
129
|
+
async categories() {
|
|
130
|
+
const out = await this.request('GET', '/bot/categories');
|
|
131
|
+
return out?.categories ?? [];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
createCategory(category) {
|
|
135
|
+
return this.request('POST', '/bot/categories', category);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
updateCategory(categoryId, changes) {
|
|
139
|
+
return this.request('PATCH', `/bot/categories/${enc(categoryId)}`, changes);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Removing a category leaves its channels; they come back to the top level. */
|
|
143
|
+
deleteCategory(categoryId) {
|
|
144
|
+
return this.request('DELETE', `/bot/categories/${enc(categoryId)}`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Who can do what in one channel.
|
|
148
|
+
|
|
149
|
+
async channelPermissions(channelId) {
|
|
150
|
+
const out = await this.request('GET', `/bot/channels/${enc(channelId)}/overrides`);
|
|
151
|
+
return out?.overrides ?? [];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Allow or deny permissions in one channel, for a role or one person.
|
|
156
|
+
*
|
|
157
|
+
* The call a setup bot is mostly made of: deny `view_channel` to `@everyone` and allow
|
|
158
|
+
* it to one role, and the channel is that team's. `allow` and `deny` take permission
|
|
159
|
+
* names as well as numbers.
|
|
160
|
+
*/
|
|
161
|
+
setChannelPermission(channelId, { role, member, allow, deny } = {}) {
|
|
162
|
+
if (!role && !member) {
|
|
163
|
+
return Promise.reject(new Error('a channel permission needs a role or a member to be about'));
|
|
164
|
+
}
|
|
165
|
+
return this.request('PUT', `/bot/channels/${enc(channelId)}/overrides`, {
|
|
166
|
+
target_type: role ? 'role' : 'member',
|
|
167
|
+
target_id: role || member,
|
|
168
|
+
allow: toMask(allow),
|
|
169
|
+
deny: toMask(deny),
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
removeChannelPermission(channelId, overrideId) {
|
|
174
|
+
return this.request(
|
|
175
|
+
'DELETE',
|
|
176
|
+
`/bot/channels/${enc(channelId)}/overrides/${enc(overrideId)}`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Roles.
|
|
181
|
+
|
|
182
|
+
async roles() {
|
|
183
|
+
const out = await this.request('GET', '/bot/roles');
|
|
184
|
+
return out?.roles ?? [];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
createRole(role) {
|
|
188
|
+
return this.request('POST', '/bot/roles', role);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
updateRole(roleId, changes) {
|
|
192
|
+
return this.request('PATCH', `/bot/roles/${enc(roleId)}`, changes);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
deleteRole(roleId) {
|
|
196
|
+
return this.request('DELETE', `/bot/roles/${enc(roleId)}`);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Members.
|
|
200
|
+
|
|
201
|
+
async members() {
|
|
202
|
+
const out = await this.request('GET', '/bot/members');
|
|
203
|
+
return out?.members ?? [];
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
addRole(userId, roleId) {
|
|
207
|
+
return this.request('PUT', `/bot/members/${enc(userId)}/roles/${enc(roleId)}`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
removeRole(userId, roleId) {
|
|
211
|
+
return this.request('DELETE', `/bot/members/${enc(userId)}/roles/${enc(roleId)}`);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** A nickname, or null to put somebody back to their own name. `@me` renames the bot. */
|
|
215
|
+
setNickname(userId, nickname) {
|
|
216
|
+
return this.request('PATCH', `/bot/members/${enc(userId)}`, { nickname });
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Moderation.
|
|
220
|
+
|
|
221
|
+
kick(userId) {
|
|
222
|
+
return this.request('DELETE', `/bot/members/${enc(userId)}`);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Silence somebody for a while. Seconds, because "until" invites timezone bugs. */
|
|
226
|
+
timeout(userId, seconds, reason = '') {
|
|
227
|
+
return this.request('POST', `/bot/members/${enc(userId)}/timeout`, { seconds, reason });
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
clearTimeout(userId) {
|
|
231
|
+
return this.request('DELETE', `/bot/members/${enc(userId)}/timeout`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
ban(userId, reason = '') {
|
|
235
|
+
return this.request('POST', '/bot/bans', { user_id: userId, reason });
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
unban(userId) {
|
|
239
|
+
return this.request('DELETE', `/bot/bans/${enc(userId)}`);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async bans() {
|
|
243
|
+
const out = await this.request('GET', '/bot/bans');
|
|
244
|
+
return out?.bans ?? [];
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Commands.
|
|
248
|
+
|
|
249
|
+
async commands() {
|
|
250
|
+
const out = await this.request('GET', '/bot/commands');
|
|
251
|
+
return out?.commands ?? [];
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Replace the command list. Whatever is not in it stops being offered. */
|
|
255
|
+
setCommands(commands) {
|
|
256
|
+
return this.request('PUT', '/bot/commands', { commands });
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Voice.
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Appear in a voice channel.
|
|
263
|
+
*
|
|
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`).
|
|
267
|
+
*/
|
|
268
|
+
joinVoice(channelId) {
|
|
269
|
+
return this.request('POST', '/bot/voice/join', { channel_id: channelId });
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
leaveVoice(channelId) {
|
|
273
|
+
return this.request('POST', '/bot/voice/leave', { channel_id: channelId });
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
voiceToken(channelId) {
|
|
277
|
+
return this.request('POST', '/bot/livekit/token', { room: `channel_${channelId}` });
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** A string is content; anything else is already a body. */
|
|
282
|
+
function contentOf(message) {
|
|
283
|
+
return typeof message === 'string' ? { content: message } : message;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function enc(value) {
|
|
287
|
+
return encodeURIComponent(String(value ?? ''));
|
|
288
|
+
}
|