@tusile/bot 0.4.0 → 0.6.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 CHANGED
@@ -1,439 +1,441 @@
1
- # @tusile/bot
2
-
3
- Write a [Tusile](https://tusile.com) bot: slash commands, buttons, forms, embeds and voice.
4
-
5
- There is no inbound port and no public URL. The bot dials out, one WebSocket per community
6
- it has been added to, so it runs anywhere that can make an outgoing connection: a laptop, a
7
- Raspberry Pi, a free dyno.
8
-
9
- ```bash
10
- npm install @tusile/bot
11
- ```
12
-
13
- ## A whole bot
14
-
15
- ```js
16
- import { Bot } from '@tusile/bot';
17
-
18
- const bot = new Bot(); // reads CORE_SERVER_URL, BOT_KEY, BOT_SECRET
19
-
20
- bot.command({ name: 'ping', description: 'Check the bot is awake' }, (i) => i.reply('pong'));
21
-
22
- await bot.start();
23
- ```
24
-
25
- ```bash
26
- CORE_SERVER_URL=https://api.tusile.com BOT_KEY=... BOT_SECRET=... node bot.js
27
- ```
28
-
29
- That bot is live on every community that has added it, and joins new ones by itself.
30
-
31
- ## Getting a key and a secret
32
-
33
- 1. In the Tusile app: **Settings → Bots → Create bot**. Give it a name and a picture.
34
- 2. Copy the key and the secret. **The secret is shown once**; a lost one is rotated, not
35
- recovered.
36
- 3. Add the bot to a community: **Community settings → Bots**, or tick "List in bot
37
- discovery" when you create it and let people add it themselves.
38
-
39
- A bot is an ordinary member of the community. Its permissions are checked the same way yours
40
- are, and it cannot see a channel it has not been given access to.
41
-
42
- ## Saying what your bot needs
43
-
44
- Whoever adds your bot is shown the permissions it asks for, and ticks what they are willing
45
- to allow. It gets exactly that, so asking for less is a bot more people will add.
46
-
47
- ```js
48
- const bot = new Bot({
49
- permissions: ['view_channel', 'send_messages', 'manage_channels', 'connect', 'speak'],
50
- });
51
- ```
52
-
53
- Declaring it here rather than in a form means the list lives next to the commands that need
54
- it, and a command that grows a new requirement says so in the same commit. The names are the
55
- ones in `Permissions`; an unknown name throws at startup rather than quietly dropping out of
56
- the list somebody is shown. Your bot's owner can tick the same list in **Settings → Bots**,
57
- and a declaration in code wins when the bot starts.
58
-
59
- A declaration grants nothing by itself. A bot that asks for nothing gets whatever everybody
60
- on that community can do, which is where bots started out before any of this existed. To
61
- give a bot more than it asked for, give it a role, the way you would anybody else.
62
-
63
- Ask for `view_channel` if your bot does anything at all in a channel. It is the floor: a bot
64
- that cannot see a channel is not handed the interactions that happen there, so a bot that
65
- asks only for `send_messages` will be added, will look installed, and will answer nothing.
66
-
67
- ## Commands
68
-
69
- ```js
70
- bot.command(
71
- {
72
- name: 'roll',
73
- description: 'Roll a die',
74
- options: [
75
- { name: 'sides', type: 'integer', description: 'How many sides', min: 2, max: 100 },
76
- { name: 'secret', type: 'boolean', description: 'Only you see the result' },
77
- ],
78
- },
79
- async (i) => {
80
- const sides = i.options.sides || 6;
81
- await i.reply(`d${sides} → ${1 + Math.floor(Math.random() * sides)}`, {
82
- ephemeral: Boolean(i.options.secret),
83
- });
84
- },
85
- );
86
- ```
87
-
88
- Option types are `string`, `integer`, `boolean`, `user`, `channel` and `role`. A `user`,
89
- `channel` or `role` option is a picker in the app and arrives as an id. `choices` turns any
90
- option into a picker, so nobody can send a value the bot will refuse. `min` and `max` apply
91
- to integers, `required: true` makes an option compulsory, and all of it is checked in the
92
- app before the bot is ever asked anything.
93
-
94
- The command list is registered on every connect, so a redeploy with a changed set takes
95
- effect immediately and a bot added to a community while it was down still registers.
96
-
97
- **Who may run it.** A command that acts on the server rather than answering a question can
98
- say what the person running it must hold:
99
-
100
- ```js
101
- bot.command(
102
- { name: 'setup', description: 'Build a server',
103
- requiredPermissions: ['manage_channels', 'manage_roles'] },
104
- async (i) => { /* ... */ },
105
- );
106
- ```
107
-
108
- All of them are required, not any of them; an administrator bypasses the check. Somebody
109
- without them is refused, and the command is not offered to them in the app at all. This is
110
- separate from the bot own permissions, which decide what the bot may do once asked and say
111
- nothing about who may ask: a /setup that rebuilds a server is open to every member who can
112
- talk in a channel until it says otherwise here.
113
-
114
- ## Answering
115
-
116
- A bot has **ten seconds** to say something, or the person who ran the command is shown "the
117
- bot did not respond".
118
-
119
- ```js
120
- await i.reply('everyone in the channel sees this');
121
- await i.replyPrivately('only the person who asked sees this');
122
- await i.defer(); // buys fifteen minutes
123
- await i.defer({ ephemeral: true }); // ...and the answer will be private
124
- await i.followUp('still working'); // another message after the first
125
- await i.editReply('done'); // rewrite what it already said
126
- await i.deleteReply();
127
- await i.showModal({ ... }); // ask them to fill something in
128
- await i.update({ content: 'x', components: [] }); // rewrite the message a button is on
129
- await i.acknowledge(); // a press that needs no visible change
130
- ```
131
-
132
- A private answer is not channel history: nobody else ever sees it, it is not searchable or
133
- exportable, and it is gone within a day.
134
-
135
- If a command or form handler takes longer than eight seconds, the SDK defers on its behalf
136
- rather than let the window run out. A button handler is left alone: a press is answered by
137
- rewriting the message it sits on, and that is only available before the interaction has
138
- been answered at all, so deferring for you would trade a late answer for one that cannot be
139
- given. A slow press should defer and `followUp`.
140
-
141
- If a handler throws, or returns without answering, the person is told something went wrong
142
- instead of being left watching a spinner, and the error reaches `bot.on('error')`.
143
-
144
- ## Embeds and controls
145
-
146
- ```js
147
- await i.reply({
148
- content: '',
149
- embeds: [{
150
- title: 'A card',
151
- description: 'Embeds carry structure that plain text cannot.',
152
- color: 0x5865f2,
153
- fields: [
154
- { name: 'Inline', value: 'shares a line', inline: true },
155
- { name: 'Stacked', value: 'takes its own line' },
156
- ],
157
- footer: { text: 'sent by a bot' },
158
- }],
159
- components: [{
160
- type: 'row',
161
- components: [
162
- { type: 'button', style: 'primary', label: 'Press me', custom_id: 'vote:yes' },
163
- { type: 'button', style: 'danger', label: 'No', custom_id: 'vote:no' },
164
- { type: 'button', style: 'link', label: 'Docs', url: 'https://tusile.com/bots' },
165
- ],
166
- }],
167
- });
168
-
169
- // Matched on the prefix, so both buttons land here and the id carries the choice.
170
- bot.component('vote', (i) => i.update({ content: `You said ${i.customId}`, components: [] }));
171
- ```
172
-
173
- A `custom_id` is yours. The server echoes it back verbatim and never parses it, so encode
174
- whatever state you need in it: `page:3`, `vote:close:99`. An exactly registered id wins over
175
- a prefix, and the longest matching prefix wins, so a general handler cannot swallow presses
176
- meant for a specific one.
177
-
178
- Button styles are `primary`, `secondary`, `danger` and `link` (which takes a `url` instead
179
- of a `custom_id`). Anything else draws as a plain button rather than not at all. A row can
180
- hold five buttons, or one select.
181
-
182
- ## Forms
183
-
184
- ```js
185
- bot.command({ name: 'feedback', description: 'Send a note' }, (i) =>
186
- i.showModal({
187
- custom_id: 'feedback',
188
- title: 'Tell us something',
189
- fields: [
190
- { custom_id: 'subject', label: 'Subject', required: true },
191
- { custom_id: 'body', label: 'Anything else', style: 'paragraph' },
192
- ],
193
- }),
194
- );
195
-
196
- bot.modal('feedback', (i) => i.replyPrivately(`Noted: ${i.fields.subject}`));
197
- ```
198
-
199
- ## Everything else
200
-
201
- Each handler is given `i.server`, the community the interaction came from:
202
-
203
- ```js
204
- bot.command({ name: 'recap', description: 'What was said here' }, async (i) => {
205
- const messages = await i.server.history(i.channelId, { limit: 10 });
206
- await i.replyPrivately(messages.map((m) => `- ${m.content}`).join('\n'));
207
- });
208
- ```
209
-
210
- | | |
211
- |---|---|
212
- | `send(channelId, message)` | say something, with embeds and components |
213
- | `history(channelId, { limit, before })` | read a channel |
214
- | `message(id)`, `editMessage(id, m)`, `deleteMessage(id)` | one message |
215
- | `deleteMessages(channelId, ids)` | clear several at once |
216
- | `react(id, emoji)`, `unreact(id, emoji)` | reactions |
217
- | `pin(id)`, `unpin(id)`, `typing(channelId)` | |
218
- | `thinking(channelId)` | Says "working on it" until you call the function it returns. The client shows a bot as **thinking**, not typing, and drops it three seconds after the last one, so this repeats for you. Nothing it does can fail an answer. |
219
- | `channels()`, `createChannel(c)`, `updateChannel(id, c)`, `deleteChannel(id)` | |
220
- | `categories()`, `createCategory(c)`, `updateCategory(id, c)`, `deleteCategory(id)` | |
221
- | `channelPermissions(channelId)` | who can do what in one channel |
222
- | `setChannelPermission(channelId, rule)`, `removeChannelPermission(channelId, id)` | |
223
- | `roles()`, `createRole(r)`, `updateRole(id, r)`, `deleteRole(id)` | |
224
- | `members()`, `addRole(userId, roleId)`, `removeRole(userId, roleId)` | |
225
- | `member(userId)` | one member with their roles, held for a minute. There is no route for one member, so the alternative is re-reading the whole community before every permission check. `member(id, { fresh: true })` skips the held copy, `forgetMembers()` drops it. |
226
- | `setNickname(userId, name)` | `'@me'` renames the bot itself |
227
- | `kick(userId)`, `ban(userId, reason)`, `unban(userId)`, `bans()` | |
228
- | `timeout(userId, seconds, reason)`, `clearTimeout(userId)` | |
229
- | `joinVoice(channelId, { speaking })`, `setVoiceState(...)`, `leaveVoice(channelId)` | |
230
- | `voiceToken(channelId)` | a media token, for publishing audio |
231
- | `serverInfo()`, `commands()`, `setCommands(list)` | |
232
- | `request(method, path, body)` | anything not on this list |
233
-
234
- `request` is not a second-class citizen: everything above is written in terms of it, so an
235
- endpoint newer than your copy of this package is one line away.
236
-
237
- A call the bot lacks permission for comes back as a `TusileApiError` with `status: 403` and
238
- a reason, never as silence.
239
-
240
- ## Who somebody is
241
-
242
- Two facts a community does not hold. Its members table knows when somebody joined and
243
- nothing else, so a bot that decides anything about a person asks Core.
244
-
245
- ```js
246
- const profile = await bot.core.profile(userId); // last_seen, is_guest, username, ...
247
- const name = await bot.core.displayName(userId); // what to call them, remembered
248
- const owner = (await bot.core.community(serverId))?.owner_id;
249
- ```
250
-
251
- | | |
252
- |---|---|
253
- | `profile(userId)` | the public profile, or `null` |
254
- | `displayName(userId)` | their name, cached for ten minutes, or `null` |
255
- | `community(serverId)` | a community's public row, which is where its owner is named, or `null` |
256
- | `request(method, path, body)` | anything else on Core, with the bot's own credentials |
257
-
258
- All three answer `null` rather than throwing, which is the opposite of everything else here
259
- and is the point: a bot sweeping two hundred members must not lose the pass to one lookup,
260
- and somebody it knows nothing about is somebody to leave alone. `displayName` returns `null`
261
- rather than a word like "someone", because the word belongs to your bot, in its own language.
262
-
263
- **`member()` is a saving, not a source of truth about what somebody may do.** The held copy
264
- carries roles, so a role taken away a minute ago is still in it. It cannot grant anybody
265
- anything, because the community server checks every action against the real roles and
266
- refuses it regardless. What it can do is let your bot offer somebody a thing the server
267
- will then refuse, or act on its own judgement where your bot is the only judge, which is
268
- most of what a moderation bot does. Pass `{ fresh: true }` when the answer is a decision
269
- rather than a label.
270
-
271
- Knowing the owner matters more than it looks. A bot holding Manage Members can remove the
272
- owner of the community that installed it: the bot kick route checks the permission bit and
273
- whether the target is a bot, and nothing else.
274
-
275
- ## A bot that sets a server up
276
-
277
- Everything a server is made of is reachable, so the first bot most people want to write is
278
- the one that builds the whole thing from a template:
279
-
280
- ```js
281
- import { Bot, Permissions } from '@tusile/bot';
282
-
283
- bot.command({ name: 'setup', description: 'Build the standard channels' }, async (i) => {
284
- await i.defer();
285
- const s = i.server;
286
-
287
- const staff = await s.createRole({ name: 'Staff', permissions: Permissions.view_channel });
288
- const team = await s.createCategory({ name: 'Team' });
289
-
290
- const general = await s.createChannel({ name: 'general', type: 'text', category_id: team.id });
291
- const private_ = await s.createChannel({ name: 'staff-only', type: 'text', category_id: team.id });
292
-
293
- // The part that makes it a server rather than a pile of channels.
294
- const [everyone] = (await s.roles()).filter((r) => r.is_default);
295
- await s.setChannelPermission(private_.id, { role: everyone.id, deny: 'view_channel' });
296
- await s.setChannelPermission(private_.id, { role: staff.id, allow: 'view_channel' });
297
-
298
- await s.addRole(i.userId, staff.id);
299
- await s.send(general.id, 'Set up. #staff-only is for the Staff role.');
300
- await i.editReply('Done.');
301
- });
302
- ```
303
-
304
- `allow` and `deny` take a permission name, a list of names, or a number:
305
- `view_channel`, `send_messages`, `manage_messages`, `manage_channels`, `manage_roles`,
306
- `manage_members`, `administrator`, `connect`, `speak`, `mute_members`, `deafen_members`,
307
- `manage_server`, `create_invite`, `attach_files`, `stream`, `use_camera`, `manage_emojis`,
308
- `manage_emoji_policy`, `timeout_members`. A name this package does not know throws, rather
309
- than quietly denying nothing.
310
-
311
- Two rules the server keeps, whatever the bot asks for: it cannot create a role above its
312
- own, and it cannot allow, in a channel, a permission it does not hold itself. Denying is
313
- not capped, so a bot locking a channel down does not need every permission it switches off.
314
-
315
- ## Playing audio
316
-
317
- A bot can be heard, not just seen. `joinVoice` puts it in the channel; the audio itself
318
- goes through LiveKit, the same path the app uses:
319
-
320
- ```bash
321
- npm install @livekit/rtc-node
322
- ```
323
-
324
- ```js
325
- import { Room, AudioSource, LocalAudioTrack, TrackPublishOptions, TrackSource, AudioFrame }
326
- from '@livekit/rtc-node';
327
-
328
- bot.command({ name: 'play', description: 'Play a tone' }, async (i) => {
329
- const channelId = i.options.channel;
330
- await i.server.joinVoice(channelId, { speaking: true });
331
- const { token, url } = await i.server.voiceToken(channelId);
332
-
333
- const room = new Room();
334
- await room.connect(url, token, { autoSubscribe: false });
335
-
336
- const source = new AudioSource(48000, 1);
337
- const options = new TrackPublishOptions();
338
- options.source = TrackSource.SOURCE_MICROPHONE;
339
- await room.localParticipant.publishTrack(
340
- LocalAudioTrack.createAudioTrack('tone', source), options);
341
-
342
- // 10 ms frames of signed 16-bit samples at 48 kHz, which is what a decoder hands you.
343
- let phase = 0;
344
- for (let n = 0; n < 300; n++) {
345
- const samples = new Int16Array(480);
346
- for (let s = 0; s < 480; s++) {
347
- samples[s] = Math.round(Math.sin(phase) * 12000);
348
- phase += (2 * Math.PI * 440) / 48000;
349
- }
350
- await source.captureFrame(new AudioFrame(samples, 48000, 1, 480));
351
- }
352
-
353
- await room.disconnect();
354
- await i.server.setVoiceState(channelId, { speaking: false });
355
- await i.reply('Done.');
356
- });
357
- ```
358
-
359
- `speaking: true` is what the channel shows. Without it the bot is drawn with the
360
- crossed-out microphone while it plays, which is right for a bot that only listens and
361
- wrong for one that is making a sound. It needs **Connect** to be in the channel and
362
- **Speak** to claim a live microphone; the server refuses the claim without it, because
363
- presence is a promise to the room that it can be heard.
364
-
365
- A bot that plays a file decodes it to the same frames: 48 kHz, signed 16-bit, mono or
366
- stereo. Nothing in Tusile transcodes for you.
367
-
368
- ## Events
369
-
370
- ```js
371
- bot.on('ready', (me, server) => {}); // connected to a community
372
- bot.on('message', (message, server) => {}); // said in a channel the bot can see
373
- bot.on('joined', (server) => {}); // added to a community
374
- bot.on('left', (serverId) => {}); // removed from one
375
- bot.on('error', (err, context) => {}); // anything that went wrong
376
- ```
377
-
378
- A bot that answers plain messages should check `message.author_id !== bot.me.id` first, or
379
- it will answer itself.
380
-
381
- ## Tokens
382
-
383
- A community issues a token good for an hour, and the socket it was minted for stays up for
384
- days. The SDK mints another the moment a community refuses one, so a bot that has been
385
- running since yesterday keeps working; there is nothing to do about this in bot code.
386
-
387
- ## Rate limits and failures
388
-
389
- Writes are about 60 a minute per community with a burst of 20; reads 300 with a burst of 60.
390
- The SDK waits out a rate limit and retries, and retries a community that is briefly
391
- unreachable, so a redeploy does not read as a lost message. A bad request is not retried.
392
- `server.rateLimit` says what was left on the last call.
393
-
394
- A bot removed from a community stops reconnecting to it and keeps every other connection.
395
- Credentials Core refuses stop it entirely, rather than being retried: a deleted bot or a
396
- rotated secret will not come right on its own, and a bot asking every thirty seconds until
397
- somebody notices fills a log and spends rate limit the working bots need.
398
-
399
- ## Running it without the Bot class
400
-
401
- The pieces are exported on their own, for a script that only wants to post a message:
402
-
403
- ```js
404
- import { CoreClient, ServerClient } from '@tusile/bot';
405
-
406
- const core = new CoreClient({ url: process.env.CORE_SERVER_URL, key, secret });
407
- const [server] = await core.servers();
408
- const client = new ServerClient({
409
- apiUrl: server.apiUrl,
410
- token: () => token,
411
- serverId: server.serverId,
412
- });
413
- ```
414
-
415
- ## If you write your own loop
416
-
417
- Two things the `Bot` class handles that are easy to get wrong, and both were found in
418
- production rather than reasoned about.
419
-
420
- **A community keeps its id when it moves and only its `api_url` changes.** Reconcile your
421
- connections on the address as well as the id. A loop that keys on `server_id` alone holds a
422
- socket to the host the community has left, which usually goes on answering, so nothing
423
- errors and nothing reconnects. The bot reads as offline in that community until it is
424
- restarted.
425
-
426
- **A close is not always a failure.** `bot_uninstalled` means stop; `server_retired` means
427
- the community has left that box, so ask Core for the list again before reconnecting rather
428
- than redialling the address you hold. Everything else is worth a backoff.
429
-
430
- ## What this does not have
431
-
432
- Deliberately, for now: direct messages to a bot, autocomplete on options, context-menu
433
- commands, a custom status line, gateway resume after a drop (it reconnects and re-registers
434
- instead), and outbound webhooks.
435
-
436
- Full reference, including every field of an embed, a component and a command option:
437
- **https://tusile.com/bots**
438
-
439
- MIT licensed.
1
+ # @tusile/bot
2
+
3
+ Write a [Tusile](https://tusile.com) bot: slash commands, buttons, forms, embeds and voice.
4
+
5
+ There is no inbound port and no public URL. The bot dials out, one WebSocket per community
6
+ it has been added to, so it runs anywhere that can make an outgoing connection: a laptop, a
7
+ Raspberry Pi, a free dyno.
8
+
9
+ ```bash
10
+ npm install @tusile/bot
11
+ ```
12
+
13
+ ## A whole bot
14
+
15
+ ```js
16
+ import { Bot } from '@tusile/bot';
17
+
18
+ const bot = new Bot(); // reads CORE_SERVER_URL, BOT_KEY, BOT_SECRET
19
+
20
+ bot.command({ name: 'ping', description: 'Check the bot is awake' }, (i) => i.reply('pong'));
21
+
22
+ await bot.start();
23
+ ```
24
+
25
+ ```bash
26
+ CORE_SERVER_URL=https://api.tusile.com BOT_KEY=... BOT_SECRET=... node bot.js
27
+ ```
28
+
29
+ That bot is live on every community that has added it, and joins new ones by itself.
30
+
31
+ ## Getting a key and a secret
32
+
33
+ 1. In the Tusile app: **Settings → Bots → Create bot**. Give it a name and a picture.
34
+ 2. Copy the key and the secret. **The secret is shown once**; a lost one is rotated, not
35
+ recovered.
36
+ 3. Add the bot to a community: **Community settings → Bots**, or tick "List in bot
37
+ discovery" when you create it and let people add it themselves.
38
+
39
+ A bot is an ordinary member of the community. Its permissions are checked the same way yours
40
+ are, and it cannot see a channel it has not been given access to.
41
+
42
+ ## Saying what your bot needs
43
+
44
+ Whoever adds your bot is shown the permissions it asks for, and ticks what they are willing
45
+ to allow. It gets exactly that, so asking for less is a bot more people will add.
46
+
47
+ ```js
48
+ const bot = new Bot({
49
+ permissions: ['view_channel', 'send_messages', 'manage_channels', 'connect', 'speak'],
50
+ });
51
+ ```
52
+
53
+ Declaring it here rather than in a form means the list lives next to the commands that need
54
+ it, and a command that grows a new requirement says so in the same commit. The names are the
55
+ ones in `Permissions`; an unknown name throws at startup rather than quietly dropping out of
56
+ the list somebody is shown. Your bot's owner can tick the same list in **Settings → Bots**,
57
+ and a declaration in code wins when the bot starts.
58
+
59
+ A declaration grants nothing by itself. A bot that asks for nothing gets whatever everybody
60
+ on that community can do, which is where bots started out before any of this existed. To
61
+ give a bot more than it asked for, give it a role, the way you would anybody else.
62
+
63
+ Ask for `view_channel` if your bot does anything at all in a channel. It is the floor: a bot
64
+ that cannot see a channel is not handed the interactions that happen there, so a bot that
65
+ asks only for `send_messages` will be added, will look installed, and will answer nothing.
66
+
67
+ ## Commands
68
+
69
+ ```js
70
+ bot.command(
71
+ {
72
+ name: 'roll',
73
+ description: 'Roll a die',
74
+ options: [
75
+ { name: 'sides', type: 'integer', description: 'How many sides', min: 2, max: 100 },
76
+ { name: 'secret', type: 'boolean', description: 'Only you see the result' },
77
+ ],
78
+ },
79
+ async (i) => {
80
+ const sides = i.options.sides || 6;
81
+ await i.reply(`d${sides} → ${1 + Math.floor(Math.random() * sides)}`, {
82
+ ephemeral: Boolean(i.options.secret),
83
+ });
84
+ },
85
+ );
86
+ ```
87
+
88
+ Option types are `string`, `integer`, `boolean`, `user`, `channel` and `role`. A `user`,
89
+ `channel` or `role` option is a picker in the app and arrives as an id. `choices` turns any
90
+ option into a picker, so nobody can send a value the bot will refuse. `min` and `max` apply
91
+ to integers, `required: true` makes an option compulsory, and all of it is checked in the
92
+ app before the bot is ever asked anything.
93
+
94
+ The command list is registered on every connect, so a redeploy with a changed set takes
95
+ effect immediately and a bot added to a community while it was down still registers.
96
+ Since 0.6.0 the same list is also sent to Core, so the bot directory shows your commands to
97
+ somebody deciding whether to add the bot.
98
+
99
+ **Who may run it.** A command that acts on the server rather than answering a question can
100
+ say what the person running it must hold:
101
+
102
+ ```js
103
+ bot.command(
104
+ { name: 'setup', description: 'Build a server',
105
+ requiredPermissions: ['manage_channels', 'manage_roles'] },
106
+ async (i) => { /* ... */ },
107
+ );
108
+ ```
109
+
110
+ All of them are required, not any of them; an administrator bypasses the check. Somebody
111
+ without them is refused, and the command is not offered to them in the app at all. This is
112
+ separate from the bot own permissions, which decide what the bot may do once asked and say
113
+ nothing about who may ask: a /setup that rebuilds a server is open to every member who can
114
+ talk in a channel until it says otherwise here.
115
+
116
+ ## Answering
117
+
118
+ A bot has **ten seconds** to say something, or the person who ran the command is shown "the
119
+ bot did not respond".
120
+
121
+ ```js
122
+ await i.reply('everyone in the channel sees this');
123
+ await i.replyPrivately('only the person who asked sees this');
124
+ await i.defer(); // buys fifteen minutes
125
+ await i.defer({ ephemeral: true }); // ...and the answer will be private
126
+ await i.followUp('still working'); // another message after the first
127
+ await i.editReply('done'); // rewrite what it already said
128
+ await i.deleteReply();
129
+ await i.showModal({ ... }); // ask them to fill something in
130
+ await i.update({ content: 'x', components: [] }); // rewrite the message a button is on
131
+ await i.acknowledge(); // a press that needs no visible change
132
+ ```
133
+
134
+ A private answer is not channel history: nobody else ever sees it, it is not searchable or
135
+ exportable, and it is gone within a day.
136
+
137
+ If a command or form handler takes longer than eight seconds, the SDK defers on its behalf
138
+ rather than let the window run out. A button handler is left alone: a press is answered by
139
+ rewriting the message it sits on, and that is only available before the interaction has
140
+ been answered at all, so deferring for you would trade a late answer for one that cannot be
141
+ given. A slow press should defer and `followUp`.
142
+
143
+ If a handler throws, or returns without answering, the person is told something went wrong
144
+ instead of being left watching a spinner, and the error reaches `bot.on('error')`.
145
+
146
+ ## Embeds and controls
147
+
148
+ ```js
149
+ await i.reply({
150
+ content: '',
151
+ embeds: [{
152
+ title: 'A card',
153
+ description: 'Embeds carry structure that plain text cannot.',
154
+ color: 0x5865f2,
155
+ fields: [
156
+ { name: 'Inline', value: 'shares a line', inline: true },
157
+ { name: 'Stacked', value: 'takes its own line' },
158
+ ],
159
+ footer: { text: 'sent by a bot' },
160
+ }],
161
+ components: [{
162
+ type: 'row',
163
+ components: [
164
+ { type: 'button', style: 'primary', label: 'Press me', custom_id: 'vote:yes' },
165
+ { type: 'button', style: 'danger', label: 'No', custom_id: 'vote:no' },
166
+ { type: 'button', style: 'link', label: 'Docs', url: 'https://tusile.com/bots' },
167
+ ],
168
+ }],
169
+ });
170
+
171
+ // Matched on the prefix, so both buttons land here and the id carries the choice.
172
+ bot.component('vote', (i) => i.update({ content: `You said ${i.customId}`, components: [] }));
173
+ ```
174
+
175
+ A `custom_id` is yours. The server echoes it back verbatim and never parses it, so encode
176
+ whatever state you need in it: `page:3`, `vote:close:99`. An exactly registered id wins over
177
+ a prefix, and the longest matching prefix wins, so a general handler cannot swallow presses
178
+ meant for a specific one.
179
+
180
+ Button styles are `primary`, `secondary`, `danger` and `link` (which takes a `url` instead
181
+ of a `custom_id`). Anything else draws as a plain button rather than not at all. A row can
182
+ hold five buttons, or one select.
183
+
184
+ ## Forms
185
+
186
+ ```js
187
+ bot.command({ name: 'feedback', description: 'Send a note' }, (i) =>
188
+ i.showModal({
189
+ custom_id: 'feedback',
190
+ title: 'Tell us something',
191
+ fields: [
192
+ { custom_id: 'subject', label: 'Subject', required: true },
193
+ { custom_id: 'body', label: 'Anything else', style: 'paragraph' },
194
+ ],
195
+ }),
196
+ );
197
+
198
+ bot.modal('feedback', (i) => i.replyPrivately(`Noted: ${i.fields.subject}`));
199
+ ```
200
+
201
+ ## Everything else
202
+
203
+ Each handler is given `i.server`, the community the interaction came from:
204
+
205
+ ```js
206
+ bot.command({ name: 'recap', description: 'What was said here' }, async (i) => {
207
+ const messages = await i.server.history(i.channelId, { limit: 10 });
208
+ await i.replyPrivately(messages.map((m) => `- ${m.content}`).join('\n'));
209
+ });
210
+ ```
211
+
212
+ | | |
213
+ |---|---|
214
+ | `send(channelId, message)` | say something, with embeds and components |
215
+ | `history(channelId, { limit, before })` | read a channel |
216
+ | `message(id)`, `editMessage(id, m)`, `deleteMessage(id)` | one message |
217
+ | `deleteMessages(channelId, ids)` | clear several at once |
218
+ | `react(id, emoji)`, `unreact(id, emoji)` | reactions |
219
+ | `pin(id)`, `unpin(id)`, `typing(channelId)` | |
220
+ | `thinking(channelId)` | Says "working on it" until you call the function it returns. The client shows a bot as **thinking**, not typing, and drops it three seconds after the last one, so this repeats for you. Nothing it does can fail an answer. |
221
+ | `channels()`, `createChannel(c)`, `updateChannel(id, c)`, `deleteChannel(id)` | |
222
+ | `categories()`, `createCategory(c)`, `updateCategory(id, c)`, `deleteCategory(id)` | |
223
+ | `channelPermissions(channelId)` | who can do what in one channel |
224
+ | `setChannelPermission(channelId, rule)`, `removeChannelPermission(channelId, id)` | |
225
+ | `roles()`, `createRole(r)`, `updateRole(id, r)`, `deleteRole(id)` | |
226
+ | `members()`, `addRole(userId, roleId)`, `removeRole(userId, roleId)` | |
227
+ | `member(userId)` | one member with their roles, held for a minute. There is no route for one member, so the alternative is re-reading the whole community before every permission check. `member(id, { fresh: true })` skips the held copy, `forgetMembers()` drops it. |
228
+ | `setNickname(userId, name)` | `'@me'` renames the bot itself |
229
+ | `kick(userId)`, `ban(userId, reason)`, `unban(userId)`, `bans()` | |
230
+ | `timeout(userId, seconds, reason)`, `clearTimeout(userId)` | |
231
+ | `joinVoice(channelId, { speaking })`, `setVoiceState(...)`, `leaveVoice(channelId)` | |
232
+ | `voiceToken(channelId)` | a media token, for publishing audio |
233
+ | `serverInfo()`, `commands()`, `setCommands(list)` | |
234
+ | `request(method, path, body)` | anything not on this list |
235
+
236
+ `request` is not a second-class citizen: everything above is written in terms of it, so an
237
+ endpoint newer than your copy of this package is one line away.
238
+
239
+ A call the bot lacks permission for comes back as a `TusileApiError` with `status: 403` and
240
+ a reason, never as silence.
241
+
242
+ ## Who somebody is
243
+
244
+ Two facts a community does not hold. Its members table knows when somebody joined and
245
+ nothing else, so a bot that decides anything about a person asks Core.
246
+
247
+ ```js
248
+ const profile = await bot.core.profile(userId); // last_seen, is_guest, username, ...
249
+ const name = await bot.core.displayName(userId); // what to call them, remembered
250
+ const owner = (await bot.core.community(serverId))?.owner_id;
251
+ ```
252
+
253
+ | | |
254
+ |---|---|
255
+ | `profile(userId)` | the public profile, or `null` |
256
+ | `displayName(userId)` | their name, cached for ten minutes, or `null` |
257
+ | `community(serverId)` | a community's public row, which is where its owner is named, or `null` |
258
+ | `request(method, path, body)` | anything else on Core, with the bot's own credentials |
259
+
260
+ All three answer `null` rather than throwing, which is the opposite of everything else here
261
+ and is the point: a bot sweeping two hundred members must not lose the pass to one lookup,
262
+ and somebody it knows nothing about is somebody to leave alone. `displayName` returns `null`
263
+ rather than a word like "someone", because the word belongs to your bot, in its own language.
264
+
265
+ **`member()` is a saving, not a source of truth about what somebody may do.** The held copy
266
+ carries roles, so a role taken away a minute ago is still in it. It cannot grant anybody
267
+ anything, because the community server checks every action against the real roles and
268
+ refuses it regardless. What it can do is let your bot offer somebody a thing the server
269
+ will then refuse, or act on its own judgement where your bot is the only judge, which is
270
+ most of what a moderation bot does. Pass `{ fresh: true }` when the answer is a decision
271
+ rather than a label.
272
+
273
+ Knowing the owner matters more than it looks. A bot holding Manage Members can remove the
274
+ owner of the community that installed it: the bot kick route checks the permission bit and
275
+ whether the target is a bot, and nothing else.
276
+
277
+ ## A bot that sets a server up
278
+
279
+ Everything a server is made of is reachable, so the first bot most people want to write is
280
+ the one that builds the whole thing from a template:
281
+
282
+ ```js
283
+ import { Bot, Permissions } from '@tusile/bot';
284
+
285
+ bot.command({ name: 'setup', description: 'Build the standard channels' }, async (i) => {
286
+ await i.defer();
287
+ const s = i.server;
288
+
289
+ const staff = await s.createRole({ name: 'Staff', permissions: Permissions.view_channel });
290
+ const team = await s.createCategory({ name: 'Team' });
291
+
292
+ const general = await s.createChannel({ name: 'general', type: 'text', category_id: team.id });
293
+ const private_ = await s.createChannel({ name: 'staff-only', type: 'text', category_id: team.id });
294
+
295
+ // The part that makes it a server rather than a pile of channels.
296
+ const [everyone] = (await s.roles()).filter((r) => r.is_default);
297
+ await s.setChannelPermission(private_.id, { role: everyone.id, deny: 'view_channel' });
298
+ await s.setChannelPermission(private_.id, { role: staff.id, allow: 'view_channel' });
299
+
300
+ await s.addRole(i.userId, staff.id);
301
+ await s.send(general.id, 'Set up. #staff-only is for the Staff role.');
302
+ await i.editReply('Done.');
303
+ });
304
+ ```
305
+
306
+ `allow` and `deny` take a permission name, a list of names, or a number:
307
+ `view_channel`, `send_messages`, `manage_messages`, `manage_channels`, `manage_roles`,
308
+ `manage_members`, `administrator`, `connect`, `speak`, `mute_members`, `deafen_members`,
309
+ `manage_server`, `create_invite`, `attach_files`, `stream`, `use_camera`, `manage_emojis`,
310
+ `manage_emoji_policy`, `timeout_members`. A name this package does not know throws, rather
311
+ than quietly denying nothing.
312
+
313
+ Two rules the server keeps, whatever the bot asks for: it cannot create a role above its
314
+ own, and it cannot allow, in a channel, a permission it does not hold itself. Denying is
315
+ not capped, so a bot locking a channel down does not need every permission it switches off.
316
+
317
+ ## Playing audio
318
+
319
+ A bot can be heard, not just seen. `joinVoice` puts it in the channel; the audio itself
320
+ goes through LiveKit, the same path the app uses:
321
+
322
+ ```bash
323
+ npm install @livekit/rtc-node
324
+ ```
325
+
326
+ ```js
327
+ import { Room, AudioSource, LocalAudioTrack, TrackPublishOptions, TrackSource, AudioFrame }
328
+ from '@livekit/rtc-node';
329
+
330
+ bot.command({ name: 'play', description: 'Play a tone' }, async (i) => {
331
+ const channelId = i.options.channel;
332
+ await i.server.joinVoice(channelId, { speaking: true });
333
+ const { token, url } = await i.server.voiceToken(channelId);
334
+
335
+ const room = new Room();
336
+ await room.connect(url, token, { autoSubscribe: false });
337
+
338
+ const source = new AudioSource(48000, 1);
339
+ const options = new TrackPublishOptions();
340
+ options.source = TrackSource.SOURCE_MICROPHONE;
341
+ await room.localParticipant.publishTrack(
342
+ LocalAudioTrack.createAudioTrack('tone', source), options);
343
+
344
+ // 10 ms frames of signed 16-bit samples at 48 kHz, which is what a decoder hands you.
345
+ let phase = 0;
346
+ for (let n = 0; n < 300; n++) {
347
+ const samples = new Int16Array(480);
348
+ for (let s = 0; s < 480; s++) {
349
+ samples[s] = Math.round(Math.sin(phase) * 12000);
350
+ phase += (2 * Math.PI * 440) / 48000;
351
+ }
352
+ await source.captureFrame(new AudioFrame(samples, 48000, 1, 480));
353
+ }
354
+
355
+ await room.disconnect();
356
+ await i.server.setVoiceState(channelId, { speaking: false });
357
+ await i.reply('Done.');
358
+ });
359
+ ```
360
+
361
+ `speaking: true` is what the channel shows. Without it the bot is drawn with the
362
+ crossed-out microphone while it plays, which is right for a bot that only listens and
363
+ wrong for one that is making a sound. It needs **Connect** to be in the channel and
364
+ **Speak** to claim a live microphone; the server refuses the claim without it, because
365
+ presence is a promise to the room that it can be heard.
366
+
367
+ A bot that plays a file decodes it to the same frames: 48 kHz, signed 16-bit, mono or
368
+ stereo. Nothing in Tusile transcodes for you.
369
+
370
+ ## Events
371
+
372
+ ```js
373
+ bot.on('ready', (me, server) => {}); // connected to a community
374
+ bot.on('message', (message, server) => {}); // said in a channel the bot can see
375
+ bot.on('joined', (server) => {}); // added to a community
376
+ bot.on('left', (serverId) => {}); // removed from one
377
+ bot.on('error', (err, context) => {}); // anything that went wrong
378
+ ```
379
+
380
+ A bot that answers plain messages should check `message.author_id !== bot.me.id` first, or
381
+ it will answer itself.
382
+
383
+ ## Tokens
384
+
385
+ A community issues a token good for an hour, and the socket it was minted for stays up for
386
+ days. The SDK mints another the moment a community refuses one, so a bot that has been
387
+ running since yesterday keeps working; there is nothing to do about this in bot code.
388
+
389
+ ## Rate limits and failures
390
+
391
+ Writes are about 60 a minute per community with a burst of 20; reads 300 with a burst of 60.
392
+ The SDK waits out a rate limit and retries, and retries a community that is briefly
393
+ unreachable, so a redeploy does not read as a lost message. A bad request is not retried.
394
+ `server.rateLimit` says what was left on the last call.
395
+
396
+ A bot removed from a community stops reconnecting to it and keeps every other connection.
397
+ Credentials Core refuses stop it entirely, rather than being retried: a deleted bot or a
398
+ rotated secret will not come right on its own, and a bot asking every thirty seconds until
399
+ somebody notices fills a log and spends rate limit the working bots need.
400
+
401
+ ## Running it without the Bot class
402
+
403
+ The pieces are exported on their own, for a script that only wants to post a message:
404
+
405
+ ```js
406
+ import { CoreClient, ServerClient } from '@tusile/bot';
407
+
408
+ const core = new CoreClient({ url: process.env.CORE_SERVER_URL, key, secret });
409
+ const [server] = await core.servers();
410
+ const client = new ServerClient({
411
+ apiUrl: server.apiUrl,
412
+ token: () => token,
413
+ serverId: server.serverId,
414
+ });
415
+ ```
416
+
417
+ ## If you write your own loop
418
+
419
+ Two things the `Bot` class handles that are easy to get wrong, and both were found in
420
+ production rather than reasoned about.
421
+
422
+ **A community keeps its id when it moves and only its `api_url` changes.** Reconcile your
423
+ connections on the address as well as the id. A loop that keys on `server_id` alone holds a
424
+ socket to the host the community has left, which usually goes on answering, so nothing
425
+ errors and nothing reconnects. The bot reads as offline in that community until it is
426
+ restarted.
427
+
428
+ **A close is not always a failure.** `bot_uninstalled` means stop; `server_retired` means
429
+ the community has left that box, so ask Core for the list again before reconnecting rather
430
+ than redialling the address you hold. Everything else is worth a backoff.
431
+
432
+ ## What this does not have
433
+
434
+ Deliberately, for now: direct messages to a bot, autocomplete on options, context-menu
435
+ commands, a custom status line, gateway resume after a drop (it reconnects and re-registers
436
+ instead), and outbound webhooks.
437
+
438
+ Full reference, including every field of an embed, a component and a command option:
439
+ **https://tusile.com/bots**
440
+
441
+ MIT licensed.