@tusile/bot 0.1.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 ADDED
@@ -0,0 +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.
package/README.md ADDED
@@ -0,0 +1,234 @@
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. 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.
42
+
43
+ ## Commands
44
+
45
+ ```js
46
+ bot.command(
47
+ {
48
+ name: 'roll',
49
+ description: 'Roll a die',
50
+ options: [
51
+ { name: 'sides', type: 'integer', description: 'How many sides', min: 2, max: 100 },
52
+ { name: 'secret', type: 'boolean', description: 'Only you see the result' },
53
+ ],
54
+ },
55
+ async (i) => {
56
+ const sides = i.options.sides || 6;
57
+ await i.reply(`d${sides} → ${1 + Math.floor(Math.random() * sides)}`, {
58
+ ephemeral: Boolean(i.options.secret),
59
+ });
60
+ },
61
+ );
62
+ ```
63
+
64
+ Option types are `string`, `integer`, `boolean`, `user`, `channel` and `role`. A `user`,
65
+ `channel` or `role` option is a picker in the app and arrives as an id. `choices` turns any
66
+ option into a picker, so nobody can send a value the bot will refuse. `min` and `max` apply
67
+ to integers, `required: true` makes an option compulsory, and all of it is checked in the
68
+ app before the bot is ever asked anything.
69
+
70
+ The command list is registered on every connect, so a redeploy with a changed set takes
71
+ effect immediately and a bot added to a community while it was down still registers.
72
+
73
+ ## Answering
74
+
75
+ A bot has **ten seconds** to say something, or the person who ran the command is shown "the
76
+ bot did not respond".
77
+
78
+ ```js
79
+ await i.reply('everyone in the channel sees this');
80
+ await i.replyPrivately('only the person who asked sees this');
81
+ await i.defer(); // buys fifteen minutes
82
+ await i.followUp('still working'); // another message after the first
83
+ await i.editReply('done'); // rewrite what it already said
84
+ await i.deleteReply();
85
+ await i.showModal({ ... }); // ask them to fill something in
86
+ await i.update({ content: 'x', components: [] }); // rewrite the message a button is on
87
+ await i.acknowledge(); // a press that needs no visible change
88
+ ```
89
+
90
+ A private answer is not channel history: nobody else ever sees it, it is not searchable or
91
+ exportable, and it is gone within a day.
92
+
93
+ If a handler takes longer than eight seconds, the SDK defers on its behalf rather than let
94
+ the window run out. If a handler throws, or returns without answering, the person is told
95
+ something went wrong instead of being left watching a spinner, and the error reaches
96
+ `bot.on('error')`.
97
+
98
+ ## Embeds and controls
99
+
100
+ ```js
101
+ await i.reply({
102
+ content: '',
103
+ embeds: [{
104
+ title: 'A card',
105
+ description: 'Embeds carry structure that plain text cannot.',
106
+ color: 0x5865f2,
107
+ fields: [
108
+ { name: 'Inline', value: 'shares a line', inline: true },
109
+ { name: 'Stacked', value: 'takes its own line' },
110
+ ],
111
+ footer: { text: 'sent by a bot' },
112
+ }],
113
+ components: [{
114
+ type: 'row',
115
+ components: [
116
+ { type: 'button', style: 'primary', label: 'Press me', custom_id: 'vote:yes' },
117
+ { type: 'button', style: 'danger', label: 'No', custom_id: 'vote:no' },
118
+ { type: 'button', style: 'link', label: 'Docs', url: 'https://tusile.com/bots.html' },
119
+ ],
120
+ }],
121
+ });
122
+
123
+ // Matched on the prefix, so both buttons land here and the id carries the choice.
124
+ bot.component('vote', (i) => i.update({ content: `You said ${i.customId}`, components: [] }));
125
+ ```
126
+
127
+ A `custom_id` is yours. The server echoes it back verbatim and never parses it, so encode
128
+ whatever state you need in it: `page:3`, `vote:close:99`. An exactly registered id wins over
129
+ a prefix, and the longest matching prefix wins, so a general handler cannot swallow presses
130
+ meant for a specific one.
131
+
132
+ Button styles are `primary`, `secondary`, `success`, `danger` and `link` (which takes a
133
+ `url` instead of a `custom_id`). A row can hold five buttons, or one select.
134
+
135
+ ## Forms
136
+
137
+ ```js
138
+ bot.command({ name: 'feedback', description: 'Send a note' }, (i) =>
139
+ i.showModal({
140
+ custom_id: 'feedback',
141
+ title: 'Tell us something',
142
+ fields: [
143
+ { custom_id: 'subject', label: 'Subject', required: true },
144
+ { custom_id: 'body', label: 'Anything else', style: 'paragraph' },
145
+ ],
146
+ }),
147
+ );
148
+
149
+ bot.modal('feedback', (i) => i.replyPrivately(`Noted: ${i.fields.subject}`));
150
+ ```
151
+
152
+ ## Everything else
153
+
154
+ Each handler is given `i.server`, the community the interaction came from:
155
+
156
+ ```js
157
+ bot.command({ name: 'recap', description: 'What was said here' }, async (i) => {
158
+ const messages = await i.server.history(i.channelId, { limit: 10 });
159
+ await i.replyPrivately(messages.map((m) => `- ${m.content}`).join('\n'));
160
+ });
161
+ ```
162
+
163
+ | | |
164
+ |---|---|
165
+ | `send(channelId, message)` | say something, with embeds and components |
166
+ | `history(channelId, { limit, before })` | read a channel |
167
+ | `message(id)`, `editMessage(id, m)`, `deleteMessage(id)` | one message |
168
+ | `deleteMessages(channelId, ids)` | clear several at once |
169
+ | `react(id, emoji)`, `unreact(id, emoji)` | reactions |
170
+ | `pin(id)`, `unpin(id)`, `typing(channelId)` | |
171
+ | `channels()`, `createChannel(c)`, `updateChannel(id, c)`, `deleteChannel(id)` | |
172
+ | `roles()`, `createRole(r)`, `updateRole(id, r)`, `deleteRole(id)` | |
173
+ | `members()`, `addRole(userId, roleId)`, `removeRole(userId, roleId)` | |
174
+ | `setNickname(userId, name)` | `'@me'` renames the bot itself |
175
+ | `kick(userId)`, `ban(userId, reason)`, `unban(userId)`, `bans()` | |
176
+ | `timeout(userId, seconds, reason)`, `clearTimeout(userId)` | |
177
+ | `joinVoice(channelId)`, `leaveVoice(channelId)`, `voiceToken(channelId)` | |
178
+ | `serverInfo()`, `commands()`, `setCommands(list)` | |
179
+ | `request(method, path, body)` | anything not on this list |
180
+
181
+ `request` is not a second-class citizen: everything above is written in terms of it, so an
182
+ endpoint newer than your copy of this package is one line away.
183
+
184
+ A call the bot lacks permission for comes back as a `TusileApiError` with `status: 403` and
185
+ a reason, never as silence.
186
+
187
+ ## Events
188
+
189
+ ```js
190
+ bot.on('ready', (me, server) => {}); // connected to a community
191
+ bot.on('message', (message, server) => {}); // said in a channel the bot can see
192
+ bot.on('joined', (server) => {}); // added to a community
193
+ bot.on('left', (serverId) => {}); // removed from one
194
+ bot.on('error', (err, context) => {}); // anything that went wrong
195
+ ```
196
+
197
+ A bot that answers plain messages should check `message.author_id !== bot.me.id` first, or
198
+ it will answer itself.
199
+
200
+ ## Rate limits and failures
201
+
202
+ Writes are about 60 a minute per community with a burst of 20; reads 300 with a burst of 60.
203
+ The SDK waits out a rate limit and retries, and retries a community that is briefly
204
+ unreachable, so a redeploy does not read as a lost message. A bad request is not retried.
205
+ `server.rateLimit` says what was left on the last call.
206
+
207
+ A bot removed from a community stops reconnecting to it and keeps every other connection.
208
+
209
+ ## Running it without the Bot class
210
+
211
+ The pieces are exported on their own, for a script that only wants to post a message:
212
+
213
+ ```js
214
+ import { CoreClient, ServerClient } from '@tusile/bot';
215
+
216
+ const core = new CoreClient({ url: process.env.CORE_SERVER_URL, key, secret });
217
+ const [server] = await core.servers();
218
+ const client = new ServerClient({
219
+ apiUrl: server.apiUrl,
220
+ token: () => token,
221
+ serverId: server.serverId,
222
+ });
223
+ ```
224
+
225
+ ## What this does not have
226
+
227
+ Deliberately, for now: direct messages to a bot, autocomplete on options, context-menu
228
+ commands, a custom status line, gateway resume after a drop (it reconnects and re-registers
229
+ instead), and outbound webhooks.
230
+
231
+ Full reference, including every field of an embed, a component and a command option:
232
+ **https://tusile.com/bots.html**
233
+
234
+ MIT licensed.
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@tusile/bot",
3
+ "version": "0.1.0",
4
+ "description": "Write a Tusile bot: slash commands, buttons, forms and voice, over an outbound WebSocket.",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "exports": {
8
+ ".": "./src/index.js"
9
+ },
10
+ "files": [
11
+ "src",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "engines": {
16
+ "node": ">=20"
17
+ },
18
+ "scripts": {
19
+ "test": "node --test"
20
+ },
21
+ "dependencies": {
22
+ "ws": "^8.18.0"
23
+ },
24
+ "keywords": [
25
+ "tusile",
26
+ "bot",
27
+ "chat",
28
+ "slash-commands"
29
+ ],
30
+ "homepage": "https://tusile.com/bots.html",
31
+ "license": "MIT",
32
+ "author": "Tusile"
33
+ }
package/src/bot.js ADDED
@@ -0,0 +1,319 @@
1
+ import { CoreClient } from './core.js';
2
+ import { Dispatcher } from './dispatch.js';
3
+ import { Gateway, nextBackoff } from './gateway.js';
4
+ import { Interaction } from './interaction.js';
5
+ import { ServerClient } from './rest.js';
6
+
7
+ /**
8
+ * A bot, on every community that has added it.
9
+ *
10
+ * The shape of the problem: one identity held by Core, one short-lived token per
11
+ * community, one socket per community, and a list of communities that changes while the
12
+ * bot runs. None of that is a bot author's business, and all of it used to be. What is
13
+ * left is `command`, `component`, `modal`, and `start`.
14
+ */
15
+
16
+ const DEFAULT_POLL_MS = 60 * 1000;
17
+
18
+ /** How long to let Core hold a request open while the bot is on no communities at all. */
19
+ const JOIN_WAIT_SECONDS = 25;
20
+
21
+ const RECONNECT_MIN_MS = 1000;
22
+ const RECONNECT_MAX_MS = 30 * 1000;
23
+
24
+ export class Bot {
25
+ #core;
26
+ #dispatcher;
27
+ #links = new Map();
28
+ #listeners = new Map();
29
+ #connect;
30
+ #timers;
31
+ #sleep;
32
+ #fetch;
33
+ #pollMs;
34
+ #poll = null;
35
+ #stopped = false;
36
+
37
+ constructor({
38
+ coreUrl = process.env?.CORE_SERVER_URL,
39
+ key = process.env?.BOT_KEY,
40
+ secret = process.env?.BOT_SECRET,
41
+ fetch,
42
+ sleep,
43
+ connect,
44
+ timers = globalThis,
45
+ onError,
46
+ autoDeferMs,
47
+ pollMs = DEFAULT_POLL_MS,
48
+ } = {}) {
49
+ if (!coreUrl) throw new Error('no coreUrl: set CORE_SERVER_URL to the Tusile core server');
50
+ if (!key || !secret) {
51
+ // Said here rather than at the first request, because the first request happens
52
+ // inside a deploy where nobody is watching, and this happens at startup.
53
+ throw new Error(
54
+ 'no bot credentials: set BOT_KEY and BOT_SECRET. Create a bot in the Tusile app, ' +
55
+ 'under Settings, Bots, and copy them there.',
56
+ );
57
+ }
58
+
59
+ this.#core = new CoreClient({ url: coreUrl, key, secret, fetch, sleep });
60
+ this.#fetch = fetch;
61
+ this.#sleep = sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
62
+ this.#connect = connect || null;
63
+ this.#timers = timers;
64
+ this.#pollMs = pollMs;
65
+ this.#dispatcher = new Dispatcher({
66
+ autoDeferMs,
67
+ onError: (err, interaction) => this.#report(err, interaction),
68
+ });
69
+ if (onError) this.on('error', onError);
70
+
71
+ /** This bot's own account, once it has asked. Its id is how it recognises itself. */
72
+ this.me = null;
73
+ /** Every community it is connected to, by id. */
74
+ this.servers = new Map();
75
+ }
76
+
77
+ // What the bot does.
78
+
79
+ command(definition, handler) {
80
+ this.#dispatcher.command(definition, handler);
81
+ return this;
82
+ }
83
+
84
+ component(customId, handler) {
85
+ this.#dispatcher.component(customId, handler);
86
+ return this;
87
+ }
88
+
89
+ modal(customId, handler) {
90
+ this.#dispatcher.modal(customId, handler);
91
+ return this;
92
+ }
93
+
94
+ /**
95
+ * ready (bot, server) connected to a community and registered there
96
+ * message (message, server) something said in a channel the bot can see
97
+ * joined (server) added to a community
98
+ * left (serverId) removed from one
99
+ * error (error, context) anything that went wrong, including in a handler
100
+ */
101
+ on(event, listener) {
102
+ if (!this.#listeners.has(event)) this.#listeners.set(event, []);
103
+ this.#listeners.get(event).push(listener);
104
+ return this;
105
+ }
106
+
107
+ /**
108
+ * Connect, and stay connected.
109
+ *
110
+ * Resolves once every community known right now has a socket, so that a caller can
111
+ * reasonably look at `servers` afterwards. Keeps running until `stop`.
112
+ */
113
+ async start() {
114
+ // `ws` is loaded only if nobody supplied a socket, so a runtime with its own
115
+ // WebSocket, or a test, never pays for it.
116
+ if (!this.#connect) {
117
+ const { default: WebSocket } = await import('ws');
118
+ this.#connect = (url) => new WebSocket(url);
119
+ }
120
+ this.me = await this.#core.me();
121
+ await this.sync();
122
+ // Being added to a community is a change Core cannot push to a bot, so this is a
123
+ // poll. It is a slow one, because the empty case, which is the one somebody is
124
+ // watching, is handled by asking Core to hold the request open instead.
125
+ this.#poll = this.#timers.setInterval(() => {
126
+ this.sync().catch((err) => this.#report(err));
127
+ }, this.#pollMs);
128
+ return this;
129
+ }
130
+
131
+ /**
132
+ * Bring the set of connections in line with the set of communities.
133
+ *
134
+ * Called on a timer, and worth calling by hand after installing the bot somewhere if a
135
+ * caller happens to know that has just happened.
136
+ */
137
+ async sync() {
138
+ if (this.#stopped) return;
139
+ const waitSeconds = this.servers.size === 0 ? JOIN_WAIT_SECONDS : undefined;
140
+ const servers = await this.#core.servers({ waitSeconds });
141
+ if (this.#stopped) return;
142
+
143
+ const seen = new Set();
144
+ for (const server of servers) {
145
+ seen.add(server.serverId);
146
+ if (this.#links.has(server.serverId)) continue;
147
+ await this.#join(server);
148
+ }
149
+ // A community that removed the bot stops being listed, so its socket goes with it.
150
+ for (const serverId of [...this.#links.keys()]) {
151
+ if (!seen.has(serverId)) this.#leave(serverId);
152
+ }
153
+ }
154
+
155
+ /** Close every socket and stop looking for new communities. */
156
+ async stop() {
157
+ this.#stopped = true;
158
+ if (this.#poll) this.#timers.clearInterval(this.#poll);
159
+ this.#poll = null;
160
+ for (const serverId of [...this.#links.keys()]) this.#leave(serverId);
161
+ }
162
+
163
+ async #join({ serverId, apiUrl, raw }) {
164
+ const link = new Link({
165
+ serverId,
166
+ apiUrl,
167
+ raw,
168
+ core: this.#core,
169
+ fetch: this.#fetch,
170
+ sleep: this.#sleep,
171
+ connect: this.#connect,
172
+ timers: this.#timers,
173
+ onReady: (bot, client) => {
174
+ // Registered on every connect rather than once at startup: a bot redeployed with
175
+ // a changed command set, or added to a community while it was down, would
176
+ // otherwise go on offering whatever it registered last time.
177
+ client
178
+ .setCommands(this.#dispatcher.definitions())
179
+ .catch((err) => this.#report(err, { serverId }));
180
+ this.#emit('ready', bot, client);
181
+ },
182
+ onFrame: (frame, client) => this.#onFrame(frame, client),
183
+ onError: (err) => this.#report(err, { serverId }),
184
+ });
185
+
186
+ this.#links.set(serverId, link);
187
+ this.servers.set(serverId, link.client);
188
+ await link.start();
189
+ this.#emit('joined', link.client);
190
+ }
191
+
192
+ #leave(serverId) {
193
+ this.#links.get(serverId)?.stop();
194
+ this.#links.delete(serverId);
195
+ this.servers.delete(serverId);
196
+ this.#emit('left', serverId);
197
+ }
198
+
199
+ #onFrame(frame, client) {
200
+ if (frame?.type === 'interaction_create') {
201
+ const interaction = new Interaction(frame.data || {}, client);
202
+ this.#dispatcher.handle(interaction).catch((err) => this.#report(err, interaction));
203
+ return;
204
+ }
205
+ if (frame?.type === 'message_create') {
206
+ this.#emit('message', frame.data || {}, client);
207
+ }
208
+ }
209
+
210
+ #emit(event, ...args) {
211
+ for (const listener of this.#listeners.get(event) ?? []) {
212
+ try {
213
+ listener(...args);
214
+ } catch (err) {
215
+ // A throwing listener must not take down the socket that fed it.
216
+ if (event !== 'error') this.#report(err);
217
+ }
218
+ }
219
+ }
220
+
221
+ #report(err, context) {
222
+ const listeners = this.#listeners.get('error') ?? [];
223
+ if (!listeners.length) {
224
+ // Better a line on stderr than a bot that fails in total silence because nobody
225
+ // wired up an error handler.
226
+ console.error('[tusile-bot]', err);
227
+ return;
228
+ }
229
+ this.#emit('error', err, context);
230
+ }
231
+ }
232
+
233
+ /**
234
+ * One community: its socket, its token, and its reconnects.
235
+ *
236
+ * Kept separate from the bot because everything here is per community. One community being
237
+ * down, or having removed the bot, must leave every other connection alone.
238
+ */
239
+ class Link {
240
+ #gateway;
241
+ #core;
242
+ #sleep;
243
+ #onError;
244
+ #onReady;
245
+ #onFrame;
246
+ #token = '';
247
+ #backoff = 0;
248
+ #stopped = false;
249
+
250
+ constructor({ serverId, apiUrl, raw, core, fetch, sleep, connect, timers, onReady, onFrame, onError }) {
251
+ this.serverId = serverId;
252
+ this.apiUrl = apiUrl;
253
+ this.raw = raw;
254
+ this.#core = core;
255
+ this.#sleep = sleep;
256
+ this.#onError = onError;
257
+ this.#onReady = onReady;
258
+ this.#onFrame = onFrame;
259
+
260
+ this.client = new ServerClient({
261
+ apiUrl,
262
+ serverId,
263
+ // Read per request, so a reconnect that mints a new token does not leave the rest
264
+ // client holding an expired one.
265
+ token: () => this.#token,
266
+ fetch,
267
+ sleep,
268
+ });
269
+
270
+ this.#gateway = new Gateway({
271
+ url: apiUrl,
272
+ token: async () => {
273
+ this.#token = await this.#core.token(serverId);
274
+ return this.#token;
275
+ },
276
+ connect,
277
+ timers,
278
+ onReady: (bot) => this.#onReady(bot, this.client),
279
+ onFrame: (frame) => this.#onFrame(frame, this.client),
280
+ });
281
+ }
282
+
283
+ /** Open the socket, then keep it open in the background. */
284
+ async start() {
285
+ await this.#open();
286
+ this.#keepAlive().catch((err) => this.#onError(err));
287
+ }
288
+
289
+ stop() {
290
+ this.#stopped = true;
291
+ this.#gateway.stop();
292
+ }
293
+
294
+ async #open() {
295
+ try {
296
+ await this.#gateway.open();
297
+ } catch (err) {
298
+ // Core refusing a token, most likely. Swallowed here because the failure is also
299
+ // settled on the connection, and the loop below reports it from there: what
300
+ // matters is that it does not take the bot's other communities with it.
301
+ }
302
+ }
303
+
304
+ async #keepAlive() {
305
+ while (!this.#stopped) {
306
+ const { fatal, reason, wasLive } = await this.#gateway.closed();
307
+ if (reason) this.#onError(new Error(`[${this.serverId}] ${reason}`));
308
+ if (fatal || this.#stopped) return;
309
+ // A connection that was up and then dropped is worth picking straight back up.
310
+ // Carrying the backoff over would leave a bot that reconnects cleanly once an hour
311
+ // eventually waiting half a minute every time, for nothing.
312
+ if (wasLive) this.#backoff = 0;
313
+ this.#backoff = nextBackoff(this.#backoff, RECONNECT_MIN_MS, RECONNECT_MAX_MS);
314
+ await this.#sleep(this.#backoff);
315
+ if (this.#stopped) return;
316
+ await this.#open();
317
+ }
318
+ }
319
+ }
package/src/core.js ADDED
@@ -0,0 +1,68 @@
1
+ import { Transport, query } from './http.js';
2
+
3
+ /**
4
+ * Tusile Core: who this bot is, and which communities have added it.
5
+ *
6
+ * A bot has one identity across the whole network and a separate short-lived token for
7
+ * each community it is in. Core is where both come from. The key and secret only ever go
8
+ * here; a community never sees them.
9
+ */
10
+ export class CoreClient {
11
+ #transport;
12
+
13
+ constructor({ url, key, secret, fetch, sleep, maxRetries }) {
14
+ this.url = String(url || '').replace(/\/+$/, '');
15
+ this.#transport = new Transport({
16
+ baseUrl: this.url,
17
+ authorization: () => 'Bot ' + base64(`${key}:${secret}`),
18
+ fetch,
19
+ sleep,
20
+ maxRetries,
21
+ });
22
+ }
23
+
24
+ /** The bot's own account: id, name, picture. */
25
+ me() {
26
+ return this.#transport.request('GET', '/bots/me');
27
+ }
28
+
29
+ /**
30
+ * Every community this bot is in.
31
+ *
32
+ * `waitSeconds` asks Core to hold the request open until something changes, which is for
33
+ * the case that matters: a bot running on no servers at all. Polling that once a minute
34
+ * means somebody adds the bot, watches nothing happen, and concludes it is broken.
35
+ */
36
+ async servers({ waitSeconds } = {}) {
37
+ const out = await this.#transport.request(
38
+ 'GET',
39
+ `/bots/me/servers${query({ wait: waitSeconds })}`,
40
+ );
41
+ return (out?.servers ?? []).map((row) => ({
42
+ serverId: row.server_id,
43
+ apiUrl: row.api_url,
44
+ // Whatever else Core said about it, kept as it came, so a field added later is
45
+ // already available without a release of this package.
46
+ raw: row,
47
+ }));
48
+ }
49
+
50
+ /**
51
+ * A token for one community.
52
+ *
53
+ * Good for an hour and for that community only, so every connect mints a new one rather
54
+ * than trying to work out whether the last one is still alive.
55
+ */
56
+ async token(serverId) {
57
+ const out = await this.#transport.request('POST', '/bots/token', { server_id: serverId });
58
+ if (!out?.token) throw new Error(`Core issued no token for ${serverId}`);
59
+ return out.token;
60
+ }
61
+ }
62
+
63
+ function base64(text) {
64
+ if (typeof Buffer !== 'undefined') return Buffer.from(text, 'utf8').toString('base64');
65
+ // Not Node. Nothing in this package needs it, but failing here would be a strange way
66
+ // to find that out.
67
+ return btoa(text);
68
+ }