@tusile/bot 0.1.2 → 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 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. It has roles, its permissions are checked the
40
- same way yours are, and it cannot see a channel it has not been given access to. Giving a
41
- bot a role is how you decide what it may do.
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,13 +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)` | |
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)` | |
177
223
  | `roles()`, `createRole(r)`, `updateRole(id, r)`, `deleteRole(id)` | |
178
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. |
179
226
  | `setNickname(userId, name)` | `'@me'` renames the bot itself |
180
227
  | `kick(userId)`, `ban(userId, reason)`, `unban(userId)`, `bans()` | |
181
228
  | `timeout(userId, seconds, reason)`, `clearTimeout(userId)` | |
182
- | `joinVoice(channelId)`, `leaveVoice(channelId)`, `voiceToken(channelId)` | |
229
+ | `joinVoice(channelId, { speaking })`, `setVoiceState(...)`, `leaveVoice(channelId)` | |
230
+ | `voiceToken(channelId)` | a media token, for publishing audio |
183
231
  | `serverInfo()`, `commands()`, `setCommands(list)` | |
184
232
  | `request(method, path, body)` | anything not on this list |
185
233
 
@@ -189,6 +237,134 @@ endpoint newer than your copy of this package is one line away.
189
237
  A call the bot lacks permission for comes back as a `TusileApiError` with `status: 403` and
190
238
  a reason, never as silence.
191
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
+
192
368
  ## Events
193
369
 
194
370
  ```js
@@ -202,6 +378,12 @@ bot.on('error', (err, context) => {}); // anything that went wrong
202
378
  A bot that answers plain messages should check `message.author_id !== bot.me.id` first, or
203
379
  it will answer itself.
204
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
+
205
387
  ## Rate limits and failures
206
388
 
207
389
  Writes are about 60 a minute per community with a burst of 20; reads 300 with a burst of 60.
@@ -210,6 +392,9 @@ unreachable, so a redeploy does not read as a lost message. A bad request is not
210
392
  `server.rateLimit` says what was left on the last call.
211
393
 
212
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.
213
398
 
214
399
  ## Running it without the Bot class
215
400
 
@@ -227,6 +412,21 @@ const client = new ServerClient({
227
412
  });
228
413
  ```
229
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
+
230
430
  ## What this does not have
231
431
 
232
432
  Deliberately, for now: direct messages to a bot, autocomplete on options, context-menu
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tusile/bot",
3
- "version": "0.1.2",
3
+ "version": "0.4.0",
4
4
  "description": "Write a Tusile bot: slash commands, buttons, forms and voice, over an outbound WebSocket.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",