@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 +21 -0
- package/README.md +234 -0
- package/package.json +33 -0
- package/src/bot.js +319 -0
- package/src/core.js +68 -0
- package/src/dispatch.js +173 -0
- package/src/errors.js +49 -0
- package/src/gateway.js +180 -0
- package/src/http.js +136 -0
- package/src/index.js +26 -0
- package/src/interaction.js +140 -0
- package/src/rest.js +234 -0
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
|
+
}
|