@tusile/bot 0.2.1 → 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/src/dispatch.js CHANGED
@@ -1,178 +1,195 @@
1
- /**
2
- * Which handler wants this interaction, and what to do when none of them does.
3
- *
4
- * The routing is the easy half. The half worth writing carefully is the part that runs
5
- * when a handler throws, forgets to answer, or takes longer than the server's ten second
6
- * window: the person who ran the command is sitting in front of a spinner, and every one
7
- * of those cases ends with them being told "the bot did not respond", which reads as a
8
- * dead bot rather than a bug in one command. So every path through here answers.
9
- */
10
-
11
- /**
12
- * When to defer on the handler's behalf.
13
- *
14
- * The server's window is ten seconds. Two seconds of margin covers the round trip from
15
- * wherever the bot is running, and a bot author who never thinks about any of this gets
16
- * the same behaviour as one who deferred by hand.
17
- */
18
- const DEFAULT_AUTO_DEFER_MS = 8000;
19
-
20
- export class Dispatcher {
21
- #commands = new Map();
22
- #components = [];
23
- #modals = [];
24
- #onError;
25
- #autoDeferMs;
26
-
27
- constructor({ onError = () => {}, autoDeferMs = DEFAULT_AUTO_DEFER_MS } = {}) {
28
- this.#onError = onError;
29
- this.#autoDeferMs = autoDeferMs;
30
- }
31
-
32
- /**
33
- * A slash command and what runs it.
34
- *
35
- * The definition is what people see in the app: name, description, and options that are
36
- * checked and turned into pickers before the bot is ever asked anything.
37
- */
38
- command(definition, handler) {
39
- const name = definition?.name;
40
- if (!name) throw new Error('a command needs a name');
41
- if (typeof handler !== 'function') throw new Error(`/${name} needs a handler`);
42
- if (this.#commands.has(name)) throw new Error(`/${name} is already registered`);
43
- this.#commands.set(name, { definition: { ...definition }, handler });
44
- return this;
45
- }
46
-
47
- /**
48
- * A button or select, matched on its custom id exactly or by prefix.
49
- *
50
- * A custom id is the bot's own: the server echoes it back without ever parsing it, so
51
- * `vote:close:99` is how a control carries its state. Prefix matching is what makes
52
- * that usable, and an exact registration still wins over a prefix that would match it.
53
- */
54
- component(customId, handler) {
55
- if (!customId) throw new Error('a component handler needs a custom id');
56
- if (typeof handler !== 'function') throw new Error(`the ${customId} handler is not a function`);
57
- this.#components.push({ customId, handler });
58
- return this;
59
- }
60
-
61
- /** A form submission, matched the same way. */
62
- modal(customId, handler) {
63
- if (!customId) throw new Error('a modal handler needs a custom id');
64
- if (typeof handler !== 'function') throw new Error(`the ${customId} handler is not a function`);
65
- this.#modals.push({ customId, handler });
66
- return this;
67
- }
68
-
69
- /** What to send the server as this bot's command list. */
70
- definitions() {
71
- return [...this.#commands.values()].map((c) => c.definition);
72
- }
73
-
74
- async handle(interaction) {
75
- const found = this.#route(interaction);
76
- if (!found) return;
77
- if (!found.handler) {
78
- // Something is offered that this bot no longer implements: a command list left over
79
- // from an older deploy, or a button on a message from one. Say so quietly rather
80
- // than leave somebody watching a spinner run out.
81
- await this.#sayQuietly(interaction, found.missing);
82
- return;
83
- }
84
-
85
- const deferring = this.#deferAfterAWhile(interaction);
86
- try {
87
- await found.handler(interaction);
88
- if (!interaction.answered) {
89
- // The commonest bot bug there is, and from the outside identical to a crash.
90
- throw new Error(`${describe(interaction)} was handled without an answer`);
91
- }
92
- } catch (err) {
93
- this.#onError(err, interaction);
94
- await this.#apologise(interaction);
95
- } finally {
96
- deferring.cancel();
97
- }
98
- }
99
-
100
- #route(interaction) {
101
- if (interaction.isCommand) {
102
- const found = this.#commands.get(interaction.command);
103
- return found || { missing: `I do not know /${interaction.command} any more.` };
104
- }
105
- if (interaction.isComponent) {
106
- const found = longestMatch(this.#components, interaction.customId);
107
- return found || { missing: 'That control belongs to an older version of this bot.' };
108
- }
109
- if (interaction.isModalSubmit) {
110
- const found = longestMatch(this.#modals, interaction.customId);
111
- return found || { missing: 'That form belongs to an older version of this bot.' };
112
- }
113
- // A kind of interaction newer than this package. Answering it could say the wrong
114
- // thing, and it is not a bug in the bot, so it goes no further.
115
- return null;
116
- }
117
-
118
- /**
119
- * Defer if the handler is still working when the window is nearly up.
120
- *
121
- * Cancelled the moment the handler returns, and it checks again before sending: an
122
- * unasked-for defer arriving after a normal answer would be refused every time.
123
- *
124
- * Not for a press. A press is answered by rewriting the message it sits on, and that is
125
- * only available before the interaction has been answered at all; after a bare defer the
126
- * server has no message to edit and refuses. Deferring on the handler's behalf would
127
- * trade a late answer for one that cannot be given, and leave the buttons live.
128
- */
129
- #deferAfterAWhile(interaction) {
130
- if (!(this.#autoDeferMs > 0) || interaction.isComponent) return { cancel() {} };
131
- const timer = setTimeout(() => {
132
- if (interaction.answered) return;
133
- interaction.defer().catch((err) => this.#onError(err, interaction));
134
- }, this.#autoDeferMs);
135
- return { cancel: () => clearTimeout(timer) };
136
- }
137
-
138
- async #apologise(interaction) {
139
- if (interaction.answered) return;
140
- try {
141
- await interaction.replyPrivately('Something went wrong. The bot author has been told.');
142
- } catch (err) {
143
- // Nothing left to try. The person will see the server's own timeout notice, which
144
- // at least is true.
145
- this.#onError(err, interaction);
146
- }
147
- }
148
-
149
- async #sayQuietly(interaction, text) {
150
- try {
151
- await interaction.replyPrivately(text);
152
- } catch (err) {
153
- this.#onError(err, interaction);
154
- }
155
- }
156
- }
157
-
158
- /**
159
- * The most specific registration that matches.
160
- *
161
- * An exact id is the longest possible match, so this is one rule rather than two: a
162
- * general `vote` handler cannot swallow presses meant for `vote:close`.
163
- */
164
- function longestMatch(entries, customId) {
165
- const id = customId || '';
166
- let best = null;
167
- for (const entry of entries) {
168
- if (id !== entry.customId && !id.startsWith(entry.customId)) continue;
169
- if (!best || entry.customId.length > best.customId.length) best = entry;
170
- }
171
- return best;
172
- }
173
-
174
- function describe(interaction) {
175
- if (interaction.isCommand) return `/${interaction.command}`;
176
- if (interaction.customId) return `\`${interaction.customId}\``;
177
- return interaction.type || 'an interaction';
178
- }
1
+ /**
2
+ * Which handler wants this interaction, and what to do when none of them does.
3
+ *
4
+ * The routing is the easy half. The half worth writing carefully is the part that runs
5
+ * when a handler throws, forgets to answer, or takes longer than the server's ten second
6
+ * window: the person who ran the command is sitting in front of a spinner, and every one
7
+ * of those cases ends with them being told "the bot did not respond", which reads as a
8
+ * dead bot rather than a bug in one command. So every path through here answers.
9
+ */
10
+
11
+ /**
12
+ * When to defer on the handler's behalf.
13
+ *
14
+ * The server's window is ten seconds. Two seconds of margin covers the round trip from
15
+ * wherever the bot is running, and a bot author who never thinks about any of this gets
16
+ * the same behaviour as one who deferred by hand.
17
+ */
18
+ import { toMask } from './permissions.js';
19
+
20
+ const DEFAULT_AUTO_DEFER_MS = 8000;
21
+
22
+ export class Dispatcher {
23
+ #commands = new Map();
24
+ #components = [];
25
+ #modals = [];
26
+ #onError;
27
+ #autoDeferMs;
28
+
29
+ constructor({ onError = () => {}, autoDeferMs = DEFAULT_AUTO_DEFER_MS } = {}) {
30
+ this.#onError = onError;
31
+ this.#autoDeferMs = autoDeferMs;
32
+ }
33
+
34
+ /**
35
+ * A slash command and what runs it.
36
+ *
37
+ * The definition is what people see in the app: name, description, and options that are
38
+ * checked and turned into pickers before the bot is ever asked anything.
39
+ *
40
+ * `requiredPermissions` is what the PERSON running it must hold, by the names in
41
+ * `Permissions` or as a mask: `requiredPermissions: ['manage_channels', 'manage_roles']`.
42
+ * The community refuses the invocation without them and leaves the command out of the
43
+ * picker for anybody who cannot run it. It is not the bot own permissions, which say what
44
+ * the bot may do and nothing about who may ask: a /setup that rebuilds a server is open to
45
+ * every member who can talk, unless it says otherwise here.
46
+ */
47
+ command(definition, handler) {
48
+ const name = definition?.name;
49
+ if (!name) throw new Error('a command needs a name');
50
+ if (typeof handler !== 'function') throw new Error(`/${name} needs a handler`);
51
+ if (this.#commands.has(name)) throw new Error(`/${name} is already registered`);
52
+ const { requiredPermissions, ...rest } = definition;
53
+ const wire = { ...rest };
54
+ if (requiredPermissions !== undefined && requiredPermissions !== null) {
55
+ // toMask throws on a name it does not know, at registration rather than at the
56
+ // moment somebody runs the command: a typo here is a command anybody can run on a
57
+ // bot whose author believed they had locked it.
58
+ wire.required_permissions = toMask(requiredPermissions);
59
+ }
60
+ this.#commands.set(name, { definition: wire, handler });
61
+ return this;
62
+ }
63
+
64
+ /**
65
+ * A button or select, matched on its custom id exactly or by prefix.
66
+ *
67
+ * A custom id is the bot's own: the server echoes it back without ever parsing it, so
68
+ * `vote:close:99` is how a control carries its state. Prefix matching is what makes
69
+ * that usable, and an exact registration still wins over a prefix that would match it.
70
+ */
71
+ component(customId, handler) {
72
+ if (!customId) throw new Error('a component handler needs a custom id');
73
+ if (typeof handler !== 'function') throw new Error(`the ${customId} handler is not a function`);
74
+ this.#components.push({ customId, handler });
75
+ return this;
76
+ }
77
+
78
+ /** A form submission, matched the same way. */
79
+ modal(customId, handler) {
80
+ if (!customId) throw new Error('a modal handler needs a custom id');
81
+ if (typeof handler !== 'function') throw new Error(`the ${customId} handler is not a function`);
82
+ this.#modals.push({ customId, handler });
83
+ return this;
84
+ }
85
+
86
+ /** What to send the server as this bot's command list. */
87
+ definitions() {
88
+ return [...this.#commands.values()].map((c) => c.definition);
89
+ }
90
+
91
+ async handle(interaction) {
92
+ const found = this.#route(interaction);
93
+ if (!found) return;
94
+ if (!found.handler) {
95
+ // Something is offered that this bot no longer implements: a command list left over
96
+ // from an older deploy, or a button on a message from one. Say so quietly rather
97
+ // than leave somebody watching a spinner run out.
98
+ await this.#sayQuietly(interaction, found.missing);
99
+ return;
100
+ }
101
+
102
+ const deferring = this.#deferAfterAWhile(interaction);
103
+ try {
104
+ await found.handler(interaction);
105
+ if (!interaction.answered) {
106
+ // The commonest bot bug there is, and from the outside identical to a crash.
107
+ throw new Error(`${describe(interaction)} was handled without an answer`);
108
+ }
109
+ } catch (err) {
110
+ this.#onError(err, interaction);
111
+ await this.#apologise(interaction);
112
+ } finally {
113
+ deferring.cancel();
114
+ }
115
+ }
116
+
117
+ #route(interaction) {
118
+ if (interaction.isCommand) {
119
+ const found = this.#commands.get(interaction.command);
120
+ return found || { missing: `I do not know /${interaction.command} any more.` };
121
+ }
122
+ if (interaction.isComponent) {
123
+ const found = longestMatch(this.#components, interaction.customId);
124
+ return found || { missing: 'That control belongs to an older version of this bot.' };
125
+ }
126
+ if (interaction.isModalSubmit) {
127
+ const found = longestMatch(this.#modals, interaction.customId);
128
+ return found || { missing: 'That form belongs to an older version of this bot.' };
129
+ }
130
+ // A kind of interaction newer than this package. Answering it could say the wrong
131
+ // thing, and it is not a bug in the bot, so it goes no further.
132
+ return null;
133
+ }
134
+
135
+ /**
136
+ * Defer if the handler is still working when the window is nearly up.
137
+ *
138
+ * Cancelled the moment the handler returns, and it checks again before sending: an
139
+ * unasked-for defer arriving after a normal answer would be refused every time.
140
+ *
141
+ * Not for a press. A press is answered by rewriting the message it sits on, and that is
142
+ * only available before the interaction has been answered at all; after a bare defer the
143
+ * server has no message to edit and refuses. Deferring on the handler's behalf would
144
+ * trade a late answer for one that cannot be given, and leave the buttons live.
145
+ */
146
+ #deferAfterAWhile(interaction) {
147
+ if (!(this.#autoDeferMs > 0) || interaction.isComponent) return { cancel() {} };
148
+ const timer = setTimeout(() => {
149
+ if (interaction.answered) return;
150
+ interaction.defer().catch((err) => this.#onError(err, interaction));
151
+ }, this.#autoDeferMs);
152
+ return { cancel: () => clearTimeout(timer) };
153
+ }
154
+
155
+ async #apologise(interaction) {
156
+ if (interaction.answered) return;
157
+ try {
158
+ await interaction.replyPrivately('Something went wrong. The bot author has been told.');
159
+ } catch (err) {
160
+ // Nothing left to try. The person will see the server's own timeout notice, which
161
+ // at least is true.
162
+ this.#onError(err, interaction);
163
+ }
164
+ }
165
+
166
+ async #sayQuietly(interaction, text) {
167
+ try {
168
+ await interaction.replyPrivately(text);
169
+ } catch (err) {
170
+ this.#onError(err, interaction);
171
+ }
172
+ }
173
+ }
174
+
175
+ /**
176
+ * The most specific registration that matches.
177
+ *
178
+ * An exact id is the longest possible match, so this is one rule rather than two: a
179
+ * general `vote` handler cannot swallow presses meant for `vote:close`.
180
+ */
181
+ function longestMatch(entries, customId) {
182
+ const id = customId || '';
183
+ let best = null;
184
+ for (const entry of entries) {
185
+ if (id !== entry.customId && !id.startsWith(entry.customId)) continue;
186
+ if (!best || entry.customId.length > best.customId.length) best = entry;
187
+ }
188
+ return best;
189
+ }
190
+
191
+ function describe(interaction) {
192
+ if (interaction.isCommand) return `/${interaction.command}`;
193
+ if (interaction.customId) return `\`${interaction.customId}\``;
194
+ return interaction.type || 'an interaction';
195
+ }
package/src/errors.js CHANGED
@@ -1,61 +1,61 @@
1
- /** What a refusal from Tusile looks like by the time a bot author sees it. */
2
- export class ApiError extends Error {
3
- constructor({ method, path, status, body, retryAfterMs = null }) {
4
- const code = codeIn(body);
5
- // Everything needed to work out what happened, in the one line that reaches a log:
6
- // which call, what the server said, and the server's own word for why.
7
- super(`${method} ${path} failed: ${status}${code ? ` ${code}` : ''}${detail(body, code)}`);
8
- this.name = 'TusileApiError';
9
- this.method = method;
10
- this.path = path;
11
- this.status = status;
12
- this.code = code;
13
- this.body = body;
14
- this.retryAfterMs = retryAfterMs;
15
- }
16
-
17
- /**
18
- * The community removed this bot.
19
- *
20
- * Worth its own question, because it is the one failure that will never come right: a
21
- * bot that keeps reconnecting to a community that dropped it is doing nothing but
22
- * making noise in two sets of logs.
23
- */
24
- get isUninstalled() {
25
- return this.code === 'bot_uninstalled';
26
- }
27
-
28
- /**
29
- * The credentials are wrong, or gone.
30
- *
31
- * Not something that comes right on its own: a bot deleted while it was running, or a
32
- * secret somebody rotated, answers this to every request for the rest of the process's
33
- * life. Retrying it on a timer is how one dead bot fills a log and then trips the rate
34
- * limiter for the live ones.
35
- */
36
- get isAuthFailure() {
37
- return this.status === 401 || this.status === 403;
38
- }
39
-
40
- get isRateLimited() {
41
- return this.status === 429;
42
- }
43
-
44
- /** Permission, usually. Nothing to retry, something to fix in the bot's roles. */
45
- get isForbidden() {
46
- return this.status === 403;
47
- }
48
- }
49
-
50
- function codeIn(body) {
51
- if (!body || typeof body !== 'object') return '';
52
- return String(body.error || body.code || '');
53
- }
54
-
55
- function detail(body, code) {
56
- if (typeof body === 'string' && body.trim() && body.length < 200) return `: ${body.trim()}`;
57
- if (body && typeof body === 'object' && body.message && body.message !== code) {
58
- return `: ${body.message}`;
59
- }
60
- return '';
61
- }
1
+ /** What a refusal from Tusile looks like by the time a bot author sees it. */
2
+ export class ApiError extends Error {
3
+ constructor({ method, path, status, body, retryAfterMs = null }) {
4
+ const code = codeIn(body);
5
+ // Everything needed to work out what happened, in the one line that reaches a log:
6
+ // which call, what the server said, and the server's own word for why.
7
+ super(`${method} ${path} failed: ${status}${code ? ` ${code}` : ''}${detail(body, code)}`);
8
+ this.name = 'TusileApiError';
9
+ this.method = method;
10
+ this.path = path;
11
+ this.status = status;
12
+ this.code = code;
13
+ this.body = body;
14
+ this.retryAfterMs = retryAfterMs;
15
+ }
16
+
17
+ /**
18
+ * The community removed this bot.
19
+ *
20
+ * Worth its own question, because it is the one failure that will never come right: a
21
+ * bot that keeps reconnecting to a community that dropped it is doing nothing but
22
+ * making noise in two sets of logs.
23
+ */
24
+ get isUninstalled() {
25
+ return this.code === 'bot_uninstalled';
26
+ }
27
+
28
+ /**
29
+ * The credentials are wrong, or gone.
30
+ *
31
+ * Not something that comes right on its own: a bot deleted while it was running, or a
32
+ * secret somebody rotated, answers this to every request for the rest of the process's
33
+ * life. Retrying it on a timer is how one dead bot fills a log and then trips the rate
34
+ * limiter for the live ones.
35
+ */
36
+ get isAuthFailure() {
37
+ return this.status === 401 || this.status === 403;
38
+ }
39
+
40
+ get isRateLimited() {
41
+ return this.status === 429;
42
+ }
43
+
44
+ /** Permission, usually. Nothing to retry, something to fix in the bot's roles. */
45
+ get isForbidden() {
46
+ return this.status === 403;
47
+ }
48
+ }
49
+
50
+ function codeIn(body) {
51
+ if (!body || typeof body !== 'object') return '';
52
+ return String(body.error || body.code || '');
53
+ }
54
+
55
+ function detail(body, code) {
56
+ if (typeof body === 'string' && body.trim() && body.length < 200) return `: ${body.trim()}`;
57
+ if (body && typeof body === 'object' && body.message && body.message !== code) {
58
+ return `: ${body.message}`;
59
+ }
60
+ return '';
61
+ }