@lunibee/builders 0.2.4 → 0.3.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/src/components.ts CHANGED
@@ -1,880 +1,764 @@
1
- import { ButtonType, ComponentEnum } from "@lunibee/types";
2
- export { ButtonType, ComponentEnum };
3
-
4
- export const TextInputType = { Short: 1, Paragraph: 2 } as const;
5
- /** One {@link TextInputType} value, usable as a type. */
6
- export type TextInputType = (typeof TextInputType)[keyof typeof TextInputType];
7
- export interface APIComponentEmoji {
8
- id?: string | null;
9
- name?: string | null;
10
- animated?: boolean;
11
- }
12
- export interface APIButtonComponent {
13
- type: typeof ComponentEnum.Button;
14
- style: 1 | 2 | 3 | 4 | 5 | 6;
15
- custom_id?: string;
16
- label?: string;
17
- emoji?: APIComponentEmoji;
18
- url?: string;
19
- /** SKU for premium (style 6) buttons. */
20
- sku_id?: string;
21
- disabled?: boolean;
22
- }
23
- export interface APISelectOption {
24
- label: string;
25
- value: string;
26
- description?: string;
27
- emoji?: APIComponentEmoji;
28
- default?: boolean;
29
- }
30
- export interface APIStringSelectComponent {
31
- type: typeof ComponentEnum.StringSelect;
32
- custom_id?: string;
33
- placeholder?: string;
34
- min_values?: number;
35
- max_values?: number;
36
- required?: boolean;
37
- disabled?: boolean;
38
- options?: APISelectOption[];
39
- }
40
- export interface APIEntitySelectComponent {
41
- type:
42
- | typeof ComponentEnum.UserSelect
43
- | typeof ComponentEnum.RoleSelect
44
- | typeof ComponentEnum.MentionableSelect
45
- | typeof ComponentEnum.ChannelSelect;
46
- custom_id?: string;
47
- placeholder?: string;
48
- min_values?: number;
49
- max_values?: number;
50
- required?: boolean;
51
- disabled?: boolean;
52
- default_values?: APISelectDefaultValue[];
53
- }
54
- export interface APITextInputComponent {
55
- type: typeof ComponentEnum.TextInput;
56
- style: typeof TextInputType.Short | typeof TextInputType.Paragraph;
57
- custom_id?: string;
58
- label?: string;
59
- placeholder?: string;
60
- min_length?: number;
61
- max_length?: number;
62
- required?: boolean;
63
- value?: string;
64
- }
65
- export interface APIModalComponent {
66
- type: 9;
67
- custom_id: string;
68
- title: string;
69
- components: APIActionRowComponent[];
70
- }
71
- export type APIActionRowChild =
72
- | APIButtonComponent
73
- | APIStringSelectComponent
74
- | APIEntitySelectComponent
75
- | APITextInputComponent;
76
- export interface APIActionRowComponent {
77
- type: typeof ComponentEnum.ActionRow;
78
- components: APIActionRowChild[];
79
- }
80
- export interface APISectionComponent {
81
- type: typeof ComponentEnum.Section;
82
- components?: APIComponent[]; // text displays
83
- accessory?: APIComponent; // buttons, thumbnails, etc.
84
- }
85
- export interface APITextDisplayComponent {
86
- type: typeof ComponentEnum.TextDisplay;
87
- content: string;
88
- }
89
- export interface APIMediaGalleryComponent {
90
- type: typeof ComponentEnum.MediaGallery;
91
- items: { media: { url: string; description?: string } }[];
92
- }
93
- /** Default values accepted by an auto-populated entity select. */
94
- export interface APISelectDefaultValue {
95
- id: string;
96
- type: "user" | "role" | "channel";
97
- }
98
- export interface APIFileComponent {
99
- type: typeof ComponentEnum.File;
100
- file: { url: string };
101
- spoiler?: boolean;
102
- }
103
- export interface APISeparatorComponent {
104
- type: typeof ComponentEnum.Separator;
105
- spacing?: 1 | 2;
106
- divider?: boolean;
107
- }
108
- export interface APIThumbnailComponent {
109
- type: typeof ComponentEnum.Thumbnail;
110
- url: string;
111
- proxy_url?: string;
112
- width?: number;
113
- height?: number;
114
- description?: string;
115
- spoiler?: boolean;
116
- }
117
- export interface APIContentInventoryEntryComponent {
118
- type: typeof ComponentEnum.ContentInventoryEntry;
119
- id: string;
120
- }
121
- export interface APIContainerComponent {
122
- type: typeof ComponentEnum.Container;
123
- components: APIComponent[];
124
- accent_color?: number;
125
- spoiler?: boolean;
126
- }
127
- export type APIComponent =
128
- | APIActionRowComponent
129
- | APIButtonComponent
130
- | APIStringSelectComponent
131
- | APIEntitySelectComponent
132
- | APITextInputComponent
133
- | APISectionComponent
134
- | APITextDisplayComponent
135
- | APIThumbnailComponent
136
- | APIMediaGalleryComponent
137
- | APIFileComponent
138
- | APISeparatorComponent
139
- | APIContentInventoryEntryComponent
140
- | APIContainerComponent;
141
-
142
- export class CreateActionRow<
143
- T extends { toJSON(): APIActionRowChild } = { toJSON(): APIActionRowChild },
144
- > {
145
- readonly #components: T[] = [];
146
- public addComponents(...components: T[]): this {
147
- if (!components.length)
148
- throw new TypeError("At least one component is required.");
149
- if (this.#components.length + components.length > 5)
150
- throw new RangeError(
151
- "An action row cannot contain more than 5 components.",
152
- );
153
- this.#components.push(...components);
154
- return this;
155
- }
156
- public clearComponents(): this {
157
- this.#components.length = 0;
158
- return this;
159
- }
160
- public toJSON(): APIActionRowComponent {
161
- const components = this.#components.map((component) =>
162
- component.toJSON(),
163
- );
164
- if (components.length === 0)
165
- throw new RangeError("An action row needs at least one component.");
166
- const types = components.map((component) => component.type);
167
- if (
168
- types.some((type) => type !== ComponentEnum.Button) &&
169
- components.length > 1
170
- )
171
- throw new RangeError(
172
- "A select menu or text input must be alone in its action row.",
173
- );
174
- return { type: ComponentEnum.ActionRow, components };
175
- }
176
- }
177
- /** Setters shared by string and entity select menus. */
178
- export class CreateSelectMenu {
179
- readonly #select: APIStringSelectComponent | APIEntitySelectComponent;
180
- protected constructor(
181
- data: APIStringSelectComponent | APIEntitySelectComponent,
182
- ) {
183
- this.#select = data;
184
- }
185
- public setCustomId(value: string): this {
186
- validateText(value, 100, "Component custom ID");
187
- this.#select.custom_id = value;
188
- return this;
189
- }
190
- public setPlaceholder(value: string): this {
191
- validateText(value, 150, "Component placeholder");
192
- this.#select.placeholder = value;
193
- return this;
194
- }
195
- public setMinValues(value: number): this {
196
- validateCount(value, "min_values", 0);
197
- this.#select.min_values = value;
198
- return this;
199
- }
200
- public setMaxValues(value: number): this {
201
- validateCount(value, "max_values", 1);
202
- this.#select.max_values = value;
203
- return this;
204
- }
205
- public setRequired(value = true): this {
206
- this.#select.required = value;
207
- return this;
208
- }
209
- public setDisabled(value = true): this {
210
- this.#select.disabled = value;
211
- return this;
212
- }
213
- }
214
- export class CreateStringSelect extends CreateSelectMenu {
215
- readonly #data: APIStringSelectComponent;
216
- public constructor() {
217
- const data: APIStringSelectComponent = {
218
- type: ComponentEnum.StringSelect,
219
- };
220
- super(data);
221
- this.#data = data;
222
- }
223
- public addOptions(...options: APISelectOption[]): this {
224
- if (!options.length)
225
- throw new TypeError("At least one select option is required.");
226
- const current = this.#data.options ?? [];
227
- if (current.length + options.length > 25)
228
- throw new RangeError(
229
- "A string select cannot contain more than 25 options.",
230
- );
231
- for (const option of options) {
232
- validateText(option.label, 100, "Select option label");
233
- validateText(option.value, 100, "Select option value");
234
- if (option.description)
235
- validateText(
236
- option.description,
237
- 100,
238
- "Select option description",
239
- );
240
- }
241
- this.#data.options = [
242
- ...current,
243
- ...options.map((option) => ({ ...option })),
244
- ];
245
- return this;
246
- }
247
- public toJSON(): APIStringSelectComponent {
248
- assertSelect(this.#data);
249
- const count = this.#data.options?.length ?? 0;
250
- if (count === 0)
251
- throw new RangeError("A string select needs at least one option.");
252
- if ((this.#data.max_values ?? 1) > count)
253
- throw new RangeError(
254
- "max_values cannot exceed the number of options.",
255
- );
256
- return structuredClone(this.#data);
257
- }
258
- }
259
- export class CreateButton {
260
- readonly #data: APIButtonComponent = {
261
- type: ComponentEnum.Button,
262
- style: ButtonType.Secondary,
263
- };
264
- /** Sets the style and drops fields that style cannot carry. */
265
- public setStyle(style: APIButtonComponent["style"]): this {
266
- const data = this.#data;
267
- data.style = style;
268
- if (style === ButtonType.Link) {
269
- delete data.custom_id;
270
- delete data.sku_id;
271
- } else if (style === ButtonType.Premium) {
272
- delete data.custom_id;
273
- delete data.url;
274
- delete data.label;
275
- delete data.emoji;
276
- } else {
277
- delete data.url;
278
- delete data.sku_id;
279
- }
280
- return this;
281
- }
282
- public setCustomId(value: string): this {
283
- validateText(value, 100, "Button custom ID");
284
- if (this.#data.style === ButtonType.Link)
285
- throw new TypeError("Link buttons cannot use custom IDs.");
286
- this.#data.custom_id = value;
287
- return this;
288
- }
289
- public setLabel(value: string): this {
290
- validateText(value, 80, "Button label");
291
- this.#data.label = value;
292
- return this;
293
- }
294
- public setURL(value: string): this {
295
- validateText(value, 512, "Button URL");
296
- let url: URL;
297
- try {
298
- url = new URL(value);
299
- } catch (error) {
300
- throw new TypeError("Button URL must be a valid URL.", {
301
- cause: error,
302
- });
303
- }
304
- this.setStyle(ButtonType.Link);
305
- this.#data.url = url.toString();
306
- return this;
307
- }
308
- /** Makes this a premium (purchase) button for a SKU. Premium buttons carry no label, emoji, custom ID or URL. */
309
- public setSKUId(skuId: string): this {
310
- validateText(skuId, 20, "Button SKU ID");
311
- this.setStyle(ButtonType.Premium);
312
- this.#data.sku_id = skuId;
313
- return this;
314
- }
315
- public setEmoji(emoji: APIComponentEmoji | string): this {
316
- this.#data.emoji =
317
- typeof emoji === "string" ? { name: emoji } : { ...emoji };
318
- return this;
319
- }
320
- public setDisabled(value = true): this {
321
- this.#data.disabled = value;
322
- return this;
323
- }
324
- public toJSON(): APIButtonComponent {
325
- const data = this.#data;
326
- if (data.style === ButtonType.Link) {
327
- if (!data.url) throw new TypeError("Link buttons require a URL.");
328
- if (data.custom_id || data.sku_id)
329
- throw new TypeError(
330
- "Link buttons cannot have a custom ID or SKU ID.",
331
- );
332
- } else if (data.style === ButtonType.Premium) {
333
- if (!data.sku_id)
334
- throw new TypeError("Premium buttons require a SKU ID.");
335
- if (data.custom_id || data.url || data.label || data.emoji)
336
- throw new TypeError(
337
- "Premium buttons cannot have a custom ID, URL, label or emoji.",
338
- );
339
- } else {
340
- if (!data.custom_id)
341
- throw new TypeError("Non-link buttons require a custom ID.");
342
- if (data.url || data.sku_id)
343
- throw new TypeError(
344
- "Only link buttons have a URL and only premium buttons have a SKU ID.",
345
- );
346
- }
347
- if (data.style !== ButtonType.Premium && !data.label && !data.emoji)
348
- throw new TypeError("Buttons require a label or an emoji.");
349
- return structuredClone(data);
350
- }
351
- }
352
- export class CreateEntitySelect extends CreateSelectMenu {
353
- readonly #data: APIEntitySelectComponent;
354
- public constructor(
355
- type:
356
- | typeof ComponentEnum.UserSelect
357
- | typeof ComponentEnum.RoleSelect
358
- | typeof ComponentEnum.MentionableSelect
359
- | typeof ComponentEnum.ChannelSelect,
360
- ) {
361
- if (
362
- type !== ComponentEnum.UserSelect &&
363
- type !== ComponentEnum.RoleSelect &&
364
- type !== ComponentEnum.MentionableSelect &&
365
- type !== ComponentEnum.ChannelSelect
366
- )
367
- throw new TypeError("Invalid select component type.");
368
- const data: APIEntitySelectComponent = { type };
369
- super(data);
370
- this.#data = data;
371
- }
372
- public setDefaultValues(...values: APISelectDefaultValue[]): this {
373
- this.#data.default_values = values.map((value) => ({ ...value }));
374
- return this;
375
- }
376
- public toJSON(): APIEntitySelectComponent {
377
- assertSelect(this.#data);
378
- return structuredClone(this.#data);
379
- }
380
- }
381
- export class CreateModal {
382
- readonly #components: Array<{ toJSON(): APIActionRowComponent }> = [];
383
- #custom_id?: string;
384
- #title?: string;
385
- public setCustomId(value: string): this {
386
- validateText(value, 100, "Modal custom ID");
387
- this.#custom_id = value;
388
- return this;
389
- }
390
- public setTitle(value: string): this {
391
- validateText(value, 45, "Modal title");
392
- this.#title = value;
393
- return this;
394
- }
395
- public addComponents(
396
- ...components: Array<{ toJSON(): APIActionRowComponent }>
397
- ): this {
398
- if (!components.length)
399
- throw new TypeError("At least one modal component is required.");
400
- if (this.#components.length + components.length > 5)
401
- throw new RangeError(
402
- "A modal cannot contain more than 5 action rows.",
403
- );
404
- this.#components.push(...components);
405
- return this;
406
- }
407
- /** Adds text inputs, each in its own action row (Discord's modal layout). @param inputs Text inputs, at most 5 per modal. */
408
- public addTextInputs(...inputs: CreateTextInput[]): this {
409
- return this.addComponents(
410
- ...inputs.map((input) =>
411
- new CreateActionRow<CreateTextInput>().addComponents(input),
412
- ),
413
- );
414
- }
415
- public toJSON(): APIModalComponent {
416
- if (!this.#custom_id || !this.#title)
417
- throw new TypeError("Modals require a custom ID and a title.");
418
- if (this.#components.length === 0)
419
- throw new RangeError("Modals require at least one component.");
420
- return {
421
- type: 9,
422
- custom_id: this.#custom_id,
423
- title: this.#title,
424
- components: this.#components.map((component) => component.toJSON()),
425
- };
426
- }
427
- }
428
- export class CreateTextInput {
429
- readonly #data: APITextInputComponent = {
430
- type: ComponentEnum.TextInput,
431
- style: TextInputType.Short,
432
- };
433
- public setStyle(style: APITextInputComponent["style"]): this {
434
- this.#data.style = style;
435
- return this;
436
- }
437
- public setCustomId(value: string): this {
438
- validateText(value, 100, "Text input custom ID");
439
- this.#data.custom_id = value;
440
- return this;
441
- }
442
- public setLabel(value: string): this {
443
- validateText(value, 45, "Text input label");
444
- this.#data.label = value;
445
- return this;
446
- }
447
- public setPlaceholder(value: string): this {
448
- validateText(value, 100, "Text input placeholder");
449
- this.#data.placeholder = value;
450
- return this;
451
- }
452
- public setMinLength(value: number): this {
453
- validateLength(value, "min_length", 4000);
454
- this.#data.min_length = value;
455
- return this;
456
- }
457
- public setMaxLength(value: number): this {
458
- validateLength(value, "max_length", 4000);
459
- this.#data.max_length = value;
460
- return this;
461
- }
462
- public setRequired(value = true): this {
463
- this.#data.required = value;
464
- return this;
465
- }
466
- public setValue(value: string): this {
467
- validateText(value, 4000, "Text input value");
468
- this.#data.value = value;
469
- return this;
470
- }
471
- public toJSON(): APITextInputComponent {
472
- const data = this.#data;
473
- if (!data.custom_id)
474
- throw new TypeError("Text inputs require a custom ID.");
475
- if (
476
- data.min_length !== undefined &&
477
- data.max_length !== undefined &&
478
- data.min_length > data.max_length
479
- )
480
- throw new RangeError("min_length cannot exceed max_length.");
481
- return structuredClone(data);
482
- }
483
- }
484
-
485
- export class CreateContainer {
486
- readonly #components: { toJSON(): APIComponent }[] = [];
487
- #accentColor?: number;
488
- #spoiler?: boolean;
489
- public addComponents(...components: { toJSON(): APIComponent }[]): this {
490
- if (!components.length)
491
- throw new TypeError("At least one component is required.");
492
- this.#components.push(...components);
493
- return this;
494
- }
495
- public setAccentColor(color: number): this {
496
- if (!Number.isInteger(color) || color < 0 || color > 0xffffff) {
497
- throw new RangeError(
498
- "Accent color must be an integer between 0 and 16777215 (0xFFFFFF).",
499
- );
500
- }
501
- this.#accentColor = color;
502
- return this;
503
- }
504
- /** Marks the whole container as a spoiler (blurred until clicked). */
505
- public setSpoiler(spoiler = true): this {
506
- this.#spoiler = spoiler;
507
- return this;
508
- }
509
- public toJSON(): APIContainerComponent {
510
- const data: APIContainerComponent = {
511
- type: ComponentEnum.Container,
512
- components: this.#components.map((c) => c.toJSON()),
513
- };
514
- if (this.#accentColor !== undefined)
515
- data.accent_color = this.#accentColor;
516
- if (this.#spoiler !== undefined) data.spoiler = this.#spoiler;
517
- return data;
518
- }
519
- }
520
-
521
- export class CreateSection {
522
- readonly #components: { toJSON(): APIComponent }[] = [];
523
- #accessory?: { toJSON(): APIComponent };
524
- public addComponents(...components: { toJSON(): APIComponent }[]): this {
525
- if (!components.length)
526
- throw new TypeError("At least one component is required.");
527
- this.#components.push(...components);
528
- return this;
529
- }
530
- public setAccessory(accessory: { toJSON(): APIComponent }): this {
531
- this.#accessory = accessory;
532
- return this;
533
- }
534
- public toJSON(): APISectionComponent {
535
- return {
536
- type: ComponentEnum.Section,
537
- components: this.#components.length
538
- ? this.#components.map((c) => c.toJSON())
539
- : undefined,
540
- accessory: this.#accessory?.toJSON(),
541
- };
542
- }
543
- }
544
-
545
- export class CreateTextDisplay {
546
- #content = "";
547
- public setContent(content: string): this {
548
- validateText(content, 4000, "Text display content");
549
- this.#content = content;
550
- return this;
551
- }
552
- public toJSON(): APITextDisplayComponent {
553
- return {
554
- type: ComponentEnum.TextDisplay,
555
- content: this.#content,
556
- };
557
- }
558
- }
559
-
560
- export class CreateMediaGallery {
561
- readonly #items: { media: { url: string; description?: string } }[] = [];
562
- public addItems(...items: { url: string; description?: string }[]): this {
563
- if (!items.length) {
564
- throw new TypeError("At least one media gallery item is required.");
565
- }
566
- if (this.#items.length + items.length > 10) {
567
- throw new RangeError(
568
- "MediaGallery can only contain up to 10 items.",
569
- );
570
- }
571
- this.#items.push(...items.map((item) => ({ media: item })));
572
- return this;
573
- }
574
- public toJSON(): APIMediaGalleryComponent {
575
- return {
576
- type: ComponentEnum.MediaGallery,
577
- items: structuredClone(this.#items),
578
- };
579
- }
580
- }
581
-
582
- export class CreateFileComponent {
583
- #url = "";
584
- #spoiler?: boolean;
585
- public setUrl(url: string): this {
586
- validateText(url, 2048, "File component URL");
587
- this.#url = url;
588
- return this;
589
- }
590
- public setSpoiler(spoiler = true): this {
591
- this.#spoiler = spoiler;
592
- return this;
593
- }
594
- public toJSON(): APIFileComponent {
595
- if (!this.#url) throw new Error("File component must have a URL.");
596
- const data: APIFileComponent = {
597
- type: ComponentEnum.File,
598
- file: { url: this.#url },
599
- };
600
- if (this.#spoiler !== undefined) data.spoiler = this.#spoiler;
601
- return data;
602
- }
603
- }
604
-
605
- export class CreateSeparator {
606
- #spacing?: 1 | 2;
607
- #divider?: boolean;
608
- public setSpacing(spacing: 1 | 2): this {
609
- this.#spacing = spacing;
610
- return this;
611
- }
612
- public setDivider(divider = true): this {
613
- this.#divider = divider;
614
- return this;
615
- }
616
- public toJSON(): APISeparatorComponent {
617
- const data: APISeparatorComponent = {
618
- type: ComponentEnum.Separator,
619
- };
620
- if (this.#spacing !== undefined) data.spacing = this.#spacing;
621
- if (this.#divider !== undefined) data.divider = this.#divider;
622
- return data;
623
- }
624
- }
625
-
626
- export class CreateThumbnail {
627
- #url = "";
628
- #description?: string;
629
- #spoiler?: boolean;
630
- public setUrl(url: string): this {
631
- validateText(url, 2048, "Thumbnail URL");
632
- this.#url = url;
633
- return this;
634
- }
635
- /** Sets the thumbnail's alt-text description. */
636
- public setDescription(description: string): this {
637
- validateText(description, 1024, "Thumbnail description");
638
- this.#description = description;
639
- return this;
640
- }
641
- /** Marks the thumbnail as a spoiler (blurred until clicked). */
642
- public setSpoiler(spoiler = true): this {
643
- this.#spoiler = spoiler;
644
- return this;
645
- }
646
- public toJSON(): APIThumbnailComponent {
647
- const data: APIThumbnailComponent = {
648
- type: ComponentEnum.Thumbnail,
649
- url: this.#url,
650
- };
651
- if (this.#description !== undefined)
652
- data.description = this.#description;
653
- if (this.#spoiler !== undefined) data.spoiler = this.#spoiler;
654
- return data;
655
- }
656
- }
657
-
658
- export class CreateContentInventoryEntry {
659
- #id = "";
660
- public setId(id: string): this {
661
- validateText(id, 100, "Content inventory entry ID");
662
- this.#id = id;
663
- return this;
664
- }
665
- public toJSON(): APIContentInventoryEntryComponent {
666
- return {
667
- type: ComponentEnum.ContentInventoryEntry,
668
- id: this.#id,
669
- };
670
- }
671
- }
672
-
673
- function validateText(value: string, max: number, field: string): void {
674
- if (typeof value !== "string")
675
- throw new TypeError(`${field} must be a string.`);
676
- if (!value.trim())
677
- throw new RangeError(`${field} must be a non-empty string.`);
678
- if (value.length > max)
679
- throw new RangeError(`${field} cannot exceed ${max} characters.`);
680
- }
681
- function validateCount(
682
- value: number,
683
- field: string,
684
- min: number,
685
- max = 25,
686
- ): void {
687
- if (!Number.isInteger(value) || value < min || value > max)
688
- throw new RangeError(
689
- `${field} must be an integer between ${min} and ${max}.`,
690
- );
691
- }
692
- function validateLength(value: number, field: string, max = 4000): void {
693
- if (!Number.isInteger(value) || value < 0 || value > max)
694
- throw new RangeError(
695
- `${field} must be an integer between 0 and ${max}.`,
696
- );
697
- }
698
-
699
- /** Discord.js-familiar alias for {@link CreateStringSelect}. */
700
- export { CreateStringSelect as CreateStringSelectMenu };
701
- /** User select menu builder (Discord.js-familiar). */
702
- export class CreateUserSelectMenu extends CreateEntitySelect {
703
- public constructor() {
704
- super(ComponentEnum.UserSelect);
705
- }
706
- }
707
- /** Role select menu builder (Discord.js-familiar). */
708
- export class CreateRoleSelectMenu extends CreateEntitySelect {
709
- public constructor() {
710
- super(ComponentEnum.RoleSelect);
711
- }
712
- }
713
- /** Mentionable select menu builder (Discord.js-familiar). */
714
- export class CreateMentionableSelectMenu extends CreateEntitySelect {
715
- public constructor() {
716
- super(ComponentEnum.MentionableSelect);
717
- }
718
- }
719
- /** Channel select menu builder (Discord.js-familiar). */
720
- export class CreateChannelSelectMenu extends CreateEntitySelect {
721
- public constructor() {
722
- super(ComponentEnum.ChannelSelect);
723
- }
724
- }
725
-
726
- /** Checks the fields every select menu needs. */
727
- function assertSelect(data: {
728
- custom_id?: string;
729
- min_values?: number;
730
- max_values?: number;
731
- }): void {
732
- if (!data.custom_id)
733
- throw new TypeError("Select menus require a custom ID.");
734
- if ((data.min_values ?? 1) > (data.max_values ?? 1))
735
- throw new RangeError("min_values cannot exceed max_values.");
736
- }
737
-
738
- /** Discord's flag for a message laid out with Components V2 (containers, text displays...). */
739
- const IS_COMPONENTS_V2 = 1 << 15;
740
-
741
- /**
742
- * A message payload laid out with Components V2: the components, and the flag
743
- * Discord requires for them. A V2 message cannot also carry `content` or `embeds`.
744
- * @param components Top-level components (containers, sections, text displays, action rows...).
745
- * @param options Extra message fields, such as `allowed_mentions` or `ephemeral`; `flags` are combined.
746
- * @example channel.send(componentsV2Message(new CreateContainer().addComponents(new CreateTextDisplay().setContent("Hi"))))
747
- */
748
- export function componentsV2Message<
749
- T extends Record<string, unknown> = Record<string, never>,
750
- >(
751
- components: { toJSON(): APIComponent } | Array<{ toJSON(): APIComponent }>,
752
- options?: T & { flags?: number },
753
- ): { components: APIComponent[]; flags: number } & T {
754
- const list = Array.isArray(components) ? components : [components];
755
- if (!list.length)
756
- throw new TypeError(
757
- "A Components V2 message needs at least one component.",
758
- );
759
- return {
760
- ...(options ?? ({} as T)),
761
- components: list.map((component) => component.toJSON()),
762
- flags: (options?.flags ?? 0) | IS_COMPONENTS_V2,
763
- };
764
- }
765
-
766
- // ─── Deprecated names (0.2.2), removed in 0.3.0 ────────────────────────────────
767
- // Builders are now `CreateX` (`ButtonBuilder` → `CreateButton`); `…Style` became
768
- // `…Type` and `…Type` became `…Enum`.
769
-
770
- /** @deprecated Use {@link CreateButton}. Removed in 0.3.0. */
771
- export const ButtonBuilder = CreateButton;
772
- /** @deprecated Use {@link CreateButton}. Removed in 0.3.0. */
773
- export type ButtonBuilder = CreateButton;
774
-
775
- /** @deprecated Use {@link CreateChannelSelectMenu}. Removed in 0.3.0. */
776
- export const ChannelSelectMenuBuilder = CreateChannelSelectMenu;
777
- /** @deprecated Use {@link CreateChannelSelectMenu}. Removed in 0.3.0. */
778
- export type ChannelSelectMenuBuilder = CreateChannelSelectMenu;
779
-
780
- /** @deprecated Use {@link CreateContainer}. Removed in 0.3.0. */
781
- export const ContainerBuilder = CreateContainer;
782
- /** @deprecated Use {@link CreateContainer}. Removed in 0.3.0. */
783
- export type ContainerBuilder = CreateContainer;
784
-
785
- /** @deprecated Use {@link CreateContentInventoryEntry}. Removed in 0.3.0. */
786
- export const ContentInventoryEntryBuilder = CreateContentInventoryEntry;
787
- /** @deprecated Use {@link CreateContentInventoryEntry}. Removed in 0.3.0. */
788
- export type ContentInventoryEntryBuilder = CreateContentInventoryEntry;
789
-
790
- /** @deprecated Use {@link CreateEntitySelect}. Removed in 0.3.0. */
791
- export const EntitySelectBuilder = CreateEntitySelect;
792
- /** @deprecated Use {@link CreateEntitySelect}. Removed in 0.3.0. */
793
- export type EntitySelectBuilder = CreateEntitySelect;
794
-
795
- /** @deprecated Use {@link CreateFileComponent}. Removed in 0.3.0. */
796
- export const FileComponentBuilder = CreateFileComponent;
797
- /** @deprecated Use {@link CreateFileComponent}. Removed in 0.3.0. */
798
- export type FileComponentBuilder = CreateFileComponent;
799
-
800
- /** @deprecated Use {@link CreateMediaGallery}. Removed in 0.3.0. */
801
- export const MediaGalleryBuilder = CreateMediaGallery;
802
- /** @deprecated Use {@link CreateMediaGallery}. Removed in 0.3.0. */
803
- export type MediaGalleryBuilder = CreateMediaGallery;
804
-
805
- /** @deprecated Use {@link CreateMentionableSelectMenu}. Removed in 0.3.0. */
806
- export const MentionableSelectMenuBuilder = CreateMentionableSelectMenu;
807
- /** @deprecated Use {@link CreateMentionableSelectMenu}. Removed in 0.3.0. */
808
- export type MentionableSelectMenuBuilder = CreateMentionableSelectMenu;
809
-
810
- /** @deprecated Use {@link CreateModal}. Removed in 0.3.0. */
811
- export const ModalBuilder = CreateModal;
812
- /** @deprecated Use {@link CreateModal}. Removed in 0.3.0. */
813
- export type ModalBuilder = CreateModal;
814
-
815
- /** @deprecated Use {@link CreateRoleSelectMenu}. Removed in 0.3.0. */
816
- export const RoleSelectMenuBuilder = CreateRoleSelectMenu;
817
- /** @deprecated Use {@link CreateRoleSelectMenu}. Removed in 0.3.0. */
818
- export type RoleSelectMenuBuilder = CreateRoleSelectMenu;
819
-
820
- /** @deprecated Use {@link CreateSection}. Removed in 0.3.0. */
821
- export const SectionBuilder = CreateSection;
822
- /** @deprecated Use {@link CreateSection}. Removed in 0.3.0. */
823
- export type SectionBuilder = CreateSection;
824
-
825
- /** @deprecated Use {@link CreateSeparator}. Removed in 0.3.0. */
826
- export const SeparatorBuilder = CreateSeparator;
827
- /** @deprecated Use {@link CreateSeparator}. Removed in 0.3.0. */
828
- export type SeparatorBuilder = CreateSeparator;
829
-
830
- /** @deprecated Use {@link CreateStringSelect}. Removed in 0.3.0. */
831
- export const StringSelectBuilder = CreateStringSelect;
832
- /** @deprecated Use {@link CreateStringSelect}. Removed in 0.3.0. */
833
- export type StringSelectBuilder = CreateStringSelect;
834
-
835
- /** @deprecated Use {@link CreateStringSelectMenu}. Removed in 0.3.0. */
836
- export const StringSelectMenuBuilder = CreateStringSelect;
837
- /** @deprecated Use {@link CreateStringSelectMenu}. Removed in 0.3.0. */
838
- export type StringSelectMenuBuilder = CreateStringSelect;
839
-
840
- /** @deprecated Use {@link CreateTextDisplay}. Removed in 0.3.0. */
841
- export const TextDisplayBuilder = CreateTextDisplay;
842
- /** @deprecated Use {@link CreateTextDisplay}. Removed in 0.3.0. */
843
- export type TextDisplayBuilder = CreateTextDisplay;
844
-
845
- /** @deprecated Use {@link CreateTextInput}. Removed in 0.3.0. */
846
- export const TextInputBuilder = CreateTextInput;
847
- /** @deprecated Use {@link CreateTextInput}. Removed in 0.3.0. */
848
- export type TextInputBuilder = CreateTextInput;
849
-
850
- /** @deprecated Use {@link CreateThumbnail}. Removed in 0.3.0. */
851
- export const ThumbnailBuilder = CreateThumbnail;
852
- /** @deprecated Use {@link CreateThumbnail}. Removed in 0.3.0. */
853
- export type ThumbnailBuilder = CreateThumbnail;
854
-
855
- /** @deprecated Use {@link CreateUserSelectMenu}. Removed in 0.3.0. */
856
- export const UserSelectMenuBuilder = CreateUserSelectMenu;
857
- /** @deprecated Use {@link CreateUserSelectMenu}. Removed in 0.3.0. */
858
- export type UserSelectMenuBuilder = CreateUserSelectMenu;
859
-
860
- /** @deprecated Use {@link CreateActionRow}. Removed in 0.3.0. */
861
- export const ActionRowBuilder = CreateActionRow;
862
- /** @deprecated Use {@link CreateActionRow}. Removed in 0.3.0. */
863
- export type ActionRowBuilder<
864
- T extends { toJSON(): APIActionRowChild } = { toJSON(): APIActionRowChild },
865
- > = CreateActionRow<T>;
866
-
867
- /** @deprecated Use {@link ButtonType}. Removed in 0.3.0. */
868
- export const ButtonStyle = ButtonType;
869
- /** @deprecated Use {@link ButtonType}. Removed in 0.3.0. */
870
- export type ButtonStyle = ButtonType;
871
-
872
- /** @deprecated Use {@link TextInputType}. Removed in 0.3.0. */
873
- export const TextInputStyle = TextInputType;
874
- /** @deprecated Use {@link TextInputType}. Removed in 0.3.0. */
875
- export type TextInputStyle = TextInputType;
876
-
877
- /** @deprecated Use {@link ComponentEnum}. Removed in 0.3.0. */
878
- export const ComponentType = ComponentEnum;
879
- /** @deprecated Use {@link ComponentEnum}. Removed in 0.3.0. */
880
- export type ComponentType = ComponentEnum;
1
+ import { ButtonType, ComponentEnum } from "@lunibee/types";
2
+ export { ButtonType, ComponentEnum };
3
+
4
+ export const TextInputType = { Short: 1, Paragraph: 2 } as const;
5
+ /** One {@link TextInputType} value, usable as a type. */
6
+ export type TextInputType = (typeof TextInputType)[keyof typeof TextInputType];
7
+ export interface APIComponentEmoji {
8
+ id?: string | null;
9
+ name?: string | null;
10
+ animated?: boolean;
11
+ }
12
+ export interface APIButtonComponent {
13
+ type: typeof ComponentEnum.Button;
14
+ style: 1 | 2 | 3 | 4 | 5 | 6;
15
+ custom_id?: string;
16
+ label?: string;
17
+ emoji?: APIComponentEmoji;
18
+ url?: string;
19
+ /** SKU for premium (style 6) buttons. */
20
+ sku_id?: string;
21
+ disabled?: boolean;
22
+ }
23
+ export interface APISelectOption {
24
+ label: string;
25
+ value: string;
26
+ description?: string;
27
+ emoji?: APIComponentEmoji;
28
+ default?: boolean;
29
+ }
30
+ export interface APIStringSelectComponent {
31
+ type: typeof ComponentEnum.StringSelect;
32
+ custom_id?: string;
33
+ placeholder?: string;
34
+ min_values?: number;
35
+ max_values?: number;
36
+ required?: boolean;
37
+ disabled?: boolean;
38
+ options?: APISelectOption[];
39
+ }
40
+ export interface APIEntitySelectComponent {
41
+ type:
42
+ | typeof ComponentEnum.UserSelect
43
+ | typeof ComponentEnum.RoleSelect
44
+ | typeof ComponentEnum.MentionableSelect
45
+ | typeof ComponentEnum.ChannelSelect;
46
+ custom_id?: string;
47
+ placeholder?: string;
48
+ min_values?: number;
49
+ max_values?: number;
50
+ required?: boolean;
51
+ disabled?: boolean;
52
+ default_values?: APISelectDefaultValue[];
53
+ }
54
+ export interface APITextInputComponent {
55
+ type: typeof ComponentEnum.TextInput;
56
+ style: typeof TextInputType.Short | typeof TextInputType.Paragraph;
57
+ custom_id?: string;
58
+ label?: string;
59
+ placeholder?: string;
60
+ min_length?: number;
61
+ max_length?: number;
62
+ required?: boolean;
63
+ value?: string;
64
+ }
65
+ export interface APIModalComponent {
66
+ type: 9;
67
+ custom_id: string;
68
+ title: string;
69
+ components: APIActionRowComponent[];
70
+ }
71
+ export type APIActionRowChild =
72
+ | APIButtonComponent
73
+ | APIStringSelectComponent
74
+ | APIEntitySelectComponent
75
+ | APITextInputComponent;
76
+ export interface APIActionRowComponent {
77
+ type: typeof ComponentEnum.ActionRow;
78
+ components: APIActionRowChild[];
79
+ }
80
+ export interface APISectionComponent {
81
+ type: typeof ComponentEnum.Section;
82
+ components?: APIComponent[]; // text displays
83
+ accessory?: APIComponent; // buttons, thumbnails, etc.
84
+ }
85
+ export interface APITextDisplayComponent {
86
+ type: typeof ComponentEnum.TextDisplay;
87
+ content: string;
88
+ }
89
+ export interface APIMediaGalleryComponent {
90
+ type: typeof ComponentEnum.MediaGallery;
91
+ items: { media: { url: string; description?: string } }[];
92
+ }
93
+ /** Default values accepted by an auto-populated entity select. */
94
+ export interface APISelectDefaultValue {
95
+ id: string;
96
+ type: "user" | "role" | "channel";
97
+ }
98
+ export interface APIFileComponent {
99
+ type: typeof ComponentEnum.File;
100
+ file: { url: string };
101
+ spoiler?: boolean;
102
+ }
103
+ export interface APISeparatorComponent {
104
+ type: typeof ComponentEnum.Separator;
105
+ spacing?: 1 | 2;
106
+ divider?: boolean;
107
+ }
108
+ export interface APIThumbnailComponent {
109
+ type: typeof ComponentEnum.Thumbnail;
110
+ url: string;
111
+ proxy_url?: string;
112
+ width?: number;
113
+ height?: number;
114
+ description?: string;
115
+ spoiler?: boolean;
116
+ }
117
+ export interface APIContentInventoryEntryComponent {
118
+ type: typeof ComponentEnum.ContentInventoryEntry;
119
+ id: string;
120
+ }
121
+ export interface APIContainerComponent {
122
+ type: typeof ComponentEnum.Container;
123
+ components: APIComponent[];
124
+ accent_color?: number;
125
+ spoiler?: boolean;
126
+ }
127
+ export type APIComponent =
128
+ | APIActionRowComponent
129
+ | APIButtonComponent
130
+ | APIStringSelectComponent
131
+ | APIEntitySelectComponent
132
+ | APITextInputComponent
133
+ | APISectionComponent
134
+ | APITextDisplayComponent
135
+ | APIThumbnailComponent
136
+ | APIMediaGalleryComponent
137
+ | APIFileComponent
138
+ | APISeparatorComponent
139
+ | APIContentInventoryEntryComponent
140
+ | APIContainerComponent;
141
+
142
+ export class CreateActionRow<
143
+ T extends { toJSON(): APIActionRowChild } = { toJSON(): APIActionRowChild },
144
+ > {
145
+ readonly #components: T[] = [];
146
+ public addComponents(...components: T[]): this {
147
+ if (!components.length)
148
+ throw new TypeError("At least one component is required.");
149
+ if (this.#components.length + components.length > 5)
150
+ throw new RangeError(
151
+ "An action row cannot contain more than 5 components.",
152
+ );
153
+ this.#components.push(...components);
154
+ return this;
155
+ }
156
+ public clearComponents(): this {
157
+ this.#components.length = 0;
158
+ return this;
159
+ }
160
+ public toJSON(): APIActionRowComponent {
161
+ const components = this.#components.map((component) =>
162
+ component.toJSON(),
163
+ );
164
+ if (components.length === 0)
165
+ throw new RangeError("An action row needs at least one component.");
166
+ const types = components.map((component) => component.type);
167
+ if (
168
+ types.some((type) => type !== ComponentEnum.Button) &&
169
+ components.length > 1
170
+ )
171
+ throw new RangeError(
172
+ "A select menu or text input must be alone in its action row.",
173
+ );
174
+ return { type: ComponentEnum.ActionRow, components };
175
+ }
176
+ }
177
+ /** Setters shared by string and entity select menus. */
178
+ export class CreateSelectMenu {
179
+ readonly #select: APIStringSelectComponent | APIEntitySelectComponent;
180
+ protected constructor(
181
+ data: APIStringSelectComponent | APIEntitySelectComponent,
182
+ ) {
183
+ this.#select = data;
184
+ }
185
+ public setCustomId(value: string): this {
186
+ validateText(value, 100, "Component custom ID");
187
+ this.#select.custom_id = value;
188
+ return this;
189
+ }
190
+ public setPlaceholder(value: string): this {
191
+ validateText(value, 150, "Component placeholder");
192
+ this.#select.placeholder = value;
193
+ return this;
194
+ }
195
+ public setMinValues(value: number): this {
196
+ validateCount(value, "min_values", 0);
197
+ this.#select.min_values = value;
198
+ return this;
199
+ }
200
+ public setMaxValues(value: number): this {
201
+ validateCount(value, "max_values", 1);
202
+ this.#select.max_values = value;
203
+ return this;
204
+ }
205
+ public setRequired(value = true): this {
206
+ this.#select.required = value;
207
+ return this;
208
+ }
209
+ public setDisabled(value = true): this {
210
+ this.#select.disabled = value;
211
+ return this;
212
+ }
213
+ }
214
+ export class CreateStringSelect extends CreateSelectMenu {
215
+ readonly #data: APIStringSelectComponent;
216
+ public constructor() {
217
+ const data: APIStringSelectComponent = {
218
+ type: ComponentEnum.StringSelect,
219
+ };
220
+ super(data);
221
+ this.#data = data;
222
+ }
223
+ public addOptions(...options: APISelectOption[]): this {
224
+ if (!options.length)
225
+ throw new TypeError("At least one select option is required.");
226
+ const current = this.#data.options ?? [];
227
+ if (current.length + options.length > 25)
228
+ throw new RangeError(
229
+ "A string select cannot contain more than 25 options.",
230
+ );
231
+ for (const option of options) {
232
+ validateText(option.label, 100, "Select option label");
233
+ validateText(option.value, 100, "Select option value");
234
+ if (option.description)
235
+ validateText(
236
+ option.description,
237
+ 100,
238
+ "Select option description",
239
+ );
240
+ }
241
+ this.#data.options = [
242
+ ...current,
243
+ ...options.map((option) => ({ ...option })),
244
+ ];
245
+ return this;
246
+ }
247
+ public toJSON(): APIStringSelectComponent {
248
+ assertSelect(this.#data);
249
+ const count = this.#data.options?.length ?? 0;
250
+ if (count === 0)
251
+ throw new RangeError("A string select needs at least one option.");
252
+ if ((this.#data.max_values ?? 1) > count)
253
+ throw new RangeError(
254
+ "max_values cannot exceed the number of options.",
255
+ );
256
+ return structuredClone(this.#data);
257
+ }
258
+ }
259
+ export class CreateButton {
260
+ readonly #data: APIButtonComponent = {
261
+ type: ComponentEnum.Button,
262
+ style: ButtonType.Secondary,
263
+ };
264
+ /** Sets the style and drops fields that style cannot carry. */
265
+ public setStyle(style: APIButtonComponent["style"]): this {
266
+ const data = this.#data;
267
+ data.style = style;
268
+ if (style === ButtonType.Link) {
269
+ delete data.custom_id;
270
+ delete data.sku_id;
271
+ } else if (style === ButtonType.Premium) {
272
+ delete data.custom_id;
273
+ delete data.url;
274
+ delete data.label;
275
+ delete data.emoji;
276
+ } else {
277
+ delete data.url;
278
+ delete data.sku_id;
279
+ }
280
+ return this;
281
+ }
282
+ public setCustomId(value: string): this {
283
+ validateText(value, 100, "Button custom ID");
284
+ if (this.#data.style === ButtonType.Link)
285
+ throw new TypeError("Link buttons cannot use custom IDs.");
286
+ this.#data.custom_id = value;
287
+ return this;
288
+ }
289
+ public setLabel(value: string): this {
290
+ validateText(value, 80, "Button label");
291
+ this.#data.label = value;
292
+ return this;
293
+ }
294
+ public setURL(value: string): this {
295
+ validateText(value, 512, "Button URL");
296
+ let url: URL;
297
+ try {
298
+ url = new URL(value);
299
+ } catch (error) {
300
+ throw new TypeError("Button URL must be a valid URL.", {
301
+ cause: error,
302
+ });
303
+ }
304
+ this.setStyle(ButtonType.Link);
305
+ this.#data.url = url.toString();
306
+ return this;
307
+ }
308
+ /** Makes this a premium (purchase) button for a SKU. Premium buttons carry no label, emoji, custom ID or URL. */
309
+ public setSKUId(skuId: string): this {
310
+ validateText(skuId, 20, "Button SKU ID");
311
+ this.setStyle(ButtonType.Premium);
312
+ this.#data.sku_id = skuId;
313
+ return this;
314
+ }
315
+ public setEmoji(emoji: APIComponentEmoji | string): this {
316
+ this.#data.emoji =
317
+ typeof emoji === "string" ? { name: emoji } : { ...emoji };
318
+ return this;
319
+ }
320
+ public setDisabled(value = true): this {
321
+ this.#data.disabled = value;
322
+ return this;
323
+ }
324
+ public toJSON(): APIButtonComponent {
325
+ const data = this.#data;
326
+ if (data.style === ButtonType.Link) {
327
+ if (!data.url) throw new TypeError("Link buttons require a URL.");
328
+ if (data.custom_id || data.sku_id)
329
+ throw new TypeError(
330
+ "Link buttons cannot have a custom ID or SKU ID.",
331
+ );
332
+ } else if (data.style === ButtonType.Premium) {
333
+ if (!data.sku_id)
334
+ throw new TypeError("Premium buttons require a SKU ID.");
335
+ if (data.custom_id || data.url || data.label || data.emoji)
336
+ throw new TypeError(
337
+ "Premium buttons cannot have a custom ID, URL, label or emoji.",
338
+ );
339
+ } else {
340
+ if (!data.custom_id)
341
+ throw new TypeError("Non-link buttons require a custom ID.");
342
+ if (data.url || data.sku_id)
343
+ throw new TypeError(
344
+ "Only link buttons have a URL and only premium buttons have a SKU ID.",
345
+ );
346
+ }
347
+ if (data.style !== ButtonType.Premium && !data.label && !data.emoji)
348
+ throw new TypeError("Buttons require a label or an emoji.");
349
+ return structuredClone(data);
350
+ }
351
+ }
352
+ export class CreateEntitySelect extends CreateSelectMenu {
353
+ readonly #data: APIEntitySelectComponent;
354
+ public constructor(
355
+ type:
356
+ | typeof ComponentEnum.UserSelect
357
+ | typeof ComponentEnum.RoleSelect
358
+ | typeof ComponentEnum.MentionableSelect
359
+ | typeof ComponentEnum.ChannelSelect,
360
+ ) {
361
+ if (
362
+ type !== ComponentEnum.UserSelect &&
363
+ type !== ComponentEnum.RoleSelect &&
364
+ type !== ComponentEnum.MentionableSelect &&
365
+ type !== ComponentEnum.ChannelSelect
366
+ )
367
+ throw new TypeError("Invalid select component type.");
368
+ const data: APIEntitySelectComponent = { type };
369
+ super(data);
370
+ this.#data = data;
371
+ }
372
+ public setDefaultValues(...values: APISelectDefaultValue[]): this {
373
+ this.#data.default_values = values.map((value) => ({ ...value }));
374
+ return this;
375
+ }
376
+ public toJSON(): APIEntitySelectComponent {
377
+ assertSelect(this.#data);
378
+ return structuredClone(this.#data);
379
+ }
380
+ }
381
+ export class CreateModal {
382
+ readonly #components: Array<{ toJSON(): APIActionRowComponent }> = [];
383
+ #custom_id?: string;
384
+ #title?: string;
385
+ public setCustomId(value: string): this {
386
+ validateText(value, 100, "Modal custom ID");
387
+ this.#custom_id = value;
388
+ return this;
389
+ }
390
+ public setTitle(value: string): this {
391
+ validateText(value, 45, "Modal title");
392
+ this.#title = value;
393
+ return this;
394
+ }
395
+ public addComponents(
396
+ ...components: Array<{ toJSON(): APIActionRowComponent }>
397
+ ): this {
398
+ if (!components.length)
399
+ throw new TypeError("At least one modal component is required.");
400
+ if (this.#components.length + components.length > 5)
401
+ throw new RangeError(
402
+ "A modal cannot contain more than 5 action rows.",
403
+ );
404
+ this.#components.push(...components);
405
+ return this;
406
+ }
407
+ /** Adds text inputs, each in its own action row (Discord's modal layout). @param inputs Text inputs, at most 5 per modal. */
408
+ public addTextInputs(...inputs: CreateTextInput[]): this {
409
+ return this.addComponents(
410
+ ...inputs.map((input) =>
411
+ new CreateActionRow<CreateTextInput>().addComponents(input),
412
+ ),
413
+ );
414
+ }
415
+ public toJSON(): APIModalComponent {
416
+ if (!this.#custom_id || !this.#title)
417
+ throw new TypeError("Modals require a custom ID and a title.");
418
+ if (this.#components.length === 0)
419
+ throw new RangeError("Modals require at least one component.");
420
+ return {
421
+ type: 9,
422
+ custom_id: this.#custom_id,
423
+ title: this.#title,
424
+ components: this.#components.map((component) => component.toJSON()),
425
+ };
426
+ }
427
+ }
428
+ export class CreateTextInput {
429
+ readonly #data: APITextInputComponent = {
430
+ type: ComponentEnum.TextInput,
431
+ style: TextInputType.Short,
432
+ };
433
+ public setStyle(style: APITextInputComponent["style"]): this {
434
+ this.#data.style = style;
435
+ return this;
436
+ }
437
+ public setCustomId(value: string): this {
438
+ validateText(value, 100, "Text input custom ID");
439
+ this.#data.custom_id = value;
440
+ return this;
441
+ }
442
+ public setLabel(value: string): this {
443
+ validateText(value, 45, "Text input label");
444
+ this.#data.label = value;
445
+ return this;
446
+ }
447
+ public setPlaceholder(value: string): this {
448
+ validateText(value, 100, "Text input placeholder");
449
+ this.#data.placeholder = value;
450
+ return this;
451
+ }
452
+ public setMinLength(value: number): this {
453
+ validateLength(value, "min_length", 4000);
454
+ this.#data.min_length = value;
455
+ return this;
456
+ }
457
+ public setMaxLength(value: number): this {
458
+ validateLength(value, "max_length", 4000);
459
+ this.#data.max_length = value;
460
+ return this;
461
+ }
462
+ public setRequired(value = true): this {
463
+ this.#data.required = value;
464
+ return this;
465
+ }
466
+ public setValue(value: string): this {
467
+ validateText(value, 4000, "Text input value");
468
+ this.#data.value = value;
469
+ return this;
470
+ }
471
+ public toJSON(): APITextInputComponent {
472
+ const data = this.#data;
473
+ if (!data.custom_id)
474
+ throw new TypeError("Text inputs require a custom ID.");
475
+ if (
476
+ data.min_length !== undefined &&
477
+ data.max_length !== undefined &&
478
+ data.min_length > data.max_length
479
+ )
480
+ throw new RangeError("min_length cannot exceed max_length.");
481
+ return structuredClone(data);
482
+ }
483
+ }
484
+
485
+ export class CreateContainer {
486
+ readonly #components: { toJSON(): APIComponent }[] = [];
487
+ #accentColor?: number;
488
+ #spoiler?: boolean;
489
+ public addComponents(...components: { toJSON(): APIComponent }[]): this {
490
+ if (!components.length)
491
+ throw new TypeError("At least one component is required.");
492
+ this.#components.push(...components);
493
+ return this;
494
+ }
495
+ public setAccentColor(color: number): this {
496
+ if (!Number.isInteger(color) || color < 0 || color > 0xffffff) {
497
+ throw new RangeError(
498
+ "Accent color must be an integer between 0 and 16777215 (0xFFFFFF).",
499
+ );
500
+ }
501
+ this.#accentColor = color;
502
+ return this;
503
+ }
504
+ /** Marks the whole container as a spoiler (blurred until clicked). */
505
+ public setSpoiler(spoiler = true): this {
506
+ this.#spoiler = spoiler;
507
+ return this;
508
+ }
509
+ public toJSON(): APIContainerComponent {
510
+ const data: APIContainerComponent = {
511
+ type: ComponentEnum.Container,
512
+ components: this.#components.map((c) => c.toJSON()),
513
+ };
514
+ if (this.#accentColor !== undefined)
515
+ data.accent_color = this.#accentColor;
516
+ if (this.#spoiler !== undefined) data.spoiler = this.#spoiler;
517
+ return data;
518
+ }
519
+ }
520
+
521
+ export class CreateSection {
522
+ readonly #components: { toJSON(): APIComponent }[] = [];
523
+ #accessory?: { toJSON(): APIComponent };
524
+ public addComponents(...components: { toJSON(): APIComponent }[]): this {
525
+ if (!components.length)
526
+ throw new TypeError("At least one component is required.");
527
+ this.#components.push(...components);
528
+ return this;
529
+ }
530
+ public setAccessory(accessory: { toJSON(): APIComponent }): this {
531
+ this.#accessory = accessory;
532
+ return this;
533
+ }
534
+ public toJSON(): APISectionComponent {
535
+ return {
536
+ type: ComponentEnum.Section,
537
+ components: this.#components.length
538
+ ? this.#components.map((c) => c.toJSON())
539
+ : undefined,
540
+ accessory: this.#accessory?.toJSON(),
541
+ };
542
+ }
543
+ }
544
+
545
+ export class CreateTextDisplay {
546
+ #content = "";
547
+ public setContent(content: string): this {
548
+ validateText(content, 4000, "Text display content");
549
+ this.#content = content;
550
+ return this;
551
+ }
552
+ public toJSON(): APITextDisplayComponent {
553
+ return {
554
+ type: ComponentEnum.TextDisplay,
555
+ content: this.#content,
556
+ };
557
+ }
558
+ }
559
+
560
+ export class CreateMediaGallery {
561
+ readonly #items: { media: { url: string; description?: string } }[] = [];
562
+ public addItems(...items: { url: string; description?: string }[]): this {
563
+ if (!items.length) {
564
+ throw new TypeError("At least one media gallery item is required.");
565
+ }
566
+ if (this.#items.length + items.length > 10) {
567
+ throw new RangeError(
568
+ "MediaGallery can only contain up to 10 items.",
569
+ );
570
+ }
571
+ this.#items.push(...items.map((item) => ({ media: item })));
572
+ return this;
573
+ }
574
+ public toJSON(): APIMediaGalleryComponent {
575
+ return {
576
+ type: ComponentEnum.MediaGallery,
577
+ items: structuredClone(this.#items),
578
+ };
579
+ }
580
+ }
581
+
582
+ export class CreateFileComponent {
583
+ #url = "";
584
+ #spoiler?: boolean;
585
+ public setUrl(url: string): this {
586
+ validateText(url, 2048, "File component URL");
587
+ this.#url = url;
588
+ return this;
589
+ }
590
+ public setSpoiler(spoiler = true): this {
591
+ this.#spoiler = spoiler;
592
+ return this;
593
+ }
594
+ public toJSON(): APIFileComponent {
595
+ if (!this.#url) throw new Error("File component must have a URL.");
596
+ const data: APIFileComponent = {
597
+ type: ComponentEnum.File,
598
+ file: { url: this.#url },
599
+ };
600
+ if (this.#spoiler !== undefined) data.spoiler = this.#spoiler;
601
+ return data;
602
+ }
603
+ }
604
+
605
+ export class CreateSeparator {
606
+ #spacing?: 1 | 2;
607
+ #divider?: boolean;
608
+ public setSpacing(spacing: 1 | 2): this {
609
+ this.#spacing = spacing;
610
+ return this;
611
+ }
612
+ public setDivider(divider = true): this {
613
+ this.#divider = divider;
614
+ return this;
615
+ }
616
+ public toJSON(): APISeparatorComponent {
617
+ const data: APISeparatorComponent = {
618
+ type: ComponentEnum.Separator,
619
+ };
620
+ if (this.#spacing !== undefined) data.spacing = this.#spacing;
621
+ if (this.#divider !== undefined) data.divider = this.#divider;
622
+ return data;
623
+ }
624
+ }
625
+
626
+ export class CreateThumbnail {
627
+ #url = "";
628
+ #description?: string;
629
+ #spoiler?: boolean;
630
+ public setUrl(url: string): this {
631
+ validateText(url, 2048, "Thumbnail URL");
632
+ this.#url = url;
633
+ return this;
634
+ }
635
+ /** Sets the thumbnail's alt-text description. */
636
+ public setDescription(description: string): this {
637
+ validateText(description, 1024, "Thumbnail description");
638
+ this.#description = description;
639
+ return this;
640
+ }
641
+ /** Marks the thumbnail as a spoiler (blurred until clicked). */
642
+ public setSpoiler(spoiler = true): this {
643
+ this.#spoiler = spoiler;
644
+ return this;
645
+ }
646
+ public toJSON(): APIThumbnailComponent {
647
+ const data: APIThumbnailComponent = {
648
+ type: ComponentEnum.Thumbnail,
649
+ url: this.#url,
650
+ };
651
+ if (this.#description !== undefined)
652
+ data.description = this.#description;
653
+ if (this.#spoiler !== undefined) data.spoiler = this.#spoiler;
654
+ return data;
655
+ }
656
+ }
657
+
658
+ export class CreateContentInventoryEntry {
659
+ #id = "";
660
+ public setId(id: string): this {
661
+ validateText(id, 100, "Content inventory entry ID");
662
+ this.#id = id;
663
+ return this;
664
+ }
665
+ public toJSON(): APIContentInventoryEntryComponent {
666
+ return {
667
+ type: ComponentEnum.ContentInventoryEntry,
668
+ id: this.#id,
669
+ };
670
+ }
671
+ }
672
+
673
+ function validateText(value: string, max: number, field: string): void {
674
+ if (typeof value !== "string")
675
+ throw new TypeError(`${field} must be a string.`);
676
+ if (!value.trim())
677
+ throw new RangeError(`${field} must be a non-empty string.`);
678
+ if (value.length > max)
679
+ throw new RangeError(`${field} cannot exceed ${max} characters.`);
680
+ }
681
+ function validateCount(
682
+ value: number,
683
+ field: string,
684
+ min: number,
685
+ max = 25,
686
+ ): void {
687
+ if (!Number.isInteger(value) || value < min || value > max)
688
+ throw new RangeError(
689
+ `${field} must be an integer between ${min} and ${max}.`,
690
+ );
691
+ }
692
+ function validateLength(value: number, field: string, max = 4000): void {
693
+ if (!Number.isInteger(value) || value < 0 || value > max)
694
+ throw new RangeError(
695
+ `${field} must be an integer between 0 and ${max}.`,
696
+ );
697
+ }
698
+
699
+ /** Discord.js-familiar alias for {@link CreateStringSelect}. */
700
+ export { CreateStringSelect as CreateStringSelectMenu };
701
+ /** User select menu builder (Discord.js-familiar). */
702
+ export class CreateUserSelectMenu extends CreateEntitySelect {
703
+ public constructor() {
704
+ super(ComponentEnum.UserSelect);
705
+ }
706
+ }
707
+ /** Role select menu builder (Discord.js-familiar). */
708
+ export class CreateRoleSelectMenu extends CreateEntitySelect {
709
+ public constructor() {
710
+ super(ComponentEnum.RoleSelect);
711
+ }
712
+ }
713
+ /** Mentionable select menu builder (Discord.js-familiar). */
714
+ export class CreateMentionableSelectMenu extends CreateEntitySelect {
715
+ public constructor() {
716
+ super(ComponentEnum.MentionableSelect);
717
+ }
718
+ }
719
+ /** Channel select menu builder (Discord.js-familiar). */
720
+ export class CreateChannelSelectMenu extends CreateEntitySelect {
721
+ public constructor() {
722
+ super(ComponentEnum.ChannelSelect);
723
+ }
724
+ }
725
+
726
+ /** Checks the fields every select menu needs. */
727
+ function assertSelect(data: {
728
+ custom_id?: string;
729
+ min_values?: number;
730
+ max_values?: number;
731
+ }): void {
732
+ if (!data.custom_id)
733
+ throw new TypeError("Select menus require a custom ID.");
734
+ if ((data.min_values ?? 1) > (data.max_values ?? 1))
735
+ throw new RangeError("min_values cannot exceed max_values.");
736
+ }
737
+
738
+ /** Discord's flag for a message laid out with Components V2 (containers, text displays...). */
739
+ const IS_COMPONENTS_V2 = 1 << 15;
740
+
741
+ /**
742
+ * A message payload laid out with Components V2: the components, and the flag
743
+ * Discord requires for them. A V2 message cannot also carry `content` or `embeds`.
744
+ * @param components Top-level components (containers, sections, text displays, action rows...).
745
+ * @param options Extra message fields, such as `allowed_mentions` or `ephemeral`; `flags` are combined.
746
+ * @example channel.send(componentsV2Message(new CreateContainer().addComponents(new CreateTextDisplay().setContent("Hi"))))
747
+ */
748
+ export function componentsV2Message<
749
+ T extends Record<string, unknown> = Record<string, never>,
750
+ >(
751
+ components: { toJSON(): APIComponent } | Array<{ toJSON(): APIComponent }>,
752
+ options?: T & { flags?: number },
753
+ ): { components: APIComponent[]; flags: number } & T {
754
+ const list = Array.isArray(components) ? components : [components];
755
+ if (!list.length)
756
+ throw new TypeError(
757
+ "A Components V2 message needs at least one component.",
758
+ );
759
+ return {
760
+ ...(options ?? ({} as T)),
761
+ components: list.map((component) => component.toJSON()),
762
+ flags: (options?.flags ?? 0) | IS_COMPONENTS_V2,
763
+ };
764
+ }