chapterjs 0.4.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 (84) hide show
  1. package/README.md +3 -1
  2. package/dist/assets/public.js +4 -4
  3. package/dist/cli/build.d.ts +6 -2
  4. package/dist/cli/build.js +3 -3
  5. package/dist/cli/dev.js +8 -5
  6. package/dist/cli/project.d.ts +31 -2
  7. package/dist/cli/project.js +166 -34
  8. package/dist/cli/redis.js +14 -2
  9. package/dist/cli/start.js +11 -1
  10. package/dist/cli/test.js +108 -16
  11. package/dist/commands/command.d.ts +140 -5
  12. package/dist/commands/command.js +17 -2
  13. package/dist/commands/declaration.d.ts +17 -4
  14. package/dist/commands/declaration.js +128 -2
  15. package/dist/commands/router.js +78 -18
  16. package/dist/commands/tree.d.ts +25 -4
  17. package/dist/commands/tree.js +95 -26
  18. package/dist/commands/twins.d.ts +8 -0
  19. package/dist/commands/twins.js +24 -6
  20. package/dist/components/button.d.ts +8 -3
  21. package/dist/components/declaration.d.ts +2 -2
  22. package/dist/components/declaration.js +16 -12
  23. package/dist/components/render.js +5 -0
  24. package/dist/components/router.js +41 -0
  25. package/dist/components/select.d.ts +12 -5
  26. package/dist/core/bot.d.ts +7 -0
  27. package/dist/core/bot.js +44 -0
  28. package/dist/discord/api.d.ts +2 -0
  29. package/dist/discord/api.js +2 -0
  30. package/dist/discord/permissions.d.ts +4 -4
  31. package/dist/discord/permissions.js +13 -0
  32. package/dist/events/registry.d.ts +19 -10
  33. package/dist/events/registry.js +10 -4
  34. package/dist/index.d.ts +3 -2
  35. package/dist/index.js +1 -1
  36. package/dist/interactions/dispatch.js +4 -2
  37. package/dist/loader/loader.d.ts +16 -0
  38. package/dist/loader/loader.js +52 -7
  39. package/dist/loader/usage.d.ts +15 -0
  40. package/dist/loader/usage.js +40 -0
  41. package/dist/loader/watch.d.ts +5 -3
  42. package/dist/loader/watch.js +88 -17
  43. package/dist/messages/commands.js +3 -0
  44. package/dist/messages/messages.d.ts +1 -0
  45. package/dist/messages/translate.js +5 -1
  46. package/dist/presence/declaration.d.ts +49 -2
  47. package/dist/presence/declaration.js +122 -39
  48. package/dist/presence/presence.d.ts +65 -6
  49. package/dist/presence/presence.js +11 -0
  50. package/dist/presence/rotation.d.ts +32 -0
  51. package/dist/presence/rotation.js +167 -0
  52. package/dist/rest/errors.d.ts +0 -1
  53. package/dist/rest/errors.js +15 -1
  54. package/dist/rest/rest.js +10 -1
  55. package/dist/store/file-backend.d.ts +0 -1
  56. package/dist/store/file-backend.js +18 -1
  57. package/dist/store/scope.d.ts +6 -1
  58. package/dist/store/scope.js +9 -3
  59. package/dist/structures/channel.d.ts +13 -0
  60. package/dist/structures/channel.js +24 -0
  61. package/dist/structures/context.d.ts +3 -0
  62. package/dist/structures/entities.js +1 -0
  63. package/dist/structures/interaction.d.ts +6 -0
  64. package/dist/structures/interaction.js +20 -0
  65. package/dist/structures/message.d.ts +17 -0
  66. package/dist/structures/message.js +33 -0
  67. package/dist/structures/wait.d.ts +105 -0
  68. package/dist/structures/wait.js +85 -0
  69. package/dist/testing/harness.d.ts +48 -8
  70. package/dist/testing/harness.js +156 -36
  71. package/dist/testing/index.d.ts +1 -1
  72. package/dist/testing/smoke.js +112 -1
  73. package/dist/testing/world.js +51 -2
  74. package/dist/util/call-site.d.ts +4 -0
  75. package/dist/util/call-site.js +22 -0
  76. package/dist/voice/connection.d.ts +2 -0
  77. package/dist/voice/connection.js +15 -1
  78. package/dist/voice/usage.d.ts +3 -2
  79. package/dist/voice/usage.js +7 -19
  80. package/dist/waiting/usage.d.ts +5 -0
  81. package/dist/waiting/usage.js +19 -0
  82. package/dist/waiting/waiters.d.ts +48 -0
  83. package/dist/waiting/waiters.js +107 -0
  84. package/package.json +1 -1
@@ -0,0 +1,32 @@
1
+ import type { RawGatewayPresenceUpdate } from '../discord/types/gateway-events.js';
2
+ import { type LoadedActivity, type LoadedPresence } from './declaration.js';
3
+ import type { PresenceContext } from './presence.js';
4
+ export interface RotationOptions {
5
+ /** Sends a presence to the bot of the moment. */
6
+ apply(raw: RawGatewayPresenceUpdate): void;
7
+ /** What the functions receive, or `null` while there is no bot to show it. */
8
+ context(): PresenceContext | null;
9
+ /** A function of the file threw, or gave something wrong. Said once until it changes. */
10
+ onError(error: unknown): void;
11
+ /** The clock, in milliseconds (tests). */
12
+ now?: () => number;
13
+ }
14
+ /**
15
+ * Which activity of a list the clock says to show, and how long until the
16
+ * next one.
17
+ */
18
+ export declare function activityAt(activities: readonly LoadedActivity[], now: number): {
19
+ index: number;
20
+ left: number;
21
+ };
22
+ /** The presence of the project, shown in turn while it is started. */
23
+ export declare class PresenceRotation {
24
+ #private;
25
+ constructor(options: RotationOptions);
26
+ /** Changes the presence (a load, a reload): shown at once when started. */
27
+ set(presence: LoadedPresence | null): void;
28
+ /** Starts showing the presence. */
29
+ start(): void;
30
+ /** Stops: nothing runs after this. */
31
+ stop(): void;
32
+ }
@@ -0,0 +1,167 @@
1
+ // Shows the activities of the presence in turn, each for its `for`, and
2
+ // runs the functions of the file when their activity comes: the text of an
3
+ // activity, or the list itself.
4
+ //
5
+ // Which activity is shown is read from the clock: the list is a cycle
6
+ // starting at the epoch, so every process of a large bot shows the same
7
+ // activity at the same time, with nothing to coordinate.
8
+ import { checkText, DEFAULT_FOR, DEFAULT_PRESENCE, readActivities, toRaw, } from './declaration.js';
9
+ /** How long to wait for a bot before trying again. */
10
+ const NO_BOT_RETRY = 1_000;
11
+ /**
12
+ * Which activity of a list the clock says to show, and how long until the
13
+ * next one.
14
+ */
15
+ export function activityAt(activities, now) {
16
+ const cycle = activities.reduce((sum, one) => sum + one.for, 0);
17
+ let position = now % cycle;
18
+ for (const [index, activity] of activities.entries()) {
19
+ if (position < activity.for) {
20
+ return { index, left: activity.for - position };
21
+ }
22
+ position -= activity.for;
23
+ }
24
+ // Not reached: the position is below the cycle.
25
+ return { index: 0, left: activities[0].for };
26
+ }
27
+ /** The presence of the project, shown in turn while it is started. */
28
+ export class PresenceRotation {
29
+ #options;
30
+ #now;
31
+ #presence = null;
32
+ #started = false;
33
+ #timer;
34
+ /** Each tick has its number: one started since then makes it give up. */
35
+ #generation = 0;
36
+ /** The list a function gave last, and until when it is used. */
37
+ #list = null;
38
+ #listUntil = 0;
39
+ /** The last text of each activity a function gave, by its place. */
40
+ #texts = new Map();
41
+ /** What was said last of each function (`list`, or the place of an activity). */
42
+ #problems = new Map();
43
+ constructor(options) {
44
+ this.#options = options;
45
+ this.#now = options.now ?? Date.now;
46
+ }
47
+ /** Changes the presence (a load, a reload): shown at once when started. */
48
+ set(presence) {
49
+ this.#presence = presence;
50
+ this.#list = null;
51
+ this.#texts.clear();
52
+ this.#problems.clear();
53
+ if (this.#started)
54
+ void this.#tick();
55
+ }
56
+ /** Starts showing the presence. */
57
+ start() {
58
+ this.#started = true;
59
+ void this.#tick();
60
+ }
61
+ /** Stops: nothing runs after this. */
62
+ stop() {
63
+ this.#started = false;
64
+ this.#generation++;
65
+ clearTimeout(this.#timer);
66
+ }
67
+ /** What is shown right now, as the gateway sends it: one step of the cycle. */
68
+ async #tick() {
69
+ const generation = ++this.#generation;
70
+ clearTimeout(this.#timer);
71
+ const current = () => this.#started && generation === this.#generation;
72
+ const presence = this.#presence;
73
+ if (!presence) {
74
+ this.#options.apply(DEFAULT_PRESENCE);
75
+ return;
76
+ }
77
+ const context = this.#options.context();
78
+ if (!context) {
79
+ this.#later(NO_BOT_RETRY);
80
+ return;
81
+ }
82
+ let activities;
83
+ if (typeof presence.activities === 'function') {
84
+ if (this.#list === null || this.#now() >= this.#listUntil) {
85
+ try {
86
+ const given = await presence.activities(context);
87
+ if (!current())
88
+ return;
89
+ this.#list = readActivities(given);
90
+ this.#texts.clear();
91
+ this.#problems.delete('list');
92
+ }
93
+ catch (error) {
94
+ if (!current())
95
+ return;
96
+ this.#problem('list', error);
97
+ // The last good list stays; none at all shows the status alone.
98
+ this.#list ??= [];
99
+ }
100
+ const cycle = this.#list.reduce((sum, one) => sum + one.for, 0);
101
+ this.#listUntil = this.#now() + (cycle || DEFAULT_FOR);
102
+ }
103
+ activities = this.#list;
104
+ }
105
+ else {
106
+ activities = presence.activities;
107
+ }
108
+ if (activities.length === 0) {
109
+ this.#options.apply(toRaw(presence.status, null, ''));
110
+ // A function may give activities later.
111
+ if (typeof presence.activities === 'function') {
112
+ this.#later(this.#listUntil - this.#now());
113
+ }
114
+ return;
115
+ }
116
+ const { index, left } = activityAt(activities, this.#now());
117
+ const activity = activities[index];
118
+ let text;
119
+ if (typeof activity.name === 'function') {
120
+ try {
121
+ text = checkText(await activity.name(context));
122
+ if (!current())
123
+ return;
124
+ this.#texts.set(index, text);
125
+ this.#problems.delete(`name ${index}`);
126
+ }
127
+ catch (error) {
128
+ if (!current())
129
+ return;
130
+ this.#problem(`name ${index}`, error);
131
+ text = this.#texts.get(index);
132
+ }
133
+ }
134
+ else {
135
+ text = activity.name;
136
+ }
137
+ if (text !== undefined) {
138
+ this.#options.apply(toRaw(presence.status, activity, text));
139
+ }
140
+ const moves = activities.length > 1 ||
141
+ typeof activity.name === 'function' ||
142
+ typeof presence.activities === 'function';
143
+ if (moves) {
144
+ // A list that ends before this activity does is asked again then.
145
+ const until = typeof presence.activities === 'function'
146
+ ? Math.min(left, Math.max(0, this.#listUntil - this.#now()))
147
+ : left;
148
+ this.#later(until);
149
+ }
150
+ }
151
+ #later(ms) {
152
+ if (!this.#started)
153
+ return;
154
+ // At least 1 ms: a list asked again on the very boundary.
155
+ this.#timer = setTimeout(() => void this.#tick(), Math.max(1, ms));
156
+ // Nothing of the bot waits for it: the gateway keeps the process up.
157
+ this.#timer.unref();
158
+ }
159
+ /** Says what went wrong with a function, once until it changes. */
160
+ #problem(what, error) {
161
+ const message = error instanceof Error ? error.message : String(error);
162
+ if (this.#problems.get(what) === message)
163
+ return;
164
+ this.#problems.set(what, message);
165
+ this.#options.onError(error);
166
+ }
167
+ }
@@ -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();
@@ -8,7 +8,6 @@ export interface FileStoreBackendOptions {
8
8
  sweepEvery?: number;
9
9
  now?: () => number;
10
10
  }
11
- /** The data of every store, one file each, loaded on first use. */
12
11
  export declare class FileStoreBackend implements StoreBackend {
13
12
  #private;
14
13
  constructor(dir: string, options?: FileStoreBackendOptions);
@@ -12,6 +12,21 @@ export const DATA_FOLDER = 'data';
12
12
  /** A name a file can be called: what an export is called. */
13
13
  const FILE_NAME = /^[\w.-]+$/;
14
14
  /** The data of every store, one file each, loaded on first use. */
15
+ /**
16
+ * One entry as this backend writes it: `[key parts, value, expiresAt]`. An
17
+ * entry of another shape makes the whole file one it did not write, rather
18
+ * than an entry dropped without a word the next time the file is written.
19
+ */
20
+ function isEntry(entry) {
21
+ if (!Array.isArray(entry) || entry.length !== 3)
22
+ return false;
23
+ const [key, value, expiresAt] = entry;
24
+ return (Array.isArray(key) &&
25
+ key.length > 0 &&
26
+ key.every(part => typeof part === 'string') &&
27
+ value !== undefined &&
28
+ (expiresAt === null || typeof expiresAt === 'number'));
29
+ }
15
30
  export class FileStoreBackend {
16
31
  #dir;
17
32
  #stores = new Map();
@@ -55,7 +70,9 @@ export class FileStoreBackend {
55
70
  let parsed;
56
71
  try {
57
72
  parsed = JSON.parse(text);
58
- if (parsed?.version !== 1 || !Array.isArray(parsed.entries)) {
73
+ if (parsed?.version !== 1 ||
74
+ !Array.isArray(parsed.entries) ||
75
+ !parsed.entries.every(isEntry)) {
59
76
  throw new Error('not a store file');
60
77
  }
61
78
  }
@@ -1,4 +1,4 @@
1
- /** Runs `fn` for a server (or for none, with `null`). */
1
+ /** Runs `fn` for a server (or for none, with `null`), as one run. */
2
2
  export declare function withGuild<T>(guildId: string | null, fn: () => T): T;
3
3
  /**
4
4
  * The id of the server the current function runs for: `null` when it runs
@@ -6,6 +6,11 @@ export declare function withGuild<T>(guildId: string | null, fn: () => T): T;
6
6
  * the framework runs.
7
7
  */
8
8
  export declare function currentGuildId(): string | null | undefined;
9
+ /**
10
+ * The run of a function of the files the current code belongs to, or
11
+ * `undefined` outside any.
12
+ */
13
+ export declare function currentRun(): object | undefined;
9
14
  /**
10
15
  * The scope part of a key, for data per server or for the whole bot: the
11
16
  * id of the server the code runs for (`guildOf`), or nothing for a global
@@ -4,11 +4,10 @@
4
4
  // the routers around every function they run, on an asynchronous context
5
5
  // of Node, so it follows the `await`s into the files the function imports.
6
6
  import { AsyncLocalStorage } from 'node:async_hooks';
7
- /** The id of the server the current function runs for, or `null` for none. */
8
7
  const scope = new AsyncLocalStorage();
9
- /** Runs `fn` for a server (or for none, with `null`). */
8
+ /** Runs `fn` for a server (or for none, with `null`), as one run. */
10
9
  export function withGuild(guildId, fn) {
11
- return scope.run(guildId, fn);
10
+ return scope.run({ guildId }, fn);
12
11
  }
13
12
  /**
14
13
  * The id of the server the current function runs for: `null` when it runs
@@ -16,6 +15,13 @@ export function withGuild(guildId, fn) {
16
15
  * the framework runs.
17
16
  */
18
17
  export function currentGuildId() {
18
+ return scope.getStore()?.guildId;
19
+ }
20
+ /**
21
+ * The run of a function of the files the current code belongs to, or
22
+ * `undefined` outside any.
23
+ */
24
+ export function currentRun() {
19
25
  return scope.getStore();
20
26
  }
21
27
  /**
@@ -1,3 +1,4 @@
1
+ import { type CollectMessagesOptions, type WaitForMessageOptions } from './wait.js';
1
2
  import type { CacheStore } from '../cache/store.js';
2
3
  import { Permissions, type PermissionResolvable } from '../discord/permissions.js';
3
4
  import { ChannelType, type CreateChannelInviteJSONParams, type ModifyGuildChannelJSONParams, type ModifyThreadJSONParams, type RawChannel, type RawForumTag, type RawThreadMember, type StartThreadWithoutMessageJSONParams } from '../discord/types/channel.js';
@@ -117,6 +118,18 @@ export interface TextBasedMethods {
117
118
  * @see https://docs.discord.com/developers/resources/message#get-channel-pins
118
119
  */
119
120
  fetchPins(): Promise<Message[]>;
121
+ /**
122
+ * Waits for the next message of the channel that fits: of a person
123
+ * (`from`), saying something (`matches`). Anyone's but a bot's by
124
+ * default. Resolves with the message, or `null` when `within` (one
125
+ * minute by default) passed first. A restart of the bot ends the wait.
126
+ */
127
+ waitForMessage(options?: WaitForMessageOptions): Promise<Message | null>;
128
+ /**
129
+ * Gathers the messages of the channel that fit, until `within` (one
130
+ * minute by default) passed or `max` came. Resolves with them, in order.
131
+ */
132
+ collectMessages(options?: CollectMessagesOptions): Promise<Message[]>;
120
133
  }
121
134
  /** What a role or a member is allowed or denied in one channel. */
122
135
  export interface PermissionOverwrite {
@@ -1,3 +1,4 @@
1
+ import { personId, readMatches, readWithin, waitFor, } from './wait.js';
1
2
  import { Limits } from '../discord/api.js';
2
3
  import { AddThreadMember, BulkDeleteMessages, CreateChannelInvite, CreateWebhook, DeleteChannelPermission, DeleteCloseChannel, DeleteMessage, EditChannelPermissions, FollowAnnouncementChannel, GetChannel, GetChannelInvites, GetChannelMessage, GetChannelMessages, GetChannelPins, GetChannelWebhooks, JoinThread, LeaveThread, ListThreadMembers, ModifyChannel, RemoveThreadMember, SetVoiceChannelStatus, StartThreadInForumOrMediaChannel, StartThreadWithoutMessage, TriggerTypingIndicator, } from '../discord/endpoints.js';
3
4
  import { channelMention } from '../discord/formatting.js';
@@ -180,7 +181,30 @@ const textBasedMethods = {
180
181
  before = last.pinned_at;
181
182
  }
182
183
  },
184
+ async waitForMessage(options = {}) {
185
+ const [message] = await waitForMessages(this, options, 1);
186
+ return message ?? null;
187
+ },
188
+ collectMessages(options = {}) {
189
+ const { max } = options;
190
+ if (max !== undefined && (!Number.isInteger(max) || max < 1)) {
191
+ throw new RangeError(`"max" is how many messages to gather at most, a whole number from 1: got ${max}.`);
192
+ }
193
+ return waitForMessages(this, options, max ?? Infinity);
194
+ },
183
195
  };
196
+ /**
197
+ * The next messages of a channel that fit the options: what
198
+ * `waitForMessage()` and `collectMessages()` share.
199
+ */
200
+ function waitForMessages(channel, options, max) {
201
+ const within = readWithin(options.within);
202
+ const from = personId(options.from);
203
+ const says = readMatches(options.matches);
204
+ return waitFor(channel, 'message', message => message.channelId === channel.id &&
205
+ (from ? message.author.id === from : !message.author.bot) &&
206
+ says(message), within, max);
207
+ }
184
208
  /**
185
209
  * A channel of a server (not a thread).
186
210
  * @see https://docs.discord.com/developers/resources/channel#channel-object
@@ -3,6 +3,7 @@ import type { Snowflake } from '../discord/types/common.js';
3
3
  import type { LoadedMessages } from '../messages/translate.js';
4
4
  import type { Rest } from '../rest/rest.js';
5
5
  import type { VoiceManager } from '../voice/connection.js';
6
+ import type { Waiters } from '../waiting/waiters.js';
6
7
  import type { Entities } from './entities.js';
7
8
  /**
8
9
  * What every structure works with. It never leaves the framework: user code
@@ -22,6 +23,8 @@ export interface Context {
22
23
  messages: LoadedMessages | null;
23
24
  /** Where the bot is in voice; `null` outside a running bot. */
24
25
  voice: VoiceManager | null;
26
+ /** What the files are waiting for; `null` outside a running bot. */
27
+ waiters: Waiters | null;
25
28
  }
26
29
  /** The identity of the bot, for actions that need it. */
27
30
  export declare function selfOf(ctx: Context): {
@@ -220,6 +220,7 @@ export function createContext(input) {
220
220
  self: null,
221
221
  messages: null,
222
222
  voice: null,
223
+ waiters: null,
223
224
  };
224
225
  ctx.entities = new Entities(ctx);
225
226
  return ctx;
@@ -13,6 +13,12 @@ import type { User } from './user.js';
13
13
  export type InteractionData = Omit<RawInteraction, 'user' | 'member' | 'message'>;
14
14
  /** Only the framework reads how far an interaction was answered. */
15
15
  export declare let isUpdating: (interaction: Interaction) => boolean;
16
+ /**
17
+ * Resolves once every answer asked so far was sent (or refused): what the
18
+ * framework waits for before saying a function never answered, since an
19
+ * answer asked without `await` is still on its way when it returns.
20
+ */
21
+ export declare let answersSent: (interaction: Interaction) => Promise<void>;
16
22
  /** An answer to an interaction: its text, or its options. */
17
23
  export type InteractionReply = string | InteractionReplyOptions;
18
24
  /** The options of an answer: everything a message takes, except `replyTo` and `stickers`. */
@@ -1,3 +1,4 @@
1
+ import { callSite, fromSite } from '../util/call-site.js';
1
2
  import { CreateFollowupMessage, CreateInteractionResponse, DeleteOriginalInteractionResponse, EditOriginalInteractionResponse, GetOriginalInteractionResponse, } from '../discord/endpoints.js';
2
3
  import { renderedOf } from '../components/instance.js';
3
4
  import { InteractionCallbackType, } from '../discord/types/interaction.js';
@@ -9,6 +10,12 @@ import { resolve } from '../components/instance.js';
9
10
  import { translatorOf } from '../messages/translate.js';
10
11
  /** Only the framework reads how far an interaction was answered. */
11
12
  export let isUpdating;
13
+ /**
14
+ * Resolves once every answer asked so far was sent (or refused): what the
15
+ * framework waits for before saying a function never answered, since an
16
+ * answer asked without `await` is still on its way when it returns.
17
+ */
18
+ export let answersSent;
12
19
  /** Answers with a change of the message: see `ComponentInteraction.update()`. */
13
20
  let updateMessage;
14
21
  let deferUpdateMessage;
@@ -33,6 +40,14 @@ export class Interaction extends IdStructure {
33
40
  #queue = Promise.resolve();
34
41
  static {
35
42
  isUpdating = interaction => interaction.#state === 'updating';
43
+ answersSent = async (interaction) => {
44
+ // Answers asked while waiting are queued after the ones before.
45
+ let last = null;
46
+ while (last !== interaction.#queue) {
47
+ last = interaction.#queue;
48
+ await last;
49
+ }
50
+ };
36
51
  updateMessage = (interaction, message) => {
37
52
  const { body, files } = buildMessage(message, {
38
53
  edit: true,
@@ -151,6 +166,8 @@ export class Interaction extends IdStructure {
151
166
  * error keeps the line of user code that asked for the answer.
152
167
  */
153
168
  async #run(action) {
169
+ // Where the files asked, for an answer refused later (`callSite`).
170
+ const site = callSite();
154
171
  const previous = this.#queue;
155
172
  let release;
156
173
  this.#queue = new Promise(resolve => (release = resolve));
@@ -158,6 +175,9 @@ export class Interaction extends IdStructure {
158
175
  try {
159
176
  return await action();
160
177
  }
178
+ catch (error) {
179
+ throw fromSite(error, site);
180
+ }
161
181
  finally {
162
182
  release();
163
183
  }
@@ -10,6 +10,7 @@ import type { Guild } from './guild.js';
10
10
  import type { GuildMember } from './member.js';
11
11
  import { type Embed, type MessageEditOptions, type MessageInput } from './payload.js';
12
12
  import type { User } from './user.js';
13
+ import { type WaitedReaction, type Click, type WaitForClickOptions, type WaitForReactionOptions } from './wait.js';
13
14
  /** What the message stores: users are kept as structures, not copies. */
14
15
  export type MessageData = Omit<RawMessage, 'author' | 'mentions'> & {
15
16
  guild_id?: Snowflake;
@@ -148,6 +149,22 @@ export declare class Message extends IdStructure<MessageData> {
148
149
  * @see https://docs.discord.com/developers/resources/channel#start-thread-from-message
149
150
  */
150
151
  startThread(options: Camelize<StartThreadFromMessageJSONParams>, reason?: string): Promise<ThreadChannel>;
152
+ /**
153
+ * Waits for a click on a button or a menu of this message: of a person
154
+ * (`from`), anyone by default. Resolves with the click, to answer with
155
+ * `click.interaction`, or `null` when `within` (one minute by default)
156
+ * passed first. A click taken here does not run the `run` of the button;
157
+ * a button that is only waited for needs none. A restart of the bot ends
158
+ * the wait.
159
+ */
160
+ waitForClick(options?: WaitForClickOptions): Promise<Click | null>;
161
+ /**
162
+ * Waits for a reaction added to this message: of a person (`from`),
163
+ * anyone's but a bot's by default, with an emoji (`emoji`), any by
164
+ * default. Resolves with it, or `null` when `within` (one minute by
165
+ * default) passed first. A restart of the bot ends the wait.
166
+ */
167
+ waitForReaction(options?: WaitForReactionOptions): Promise<WaitedReaction | null>;
151
168
  }
152
169
  /** One mechanism to send a message, used by channels, replies and users. */
153
170
  export declare function sendMessage(ctx: Context, channelId: Snowflake, message: MessageInput, guildId?: Snowflake): Promise<Message>;
@@ -6,6 +6,7 @@ import { ctxOf, dataOf, idOf, IdStructure, toDate } from './base.js';
6
6
  import { findGuild, knownChannel, knownMember } from './known.js';
7
7
  import { buildMessage, } from './payload.js';
8
8
  import { translatorOf } from '../messages/translate.js';
9
+ import { personId, readEmoji, readWithin, waitFor, } from './wait.js';
9
10
  function emojiPath(emoji) {
10
11
  if (typeof emoji !== 'string')
11
12
  return reactionEmoji(emoji);
@@ -251,6 +252,38 @@ export class Message extends IdStructure {
251
252
  const raw = await rest.request(StartThreadFromMessage, [this.channelId, this.id], { body: toSnakeCase(options), reason });
252
253
  return entities.channel(raw);
253
254
  }
255
+ /**
256
+ * Waits for a click on a button or a menu of this message: of a person
257
+ * (`from`), anyone by default. Resolves with the click, to answer with
258
+ * `click.interaction`, or `null` when `within` (one minute by default)
259
+ * passed first. A click taken here does not run the `run` of the button;
260
+ * a button that is only waited for needs none. A restart of the bot ends
261
+ * the wait.
262
+ */
263
+ async waitForClick(options = {}) {
264
+ const within = readWithin(options.within);
265
+ const from = personId(options.from);
266
+ const [click] = await waitFor(this, 'click', offered => offered.messageId === this.id && (!from || offered.user.id === from), within);
267
+ return click ?? null;
268
+ }
269
+ /**
270
+ * Waits for a reaction added to this message: of a person (`from`),
271
+ * anyone's but a bot's by default, with an emoji (`emoji`), any by
272
+ * default. Resolves with it, or `null` when `within` (one minute by
273
+ * default) passed first. A restart of the bot ends the wait.
274
+ */
275
+ async waitForReaction(options = {}) {
276
+ const within = readWithin(options.within);
277
+ const from = personId(options.from);
278
+ const isEmoji = readEmoji(options.emoji);
279
+ const [reaction] = await waitFor(this, 'reaction', offered => offered.messageId === this.id &&
280
+ (from ? offered.user.id === from : !offered.user.bot) &&
281
+ isEmoji(offered.emoji), within);
282
+ if (!reaction)
283
+ return null;
284
+ const { messageId: _, ...rest } = reaction;
285
+ return Object.freeze(rest);
286
+ }
254
287
  }
255
288
  /** One mechanism to send a message, used by channels, replies and users. */
256
289
  export async function sendMessage(ctx, channelId, message, guildId) {