@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.
@@ -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
+ }