@tusile/bot 0.1.2 → 0.4.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 -21
- package/README.md +204 -4
- package/package.json +1 -1
- package/src/bot.js +615 -319
- package/src/core.js +151 -12
- package/src/dispatch.js +195 -178
- package/src/errors.js +61 -49
- package/src/gateway.js +191 -180
- package/src/http.js +155 -136
- package/src/index.js +27 -26
- package/src/interaction.js +144 -140
- package/src/known.js +23 -0
- package/src/permissions.js +55 -0
- package/src/rest.js +189 -9
package/src/core.js
CHANGED
|
@@ -7,10 +7,15 @@ import { Transport, query } from './http.js';
|
|
|
7
7
|
* each community it is in. Core is where both come from. The key and secret only ever go
|
|
8
8
|
* here; a community never sees them.
|
|
9
9
|
*/
|
|
10
|
+
/** How long a display name is good for. Names change rarely; a stale one costs nothing. */
|
|
11
|
+
const NAME_TTL_MS = 10 * 60 * 1000;
|
|
12
|
+
|
|
10
13
|
export class CoreClient {
|
|
11
14
|
#transport;
|
|
15
|
+
#names = new Map();
|
|
16
|
+
#nameTtlMs;
|
|
12
17
|
|
|
13
|
-
constructor({ url, key, secret, fetch, sleep, maxRetries }) {
|
|
18
|
+
constructor({ url, key, secret, fetch, sleep, maxRetries, nameTtlMs = NAME_TTL_MS }) {
|
|
14
19
|
this.url = String(url || '').replace(/\/+$/, '');
|
|
15
20
|
this.#transport = new Transport({
|
|
16
21
|
baseUrl: this.url,
|
|
@@ -19,6 +24,20 @@ export class CoreClient {
|
|
|
19
24
|
sleep,
|
|
20
25
|
maxRetries,
|
|
21
26
|
});
|
|
27
|
+
this.#nameTtlMs = nameTtlMs;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Anything at all, with this bot's own credentials.
|
|
32
|
+
*
|
|
33
|
+
* The escape hatch, and not a second-class one: everything below is written in terms of
|
|
34
|
+
* it, so a bot reaching a Core endpoint this package has never heard of is in exactly the
|
|
35
|
+
* same position as one calling a named method. ServerClient has had this since it was
|
|
36
|
+
* written; without it here, a bot filing a support ticket built its own authenticated
|
|
37
|
+
* fetch and owned the base url, the timeout and the retry policy twice.
|
|
38
|
+
*/
|
|
39
|
+
request(method, path, body) {
|
|
40
|
+
return this.#transport.request(method, path, body);
|
|
22
41
|
}
|
|
23
42
|
|
|
24
43
|
/** The bot's own account: id, name, picture. */
|
|
@@ -29,22 +48,64 @@ export class CoreClient {
|
|
|
29
48
|
/**
|
|
30
49
|
* Every community this bot is in.
|
|
31
50
|
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* means somebody adds the bot, watches nothing happen, and concludes it is broken.
|
|
51
|
+
* Answers at once. Returns a plain array, which is what it has always returned: this is
|
|
52
|
+
* public API, and a bot author calling it wants the list rather than a wrapper.
|
|
35
53
|
*/
|
|
36
54
|
async servers({ waitSeconds } = {}) {
|
|
55
|
+
const { servers } = await this.watchServers({ waitSeconds });
|
|
56
|
+
return servers;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The same list, with the request held open until it would be different.
|
|
61
|
+
*
|
|
62
|
+
* `waitSeconds` is how long Core may hold it. `known` is a digest of what the bot already
|
|
63
|
+
* serves (see known.js): Core compares, and holds only while the two agree. Without it
|
|
64
|
+
* Core falls back to its older rule and holds only for a bot on no communities at all,
|
|
65
|
+
* which left every bot already serving one to hear about the next on its own timer.
|
|
66
|
+
*
|
|
67
|
+
* `watch` in the answer is Core saying it understood the token and will hold the next
|
|
68
|
+
* request. It matters because the body alone cannot distinguish a Core that held this
|
|
69
|
+
* request for twenty-five seconds and found nothing new from an older Core that ignored
|
|
70
|
+
* the parameter and answered instantly: a bot that guesses wrong either polls the old one
|
|
71
|
+
* in a tight loop or throws away the feature against the new one. Timing does not settle
|
|
72
|
+
* it either, because a duplicate install wakes every waiter while changing nothing.
|
|
73
|
+
*
|
|
74
|
+
* `signal` gives up on a hold that is never answered. Mandatory for the loop that drives
|
|
75
|
+
* this: without it one blackholed request silences the bot until it is restarted.
|
|
76
|
+
*/
|
|
77
|
+
async watchServers({ waitSeconds, known, signal } = {}) {
|
|
37
78
|
const out = await this.#transport.request(
|
|
38
79
|
'GET',
|
|
39
|
-
`/bots/me/servers${query({ wait: waitSeconds })}`,
|
|
80
|
+
`/bots/me/servers${query({ wait: waitSeconds, known })}`,
|
|
81
|
+
undefined,
|
|
82
|
+
{ signal },
|
|
40
83
|
);
|
|
41
|
-
return
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
84
|
+
return {
|
|
85
|
+
watch: out?.watch === true,
|
|
86
|
+
servers: (out?.servers ?? []).map((row) => ({
|
|
87
|
+
serverId: row.server_id,
|
|
88
|
+
apiUrl: row.api_url,
|
|
89
|
+
// Whatever else Core said about it, kept as it came, so a field added later is
|
|
90
|
+
// already available without a release of this package.
|
|
91
|
+
raw: row,
|
|
92
|
+
})),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Say what this bot needs to do its job.
|
|
98
|
+
*
|
|
99
|
+
* A declaration, not a grant. It is the list shown to whoever adds the bot to a community,
|
|
100
|
+
* and they decide what of it to allow; the bot ends up with what they ticked and nothing
|
|
101
|
+
* more. Declaring it here rather than in a form somewhere means it lives next to the
|
|
102
|
+
* commands that need it, and a bot that grows a command that needs Manage Channels says so
|
|
103
|
+
* in the same commit.
|
|
104
|
+
*/
|
|
105
|
+
declarePermissions(mask) {
|
|
106
|
+
return this.#transport.request('PUT', '/bots/me/permissions', {
|
|
107
|
+
requested_permissions: mask,
|
|
108
|
+
});
|
|
48
109
|
}
|
|
49
110
|
|
|
50
111
|
/**
|
|
@@ -58,6 +119,84 @@ export class CoreClient {
|
|
|
58
119
|
if (!out?.token) throw new Error(`Core issued no token for ${serverId}`);
|
|
59
120
|
return out.token;
|
|
60
121
|
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Somebody's public profile: when they were last here, and whether they are a guest.
|
|
125
|
+
*
|
|
126
|
+
* Neither fact is on a community's /bot routes. A community's members table knows when
|
|
127
|
+
* somebody joined and nothing else, so a bot that decides anything about a person, who to
|
|
128
|
+
* warn, who to retire, who to leave alone, has to ask Core, once per person.
|
|
129
|
+
*
|
|
130
|
+
* Null for a profile that is not there or cannot be read, rather than a throw. That is the
|
|
131
|
+
* difference between this and everything else on this client, and it is deliberate: a bot
|
|
132
|
+
* sweeping a community of two hundred must not lose the whole pass to one lookup, and
|
|
133
|
+
* somebody it knows nothing about is somebody to leave alone. A caller that treats null as
|
|
134
|
+
* "no information" gets the safe behaviour for free.
|
|
135
|
+
*/
|
|
136
|
+
profile(userId) {
|
|
137
|
+
return this.#publicRead(`/profiles/${encodeURIComponent(String(userId ?? ''))}`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* A community's public row, which is where its owner is named.
|
|
142
|
+
*
|
|
143
|
+
* A bot holding Manage Members can remove the owner of the community that installed it:
|
|
144
|
+
* the bot kick route checks the permission bit and whether the target is a bot, and
|
|
145
|
+
* nothing else. Knowing who the owner is, is how a bot refuses to.
|
|
146
|
+
*
|
|
147
|
+
* Null on anything that is not an answer, for the same reason as [profile]: not knowing
|
|
148
|
+
* who the owner is must read as "do not touch anybody", not as a crash.
|
|
149
|
+
*/
|
|
150
|
+
community(serverId) {
|
|
151
|
+
return this.#publicRead(`/servers/${encodeURIComponent(String(serverId ?? ''))}`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* What to call somebody, from their id, remembered.
|
|
156
|
+
*
|
|
157
|
+
* A message frame carries who wrote it and not what they are called. A transcript where
|
|
158
|
+
* everybody is "someone" is worse than no names at all: "she said it is broken" then has
|
|
159
|
+
* nobody to attach to. Without a cache this is a call per message.
|
|
160
|
+
*
|
|
161
|
+
* Null when Core cannot name them, rather than a word of its own: "someone", "unknown",
|
|
162
|
+
* "a member" are all decisions about tone and language, and this package ships no copy.
|
|
163
|
+
* A failure is not remembered, so somebody unnameable during a deploy is named afterwards.
|
|
164
|
+
*/
|
|
165
|
+
async displayName(userId) {
|
|
166
|
+
const key = String(userId ?? '');
|
|
167
|
+
if (!key) return null;
|
|
168
|
+
const held = this.#names.get(key);
|
|
169
|
+
if (held && held.at > Date.now() - this.#nameTtlMs) return held.name;
|
|
170
|
+
const profile = await this.profile(key);
|
|
171
|
+
const name = profile?.display_name || profile?.username || null;
|
|
172
|
+
if (name === null) return null;
|
|
173
|
+
// A community is a few hundred people, and only the ones who have spoken land here.
|
|
174
|
+
// Cleared rather than evicted one by one: the next few messages pay a call each, which
|
|
175
|
+
// is cheaper than carrying a policy nobody will ever tune.
|
|
176
|
+
if (this.#names.size > 500) this.#names.clear();
|
|
177
|
+
this.#names.set(key, { name, at: Date.now() });
|
|
178
|
+
return name;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Forget every remembered name, for a bot that has reason to think one changed. */
|
|
182
|
+
forgetNames() {
|
|
183
|
+
this.#names.clear();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* A public Core route, where absent and unreachable are the same answer: null.
|
|
188
|
+
*
|
|
189
|
+
* It goes through the same transport as everything else, so it carries the bot's own
|
|
190
|
+
* credentials. Core ignores them on these routes, and sending them keeps one place that
|
|
191
|
+
* knows the base url, the timeout and the retry policy.
|
|
192
|
+
*/
|
|
193
|
+
async #publicRead(path) {
|
|
194
|
+
try {
|
|
195
|
+
return (await this.#transport.request('GET', path)) ?? null;
|
|
196
|
+
} catch {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
61
200
|
}
|
|
62
201
|
|
|
63
202
|
function base64(text) {
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
+
}
|