@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/src/dispatch.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
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
|
+
#deferAfterAWhile(interaction) {
|
|
125
|
+
if (!(this.#autoDeferMs > 0)) return { cancel() {} };
|
|
126
|
+
const timer = setTimeout(() => {
|
|
127
|
+
if (interaction.answered) return;
|
|
128
|
+
interaction.defer().catch((err) => this.#onError(err, interaction));
|
|
129
|
+
}, this.#autoDeferMs);
|
|
130
|
+
return { cancel: () => clearTimeout(timer) };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async #apologise(interaction) {
|
|
134
|
+
if (interaction.answered) return;
|
|
135
|
+
try {
|
|
136
|
+
await interaction.replyPrivately('Something went wrong. The bot author has been told.');
|
|
137
|
+
} catch (err) {
|
|
138
|
+
// Nothing left to try. The person will see the server's own timeout notice, which
|
|
139
|
+
// at least is true.
|
|
140
|
+
this.#onError(err, interaction);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async #sayQuietly(interaction, text) {
|
|
145
|
+
try {
|
|
146
|
+
await interaction.replyPrivately(text);
|
|
147
|
+
} catch (err) {
|
|
148
|
+
this.#onError(err, interaction);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The most specific registration that matches.
|
|
155
|
+
*
|
|
156
|
+
* An exact id is the longest possible match, so this is one rule rather than two: a
|
|
157
|
+
* general `vote` handler cannot swallow presses meant for `vote:close`.
|
|
158
|
+
*/
|
|
159
|
+
function longestMatch(entries, customId) {
|
|
160
|
+
const id = customId || '';
|
|
161
|
+
let best = null;
|
|
162
|
+
for (const entry of entries) {
|
|
163
|
+
if (id !== entry.customId && !id.startsWith(entry.customId)) continue;
|
|
164
|
+
if (!best || entry.customId.length > best.customId.length) best = entry;
|
|
165
|
+
}
|
|
166
|
+
return best;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function describe(interaction) {
|
|
170
|
+
if (interaction.isCommand) return `/${interaction.command}`;
|
|
171
|
+
if (interaction.customId) return `\`${interaction.customId}\``;
|
|
172
|
+
return interaction.type || 'an interaction';
|
|
173
|
+
}
|
package/src/errors.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
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
|
+
get isRateLimited() {
|
|
29
|
+
return this.status === 429;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Permission, usually. Nothing to retry, something to fix in the bot's roles. */
|
|
33
|
+
get isForbidden() {
|
|
34
|
+
return this.status === 403;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function codeIn(body) {
|
|
39
|
+
if (!body || typeof body !== 'object') return '';
|
|
40
|
+
return String(body.error || body.code || '');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function detail(body, code) {
|
|
44
|
+
if (typeof body === 'string' && body.trim() && body.length < 200) return `: ${body.trim()}`;
|
|
45
|
+
if (body && typeof body === 'object' && body.message && body.message !== code) {
|
|
46
|
+
return `: ${body.message}`;
|
|
47
|
+
}
|
|
48
|
+
return '';
|
|
49
|
+
}
|
package/src/gateway.js
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The socket a bot keeps open to one community.
|
|
3
|
+
*
|
|
4
|
+
* A Tusile bot dials out. There is no inbound port and no public url to arrange, which is
|
|
5
|
+
* most of why the webhook bots this replaces went unwritten: a hobby bot on a laptop
|
|
6
|
+
* cannot be called back. The cost of dialling out is that the connection is now the bot's
|
|
7
|
+
* problem, and that is what this file is: the auth frame first, a heartbeat at whatever
|
|
8
|
+
* interval the server asks for, and a clear answer to the only question that matters when
|
|
9
|
+
* a socket dies, which is whether to try again.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const DEFAULT_HEARTBEAT_SECONDS = 30;
|
|
13
|
+
|
|
14
|
+
/** Where the gateway lives, given where the community's api lives. */
|
|
15
|
+
export function gatewayUrl(apiUrl) {
|
|
16
|
+
return String(apiUrl || '').replace(/\/+$/, '').replace(/^http/, 'ws') + '/bot/gateway';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* How long to wait before trying a dead connection again.
|
|
21
|
+
*
|
|
22
|
+
* Doubling, and capped: a community that is down for the afternoon should not be asked
|
|
23
|
+
* every millisecond, and a bot should not sit out an hour because of one bad minute.
|
|
24
|
+
*/
|
|
25
|
+
export function nextBackoff(current, min, max) {
|
|
26
|
+
if (!current) return min;
|
|
27
|
+
return Math.min(current * 2, max);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class Gateway {
|
|
31
|
+
#token;
|
|
32
|
+
#connect;
|
|
33
|
+
#timers;
|
|
34
|
+
#onReady;
|
|
35
|
+
#onFrame;
|
|
36
|
+
#heartbeat = null;
|
|
37
|
+
#settle = null;
|
|
38
|
+
#closed = null;
|
|
39
|
+
#generation = 0;
|
|
40
|
+
#wasLive = false;
|
|
41
|
+
|
|
42
|
+
constructor({ url, token, connect, timers = globalThis, onReady = () => {}, onFrame = () => {} }) {
|
|
43
|
+
this.url = url;
|
|
44
|
+
this.socket = null;
|
|
45
|
+
this.stopped = false;
|
|
46
|
+
this.#token = token;
|
|
47
|
+
this.#connect = connect;
|
|
48
|
+
this.#timers = timers;
|
|
49
|
+
this.#onReady = onReady;
|
|
50
|
+
this.#onFrame = onFrame;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Mint a token and open the socket.
|
|
55
|
+
*
|
|
56
|
+
* Resolves once the socket exists and is wired up, not once it is authenticated: a
|
|
57
|
+
* caller that wants to know the connection worked waits on `closed()`, which is the
|
|
58
|
+
* same thing as waiting for it to stop working.
|
|
59
|
+
*/
|
|
60
|
+
async open() {
|
|
61
|
+
this.#generation += 1;
|
|
62
|
+
const generation = this.#generation;
|
|
63
|
+
this.#wasLive = false;
|
|
64
|
+
this.#closed = new Promise((resolve) => {
|
|
65
|
+
this.#settle = (outcome) => {
|
|
66
|
+
if (generation !== this.#generation) return;
|
|
67
|
+
this.#stopHeartbeat();
|
|
68
|
+
// Whether this connection ever worked is the difference between a community
|
|
69
|
+
// that is down and one that drops a socket now and then: the first should be
|
|
70
|
+
// asked less and less often, the second should be picked up at once.
|
|
71
|
+
resolve({ ...outcome, wasLive: this.#wasLive });
|
|
72
|
+
};
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
// A token is minted per community and lives an hour, so every connect gets a fresh
|
|
77
|
+
// one rather than guessing whether the last one is still good.
|
|
78
|
+
const token = await this.#token();
|
|
79
|
+
if (this.stopped) {
|
|
80
|
+
this.#settle({ fatal: true, reason: 'stopped' });
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const socket = this.#connect(gatewayUrl(this.url));
|
|
85
|
+
this.socket = socket;
|
|
86
|
+
|
|
87
|
+
// The auth frame goes first, before anything else is sent or expected.
|
|
88
|
+
socket.on('open', () => this.#send({ type: 'auth', token }));
|
|
89
|
+
socket.on('message', (raw) => this.#onMessage(raw));
|
|
90
|
+
socket.on('close', (code, reason) => {
|
|
91
|
+
const why = reason ? reason.toString() : '';
|
|
92
|
+
// The server says this distinctly rather than just closing, so that a bot which
|
|
93
|
+
// has been removed can stop instead of reconnecting for the rest of its life.
|
|
94
|
+
this.#settle({ fatal: this.stopped || why.includes('bot_uninstalled'), reason: why });
|
|
95
|
+
});
|
|
96
|
+
socket.on('error', (err) => {
|
|
97
|
+
this.#settle({ fatal: this.stopped, reason: String(err?.message || err) });
|
|
98
|
+
});
|
|
99
|
+
} catch (err) {
|
|
100
|
+
// Core refusing a token, or a socket that would not even be constructed. Settled as
|
|
101
|
+
// well as thrown: a caller waiting on `closed` would otherwise wait for good.
|
|
102
|
+
this.#settle({ fatal: err?.isUninstalled === true, reason: String(err?.message || err) });
|
|
103
|
+
throw err;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Resolves when this connection ends, saying whether trying again is worth anything. */
|
|
108
|
+
closed() {
|
|
109
|
+
return (
|
|
110
|
+
this.#closed ?? Promise.resolve({ fatal: this.stopped, reason: 'never opened', wasLive: false })
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Open it and wait for it to end. Never throws: the outcome says what happened. */
|
|
115
|
+
async run() {
|
|
116
|
+
try {
|
|
117
|
+
await this.open();
|
|
118
|
+
} catch (err) {
|
|
119
|
+
// Core refusing to mint a token, usually. Not a reason to give up on the community.
|
|
120
|
+
return { fatal: err?.isUninstalled === true, reason: String(err?.message || err), wasLive: false };
|
|
121
|
+
}
|
|
122
|
+
return this.closed();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
stop() {
|
|
126
|
+
this.stopped = true;
|
|
127
|
+
this.#stopHeartbeat();
|
|
128
|
+
const settle = this.#settle;
|
|
129
|
+
// Settled before the close event arrives, so that a bot on its way out does not
|
|
130
|
+
// reconnect on the strength of its own goodbye.
|
|
131
|
+
if (settle) settle({ fatal: true, reason: 'stopped' });
|
|
132
|
+
try {
|
|
133
|
+
this.socket?.close();
|
|
134
|
+
} catch {
|
|
135
|
+
// Closing an already-dead socket is not news.
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
#onMessage(raw) {
|
|
140
|
+
let frame;
|
|
141
|
+
try {
|
|
142
|
+
frame = JSON.parse(raw.toString());
|
|
143
|
+
} catch {
|
|
144
|
+
// A proxy answering with html, most often. Not worth taking a running bot down for.
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (frame?.type === 'ready') {
|
|
148
|
+
this.#wasLive = true;
|
|
149
|
+
this.#startHeartbeat(frame.heartbeat_interval_seconds);
|
|
150
|
+
this.#onReady(frame.bot || {}, frame);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
this.#onFrame(frame);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Check in at the interval the server names.
|
|
158
|
+
*
|
|
159
|
+
* A socket that goes quiet is dropped, and on a phone network or behind an idle proxy
|
|
160
|
+
* that is otherwise indistinguishable from one that is working perfectly well.
|
|
161
|
+
*/
|
|
162
|
+
#startHeartbeat(seconds) {
|
|
163
|
+
this.#stopHeartbeat();
|
|
164
|
+
const ms = (Number(seconds) > 0 ? Number(seconds) : DEFAULT_HEARTBEAT_SECONDS) * 1000;
|
|
165
|
+
this.#heartbeat = this.#timers.setInterval(() => this.#send({ type: 'heartbeat' }), ms);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
#stopHeartbeat() {
|
|
169
|
+
if (this.#heartbeat) this.#timers.clearInterval(this.#heartbeat);
|
|
170
|
+
this.#heartbeat = null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
#send(frame) {
|
|
174
|
+
try {
|
|
175
|
+
this.socket?.send(JSON.stringify(frame));
|
|
176
|
+
} catch {
|
|
177
|
+
// The socket died between the timer firing and this line. The close handler has it.
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
package/src/http.js
ADDED
|
@@ -0,0 +1,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
|
+
|
|
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
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @tusile/bot
|
|
3
|
+
*
|
|
4
|
+
* Everything a Tusile bot needs, in one place. The short version:
|
|
5
|
+
*
|
|
6
|
+
* import { Bot } from '@tusile/bot';
|
|
7
|
+
*
|
|
8
|
+
* const bot = new Bot(); // reads CORE_SERVER_URL, BOT_KEY, BOT_SECRET
|
|
9
|
+
* bot.command({ name: 'ping', description: 'Check the bot is awake' },
|
|
10
|
+
* (i) => i.reply('pong'));
|
|
11
|
+
* await bot.start();
|
|
12
|
+
*
|
|
13
|
+
* That is a working bot on every community that has added it. No inbound port, no public
|
|
14
|
+
* url, no webhook to register: it dials out.
|
|
15
|
+
*
|
|
16
|
+
* The pieces are exported as well, for a bot that wants one without the rest: a script
|
|
17
|
+
* that only posts a message needs a CoreClient and a ServerClient and no gateway at all.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export { Bot } from './bot.js';
|
|
21
|
+
export { CoreClient } from './core.js';
|
|
22
|
+
export { ServerClient } from './rest.js';
|
|
23
|
+
export { Gateway, gatewayUrl, nextBackoff } from './gateway.js';
|
|
24
|
+
export { Dispatcher } from './dispatch.js';
|
|
25
|
+
export { Interaction, ANSWER_WINDOW_MS } from './interaction.js';
|
|
26
|
+
export { ApiError } from './errors.js';
|