chapterjs 0.3.0 → 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.
Files changed (115) hide show
  1. package/README.md +5 -3
  2. package/dist/assets/public.js +4 -4
  3. package/dist/cli/build.d.ts +6 -2
  4. package/dist/cli/build.js +17 -4
  5. package/dist/cli/dev.js +37 -6
  6. package/dist/cli/env.d.ts +5 -0
  7. package/dist/cli/env.js +39 -3
  8. package/dist/cli/project.d.ts +61 -9
  9. package/dist/cli/project.js +250 -44
  10. package/dist/cli/redis.d.ts +23 -0
  11. package/dist/cli/redis.js +69 -0
  12. package/dist/cli/start.js +54 -18
  13. package/dist/cli/test.js +112 -18
  14. package/dist/commands/command.d.ts +140 -5
  15. package/dist/commands/command.js +17 -2
  16. package/dist/commands/declaration.d.ts +17 -4
  17. package/dist/commands/declaration.js +128 -2
  18. package/dist/commands/router.js +78 -18
  19. package/dist/commands/tree.d.ts +25 -4
  20. package/dist/commands/tree.js +95 -26
  21. package/dist/commands/twins.d.ts +8 -0
  22. package/dist/commands/twins.js +24 -6
  23. package/dist/components/button.d.ts +8 -3
  24. package/dist/components/declaration.d.ts +2 -2
  25. package/dist/components/declaration.js +16 -12
  26. package/dist/components/render.js +5 -0
  27. package/dist/components/router.js +41 -0
  28. package/dist/components/select.d.ts +12 -5
  29. package/dist/core/bot.d.ts +7 -0
  30. package/dist/core/bot.js +44 -0
  31. package/dist/discord/api.d.ts +2 -0
  32. package/dist/discord/api.js +2 -0
  33. package/dist/discord/permissions.d.ts +4 -4
  34. package/dist/discord/permissions.js +13 -0
  35. package/dist/events/registry.d.ts +265 -243
  36. package/dist/events/registry.js +131 -100
  37. package/dist/index.d.ts +7 -4
  38. package/dist/index.js +2 -1
  39. package/dist/interactions/dispatch.js +4 -2
  40. package/dist/loader/loader.d.ts +16 -0
  41. package/dist/loader/loader.js +52 -7
  42. package/dist/loader/usage.d.ts +15 -0
  43. package/dist/loader/usage.js +40 -0
  44. package/dist/loader/watch.d.ts +5 -3
  45. package/dist/loader/watch.js +88 -17
  46. package/dist/messages/commands.js +3 -0
  47. package/dist/messages/messages.d.ts +1 -0
  48. package/dist/messages/translate.js +5 -1
  49. package/dist/presence/declaration.d.ts +49 -2
  50. package/dist/presence/declaration.js +122 -39
  51. package/dist/presence/presence.d.ts +65 -6
  52. package/dist/presence/presence.js +11 -0
  53. package/dist/presence/rotation.d.ts +32 -0
  54. package/dist/presence/rotation.js +167 -0
  55. package/dist/ranking/declaration.d.ts +12 -0
  56. package/dist/ranking/declaration.js +48 -0
  57. package/dist/ranking/ranking.d.ts +102 -0
  58. package/dist/ranking/ranking.js +129 -0
  59. package/dist/redis/client.d.ts +36 -0
  60. package/dist/redis/client.js +252 -0
  61. package/dist/redis/protocol.d.ts +22 -0
  62. package/dist/redis/protocol.js +99 -0
  63. package/dist/redis/url.d.ts +24 -0
  64. package/dist/redis/url.js +50 -0
  65. package/dist/rest/errors.d.ts +0 -1
  66. package/dist/rest/errors.js +15 -1
  67. package/dist/rest/rest.js +10 -1
  68. package/dist/store/backend.d.ts +49 -4
  69. package/dist/store/backend.js +66 -11
  70. package/dist/store/declaration.d.ts +3 -0
  71. package/dist/store/declaration.js +8 -3
  72. package/dist/store/file-backend.d.ts +3 -2
  73. package/dist/store/file-backend.js +47 -1
  74. package/dist/store/ipc-backend.d.ts +24 -1
  75. package/dist/store/ipc-backend.js +49 -0
  76. package/dist/store/memory-backend.d.ts +4 -2
  77. package/dist/store/memory-backend.js +27 -1
  78. package/dist/store/ranking-backend.d.ts +45 -0
  79. package/dist/store/ranking-backend.js +82 -0
  80. package/dist/store/redis-backend.d.ts +38 -0
  81. package/dist/store/redis-backend.js +245 -0
  82. package/dist/store/redis-ranking.d.ts +15 -0
  83. package/dist/store/redis-ranking.js +69 -0
  84. package/dist/store/scope.d.ts +17 -1
  85. package/dist/store/scope.js +32 -3
  86. package/dist/store/store.d.ts +39 -5
  87. package/dist/store/store.js +79 -32
  88. package/dist/structures/channel.d.ts +13 -0
  89. package/dist/structures/channel.js +24 -0
  90. package/dist/structures/context.d.ts +3 -0
  91. package/dist/structures/entities.js +1 -0
  92. package/dist/structures/interaction.d.ts +6 -0
  93. package/dist/structures/interaction.js +20 -0
  94. package/dist/structures/invite.d.ts +6 -0
  95. package/dist/structures/invite.js +11 -0
  96. package/dist/structures/message.d.ts +17 -0
  97. package/dist/structures/message.js +33 -0
  98. package/dist/structures/wait.d.ts +105 -0
  99. package/dist/structures/wait.js +85 -0
  100. package/dist/testing/harness.d.ts +48 -8
  101. package/dist/testing/harness.js +156 -36
  102. package/dist/testing/index.d.ts +1 -1
  103. package/dist/testing/smoke.js +112 -1
  104. package/dist/testing/world.js +51 -2
  105. package/dist/util/call-site.d.ts +4 -0
  106. package/dist/util/call-site.js +22 -0
  107. package/dist/voice/connection.d.ts +2 -0
  108. package/dist/voice/connection.js +15 -1
  109. package/dist/voice/usage.d.ts +3 -2
  110. package/dist/voice/usage.js +7 -19
  111. package/dist/waiting/usage.d.ts +5 -0
  112. package/dist/waiting/usage.js +19 -0
  113. package/dist/waiting/waiters.d.ts +48 -0
  114. package/dist/waiting/waiters.js +107 -0
  115. package/package.json +1 -1
@@ -0,0 +1,252 @@
1
+ // The one connection of the process to Redis, on Node's `net` and `tls`
2
+ // and nothing else. Commands are sent in order and answered in order
3
+ // (RESP pipelines by itself), so every call is a promise of its reply.
4
+ // The connection is opened on the first command, logs in and picks the
5
+ // database by itself, and comes back alone when it was lost: a command
6
+ // sent meanwhile fails at once with why, instead of waiting for nobody.
7
+ // https://redis.io/docs/latest/commands/auth/ and /commands/select/
8
+ import { connect as connectTcp } from 'node:net';
9
+ import { connect as connectTls } from 'node:tls';
10
+ import { encodeCommand, RedisError, ReplyReader, } from './protocol.js';
11
+ import { describeRedis } from './url.js';
12
+ /** A thrown reason, in plain words, with what to do. */
13
+ function explain(address, error) {
14
+ const where = `Redis at ${describeRedis(address)}`;
15
+ // Already said in these words (a password refused during the handshake).
16
+ if (error instanceof Error && error.message.startsWith(where)) {
17
+ return error.message;
18
+ }
19
+ if (error instanceof RedisError) {
20
+ if (error.code === 'WRONGPASS') {
21
+ return `${where} refused the password: check REDIS_PASSWORD (or the password in REDIS_URL) in .env.`;
22
+ }
23
+ if (error.code === 'NOAUTH') {
24
+ return `${where} asks for a password: set REDIS_PASSWORD (or the password in REDIS_URL) in .env.`;
25
+ }
26
+ if (error.code === 'NOPERM') {
27
+ return `${where} refused the user: check the user in REDIS_URL, and what it may do.`;
28
+ }
29
+ return `${where} refused the connection: ${error.message}`;
30
+ }
31
+ const code = error?.code;
32
+ switch (code) {
33
+ case 'ECONNREFUSED':
34
+ return `${where} can't be reached: nothing answers there. Start Redis, or check REDIS_URL in .env.`;
35
+ case 'ENOTFOUND':
36
+ case 'EAI_AGAIN':
37
+ return `${where} can't be reached: the host is unknown. Check REDIS_URL in .env.`;
38
+ case 'ETIMEDOUT':
39
+ return `${where} can't be reached: it did not answer in time. Check REDIS_URL in .env, and that the machine lets you reach it.`;
40
+ case 'ECONNRESET':
41
+ case 'EPIPE':
42
+ return `${where} closed the connection.`;
43
+ default:
44
+ return `${where} can't be reached: ${error instanceof Error ? error.message : String(error)}`;
45
+ }
46
+ }
47
+ /** One connection to Redis, which only opens when a command needs it. */
48
+ export class RedisClient {
49
+ address;
50
+ #options;
51
+ #socket = null;
52
+ #state = 'idle';
53
+ /** Commands sent, waiting for their reply, in order. */
54
+ #waiting = [];
55
+ /** Commands given while connecting, sent once the connection is ready. */
56
+ #queue = [];
57
+ /** Why the last attempt failed: what a command gets while waiting to retry. */
58
+ #outage = null;
59
+ #wasReady = false;
60
+ #attempts = 0;
61
+ #retryTimer = null;
62
+ constructor(address, options = {}) {
63
+ this.address = address;
64
+ this.#options = {
65
+ connectTimeout: 5000,
66
+ retryDelay: 1000,
67
+ maxRetryDelay: 30_000,
68
+ ...options,
69
+ };
70
+ }
71
+ /** `host:port` and the database, for messages. */
72
+ get where() {
73
+ return describeRedis(this.address);
74
+ }
75
+ /** Whether a command sent now goes out at once. */
76
+ get connected() {
77
+ return this.#state === 'ready';
78
+ }
79
+ /**
80
+ * Sends a command and resolves with its reply. An error reply (`-ERR`)
81
+ * rejects with a `RedisError`; a connection that fails or is lost
82
+ * rejects with what happened, in plain words.
83
+ */
84
+ send(...args) {
85
+ return new Promise((resolve, reject) => {
86
+ const pending = { resolve, reject };
87
+ switch (this.#state) {
88
+ case 'closed':
89
+ reject(new Error(`Redis at ${this.where} is closed: the bot stopped.`));
90
+ return;
91
+ case 'ready':
92
+ this.#write(args, pending);
93
+ return;
94
+ case 'connecting':
95
+ this.#queue.push({ args, pending });
96
+ return;
97
+ case 'idle':
98
+ if (this.#outage && this.#retryTimer) {
99
+ reject(new Error(this.#outage));
100
+ return;
101
+ }
102
+ this.#queue.push({ args, pending });
103
+ this.#connect();
104
+ }
105
+ });
106
+ }
107
+ /**
108
+ * Makes sure Redis answers: the connection is opened, logged in and on
109
+ * the right database, or the reason it can't be is thrown.
110
+ */
111
+ async ping() {
112
+ const reply = await this.send('PING');
113
+ if (reply !== 'PONG') {
114
+ throw new Error(`Redis at ${this.where} answered ${JSON.stringify(reply)} to PING: is REDIS_URL the address of a Redis server?`);
115
+ }
116
+ }
117
+ #write(args, pending) {
118
+ this.#waiting.push(pending);
119
+ this.#socket.write(encodeCommand(args));
120
+ }
121
+ #connect() {
122
+ this.#state = 'connecting';
123
+ const { host, port } = this.address;
124
+ const socket = this.address.tls
125
+ ? connectTls({ host, port, servername: host })
126
+ : connectTcp({ host, port });
127
+ this.#socket = socket;
128
+ socket.setNoDelay(true);
129
+ const reader = new ReplyReader();
130
+ const timer = setTimeout(() => {
131
+ socket.destroy(Object.assign(new Error('timed out'), { code: 'ETIMEDOUT' }));
132
+ }, this.#options.connectTimeout);
133
+ // The end of this connection, whenever it comes: while connecting, or
134
+ // once it served. Told once: a destroyed socket says error, then close.
135
+ let ended = false;
136
+ const fail = (error) => {
137
+ clearTimeout(timer);
138
+ if (ended)
139
+ return;
140
+ ended = true;
141
+ if (socket !== this.#socket)
142
+ return;
143
+ this.#socket = null;
144
+ socket.destroy();
145
+ const message = explain(this.address, error);
146
+ // Everything sent or about to be sent fails: nobody is there.
147
+ const waiting = this.#waiting;
148
+ const queue = this.#queue;
149
+ this.#waiting = [];
150
+ this.#queue = [];
151
+ for (const pending of waiting)
152
+ pending.reject(new Error(message));
153
+ for (const { pending } of queue)
154
+ pending.reject(new Error(message));
155
+ if (this.#state === 'closed')
156
+ return;
157
+ this.#state = 'idle';
158
+ if (this.#outage === null) {
159
+ this.#options.onError?.(`${message} Trying again every few seconds.`);
160
+ }
161
+ const delay = Math.min(this.#options.retryDelay * 2 ** this.#attempts, this.#options.maxRetryDelay);
162
+ this.#attempts++;
163
+ this.#outage = `${message} Trying again in ${Math.round(delay / 1000) || 1} s.`;
164
+ this.#retryTimer = setTimeout(() => {
165
+ this.#retryTimer = null;
166
+ if (this.#state === 'idle')
167
+ this.#connect();
168
+ }, delay);
169
+ this.#retryTimer.unref();
170
+ };
171
+ socket.on('error', fail);
172
+ socket.on('close', () => fail(Object.assign(new Error('closed'), { code: 'ECONNRESET' })));
173
+ socket.on('data', chunk => {
174
+ reader.push(chunk);
175
+ try {
176
+ let reply;
177
+ while ((reply = reader.next()) !== undefined) {
178
+ const pending = this.#waiting.shift();
179
+ if (!pending)
180
+ continue;
181
+ if (reply instanceof RedisError) {
182
+ // A password refused, or missing, is about the connection, not
183
+ // the command: said in the words of .env.
184
+ pending.reject(reply.code === 'NOAUTH' || reply.code === 'WRONGPASS'
185
+ ? new Error(explain(this.address, reply))
186
+ : reply);
187
+ }
188
+ else
189
+ pending.resolve(reply);
190
+ }
191
+ }
192
+ catch (error) {
193
+ socket.destroy(error);
194
+ }
195
+ });
196
+ socket.once(this.address.tls ? 'secureConnect' : 'connect', () => {
197
+ this.#handshake(socket)
198
+ .then(() => {
199
+ clearTimeout(timer);
200
+ if (this.#state !== 'connecting' || socket !== this.#socket)
201
+ return;
202
+ this.#state = 'ready';
203
+ this.#attempts = 0;
204
+ const back = this.#outage !== null && this.#wasReady;
205
+ this.#outage = null;
206
+ this.#wasReady = true;
207
+ if (back)
208
+ this.#options.onReconnect?.();
209
+ const queue = this.#queue;
210
+ this.#queue = [];
211
+ for (const { args, pending } of queue)
212
+ this.#write(args, pending);
213
+ })
214
+ .catch(fail);
215
+ });
216
+ }
217
+ /** Logs in and picks the database, as the first commands of a connection. */
218
+ async #handshake(socket) {
219
+ const { username, password, db } = this.address;
220
+ const steps = [];
221
+ const ask = (...args) => {
222
+ steps.push(new Promise((resolve, reject) => {
223
+ this.#waiting.push({ resolve, reject });
224
+ socket.write(encodeCommand(args));
225
+ }));
226
+ };
227
+ if (password !== null) {
228
+ ask('AUTH', ...(username === null ? [password] : [username, password]));
229
+ }
230
+ if (db !== 0)
231
+ ask('SELECT', String(db));
232
+ await Promise.all(steps);
233
+ }
234
+ /** Ends the connection; every command sent from now on fails. */
235
+ async close() {
236
+ this.#state = 'closed';
237
+ if (this.#retryTimer) {
238
+ clearTimeout(this.#retryTimer);
239
+ this.#retryTimer = null;
240
+ }
241
+ const socket = this.#socket;
242
+ this.#socket = null;
243
+ const message = `Redis at ${this.where} is closed: the bot stopped.`;
244
+ for (const pending of this.#waiting)
245
+ pending.reject(new Error(message));
246
+ for (const { pending } of this.#queue)
247
+ pending.reject(new Error(message));
248
+ this.#waiting = [];
249
+ this.#queue = [];
250
+ socket?.destroy();
251
+ }
252
+ }
@@ -0,0 +1,22 @@
1
+ /** What Redis answers: a text, a number, nothing, or a list of those. */
2
+ export type RedisReply = string | number | null | RedisReply[];
3
+ /** A `-ERR …` reply: Redis refused the command and says why. */
4
+ export declare class RedisError extends Error {
5
+ name: string;
6
+ /** The first word of the message: `ERR`, `WRONGPASS`, `NOAUTH`… */
7
+ readonly code: string;
8
+ constructor(message: string);
9
+ }
10
+ /** A command as Redis reads it: an array of bulk strings. */
11
+ export declare function encodeCommand(args: readonly (string | number)[]): Buffer;
12
+ /**
13
+ * Reads replies out of the bytes received so far. `push()` adds what
14
+ * arrived, `next()` gives the next whole reply, or `undefined` while a
15
+ * reply is still incomplete: what was read of it is kept for the next
16
+ * push, so a reply cut anywhere by the network is read whole.
17
+ */
18
+ export declare class ReplyReader {
19
+ #private;
20
+ push(chunk: Buffer): void;
21
+ next(): RedisReply | undefined;
22
+ }
@@ -0,0 +1,99 @@
1
+ // RESP, the protocol of Redis, on nothing but Buffers: how a command is
2
+ // written on the wire, and how what comes back is read, one reply at a
3
+ // time, from bytes that arrive in any number of pieces. RESP2 only: every
4
+ // Redis speaks it, and the few commands the framework sends need no more.
5
+ // https://redis.io/docs/latest/develop/reference/protocol-spec/
6
+ /** A `-ERR …` reply: Redis refused the command and says why. */
7
+ export class RedisError extends Error {
8
+ name = 'RedisError';
9
+ /** The first word of the message: `ERR`, `WRONGPASS`, `NOAUTH`… */
10
+ code;
11
+ constructor(message) {
12
+ super(message);
13
+ this.code = message.split(' ', 1)[0] ?? '';
14
+ }
15
+ }
16
+ const CRLF = '\r\n';
17
+ /** A command as Redis reads it: an array of bulk strings. */
18
+ export function encodeCommand(args) {
19
+ const parts = [Buffer.from(`*${args.length}${CRLF}`)];
20
+ for (const arg of args) {
21
+ const bytes = Buffer.from(String(arg));
22
+ parts.push(Buffer.from(`$${bytes.length}${CRLF}`), bytes, Buffer.from(CRLF));
23
+ }
24
+ return Buffer.concat(parts);
25
+ }
26
+ /**
27
+ * Reads replies out of the bytes received so far. `push()` adds what
28
+ * arrived, `next()` gives the next whole reply, or `undefined` while a
29
+ * reply is still incomplete: what was read of it is kept for the next
30
+ * push, so a reply cut anywhere by the network is read whole.
31
+ */
32
+ export class ReplyReader {
33
+ #buffer = Buffer.alloc(0);
34
+ push(chunk) {
35
+ this.#buffer =
36
+ this.#buffer.length === 0 ? chunk : Buffer.concat([this.#buffer, chunk]);
37
+ }
38
+ next() {
39
+ const read = this.#read(0);
40
+ if (!read)
41
+ return undefined;
42
+ this.#buffer = this.#buffer.subarray(read.end);
43
+ return read.reply;
44
+ }
45
+ /** A line ending in CRLF from `start`: its text and where the next starts. */
46
+ #line(start) {
47
+ const at = this.#buffer.indexOf(CRLF, start);
48
+ if (at === -1)
49
+ return null;
50
+ return { text: this.#buffer.toString('utf8', start, at), end: at + 2 };
51
+ }
52
+ #read(start) {
53
+ if (start >= this.#buffer.length)
54
+ return null;
55
+ const type = this.#buffer[start];
56
+ const line = this.#line(start + 1);
57
+ if (!line)
58
+ return null;
59
+ switch (type) {
60
+ case 0x2b: // +
61
+ return { reply: line.text, end: line.end };
62
+ case 0x2d: // -
63
+ return { reply: new RedisError(line.text), end: line.end };
64
+ case 0x3a: // :
65
+ return { reply: Number(line.text), end: line.end };
66
+ case 0x24: {
67
+ // $
68
+ const length = Number(line.text);
69
+ if (length === -1)
70
+ return { reply: null, end: line.end };
71
+ const end = line.end + length + 2;
72
+ if (this.#buffer.length < end)
73
+ return null;
74
+ return {
75
+ reply: this.#buffer.toString('utf8', line.end, line.end + length),
76
+ end,
77
+ };
78
+ }
79
+ case 0x2a: {
80
+ // *
81
+ const count = Number(line.text);
82
+ if (count === -1)
83
+ return { reply: null, end: line.end };
84
+ const items = [];
85
+ let at = line.end;
86
+ for (let index = 0; index < count; index++) {
87
+ const item = this.#read(at);
88
+ if (!item)
89
+ return null;
90
+ items.push(item.reply);
91
+ at = item.end;
92
+ }
93
+ return { reply: items, end: at };
94
+ }
95
+ default:
96
+ throw new Error(`Redis answered something that is not RESP (a line starting with ${JSON.stringify(String.fromCharCode(type ?? 0))}): is REDIS_URL the address of a Redis server?`);
97
+ }
98
+ }
99
+ }
@@ -0,0 +1,24 @@
1
+ /** Where Redis is and how to connect: what the client is created with. */
2
+ export interface RedisAddress {
3
+ host: string;
4
+ port: number;
5
+ /** `rediss://`: the connection is encrypted with TLS. */
6
+ tls: boolean;
7
+ /** The user of the ACL, when the URL names one; the default user otherwise. */
8
+ username: string | null;
9
+ password: string | null;
10
+ /** The database index: `0` unless the URL ends with `/N`. */
11
+ db: number;
12
+ }
13
+ export declare const REDIS_URL_HELP = "Write it like redis://localhost:6379 (rediss:// for a TLS connection, /1 at the end for a database other than 0), with the user and password when there are some: redis://user:password@host:6379.";
14
+ /** `host:port`, and the database when it is not the first: what messages show. Never the password. */
15
+ export declare const describeRedis: (address: RedisAddress) => string;
16
+ /**
17
+ * Reads a `REDIS_URL`, with the `REDIS_PASSWORD` set beside it winning over
18
+ * the one of the URL. Answers what is wrong when it is not a Redis address.
19
+ */
20
+ export declare function parseRedisUrl(text: string, password: string): {
21
+ address: RedisAddress;
22
+ } | {
23
+ problem: string;
24
+ };
@@ -0,0 +1,50 @@
1
+ // What `REDIS_URL` (and `REDIS_PASSWORD`) say: where Redis is, how to
2
+ // reach it, and as what. Read once from the environment by the CLI and
3
+ // given to the client; a wrong value is explained in the words of `.env`.
4
+ // https://redis.io/docs/latest/develop/clients/nodejs/connect/ (the
5
+ // `redis://` and `rediss://` forms every client reads)
6
+ export const REDIS_URL_HELP = 'Write it like redis://localhost:6379 (rediss:// for a TLS connection, /1 at the end for a database other than 0), with the user and password when there are some: redis://user:password@host:6379.';
7
+ /** `host:port`, and the database when it is not the first: what messages show. Never the password. */
8
+ export const describeRedis = (address) => `${address.host}:${address.port}${address.db === 0 ? '' : `/${address.db}`}`;
9
+ /**
10
+ * Reads a `REDIS_URL`, with the `REDIS_PASSWORD` set beside it winning over
11
+ * the one of the URL. Answers what is wrong when it is not a Redis address.
12
+ */
13
+ export function parseRedisUrl(text, password) {
14
+ let url;
15
+ try {
16
+ if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(text))
17
+ throw new Error('no scheme');
18
+ url = new URL(text);
19
+ }
20
+ catch {
21
+ return { problem: `is not an address. ${REDIS_URL_HELP}` };
22
+ }
23
+ if (url.protocol !== 'redis:' && url.protocol !== 'rediss:') {
24
+ return {
25
+ problem: `starts with "${url.protocol}//", and a Redis address starts with redis:// or rediss://. ${REDIS_URL_HELP}`,
26
+ };
27
+ }
28
+ if (url.hostname === '') {
29
+ return { problem: `names no host. ${REDIS_URL_HELP}` };
30
+ }
31
+ const port = url.port === '' ? 6379 : Number(url.port);
32
+ const path = url.pathname.replace(/^\//, '');
33
+ if (path !== '' && !/^\d+$/.test(path)) {
34
+ return {
35
+ problem: `ends with "/${path}", and what follows the host is the number of the database. ${REDIS_URL_HELP}`,
36
+ };
37
+ }
38
+ const own = password !== '' ? password : decodeURIComponent(url.password);
39
+ return {
40
+ address: {
41
+ // A URL keeps IPv6 brackets around the host; a socket wants none.
42
+ host: url.hostname.replace(/^\[(.*)\]$/, '$1'),
43
+ port,
44
+ tls: url.protocol === 'rediss:',
45
+ username: url.username === '' ? null : decodeURIComponent(url.username),
46
+ password: own === '' ? null : own,
47
+ db: path === '' ? 0 : Number(path),
48
+ },
49
+ };
50
+ }
@@ -15,7 +15,6 @@ export interface DiscordFieldError {
15
15
  * @see https://docs.discord.com/developers/reference#error-messages
16
16
  */
17
17
  export declare function flattenFieldErrors(errors: unknown, path?: string): DiscordFieldError[];
18
- /** Discord answered a request with an error. */
19
18
  export declare class DiscordApiError extends Error {
20
19
  readonly name = "DiscordApiError";
21
20
  /** The HTTP status of the response (403, 404...). */
@@ -32,6 +32,19 @@ export function flattenFieldErrors(errors, path = '') {
32
32
  return found;
33
33
  }
34
34
  /** Discord answered a request with an error. */
35
+ /**
36
+ * What a refusal means, in plain words, for the codes a bot meets most: the
37
+ * text of Discord names the problem, not what to do about it.
38
+ * @see https://docs.discord.com/developers/topics/opcodes-and-status-codes#json-json-error-codes
39
+ */
40
+ const HINTS = {
41
+ 50013: "The bot is missing a permission for this, or acts on someone above it: the owner of the server, or a member or role higher than the bot's highest role.",
42
+ 50001: 'The bot can not see this channel or server: give it View Channel there, or check that it is still in the server.',
43
+ 10008: 'The message does not exist any more: it was deleted.',
44
+ 10003: 'The channel does not exist any more: it was deleted.',
45
+ 10007: 'This person is not a member of the server any more.',
46
+ 50007: 'This person does not accept private messages from the bot: they closed them, or share no server with it.',
47
+ };
35
48
  export class DiscordApiError extends Error {
36
49
  name = 'DiscordApiError';
37
50
  /** The HTTP status of the response (403, 404...). */
@@ -62,7 +75,8 @@ export class DiscordApiError extends Error {
62
75
  const details = fieldErrors
63
76
  .map(error => `\n ${error.path || 'request'}: ${error.message}`)
64
77
  .join('');
65
- super(`Discord refused ${input.method} ${input.route}: ${reason} (${code || input.status})${details}`);
78
+ const hint = HINTS[code] ? ` ${HINTS[code]}` : '';
79
+ super(`Discord refused ${input.method} ${input.route}: ${reason} (${code || input.status}).${hint}${details}`);
66
80
  this.status = input.status;
67
81
  this.code = code;
68
82
  this.fieldErrors = fieldErrors;
package/dist/rest/rest.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // The only place that sends HTTP requests to Discord.
2
2
  // https://docs.discord.com/developers/reference#http-api
3
+ import { callSite, fromSite } from '../util/call-site.js';
3
4
  import { setTimeout as sleep } from 'node:timers/promises';
4
5
  import { API_BASE_URL, API_VERSION, AUDIT_LOG_REASON_MAX_LENGTH, userAgent, } from '../discord/api.js';
5
6
  import { HttpStatus } from '../discord/codes.js';
@@ -68,7 +69,15 @@ export class RestClient {
68
69
  onWait: options.onRateLimit,
69
70
  });
70
71
  }
71
- async request(endpoint, params, options = {}) {
72
+ request(endpoint, params, options = {}) {
73
+ // Taken now, while the files are in the stack: a refusal that comes
74
+ // later still names their line, even when they did not await.
75
+ const site = callSite();
76
+ return this.#send(endpoint, params, options).catch((error) => {
77
+ throw fromSite(error, site);
78
+ });
79
+ }
80
+ async #send(endpoint, params, options) {
72
81
  const auth = options.auth ?? true;
73
82
  if (auth && this.#tokenRefused)
74
83
  throw new InvalidTokenError();
@@ -1,3 +1,6 @@
1
+ /** Where a store keeps its data: what `storage` of its config names. */
2
+ export type StoreStorage = 'file' | 'redis';
3
+ export declare const STORAGES: readonly StoreStorage[];
1
4
  /** One value of a store, with when it expires. */
2
5
  export interface StoreEntry {
3
6
  value: unknown;
@@ -10,6 +13,7 @@ export type StoreKeyParts = readonly string[];
10
13
  export interface StoreListedEntry extends StoreEntry {
11
14
  key: StoreKeyParts;
12
15
  }
16
+ import type { RankingBackend } from './ranking-backend.js';
13
17
  /** What keeps the data of every store. Expired entries are never given. */
14
18
  export interface StoreBackend {
15
19
  get(store: string, key: StoreKeyParts): Promise<StoreEntry | undefined>;
@@ -17,9 +21,35 @@ export interface StoreBackend {
17
21
  delete(store: string, key: StoreKeyParts): Promise<boolean>;
18
22
  /** The entries whose key starts with `prefix`, in no particular order. */
19
23
  entries(store: string, prefix: StoreKeyParts): Promise<StoreListedEntry[]>;
24
+ /**
25
+ * Reads the entry at `key` and keeps what `change` returns, with nothing
26
+ * written there by anyone in between: two updates never lose one
27
+ * another, in one process or in several. `change` may run more than
28
+ * once, when the entry changed meanwhile; `undefined` writes nothing.
29
+ * Resolves with the entry kept.
30
+ */
31
+ update(store: string, key: StoreKeyParts, change: StoreChange): Promise<StoreEntry | undefined>;
32
+ /** Forgets every entry whose key starts with `prefix`; how many there were. */
33
+ clear(store: string, prefix: StoreKeyParts): Promise<number>;
34
+ /**
35
+ * A native way to keep rankings, when the backend has one. Without it,
36
+ * rankings are kept as entries of the backend (`StoreRankingBackend`).
37
+ */
38
+ rankings?(): RankingBackend;
20
39
  /** Writes what is pending, if anything. */
21
40
  close(): Promise<void>;
22
41
  }
42
+ /** What `update()` makes of the current entry: the next one, or `undefined` for none. */
43
+ export type StoreChange = (current: StoreEntry | undefined) => StoreEntry | undefined;
44
+ /**
45
+ * Runs what is given for one key one after the other, in this process:
46
+ * updates of one key here never compete with each other, only with the
47
+ * other processes. Keys that are not busy cost nothing.
48
+ */
49
+ export declare class KeyQueue {
50
+ #private;
51
+ run<T>(id: string, fn: () => Promise<T>): Promise<T>;
52
+ }
23
53
  /** The map key of a key: unambiguous whatever the parts hold. */
24
54
  export declare const encodeKey: (key: StoreKeyParts) => string;
25
55
  export declare const decodeKey: (encoded: string) => string[];
@@ -27,7 +57,22 @@ export declare const decodeKey: (encoded: string) => string[];
27
57
  export declare function startsWith(key: StoreKeyParts, prefix: StoreKeyParts): boolean;
28
58
  /** Whether an entry is still good at `now`. */
29
59
  export declare const alive: (entry: StoreEntry, now?: number) => boolean;
30
- /** Gives every store its backend: called by the CLI, once per process. */
31
- export declare function setStoreBackend(backend: StoreBackend | null): void;
32
- /** The backend of the process, or an error that says when stores work. */
33
- export declare function storeBackend(): StoreBackend;
60
+ /**
61
+ * Gives every store of one storage its backend: called by the CLI, once
62
+ * per process and per storage the project has a backend for; `null`
63
+ * takes it away.
64
+ */
65
+ export declare function setStoreBackend(storage: StoreStorage, backend: StoreBackend | null): void;
66
+ /** The backends of the process, by storage. */
67
+ export declare const storeBackends: () => ReadonlyMap<StoreStorage, StoreBackend>;
68
+ /** A store of a storage the process has no backend for: nothing to fix in the file. */
69
+ export declare class NowhereToKeepError extends TypeError {
70
+ name: string;
71
+ }
72
+ /** Why a store of `storage` can't be used: the process has no backend for it. */
73
+ export declare function nowhereToKeep(storage: StoreStorage, name: string, what?: string): string;
74
+ /**
75
+ * The backend of a storage, or an error that says when stores work: for
76
+ * Redis, that the project gave no address.
77
+ */
78
+ export declare function storeBackend(storage: StoreStorage, name?: string): StoreBackend;