@lilsnibbi/discord 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +5 -0
- package/package.json +45 -0
- package/src/DiscordClient.ts +598 -0
- package/src/DiscordCommand.ts +93 -0
- package/src/DiscordEvent.ts +126 -0
- package/src/DiscordModuleLoader.ts +49 -0
- package/src/DiscordPagination.ts +626 -0
- package/src/DiscordShutdown.ts +110 -0
- package/src/container.ts +65 -0
- package/src/index.ts +18 -0
|
@@ -0,0 +1,626 @@
|
|
|
1
|
+
import { randomUUIDv7 } from "bun";
|
|
2
|
+
import {
|
|
3
|
+
ActionRowBuilder,
|
|
4
|
+
ButtonBuilder,
|
|
5
|
+
type ButtonInteraction,
|
|
6
|
+
ButtonStyle,
|
|
7
|
+
type ChannelSelectMenuBuilder,
|
|
8
|
+
type ChatInputCommandInteraction,
|
|
9
|
+
ComponentType,
|
|
10
|
+
ContainerBuilder,
|
|
11
|
+
EmbedBuilder,
|
|
12
|
+
type FileBuilder,
|
|
13
|
+
LabelBuilder,
|
|
14
|
+
type MediaGalleryBuilder,
|
|
15
|
+
type MentionableSelectMenuBuilder,
|
|
16
|
+
type Message,
|
|
17
|
+
ModalBuilder,
|
|
18
|
+
type RoleSelectMenuBuilder,
|
|
19
|
+
type SectionBuilder,
|
|
20
|
+
type SeparatorBuilder,
|
|
21
|
+
type StringSelectMenuBuilder,
|
|
22
|
+
TextDisplayBuilder,
|
|
23
|
+
TextInputBuilder,
|
|
24
|
+
TextInputStyle,
|
|
25
|
+
type UserSelectMenuBuilder,
|
|
26
|
+
} from "discord.js";
|
|
27
|
+
|
|
28
|
+
/** Any of the five select menu builders discord.js ships. */
|
|
29
|
+
export type AnySelectMenuBuilder =
|
|
30
|
+
| StringSelectMenuBuilder
|
|
31
|
+
| UserSelectMenuBuilder
|
|
32
|
+
| RoleSelectMenuBuilder
|
|
33
|
+
| ChannelSelectMenuBuilder
|
|
34
|
+
| MentionableSelectMenuBuilder;
|
|
35
|
+
|
|
36
|
+
/** An action row holding any interactive message component. */
|
|
37
|
+
export type MessageActionRow = ActionRowBuilder<
|
|
38
|
+
ButtonBuilder | AnySelectMenuBuilder
|
|
39
|
+
>;
|
|
40
|
+
|
|
41
|
+
/** Interactions a pagination can attach itself to. */
|
|
42
|
+
export type PaginationInteraction =
|
|
43
|
+
| ButtonInteraction
|
|
44
|
+
| ChatInputCommandInteraction;
|
|
45
|
+
|
|
46
|
+
/** Anything a pagination can be sent in reply to. */
|
|
47
|
+
export type PaginationTarget = PaginationInteraction | Message;
|
|
48
|
+
|
|
49
|
+
/** Marks where the navigation buttons render within a layout. */
|
|
50
|
+
export const BUTTONS_SYMBOL: unique symbol = Symbol("pagination-buttons");
|
|
51
|
+
|
|
52
|
+
/** Marks where the current page's entries render within a layout. */
|
|
53
|
+
export const DATA_SYMBOL: unique symbol = Symbol("pagination-data");
|
|
54
|
+
|
|
55
|
+
/** A layout component that renders identically on every page. */
|
|
56
|
+
export type PaginationStaticComponent =
|
|
57
|
+
| TextDisplayBuilder
|
|
58
|
+
| SectionBuilder
|
|
59
|
+
| SeparatorBuilder
|
|
60
|
+
| FileBuilder
|
|
61
|
+
| MediaGalleryBuilder
|
|
62
|
+
| MessageActionRow;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* An entry in a container layout: a static component, a bare string (shorthand
|
|
66
|
+
* for a `TextDisplayBuilder`), or one of the two placement sentinels.
|
|
67
|
+
*/
|
|
68
|
+
export type PaginationInput =
|
|
69
|
+
| string
|
|
70
|
+
| PaginationStaticComponent
|
|
71
|
+
| typeof BUTTONS_SYMBOL
|
|
72
|
+
| typeof DATA_SYMBOL;
|
|
73
|
+
|
|
74
|
+
/** A layout entry after the sentinels have been resolved. */
|
|
75
|
+
type PaginationComponent =
|
|
76
|
+
| { kind: "buttons" }
|
|
77
|
+
| { kind: "data" }
|
|
78
|
+
| { kind: "static"; component: PaginationStaticComponent };
|
|
79
|
+
|
|
80
|
+
/** Appearance of a single navigation button. */
|
|
81
|
+
export interface PaginationButtonConfig {
|
|
82
|
+
/** Replaces the default label. */
|
|
83
|
+
label?: string;
|
|
84
|
+
/** Adds an emoji alongside the label. */
|
|
85
|
+
emoji?: string;
|
|
86
|
+
/** Replaces the default `Secondary` style. */
|
|
87
|
+
style?: ButtonStyle;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Per-button appearance overrides. */
|
|
91
|
+
export interface PaginationButtonOptions {
|
|
92
|
+
/** Jump to the first page. Only rendered when `showSkipButtons` is set. */
|
|
93
|
+
first?: PaginationButtonConfig;
|
|
94
|
+
/** Step back one page. */
|
|
95
|
+
back?: PaginationButtonConfig;
|
|
96
|
+
/** Step forward one page. */
|
|
97
|
+
next?: PaginationButtonConfig;
|
|
98
|
+
/** Jump to the last page. Only rendered when `showSkipButtons` is set. */
|
|
99
|
+
last?: PaginationButtonConfig;
|
|
100
|
+
/** Opens the "jump to page" modal. Defaults to a `current/total` counter. */
|
|
101
|
+
jump?: PaginationButtonConfig;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Options shared by every pagination mode. */
|
|
105
|
+
export interface PaginationBaseOptions {
|
|
106
|
+
/** Entries shown per page. Defaults to `5`. */
|
|
107
|
+
entriesPerPage?: number;
|
|
108
|
+
/** Literal substrings replaced in rendered page content. */
|
|
109
|
+
replacements?: Record<string, string>;
|
|
110
|
+
/** Send the reply as ephemeral. Ignored for message targets. */
|
|
111
|
+
ephemeral?: boolean;
|
|
112
|
+
/** Idle timeout in milliseconds before the buttons disable. Defaults to `60_000`. */
|
|
113
|
+
idleTimeout?: number;
|
|
114
|
+
/** Per-button appearance overrides. */
|
|
115
|
+
buttons?: PaginationButtonOptions;
|
|
116
|
+
/** Render the first/last skip buttons. Defaults to `false`. */
|
|
117
|
+
showSkipButtons?: boolean;
|
|
118
|
+
/** Called once the collector stops, after the buttons are disabled. */
|
|
119
|
+
onEnd?: (interaction?: PaginationInteraction) => void | Promise<void>;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Options for container mode: a Components V2 layout built from a
|
|
124
|
+
* `ContainerBuilder`, sent with the `IsComponentsV2` message flag.
|
|
125
|
+
*/
|
|
126
|
+
export interface PaginationContainerOptions extends PaginationBaseOptions {
|
|
127
|
+
/** Selects container mode. */
|
|
128
|
+
type: "container";
|
|
129
|
+
/**
|
|
130
|
+
* The page template. Include {@link DiscordPagination.DATA} and
|
|
131
|
+
* {@link DiscordPagination.BUTTONS} to place the entries and the navigation
|
|
132
|
+
* buttons; everything else renders as-is on every page.
|
|
133
|
+
*/
|
|
134
|
+
layout: PaginationInput[];
|
|
135
|
+
/** Accent colour of the container's left edge. */
|
|
136
|
+
accentColor?: number;
|
|
137
|
+
/** Render the container behind a spoiler. */
|
|
138
|
+
spoiler?: boolean;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Options for embed mode: a standard `EmbedBuilder` with the navigation buttons
|
|
143
|
+
* in an action row beneath it.
|
|
144
|
+
*/
|
|
145
|
+
export interface PaginationEmbedOptions extends PaginationBaseOptions {
|
|
146
|
+
/**
|
|
147
|
+
* The page template. Its `description` and `footer` are overwritten each
|
|
148
|
+
* page with the entries and the page counter respectively.
|
|
149
|
+
*/
|
|
150
|
+
embed: EmbedBuilder;
|
|
151
|
+
/** Selects embed mode. */
|
|
152
|
+
type: "embed";
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Every pagination mode, discriminated by `type`. */
|
|
156
|
+
export type PaginationOptions =
|
|
157
|
+
| PaginationContainerOptions
|
|
158
|
+
| PaginationEmbedOptions;
|
|
159
|
+
|
|
160
|
+
/** Resolved mode, holding only the fields that mode actually uses. */
|
|
161
|
+
type PaginationMode =
|
|
162
|
+
| {
|
|
163
|
+
type: "container";
|
|
164
|
+
layout: PaginationComponent[];
|
|
165
|
+
accentColor?: number;
|
|
166
|
+
spoiler?: boolean;
|
|
167
|
+
}
|
|
168
|
+
| { type: "embed"; embed: EmbedBuilder };
|
|
169
|
+
|
|
170
|
+
const ALLOWED_MENTIONS = { parse: [] as const, repliedUser: false };
|
|
171
|
+
const EMPTY_CONTENT = "No data to show";
|
|
172
|
+
const MODAL_TIMEOUT = 60_000;
|
|
173
|
+
|
|
174
|
+
/** Narrows a target to a `Message`; only interactions can be deferred. */
|
|
175
|
+
function isMessageTarget(target: PaginationTarget): target is Message {
|
|
176
|
+
return !("deferReply" in target);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* A button-driven paginator for long lists, in either Components V2 container
|
|
181
|
+
* mode or classic embed mode.
|
|
182
|
+
*
|
|
183
|
+
* Navigation state lives on the instance, so one paginator drives one message.
|
|
184
|
+
* The collector is scoped to that message and to the user who triggered it, and
|
|
185
|
+
* every button carries a per-instance id prefix, so several paginators can run
|
|
186
|
+
* in the same channel without colliding. When the idle timeout elapses the
|
|
187
|
+
* buttons are disabled rather than removed.
|
|
188
|
+
*
|
|
189
|
+
* @example Container mode
|
|
190
|
+
* ```ts
|
|
191
|
+
* await new DiscordPagination(entries, {
|
|
192
|
+
* type: "container",
|
|
193
|
+
* layout: [
|
|
194
|
+
* "# Leaderboard",
|
|
195
|
+
* new SeparatorBuilder(),
|
|
196
|
+
* DiscordPagination.DATA,
|
|
197
|
+
* new SeparatorBuilder(),
|
|
198
|
+
* DiscordPagination.BUTTONS,
|
|
199
|
+
* ],
|
|
200
|
+
* accentColor: 0x5865f2,
|
|
201
|
+
* }).send(interaction);
|
|
202
|
+
* ```
|
|
203
|
+
*
|
|
204
|
+
* @example Embed mode
|
|
205
|
+
* ```ts
|
|
206
|
+
* await new DiscordPagination(entries, {
|
|
207
|
+
* type: "embed",
|
|
208
|
+
* embed: new EmbedBuilder().setTitle("Leaderboard").setColor(0x5865f2),
|
|
209
|
+
* showSkipButtons: true,
|
|
210
|
+
* }).send(interaction);
|
|
211
|
+
* ```
|
|
212
|
+
*/
|
|
213
|
+
export class DiscordPagination {
|
|
214
|
+
/** Sentinel marking where the navigation buttons render. */
|
|
215
|
+
static readonly BUTTONS: typeof BUTTONS_SYMBOL = BUTTONS_SYMBOL;
|
|
216
|
+
/** Sentinel marking where the current page's entries render. */
|
|
217
|
+
static readonly DATA: typeof DATA_SYMBOL = DATA_SYMBOL;
|
|
218
|
+
|
|
219
|
+
private readonly list: string[];
|
|
220
|
+
private readonly mode: PaginationMode;
|
|
221
|
+
private readonly entriesPerPage: number;
|
|
222
|
+
private readonly totalPages: number;
|
|
223
|
+
private readonly replacements?: Record<string, string>;
|
|
224
|
+
private readonly ephemeral: boolean;
|
|
225
|
+
private readonly idleTimeout: number;
|
|
226
|
+
private readonly buttons?: PaginationButtonOptions;
|
|
227
|
+
private readonly showSkipButtons: boolean;
|
|
228
|
+
private readonly onEnd?: (
|
|
229
|
+
interaction?: PaginationInteraction,
|
|
230
|
+
) => void | Promise<void>;
|
|
231
|
+
|
|
232
|
+
/** Prefix isolating this instance's button ids from any other paginator's. */
|
|
233
|
+
private readonly prefix: string;
|
|
234
|
+
|
|
235
|
+
private currentIndex = 0;
|
|
236
|
+
private ended = false;
|
|
237
|
+
private interaction?: PaginationInteraction;
|
|
238
|
+
private replyMessage?: Message;
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* @param list - The entries to paginate, one per line.
|
|
242
|
+
* @param options - See {@link PaginationOptions}.
|
|
243
|
+
* @throws {RangeError} If `entriesPerPage` is not a positive integer.
|
|
244
|
+
*/
|
|
245
|
+
constructor(list: string[], options: PaginationOptions) {
|
|
246
|
+
const {
|
|
247
|
+
entriesPerPage = 5,
|
|
248
|
+
replacements,
|
|
249
|
+
ephemeral = false,
|
|
250
|
+
idleTimeout = 60_000,
|
|
251
|
+
buttons,
|
|
252
|
+
showSkipButtons = false,
|
|
253
|
+
onEnd,
|
|
254
|
+
} = options;
|
|
255
|
+
|
|
256
|
+
if (!Number.isInteger(entriesPerPage) || entriesPerPage <= 0) {
|
|
257
|
+
throw new RangeError("entriesPerPage must be a positive integer");
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
this.list = list;
|
|
261
|
+
this.entriesPerPage = entriesPerPage;
|
|
262
|
+
this.totalPages = Math.ceil(list.length / entriesPerPage);
|
|
263
|
+
this.replacements = replacements;
|
|
264
|
+
this.ephemeral = ephemeral;
|
|
265
|
+
this.idleTimeout = idleTimeout;
|
|
266
|
+
this.buttons = buttons;
|
|
267
|
+
this.showSkipButtons = showSkipButtons;
|
|
268
|
+
this.onEnd = onEnd;
|
|
269
|
+
this.prefix = `~PAGINATION_${randomUUIDv7()}_`;
|
|
270
|
+
|
|
271
|
+
this.mode =
|
|
272
|
+
options.type === "container"
|
|
273
|
+
? {
|
|
274
|
+
type: "container",
|
|
275
|
+
layout: options.layout.map((input) => normalize(input)),
|
|
276
|
+
accentColor: options.accentColor,
|
|
277
|
+
spoiler: options.spoiler,
|
|
278
|
+
}
|
|
279
|
+
: { type: "embed", embed: options.embed };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Sends the first page and starts listening for button presses.
|
|
284
|
+
*
|
|
285
|
+
* An empty list short-circuits to a placeholder message with no collector.
|
|
286
|
+
*
|
|
287
|
+
* @param target - The interaction or message to reply to. Only the user who
|
|
288
|
+
* triggered it can drive the resulting buttons.
|
|
289
|
+
*/
|
|
290
|
+
public async send(target: PaginationTarget): Promise<void> {
|
|
291
|
+
if (!this.list.length) return this.sendEmpty(target);
|
|
292
|
+
|
|
293
|
+
const userId = isMessageTarget(target) ? target.author.id : target.user.id;
|
|
294
|
+
|
|
295
|
+
if (isMessageTarget(target)) {
|
|
296
|
+
this.replyMessage = await target.reply(this.buildPayload());
|
|
297
|
+
} else {
|
|
298
|
+
this.interaction = target;
|
|
299
|
+
|
|
300
|
+
if (!target.replied && !target.deferred) {
|
|
301
|
+
const response = await target
|
|
302
|
+
.deferReply({
|
|
303
|
+
withResponse: true,
|
|
304
|
+
flags: this.ephemeral ? ["Ephemeral"] : [],
|
|
305
|
+
})
|
|
306
|
+
.catch(() => null);
|
|
307
|
+
this.replyMessage =
|
|
308
|
+
response?.resource?.message ??
|
|
309
|
+
(await target.fetchReply().catch(() => undefined));
|
|
310
|
+
} else {
|
|
311
|
+
this.replyMessage = await target.fetchReply().catch(() => undefined);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
await this.render();
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (!this.replyMessage) return;
|
|
318
|
+
|
|
319
|
+
const collector = this.replyMessage.createMessageComponentCollector({
|
|
320
|
+
componentType: ComponentType.Button,
|
|
321
|
+
time: this.idleTimeout,
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
collector.on("collect", async (button) => {
|
|
325
|
+
// Prefix first: a static action row in the layout shares this
|
|
326
|
+
// message, and its buttons belong to the consumer's collector.
|
|
327
|
+
if (!button.customId.startsWith(this.prefix)) return;
|
|
328
|
+
if (button.user.id !== userId) return void button.deferUpdate();
|
|
329
|
+
|
|
330
|
+
this.ended = false;
|
|
331
|
+
collector.resetTimer();
|
|
332
|
+
|
|
333
|
+
const isJump = button.customId === `${this.prefix}info`;
|
|
334
|
+
|
|
335
|
+
if (isJump) {
|
|
336
|
+
await this.handlePageJump(button);
|
|
337
|
+
} else if (button.customId === `${this.prefix}first`) {
|
|
338
|
+
this.currentIndex = 0;
|
|
339
|
+
} else if (button.customId === `${this.prefix}last`) {
|
|
340
|
+
this.currentIndex = this.lastIndex;
|
|
341
|
+
} else {
|
|
342
|
+
const step =
|
|
343
|
+
button.customId === `${this.prefix}back`
|
|
344
|
+
? -this.entriesPerPage
|
|
345
|
+
: this.entriesPerPage;
|
|
346
|
+
this.currentIndex = Math.max(
|
|
347
|
+
0,
|
|
348
|
+
Math.min(this.currentIndex + step, this.lastIndex),
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (!isJump) await button.deferUpdate().catch(() => {});
|
|
353
|
+
|
|
354
|
+
await this.render();
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
collector.on("end", async () => {
|
|
358
|
+
this.ended = true;
|
|
359
|
+
await this.render();
|
|
360
|
+
await this.onEnd?.(this.interaction);
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Index of the first entry on the last page. */
|
|
365
|
+
private get lastIndex(): number {
|
|
366
|
+
return (this.totalPages - 1) * this.entriesPerPage;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Zero-based index of the page currently displayed. */
|
|
370
|
+
private get page(): number {
|
|
371
|
+
return Math.floor(this.currentIndex / this.entriesPerPage);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** The current page's entries, joined and with replacements applied. */
|
|
375
|
+
private pageContent(): string {
|
|
376
|
+
const start = this.page * this.entriesPerPage;
|
|
377
|
+
const content = this.list
|
|
378
|
+
.slice(start, start + this.entriesPerPage)
|
|
379
|
+
.join("\n");
|
|
380
|
+
|
|
381
|
+
if (!this.replacements) return content;
|
|
382
|
+
|
|
383
|
+
return Object.entries(this.replacements).reduce(
|
|
384
|
+
(acc, [key, value]) => acc.replaceAll(key, () => value),
|
|
385
|
+
content,
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Replies with a placeholder when there is nothing to paginate. */
|
|
390
|
+
private async sendEmpty(target: PaginationTarget): Promise<void> {
|
|
391
|
+
const body =
|
|
392
|
+
this.mode.type === "container"
|
|
393
|
+
? {
|
|
394
|
+
components: [
|
|
395
|
+
new ContainerBuilder().addTextDisplayComponents(
|
|
396
|
+
new TextDisplayBuilder().setContent(EMPTY_CONTENT),
|
|
397
|
+
),
|
|
398
|
+
],
|
|
399
|
+
}
|
|
400
|
+
: {
|
|
401
|
+
embeds: [
|
|
402
|
+
new EmbedBuilder(this.mode.embed.toJSON()).setDescription(
|
|
403
|
+
EMPTY_CONTENT,
|
|
404
|
+
),
|
|
405
|
+
],
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
if (isMessageTarget(target)) {
|
|
409
|
+
await target
|
|
410
|
+
.reply(
|
|
411
|
+
"components" in body
|
|
412
|
+
? {
|
|
413
|
+
...body,
|
|
414
|
+
flags: ["IsComponentsV2"] as const,
|
|
415
|
+
allowedMentions: ALLOWED_MENTIONS,
|
|
416
|
+
}
|
|
417
|
+
: { ...body, allowedMentions: ALLOWED_MENTIONS },
|
|
418
|
+
)
|
|
419
|
+
.catch(() => {});
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (target.deferred || target.replied) {
|
|
424
|
+
await target
|
|
425
|
+
.editReply({
|
|
426
|
+
...body,
|
|
427
|
+
...("components" in body
|
|
428
|
+
? { flags: ["IsComponentsV2"] as const }
|
|
429
|
+
: {}),
|
|
430
|
+
allowedMentions: ALLOWED_MENTIONS,
|
|
431
|
+
})
|
|
432
|
+
.catch(() => {});
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
await target
|
|
437
|
+
.reply(
|
|
438
|
+
"components" in body
|
|
439
|
+
? {
|
|
440
|
+
...body,
|
|
441
|
+
flags: this.ephemeral
|
|
442
|
+
? (["Ephemeral", "IsComponentsV2"] as const)
|
|
443
|
+
: (["IsComponentsV2"] as const),
|
|
444
|
+
allowedMentions: ALLOWED_MENTIONS,
|
|
445
|
+
}
|
|
446
|
+
: {
|
|
447
|
+
...body,
|
|
448
|
+
flags: this.ephemeral ? (["Ephemeral"] as const) : ([] as const),
|
|
449
|
+
allowedMentions: ALLOWED_MENTIONS,
|
|
450
|
+
},
|
|
451
|
+
)
|
|
452
|
+
.catch(() => {});
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** Builds the message payload for the current page in the active mode. */
|
|
456
|
+
private buildPayload() {
|
|
457
|
+
const mode = this.mode;
|
|
458
|
+
|
|
459
|
+
if (mode.type === "embed") {
|
|
460
|
+
return {
|
|
461
|
+
embeds: [
|
|
462
|
+
new EmbedBuilder(mode.embed.toJSON())
|
|
463
|
+
.setDescription(this.pageContent())
|
|
464
|
+
.setFooter({ text: `Page ${this.page + 1}/${this.totalPages}` }),
|
|
465
|
+
],
|
|
466
|
+
components: [this.buildButtonRow()],
|
|
467
|
+
allowedMentions: ALLOWED_MENTIONS,
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
return {
|
|
472
|
+
components: [
|
|
473
|
+
new ContainerBuilder({
|
|
474
|
+
components: mode.layout.map((entry) => {
|
|
475
|
+
switch (entry.kind) {
|
|
476
|
+
case "buttons":
|
|
477
|
+
return this.buildButtonRow().toJSON();
|
|
478
|
+
case "data":
|
|
479
|
+
return new TextDisplayBuilder()
|
|
480
|
+
.setContent(this.pageContent())
|
|
481
|
+
.toJSON();
|
|
482
|
+
default:
|
|
483
|
+
return entry.component.toJSON();
|
|
484
|
+
}
|
|
485
|
+
}),
|
|
486
|
+
accent_color: mode.accentColor,
|
|
487
|
+
spoiler: mode.spoiler,
|
|
488
|
+
}),
|
|
489
|
+
],
|
|
490
|
+
flags: ["IsComponentsV2"] as const,
|
|
491
|
+
allowedMentions: ALLOWED_MENTIONS,
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** Builds one navigation button. */
|
|
496
|
+
private button(
|
|
497
|
+
id: string,
|
|
498
|
+
defaultLabel: string,
|
|
499
|
+
config: PaginationButtonConfig | undefined,
|
|
500
|
+
disabled: boolean,
|
|
501
|
+
): ButtonBuilder {
|
|
502
|
+
const button = new ButtonBuilder()
|
|
503
|
+
.setCustomId(`${this.prefix}${id}`)
|
|
504
|
+
.setLabel(config?.label ?? defaultLabel)
|
|
505
|
+
.setStyle(config?.style ?? ButtonStyle.Secondary)
|
|
506
|
+
.setDisabled(disabled);
|
|
507
|
+
|
|
508
|
+
if (config?.emoji) button.setEmoji(config.emoji);
|
|
509
|
+
|
|
510
|
+
return button;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** Builds the navigation row for the current page. */
|
|
514
|
+
private buildButtonRow(): ActionRowBuilder<ButtonBuilder> {
|
|
515
|
+
const atStart = this.ended || this.currentIndex === 0;
|
|
516
|
+
const atEnd =
|
|
517
|
+
this.ended || this.currentIndex + this.entriesPerPage >= this.list.length;
|
|
518
|
+
|
|
519
|
+
const row = new ActionRowBuilder<ButtonBuilder>();
|
|
520
|
+
|
|
521
|
+
if (this.showSkipButtons) {
|
|
522
|
+
row.addComponents(
|
|
523
|
+
this.button("first", "<<", this.buttons?.first, atStart),
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
row.addComponents(
|
|
528
|
+
this.button("back", "<", this.buttons?.back, atStart),
|
|
529
|
+
this.button(
|
|
530
|
+
"info",
|
|
531
|
+
`${this.page + 1}/${this.totalPages}`,
|
|
532
|
+
this.buttons?.jump,
|
|
533
|
+
this.ended || this.totalPages === 1,
|
|
534
|
+
),
|
|
535
|
+
this.button("forward", ">", this.buttons?.next, atEnd),
|
|
536
|
+
);
|
|
537
|
+
|
|
538
|
+
if (this.showSkipButtons) {
|
|
539
|
+
row.addComponents(this.button("last", ">>", this.buttons?.last, atEnd));
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
return row;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** Prompts for a page number via a modal and jumps to it. */
|
|
546
|
+
private async handlePageJump(button: ButtonInteraction): Promise<void> {
|
|
547
|
+
const modal = new ModalBuilder()
|
|
548
|
+
.setCustomId(`${this.prefix}modal`)
|
|
549
|
+
.setTitle("Jump to page")
|
|
550
|
+
.addLabelComponents(
|
|
551
|
+
new LabelBuilder()
|
|
552
|
+
.setLabel("Input a page number")
|
|
553
|
+
.setTextInputComponent(
|
|
554
|
+
new TextInputBuilder()
|
|
555
|
+
.setCustomId(`${this.prefix}number`)
|
|
556
|
+
.setRequired(true)
|
|
557
|
+
.setMinLength(1)
|
|
558
|
+
.setStyle(TextInputStyle.Short),
|
|
559
|
+
),
|
|
560
|
+
);
|
|
561
|
+
|
|
562
|
+
await button.showModal(modal).catch(() => {});
|
|
563
|
+
|
|
564
|
+
const submission = await button
|
|
565
|
+
.awaitModalSubmit({
|
|
566
|
+
filter: (i) => i.customId === `${this.prefix}modal`,
|
|
567
|
+
time: MODAL_TIMEOUT,
|
|
568
|
+
})
|
|
569
|
+
.catch(() => null);
|
|
570
|
+
|
|
571
|
+
if (!submission) return;
|
|
572
|
+
|
|
573
|
+
const pageNumber = Number(
|
|
574
|
+
submission.fields.getTextInputValue(`${this.prefix}number`),
|
|
575
|
+
);
|
|
576
|
+
|
|
577
|
+
if (
|
|
578
|
+
!Number.isInteger(pageNumber) ||
|
|
579
|
+
pageNumber < 1 ||
|
|
580
|
+
pageNumber > this.totalPages
|
|
581
|
+
) {
|
|
582
|
+
await submission
|
|
583
|
+
.reply({
|
|
584
|
+
content: `Invalid page! Choose a number between **1** and **${this.totalPages}**.`,
|
|
585
|
+
flags: ["Ephemeral"],
|
|
586
|
+
allowedMentions: ALLOWED_MENTIONS,
|
|
587
|
+
})
|
|
588
|
+
.catch(() => null);
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
await submission.deferUpdate().catch(() => null);
|
|
593
|
+
this.currentIndex = (pageNumber - 1) * this.entriesPerPage;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/** Pushes the current page to the already-sent message. */
|
|
597
|
+
private async render(): Promise<void> {
|
|
598
|
+
try {
|
|
599
|
+
const payload = this.buildPayload();
|
|
600
|
+
|
|
601
|
+
if (this.interaction) {
|
|
602
|
+
await this.interaction.editReply(payload);
|
|
603
|
+
} else if (this.replyMessage) {
|
|
604
|
+
await this.replyMessage.edit(payload);
|
|
605
|
+
}
|
|
606
|
+
} catch (error) {
|
|
607
|
+
// The message was deleted while the collector was still running.
|
|
608
|
+
if (!(error as Error).message.includes("Unknown Message")) {
|
|
609
|
+
console.error("Failed to render pagination:", error);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/** Resolves a layout entry to its internal representation. */
|
|
616
|
+
function normalize(input: PaginationInput): PaginationComponent {
|
|
617
|
+
if (input === BUTTONS_SYMBOL) return { kind: "buttons" };
|
|
618
|
+
if (input === DATA_SYMBOL) return { kind: "data" };
|
|
619
|
+
if (typeof input === "string") {
|
|
620
|
+
return {
|
|
621
|
+
kind: "static",
|
|
622
|
+
component: new TextDisplayBuilder().setContent(input),
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
return { kind: "static", component: input };
|
|
626
|
+
}
|