@tusile/bot 0.2.1 → 0.4.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/LICENSE +21 -21
- package/README.md +158 -4
- package/package.json +1 -1
- package/src/bot.js +615 -350
- package/src/core.js +151 -12
- package/src/dispatch.js +195 -178
- package/src/errors.js +61 -61
- package/src/gateway.js +191 -191
- package/src/http.js +155 -136
- package/src/interaction.js +144 -140
- package/src/known.js +23 -0
- package/src/rest.js +414 -288
package/LICENSE
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 Tusile
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tusile
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -36,9 +36,33 @@ That bot is live on every community that has added it, and joins new ones by its
|
|
|
36
36
|
3. Add the bot to a community: **Community settings → Bots**, or tick "List in bot
|
|
37
37
|
discovery" when you create it and let people add it themselves.
|
|
38
38
|
|
|
39
|
-
A bot is an ordinary member of the community.
|
|
40
|
-
|
|
41
|
-
|
|
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.
|
|
42
66
|
|
|
43
67
|
## Commands
|
|
44
68
|
|
|
@@ -70,6 +94,23 @@ app before the bot is ever asked anything.
|
|
|
70
94
|
The command list is registered on every connect, so a redeploy with a changed set takes
|
|
71
95
|
effect immediately and a bot added to a community while it was down still registers.
|
|
72
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
|
+
|
|
73
114
|
## Answering
|
|
74
115
|
|
|
75
116
|
A bot has **ten seconds** to say something, or the person who ran the command is shown "the
|
|
@@ -79,6 +120,7 @@ bot did not respond".
|
|
|
79
120
|
await i.reply('everyone in the channel sees this');
|
|
80
121
|
await i.replyPrivately('only the person who asked sees this');
|
|
81
122
|
await i.defer(); // buys fifteen minutes
|
|
123
|
+
await i.defer({ ephemeral: true }); // ...and the answer will be private
|
|
82
124
|
await i.followUp('still working'); // another message after the first
|
|
83
125
|
await i.editReply('done'); // rewrite what it already said
|
|
84
126
|
await i.deleteReply();
|
|
@@ -173,16 +215,19 @@ bot.command({ name: 'recap', description: 'What was said here' }, async (i) => {
|
|
|
173
215
|
| `deleteMessages(channelId, ids)` | clear several at once |
|
|
174
216
|
| `react(id, emoji)`, `unreact(id, emoji)` | reactions |
|
|
175
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. |
|
|
176
219
|
| `channels()`, `createChannel(c)`, `updateChannel(id, c)`, `deleteChannel(id)` | |
|
|
177
220
|
| `categories()`, `createCategory(c)`, `updateCategory(id, c)`, `deleteCategory(id)` | |
|
|
178
221
|
| `channelPermissions(channelId)` | who can do what in one channel |
|
|
179
222
|
| `setChannelPermission(channelId, rule)`, `removeChannelPermission(channelId, id)` | |
|
|
180
223
|
| `roles()`, `createRole(r)`, `updateRole(id, r)`, `deleteRole(id)` | |
|
|
181
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. |
|
|
182
226
|
| `setNickname(userId, name)` | `'@me'` renames the bot itself |
|
|
183
227
|
| `kick(userId)`, `ban(userId, reason)`, `unban(userId)`, `bans()` | |
|
|
184
228
|
| `timeout(userId, seconds, reason)`, `clearTimeout(userId)` | |
|
|
185
|
-
| `joinVoice(channelId)`, `
|
|
229
|
+
| `joinVoice(channelId, { speaking })`, `setVoiceState(...)`, `leaveVoice(channelId)` | |
|
|
230
|
+
| `voiceToken(channelId)` | a media token, for publishing audio |
|
|
186
231
|
| `serverInfo()`, `commands()`, `setCommands(list)` | |
|
|
187
232
|
| `request(method, path, body)` | anything not on this list |
|
|
188
233
|
|
|
@@ -192,6 +237,41 @@ endpoint newer than your copy of this package is one line away.
|
|
|
192
237
|
A call the bot lacks permission for comes back as a `TusileApiError` with `status: 403` and
|
|
193
238
|
a reason, never as silence.
|
|
194
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
|
+
|
|
195
275
|
## A bot that sets a server up
|
|
196
276
|
|
|
197
277
|
Everything a server is made of is reachable, so the first bot most people want to write is
|
|
@@ -232,6 +312,59 @@ Two rules the server keeps, whatever the bot asks for: it cannot create a role a
|
|
|
232
312
|
own, and it cannot allow, in a channel, a permission it does not hold itself. Denying is
|
|
233
313
|
not capped, so a bot locking a channel down does not need every permission it switches off.
|
|
234
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
|
+
|
|
235
368
|
## Events
|
|
236
369
|
|
|
237
370
|
```js
|
|
@@ -245,6 +378,12 @@ bot.on('error', (err, context) => {}); // anything that went wrong
|
|
|
245
378
|
A bot that answers plain messages should check `message.author_id !== bot.me.id` first, or
|
|
246
379
|
it will answer itself.
|
|
247
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
|
+
|
|
248
387
|
## Rate limits and failures
|
|
249
388
|
|
|
250
389
|
Writes are about 60 a minute per community with a burst of 20; reads 300 with a burst of 60.
|
|
@@ -273,6 +412,21 @@ const client = new ServerClient({
|
|
|
273
412
|
});
|
|
274
413
|
```
|
|
275
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
|
+
|
|
276
430
|
## What this does not have
|
|
277
431
|
|
|
278
432
|
Deliberately, for now: direct messages to a bot, autocomplete on options, context-menu
|