@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/LICENSE +21 -21
- package/README.md +439 -285
- package/package.json +33 -33
- package/src/bot.js +615 -350
- package/src/core.js +207 -68
- package/src/dispatch.js +195 -178
- package/src/errors.js +61 -61
- package/src/gateway.js +191 -191
- package/src/http.js +155 -136
- package/src/interaction.js +156 -140
- package/src/known.js +23 -0
- package/src/rest.js +135 -9
package/src/core.js
CHANGED
|
@@ -1,68 +1,207 @@
|
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
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
|
+
/** 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
|
+
|
|
13
|
+
export class CoreClient {
|
|
14
|
+
#transport;
|
|
15
|
+
#names = new Map();
|
|
16
|
+
#nameTtlMs;
|
|
17
|
+
|
|
18
|
+
constructor({ url, key, secret, fetch, sleep, maxRetries, nameTtlMs = NAME_TTL_MS }) {
|
|
19
|
+
this.url = String(url || '').replace(/\/+$/, '');
|
|
20
|
+
this.#transport = new Transport({
|
|
21
|
+
baseUrl: this.url,
|
|
22
|
+
authorization: () => 'Bot ' + base64(`${key}:${secret}`),
|
|
23
|
+
fetch,
|
|
24
|
+
sleep,
|
|
25
|
+
maxRetries,
|
|
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);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The bot's own account: id, name, picture. */
|
|
44
|
+
me() {
|
|
45
|
+
return this.#transport.request('GET', '/bots/me');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Every community this bot is in.
|
|
50
|
+
*
|
|
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.
|
|
53
|
+
*/
|
|
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 } = {}) {
|
|
78
|
+
const out = await this.#transport.request(
|
|
79
|
+
'GET',
|
|
80
|
+
`/bots/me/servers${query({ wait: waitSeconds, known })}`,
|
|
81
|
+
undefined,
|
|
82
|
+
{ signal },
|
|
83
|
+
);
|
|
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
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* A token for one community.
|
|
113
|
+
*
|
|
114
|
+
* Good for an hour and for that community only, so every connect mints a new one rather
|
|
115
|
+
* than trying to work out whether the last one is still alive.
|
|
116
|
+
*/
|
|
117
|
+
async token(serverId) {
|
|
118
|
+
const out = await this.#transport.request('POST', '/bots/token', { server_id: serverId });
|
|
119
|
+
if (!out?.token) throw new Error(`Core issued no token for ${serverId}`);
|
|
120
|
+
return out.token;
|
|
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
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function base64(text) {
|
|
203
|
+
if (typeof Buffer !== 'undefined') return Buffer.from(text, 'utf8').toString('base64');
|
|
204
|
+
// Not Node. Nothing in this package needs it, but failing here would be a strange way
|
|
205
|
+
// to find that out.
|
|
206
|
+
return btoa(text);
|
|
207
|
+
}
|