@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/http.js CHANGED
@@ -1,136 +1,155 @@
1
- import { ApiError } from './errors.js';
2
-
3
- /**
4
- * Making a request and living with the answer.
5
- *
6
- * The one thing here that is not obvious: some failures are worth trying again and most
7
- * are not. A bot over its budget and a community mid-deploy both come back in a second or
8
- * two; a bad request will be refused just as firmly on the fifth attempt as the first.
9
- * Retrying the first two is what keeps a redeploy from reading as a bot that lost a
10
- * message, and not retrying the rest is what keeps a bug from turning into a flood.
11
- */
12
-
13
- /** Over budget, or something in front of the server having a moment. */
14
- const RETRY_STATUS = new Set([429, 502, 503, 504]);
15
-
16
- const DEFAULT_MAX_RETRIES = 3;
17
-
18
- const wait = (ms) => new Promise((r) => setTimeout(r, ms));
19
-
20
- export class Transport {
21
- #fetch;
22
- #sleep;
23
- #authorization;
24
- #maxRetries;
25
-
26
- constructor({
27
- baseUrl,
28
- authorization,
29
- fetch = globalThis.fetch,
30
- sleep = wait,
31
- maxRetries = DEFAULT_MAX_RETRIES,
32
- }) {
33
- if (typeof fetch !== 'function') {
34
- throw new Error('no fetch available: Node 20 or newer, or pass one in');
35
- }
36
- this.baseUrl = String(baseUrl || '').replace(/\/+$/, '');
37
- this.#authorization = authorization;
38
- this.#fetch = fetch;
39
- this.#sleep = sleep;
40
- this.#maxRetries = maxRetries;
41
- /** What the budget looked like on the last answer, for a bot that wants to pace itself. */
42
- this.rateLimit = null;
43
- }
44
-
45
- async request(method, path, body) {
46
- const url = this.baseUrl + path;
47
- const headers = { Accept: 'application/json', Authorization: this.#authorization() };
48
- if (body !== undefined) headers['Content-Type'] = 'application/json';
49
- const init = { method, headers, body: body === undefined ? undefined : JSON.stringify(body) };
50
-
51
- let lastError = null;
52
- for (let attempt = 0; attempt <= this.#maxRetries; attempt++) {
53
- let res;
54
- try {
55
- res = await this.#fetch(url, init);
56
- } catch (err) {
57
- // A dropped connection, dns, a laptop that slept. Worth another try; the bot has
58
- // nothing else to do and the alternative is a handler that dies for no reason.
59
- lastError = err;
60
- if (attempt === this.#maxRetries) throw err;
61
- await this.#sleep(backoffFor(attempt));
62
- continue;
63
- }
64
-
65
- this.rateLimit = readRateLimit(res);
66
-
67
- if (res.ok) return parse(await textOf(res));
68
-
69
- const raw = await textOf(res);
70
- const parsed = parse(raw);
71
- const retryAfterMs = readRetryAfter(res);
72
-
73
- if (RETRY_STATUS.has(res.status) && attempt < this.#maxRetries) {
74
- // The server said when to come back. Guessing instead is how a bot stays over
75
- // budget for as long as it keeps guessing.
76
- await this.#sleep(retryAfterMs ?? backoffFor(attempt));
77
- continue;
78
- }
79
-
80
- throw new ApiError({
81
- method,
82
- path,
83
- status: res.status,
84
- body: parsed ?? raw,
85
- retryAfterMs,
86
- });
87
- }
88
- throw lastError || new Error(`${method} ${path} failed`);
89
- }
90
- }
91
-
92
- /** Query strings, with anything nobody asked for left out. */
93
- export function query(params = {}) {
94
- const parts = Object.entries(params)
95
- .filter(([, v]) => v !== undefined && v !== null && v !== '')
96
- .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`);
97
- return parts.length ? `?${parts.join('&')}` : '';
98
- }
99
-
100
- function backoffFor(attempt) {
101
- return 500 * 2 ** attempt;
102
- }
103
-
104
- async function textOf(res) {
105
- try {
106
- return await res.text();
107
- } catch {
108
- return '';
109
- }
110
- }
111
-
112
- function parse(text) {
113
- if (!text) return null;
114
- try {
115
- return JSON.parse(text);
116
- } catch {
117
- // A proxy answering with html, most likely. The status is the real information.
118
- return null;
119
- }
120
- }
121
-
122
- function readRetryAfter(res) {
123
- const raw = res.headers?.get?.('Retry-After');
124
- const seconds = Number(raw);
125
- return raw && Number.isFinite(seconds) ? Math.max(0, seconds) * 1000 : null;
126
- }
127
-
128
- function readRateLimit(res) {
129
- const limit = Number(res.headers?.get?.('X-RateLimit-Limit'));
130
- if (!Number.isFinite(limit) || !res.headers?.get?.('X-RateLimit-Limit')) return null;
131
- return {
132
- limit,
133
- remaining: Number(res.headers.get('X-RateLimit-Remaining')),
134
- resetSeconds: Number(res.headers.get('X-RateLimit-Reset')),
135
- };
136
- }
1
+ import { ApiError } from './errors.js';
2
+
3
+ /**
4
+ * Making a request and living with the answer.
5
+ *
6
+ * The one thing here that is not obvious: some failures are worth trying again and most
7
+ * are not. A bot over its budget and a community mid-deploy both come back in a second or
8
+ * two; a bad request will be refused just as firmly on the fifth attempt as the first.
9
+ * Retrying the first two is what keeps a redeploy from reading as a bot that lost a
10
+ * message, and not retrying the rest is what keeps a bug from turning into a flood.
11
+ */
12
+
13
+ /** Over budget, or something in front of the server having a moment. */
14
+ const RETRY_STATUS = new Set([429, 502, 503, 504]);
15
+
16
+ const DEFAULT_MAX_RETRIES = 3;
17
+
18
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
19
+
20
+ export class Transport {
21
+ #fetch;
22
+ #sleep;
23
+ #authorization;
24
+ #maxRetries;
25
+ #onRefused;
26
+
27
+ constructor({
28
+ baseUrl,
29
+ authorization,
30
+ fetch = globalThis.fetch,
31
+ sleep = wait,
32
+ maxRetries = DEFAULT_MAX_RETRIES,
33
+ // Called once when a request is refused as unauthorized. Returns true if it did something
34
+ // about it, in which case the request is sent again. This is how an expired token is
35
+ // noticed: the refusal is the only moment anybody learns the old one is no longer good.
36
+ onRefused,
37
+ }) {
38
+ if (typeof fetch !== 'function') {
39
+ throw new Error('no fetch available: Node 20 or newer, or pass one in');
40
+ }
41
+ this.baseUrl = String(baseUrl || '').replace(/\/+$/, '');
42
+ this.#authorization = authorization;
43
+ this.#fetch = fetch;
44
+ this.#sleep = sleep;
45
+ this.#maxRetries = maxRetries;
46
+ this.#onRefused = onRefused || null;
47
+ /** What the budget looked like on the last answer, for a bot that wants to pace itself. */
48
+ this.rateLimit = null;
49
+ }
50
+
51
+ async request(method, path, body, { signal, refreshed = false } = {}) {
52
+ const url = this.baseUrl + path;
53
+ const headers = { Accept: 'application/json', Authorization: this.#authorization() };
54
+ if (body !== undefined) headers['Content-Type'] = 'application/json';
55
+ const init = { method, headers, body: body === undefined ? undefined : JSON.stringify(body) };
56
+ // Carried through the retries as well: a request meant to be held open needs the same
57
+ // deadline on every attempt, or the second attempt is the one that hangs forever.
58
+ if (signal) init.signal = signal;
59
+
60
+ let lastError = null;
61
+ for (let attempt = 0; attempt <= this.#maxRetries; attempt++) {
62
+ let res;
63
+ try {
64
+ res = await this.#fetch(url, init);
65
+ } catch (err) {
66
+ // A dropped connection, dns, a laptop that slept. Worth another try; the bot has
67
+ // nothing else to do and the alternative is a handler that dies for no reason.
68
+ lastError = err;
69
+ if (attempt === this.#maxRetries) throw err;
70
+ await this.#sleep(backoffFor(attempt));
71
+ continue;
72
+ }
73
+
74
+ this.rateLimit = readRateLimit(res);
75
+
76
+ if (res.ok) return parse(await textOf(res));
77
+
78
+ const raw = await textOf(res);
79
+ const parsed = parse(raw);
80
+ const retryAfterMs = readRetryAfter(res);
81
+
82
+ // A token a community issues lasts an hour and the socket it was minted for stays up for
83
+ // days, so this is the ordinary state of any bot that has been running a while: it hears
84
+ // every command and can act on none of them, with nothing visibly wrong. Once per call,
85
+ // so a community that refuses whatever it is given is an error rather than a loop.
86
+ if (res.status === 401 && !refreshed && this.#onRefused) {
87
+ if (await this.#onRefused()) {
88
+ return this.request(method, path, body, { signal, refreshed: true });
89
+ }
90
+ }
91
+
92
+ if (RETRY_STATUS.has(res.status) && attempt < this.#maxRetries) {
93
+ // The server said when to come back. Guessing instead is how a bot stays over
94
+ // budget for as long as it keeps guessing.
95
+ await this.#sleep(retryAfterMs ?? backoffFor(attempt));
96
+ continue;
97
+ }
98
+
99
+ throw new ApiError({
100
+ method,
101
+ path,
102
+ status: res.status,
103
+ body: parsed ?? raw,
104
+ retryAfterMs,
105
+ });
106
+ }
107
+ throw lastError || new Error(`${method} ${path} failed`);
108
+ }
109
+ }
110
+
111
+ /** Query strings, with anything nobody asked for left out. */
112
+ export function query(params = {}) {
113
+ const parts = Object.entries(params)
114
+ .filter(([, v]) => v !== undefined && v !== null && v !== '')
115
+ .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`);
116
+ return parts.length ? `?${parts.join('&')}` : '';
117
+ }
118
+
119
+ function backoffFor(attempt) {
120
+ return 500 * 2 ** attempt;
121
+ }
122
+
123
+ async function textOf(res) {
124
+ try {
125
+ return await res.text();
126
+ } catch {
127
+ return '';
128
+ }
129
+ }
130
+
131
+ function parse(text) {
132
+ if (!text) return null;
133
+ try {
134
+ return JSON.parse(text);
135
+ } catch {
136
+ // A proxy answering with html, most likely. The status is the real information.
137
+ return null;
138
+ }
139
+ }
140
+
141
+ function readRetryAfter(res) {
142
+ const raw = res.headers?.get?.('Retry-After');
143
+ const seconds = Number(raw);
144
+ return raw && Number.isFinite(seconds) ? Math.max(0, seconds) * 1000 : null;
145
+ }
146
+
147
+ function readRateLimit(res) {
148
+ const limit = Number(res.headers?.get?.('X-RateLimit-Limit'));
149
+ if (!Number.isFinite(limit) || !res.headers?.get?.('X-RateLimit-Limit')) return null;
150
+ return {
151
+ limit,
152
+ remaining: Number(res.headers.get('X-RateLimit-Remaining')),
153
+ resetSeconds: Number(res.headers.get('X-RateLimit-Reset')),
154
+ };
155
+ }
@@ -1,140 +1,156 @@
1
- /**
2
- * One thing somebody asked a bot to do, and the ways of answering it.
3
- *
4
- * The protocol underneath is a response type and a JSON body. Written out by hand that is
5
- * four lines of ceremony around every answer, repeated at every branch of a switch, which
6
- * is how a bot ends up with a `defer` it forgot to follow up and an `ephemeral` it meant
7
- * to be public. These are the same four lines, named after what they do.
8
- */
9
-
10
- /** How long the server gives a bot to say something before the person is told it did not. */
11
- export const ANSWER_WINDOW_MS = 10_000;
12
-
13
- export class Interaction {
14
- constructor(data, server) {
15
- this.raw = data;
16
- this.server = server;
17
-
18
- this.id = data.id;
19
- this.type = data.type;
20
- this.command = data.command || '';
21
- this.options = data.options || {};
22
- this.customId = data.custom_id || '';
23
- this.values = data.values || [];
24
- this.fields = data.fields || {};
25
- this.channelId = data.channel_id;
26
- this.userId = data.user_id;
27
- /** The message a control came from, when there is one. */
28
- this.messageId = data.message_id || '';
29
- /** The earlier answer a control came from, for a control on a private answer. */
30
- this.sourceInteractionId = data.source_interaction_id || '';
31
-
32
- this.answered = false;
33
- }
34
-
35
- get isCommand() {
36
- return this.type === 'command';
37
- }
38
-
39
- get isComponent() {
40
- return this.type === 'component';
41
- }
42
-
43
- get isModalSubmit() {
44
- return this.type === 'modal_submit';
45
- }
46
-
47
- /**
48
- * Answer it.
49
- *
50
- * A string is the common case and means a public message. An object is passed through,
51
- * so anything the API accepts is still reachable.
52
- */
53
- reply(response, { ephemeral = false } = {}) {
54
- const body =
55
- typeof response === 'string'
56
- ? { type: ephemeral ? 'ephemeral' : 'message', content: response }
57
- : { type: ephemeral ? 'ephemeral' : 'message', ...response };
58
- return this.#answer(body);
59
- }
60
-
61
- /** Answer only the person who asked. Not channel history, and gone within a day. */
62
- replyPrivately(response) {
63
- return this.reply(response, { ephemeral: true });
64
- }
65
-
66
- /**
67
- * Say "give me a moment".
68
- *
69
- * Buys fifteen minutes. Without it a bot that takes longer than ten seconds is reported
70
- * to the person as not having responded, which is a lie about a bot that is working.
71
- */
72
- defer() {
73
- return this.#answer({ type: 'defer' });
74
- }
75
-
76
- /**
77
- * Rewrite the message the control is on.
78
- *
79
- * The usual answer to a press: a confirm that removes its own buttons, a page that
80
- * turns, a toggle that redraws. `components: []` is how the buttons come off.
81
- */
82
- update(response) {
83
- const body = typeof response === 'string' ? { content: response } : response;
84
- return this.#answer({ type: 'update_message', ...body });
85
- }
86
-
87
- /** Acknowledge a press that needs no visible change, because it did its work elsewhere. */
88
- acknowledge() {
89
- return this.#answer({ type: 'defer_update' });
90
- }
91
-
92
- /** Ask the person to fill something in. The submission arrives as a modal_submit. */
93
- showModal(modal) {
94
- return this.#answer({ type: 'modal', modal });
95
- }
96
-
97
- /** Another message after the first. Only after defer or a first answer. */
98
- followUp(response, { ephemeral = false } = {}) {
99
- const body =
100
- typeof response === 'string'
101
- ? { type: ephemeral ? 'ephemeral' : 'message', content: response }
102
- : { type: ephemeral ? 'ephemeral' : 'message', ...response };
103
- return this.server.request('POST', `/bot/interactions/${this.id}/followup`, body);
104
- }
105
-
106
- /**
107
- * Change the answer already given.
108
- *
109
- * What a slow bot wants: defer, say "working on it", then say what happened, in the
110
- * same message rather than a second one underneath the first.
111
- */
112
- editReply(response) {
113
- const body = typeof response === 'string' ? { content: response } : response;
114
- return this.server.request('PATCH', `/bot/interactions/${this.id}/response`, body);
115
- }
116
-
117
- /** Take the answer back. */
118
- deleteReply() {
119
- return this.server.request('DELETE', `/bot/interactions/${this.id}/response`);
120
- }
121
-
122
- async #answer(body) {
123
- // Guarded here as well as on the server, because the server's refusal arrives as a
124
- // 409 in a log somewhere and this arrives in the bot author's own stack trace.
125
- if (this.answered) {
126
- throw new Error(
127
- `interaction ${this.id} has already been answered; use followUp or editReply`,
128
- );
129
- }
130
- this.answered = true;
131
- try {
132
- return await this.server.request('POST', `/bot/interactions/${this.id}/callback`, body);
133
- } catch (err) {
134
- // Not answered after all, so a retry is allowed. Otherwise one dropped connection
135
- // makes the interaction unanswerable for the rest of its life.
136
- this.answered = false;
137
- throw err;
138
- }
139
- }
140
- }
1
+ /**
2
+ * One thing somebody asked a bot to do, and the ways of answering it.
3
+ *
4
+ * The protocol underneath is a response type and a JSON body. Written out by hand that is
5
+ * four lines of ceremony around every answer, repeated at every branch of a switch, which
6
+ * is how a bot ends up with a `defer` it forgot to follow up and an `ephemeral` it meant
7
+ * to be public. These are the same four lines, named after what they do.
8
+ */
9
+
10
+ /** How long the server gives a bot to say something before the person is told it did not. */
11
+ export const ANSWER_WINDOW_MS = 10_000;
12
+
13
+ export class Interaction {
14
+ constructor(data, server) {
15
+ this.raw = data;
16
+ this.server = server;
17
+
18
+ this.id = data.id;
19
+ this.type = data.type;
20
+ this.command = data.command || '';
21
+ this.options = data.options || {};
22
+ this.customId = data.custom_id || '';
23
+ this.values = data.values || [];
24
+ this.fields = data.fields || {};
25
+ this.channelId = data.channel_id;
26
+ this.userId = data.user_id;
27
+ /**
28
+ * The voice channel the person who did this is sitting in, or '' for none.
29
+ *
30
+ * Carried on the interaction rather than fetched, so a bot asked to do something in
31
+ * voice can use the room they are already in instead of making them name one. It is
32
+ * also the only channel id a bot can act on without wondering whether the caller was
33
+ * allowed to name it: they are demonstrably in it.
34
+ *
35
+ * Communities older than this send nothing, so treat '' as "they did not say", not as
36
+ * "they are nowhere".
37
+ */
38
+ this.userVoiceChannelId = data.user_voice_channel_id || '';
39
+ /** The message a control came from, when there is one. */
40
+ this.messageId = data.message_id || '';
41
+ /** The earlier answer a control came from, for a control on a private answer. */
42
+ this.sourceInteractionId = data.source_interaction_id || '';
43
+
44
+ this.answered = false;
45
+ }
46
+
47
+ get isCommand() {
48
+ return this.type === 'command';
49
+ }
50
+
51
+ get isComponent() {
52
+ return this.type === 'component';
53
+ }
54
+
55
+ get isModalSubmit() {
56
+ return this.type === 'modal_submit';
57
+ }
58
+
59
+ /**
60
+ * Answer it.
61
+ *
62
+ * A string is the common case and means a public message. An object is passed through,
63
+ * so anything the API accepts is still reachable.
64
+ */
65
+ reply(response, { ephemeral = false } = {}) {
66
+ const body =
67
+ typeof response === 'string'
68
+ ? { type: ephemeral ? 'ephemeral' : 'message', content: response }
69
+ : { type: ephemeral ? 'ephemeral' : 'message', ...response };
70
+ return this.#answer(body);
71
+ }
72
+
73
+ /** Answer only the person who asked. Not channel history, and gone within a day. */
74
+ replyPrivately(response) {
75
+ return this.reply(response, { ephemeral: true });
76
+ }
77
+
78
+ /**
79
+ * Say "give me a moment".
80
+ *
81
+ * Buys fifteen minutes. Without it a bot that takes longer than ten seconds is reported
82
+ * to the person as not having responded, which is a lie about a bot that is working.
83
+ *
84
+ * `ephemeral` says who the answer will be for when it arrives, because this is the only
85
+ * call that can: a defer delivers nothing, and the edit that carries the answer later
86
+ * has no type of its own. Left out, the answer goes to the channel.
87
+ */
88
+ defer({ ephemeral = false } = {}) {
89
+ return this.#answer(ephemeral ? { type: 'defer', ephemeral: true } : { type: 'defer' });
90
+ }
91
+
92
+ /**
93
+ * Rewrite the message the control is on.
94
+ *
95
+ * The usual answer to a press: a confirm that removes its own buttons, a page that
96
+ * turns, a toggle that redraws. `components: []` is how the buttons come off.
97
+ */
98
+ update(response) {
99
+ const body = typeof response === 'string' ? { content: response } : response;
100
+ return this.#answer({ type: 'update_message', ...body });
101
+ }
102
+
103
+ /** Acknowledge a press that needs no visible change, because it did its work elsewhere. */
104
+ acknowledge() {
105
+ return this.#answer({ type: 'defer_update' });
106
+ }
107
+
108
+ /** Ask the person to fill something in. The submission arrives as a modal_submit. */
109
+ showModal(modal) {
110
+ return this.#answer({ type: 'modal', modal });
111
+ }
112
+
113
+ /** Another message after the first. Only after defer or a first answer. */
114
+ followUp(response, { ephemeral = false } = {}) {
115
+ const body =
116
+ typeof response === 'string'
117
+ ? { type: ephemeral ? 'ephemeral' : 'message', content: response }
118
+ : { type: ephemeral ? 'ephemeral' : 'message', ...response };
119
+ return this.server.request('POST', `/bot/interactions/${this.id}/followup`, body);
120
+ }
121
+
122
+ /**
123
+ * Change the answer already given.
124
+ *
125
+ * What a slow bot wants: defer, say "working on it", then say what happened, in the
126
+ * same message rather than a second one underneath the first.
127
+ */
128
+ editReply(response) {
129
+ const body = typeof response === 'string' ? { content: response } : response;
130
+ return this.server.request('PATCH', `/bot/interactions/${this.id}/response`, body);
131
+ }
132
+
133
+ /** Take the answer back. */
134
+ deleteReply() {
135
+ return this.server.request('DELETE', `/bot/interactions/${this.id}/response`);
136
+ }
137
+
138
+ async #answer(body) {
139
+ // Guarded here as well as on the server, because the server's refusal arrives as a
140
+ // 409 in a log somewhere and this arrives in the bot author's own stack trace.
141
+ if (this.answered) {
142
+ throw new Error(
143
+ `interaction ${this.id} has already been answered; use followUp or editReply`,
144
+ );
145
+ }
146
+ this.answered = true;
147
+ try {
148
+ return await this.server.request('POST', `/bot/interactions/${this.id}/callback`, body);
149
+ } catch (err) {
150
+ // Not answered after all, so a retry is allowed. Otherwise one dropped connection
151
+ // makes the interaction unanswerable for the rest of its life.
152
+ this.answered = false;
153
+ throw err;
154
+ }
155
+ }
156
+ }
package/src/known.js ADDED
@@ -0,0 +1,23 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ /**
4
+ * What this bot already knows, as one short string.
5
+ *
6
+ * Sent to Core as ?known= alongside ?wait=, so Core can hold the request open until the
7
+ * answer would actually be different. Before this, Core would only hold a request for a bot
8
+ * that was on no communities at all, which meant every bot already serving one heard about
9
+ * the next one on its own sixty-second timer: half a minute of nothing, on average, after
10
+ * somebody pressed Add.
11
+ *
12
+ * Mirrors core-server/internal/bots/known.go exactly, and the same three vectors are pinned
13
+ * in both test suites. A count would have been easier and wrong: removed from one community
14
+ * and added to another between two polls is the same count, so Core would hold the request
15
+ * while the bot served the wrong place.
16
+ */
17
+ const VERSION = 'v1';
18
+
19
+ export function serversToken(serverIds) {
20
+ const unique = [...new Set(serverIds ?? [])].map(String).sort();
21
+ const digest = createHash('sha256').update(unique.join('\n')).digest('hex');
22
+ return `${VERSION}:${digest.slice(0, 16)}`;
23
+ }