@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/README.md
CHANGED
|
@@ -1,439 +1,439 @@
|
|
|
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
|
+
|
|
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.
|