From b6a8264d6b56b3b9359cf120d1bd2117c76d1a4c Mon Sep 17 00:00:00 2001 From: Jiralite <33201955+Jiralite@users.noreply.github.com> Date: Sat, 8 Nov 2025 23:31:07 +0000 Subject: [PATCH] chore: Merge builders/1.x into v14 (#11260) * chore: merge builders (and formatters) * chore: match cliff.toml * chore: update README.mds * build: discord-api-types 0.38.32 --- packages/builders/CHANGELOG.md | 46 ++++ packages/builders/README.md | 2 +- .../__tests__/components/actionRow.test.ts | 12 +- .../__tests__/components/components.test.ts | 4 +- .../__tests__/components/fileUpload.test.ts | 46 ++++ .../__tests__/components/textInput.test.ts | 6 +- .../__tests__/components/v2/container.test.ts | 248 ++++++++++++++++++ .../__tests__/components/v2/file.test.ts | 44 ++++ .../components/v2/mediagallery.test.ts | 150 +++++++++++ .../__tests__/components/v2/section.test.ts | 191 ++++++++++++++ .../__tests__/components/v2/separator.test.ts | 35 +++ .../components/v2/textDisplay.test.ts | 23 ++ .../__tests__/components/v2/thumbnail.test.ts | 69 +++++ .../interactions/ContextMenuCommands.test.ts | 8 +- .../SlashCommands/SlashCommands.test.ts | 2 +- .../builders/__tests__/messages/embed.test.ts | 42 ++- packages/builders/package.json | 6 +- packages/builders/src/components/ActionRow.ts | 17 +- .../builders/src/components/Assertions.ts | 7 + packages/builders/src/components/Component.ts | 29 +- .../builders/src/components/Components.ts | 102 ++++++- .../src/components/fileUpload/Assertions.ts | 12 + .../src/components/fileUpload/FileUpload.ts | 99 +++++++ .../src/components/label/Assertions.ts | 31 +++ .../builders/src/components/label/Label.ts | 213 +++++++++++++++ .../src/components/selectMenu/Assertions.ts | 92 +++++++ .../components/selectMenu/BaseSelectMenu.ts | 16 +- .../components/selectMenu/StringSelectMenu.ts | 2 +- .../src/components/textInput/Assertions.ts | 25 +- .../src/components/textInput/TextInput.ts | 7 +- .../builders/src/components/v2/Assertions.ts | 72 +++++ .../builders/src/components/v2/Container.ts | 239 +++++++++++++++++ packages/builders/src/components/v2/File.ts | 63 +++++ .../src/components/v2/MediaGallery.ts | 117 +++++++++ .../src/components/v2/MediaGalleryItem.ts | 90 +++++++ .../builders/src/components/v2/Section.ts | 153 +++++++++++ .../builders/src/components/v2/Separator.ts | 69 +++++ .../builders/src/components/v2/TextDisplay.ts | 52 ++++ .../builders/src/components/v2/Thumbnail.ts | 86 ++++++ packages/builders/src/index.ts | 16 ++ .../contextMenuCommands/Assertions.ts | 3 +- .../src/interactions/modals/Assertions.ts | 6 +- .../builders/src/interactions/modals/Modal.ts | 188 ++++++++++++- .../builders/src/messages/embed/Assertions.ts | 22 +- packages/core/package.json | 2 +- packages/discord.js/package.json | 6 +- packages/formatters/README.md | 2 +- packages/formatters/package.json | 6 +- packages/next/package.json | 2 +- packages/rest/package.json | 2 +- packages/voice/package.json | 2 +- packages/ws/package.json | 2 +- pnpm-lock.yaml | 82 ++---- 53 files changed, 2697 insertions(+), 171 deletions(-) create mode 100644 packages/builders/__tests__/components/fileUpload.test.ts create mode 100644 packages/builders/__tests__/components/v2/container.test.ts create mode 100644 packages/builders/__tests__/components/v2/file.test.ts create mode 100644 packages/builders/__tests__/components/v2/mediagallery.test.ts create mode 100644 packages/builders/__tests__/components/v2/section.test.ts create mode 100644 packages/builders/__tests__/components/v2/separator.test.ts create mode 100644 packages/builders/__tests__/components/v2/textDisplay.test.ts create mode 100644 packages/builders/__tests__/components/v2/thumbnail.test.ts create mode 100644 packages/builders/src/components/fileUpload/Assertions.ts create mode 100644 packages/builders/src/components/fileUpload/FileUpload.ts create mode 100644 packages/builders/src/components/label/Assertions.ts create mode 100644 packages/builders/src/components/label/Label.ts create mode 100644 packages/builders/src/components/selectMenu/Assertions.ts create mode 100644 packages/builders/src/components/v2/Assertions.ts create mode 100644 packages/builders/src/components/v2/Container.ts create mode 100644 packages/builders/src/components/v2/File.ts create mode 100644 packages/builders/src/components/v2/MediaGallery.ts create mode 100644 packages/builders/src/components/v2/MediaGalleryItem.ts create mode 100644 packages/builders/src/components/v2/Section.ts create mode 100644 packages/builders/src/components/v2/Separator.ts create mode 100644 packages/builders/src/components/v2/TextDisplay.ts create mode 100644 packages/builders/src/components/v2/Thumbnail.ts diff --git a/packages/builders/CHANGELOG.md b/packages/builders/CHANGELOG.md index 75ca856b8..f3e33b358 100644 --- a/packages/builders/CHANGELOG.md +++ b/packages/builders/CHANGELOG.md @@ -2,6 +2,52 @@ All notable changes to this project will be documented in this file. +# [@discordjs/builders@1.13.0](https://github.com/discordjs/discord.js/compare/@discordjs/builders@1.12.2...@discordjs/builders@1.13.0) - (2025-10-24) + +## Features + +- V1 builders file uploads support (#11196) ([1417c49](https://github.com/discordjs/discord.js/commit/1417c498a40b843d772ecf88dfff5f87a1665042)) + +## Testing + +- Fix type error ([f780c6a](https://github.com/discordjs/discord.js/commit/f780c6a5500f7ea5c7a1ea7cd6720f6159d9d36e)) + +# [@discordjs/builders@1.12.2](https://github.com/discordjs/discord.js/compare/@discordjs/builders@1.12.1...@discordjs/builders@1.12.2) - (2025-10-09) + +## Bug Fixes + +- **Assertions:** Literal default values ([43362c9](https://github.com/discordjs/discord.js/commit/43362c93525f98d72b894eb0fc6b358d30ec45b9)) + +# [@discordjs/builders@1.12.1](https://github.com/discordjs/discord.js/compare/@discordjs/builders@1.12.0...@discordjs/builders@1.12.1) - (2025-10-08) + +## Bug Fixes + +- **builders:** Text display component support for modals (#11155) ([99b8436](https://github.com/discordjs/discord.js/commit/99b8436117bc12654278337abc4a23f5bdf4ba46)) + +# [@discordjs/builders@1.12.0](https://github.com/discordjs/discord.js/compare/@discordjs/builders@1.11.3...@discordjs/builders@1.12.0) - (2025-10-08) + +## Features + +- **builders:** Modal select menus in builders v1 (#11138) ([ac683b9](https://github.com/discordjs/discord.js/commit/ac683b9d040635de8514c80a9d433d9c6d63701b)) + +# [@discordjs/builders@1.11.3](https://github.com/discordjs/discord.js/compare/@discordjs/builders@1.11.2...@discordjs/builders@1.11.3) - (2025-08-10) + +## Bug Fixes + +- **contextMenuCommands:** Remove regular expression validation (#10996) ([4906aae](https://github.com/discordjs/discord.js/commit/4906aaea4c0e6e868fa658d3359026eb662fbcb8)) + +# [@discordjs/builders@1.11.0](https://github.com/discordjs/discord.js/compare/@discordjs/builders@1.10.1...@discordjs/builders@1.11.0) - (2025-04-25) + +## Features + +- Components v2 in builders v1 (#10787) ([118e682](https://github.com/discordjs/discord.js/commit/118e6826821b3b90f5923e40f167747e0658cfd1)) + +# [@discordjs/builders@1.10.1](https://github.com/discordjs/discord.js/compare/@discordjs/builders@1.10.0...@discordjs/builders@1.10.1) - (2025-02-10) + +## Bug Fixes + +- **EmbedBuilder:** Allow empty `name` and `value` on fields (#10747) ([49ef3a8](https://github.com/discordjs/discord.js/commit/49ef3a833eab23d426d5c667e28aa493ddc9cb6c)) + # [@discordjs/builders@1.9.0](https://github.com/discordjs/discord.js/compare/@discordjs/builders@1.8.2...@discordjs/builders@1.9.0) - (2024-09-01) ## Features diff --git a/packages/builders/README.md b/packages/builders/README.md index a15102db4..626d709d4 100644 --- a/packages/builders/README.md +++ b/packages/builders/README.md @@ -24,7 +24,7 @@ ## Installation -**Node.js 18 or newer is required.** +**Node.js 16.11.0 or newer is required.** ```sh npm install @discordjs/builders diff --git a/packages/builders/__tests__/components/actionRow.test.ts b/packages/builders/__tests__/components/actionRow.test.ts index b9f63b501..9e1244fde 100644 --- a/packages/builders/__tests__/components/actionRow.test.ts +++ b/packages/builders/__tests__/components/actionRow.test.ts @@ -2,7 +2,7 @@ import { ButtonStyle, ComponentType, type APIActionRowComponent, - type APIMessageActionRowComponent, + type APIComponentInMessageActionRow, } from 'discord-api-types/v10'; import { describe, test, expect } from 'vitest'; import { @@ -13,7 +13,7 @@ import { StringSelectMenuOptionBuilder, } from '../../src/index.js'; -const rowWithButtonData: APIActionRowComponent = { +const rowWithButtonData: APIActionRowComponent = { type: ComponentType.ActionRow, components: [ { @@ -25,7 +25,7 @@ const rowWithButtonData: APIActionRowComponent = { ], }; -const rowWithSelectMenuData: APIActionRowComponent = { +const rowWithSelectMenuData: APIActionRowComponent = { type: ComponentType.ActionRow, components: [ { @@ -57,7 +57,7 @@ describe('Action Row Components', () => { }); test('GIVEN valid JSON input THEN valid JSON output is given', () => { - const actionRowData: APIActionRowComponent = { + const actionRowData: APIActionRowComponent = { type: ComponentType.ActionRow, components: [ { @@ -92,7 +92,7 @@ describe('Action Row Components', () => { }); test('GIVEN valid builder options THEN valid JSON output is given', () => { - const rowWithButtonData: APIActionRowComponent = { + const rowWithButtonData: APIActionRowComponent = { type: ComponentType.ActionRow, components: [ { @@ -104,7 +104,7 @@ describe('Action Row Components', () => { ], }; - const rowWithSelectMenuData: APIActionRowComponent = { + const rowWithSelectMenuData: APIActionRowComponent = { type: ComponentType.ActionRow, components: [ { diff --git a/packages/builders/__tests__/components/components.test.ts b/packages/builders/__tests__/components/components.test.ts index fa0bd4607..ea53b9eeb 100644 --- a/packages/builders/__tests__/components/components.test.ts +++ b/packages/builders/__tests__/components/components.test.ts @@ -3,7 +3,7 @@ import { ComponentType, TextInputStyle, type APIButtonComponent, - type APIMessageActionRowComponent, + type APIComponentInMessageActionRow, type APISelectMenuComponent, type APITextInputComponent, type APIActionRowComponent, @@ -27,7 +27,7 @@ describe('createComponentBuilder', () => { ); test('GIVEN an action row component THEN returns a ActionRowBuilder', () => { - const actionRow: APIActionRowComponent = { + const actionRow: APIActionRowComponent = { components: [], type: ComponentType.ActionRow, }; diff --git a/packages/builders/__tests__/components/fileUpload.test.ts b/packages/builders/__tests__/components/fileUpload.test.ts new file mode 100644 index 000000000..deaee5cc6 --- /dev/null +++ b/packages/builders/__tests__/components/fileUpload.test.ts @@ -0,0 +1,46 @@ +import type { APIFileUploadComponent } from 'discord-api-types/v10'; +import { ComponentType } from 'discord-api-types/v10'; +import { describe, test, expect } from 'vitest'; +import { FileUploadBuilder } from '../../src/components/fileUpload/FileUpload.js'; + +describe('File Upload Components', () => { + test('Valid builder does not throw.', () => { + expect(() => new FileUploadBuilder().setCustomId('file_upload').toJSON()).not.toThrowError(); + expect(() => new FileUploadBuilder().setCustomId('file_upload').setId(5).toJSON()).not.toThrowError(); + + expect(() => + new FileUploadBuilder().setCustomId('file_upload').setMaxValues(5).setMinValues(2).toJSON(), + ).not.toThrowError(); + + expect(() => new FileUploadBuilder().setCustomId('file_upload').setRequired(false).toJSON()).not.toThrowError(); + }); + + test('Invalid builder does throw.', () => { + expect(() => new FileUploadBuilder().toJSON()).toThrowError(); + expect(() => new FileUploadBuilder().setCustomId('file_upload').setId(-3).toJSON()).toThrowError(); + expect(() => new FileUploadBuilder().setMaxValues(5).setMinValues(2).setId(10).toJSON()).toThrowError(); + expect(() => new FileUploadBuilder().setCustomId('file_upload').setMaxValues(500).toJSON()).toThrowError(); + + expect(() => + new FileUploadBuilder().setCustomId('file_upload').setMinValues(500).setMaxValues(501).toJSON(), + ).toThrowError(); + + expect(() => new FileUploadBuilder().setRequired(false).toJSON()).toThrowError(); + }); + + test('API data equals toJSON().', () => { + const fileUploadData = { + type: ComponentType.FileUpload, + custom_id: 'file_upload', + min_values: 4, + max_values: 9, + required: false, + } satisfies APIFileUploadComponent; + + expect(new FileUploadBuilder(fileUploadData).toJSON()).toEqual(fileUploadData); + + expect( + new FileUploadBuilder().setCustomId('file_upload').setMinValues(4).setMaxValues(9).setRequired(false).toJSON(), + ).toEqual(fileUploadData); + }); +}); diff --git a/packages/builders/__tests__/components/textInput.test.ts b/packages/builders/__tests__/components/textInput.test.ts index ab09ffe00..45708cdda 100644 --- a/packages/builders/__tests__/components/textInput.test.ts +++ b/packages/builders/__tests__/components/textInput.test.ts @@ -100,11 +100,11 @@ describe('Text Input Components', () => { .setPlaceholder('hello') .setStyle(TextInputStyle.Paragraph) .toJSON(); - }).toThrowError(); + }).not.toThrowError(); }); test('GIVEN valid input THEN valid JSON outputs are given', () => { - const textInputData: APITextInputComponent = { + const textInputData = { type: ComponentType.TextInput, label: 'label', custom_id: 'custom id', @@ -114,7 +114,7 @@ describe('Text Input Components', () => { value: 'value', required: false, style: TextInputStyle.Paragraph, - }; + } satisfies APITextInputComponent; expect(new TextInputBuilder(textInputData).toJSON()).toEqual(textInputData); expect( diff --git a/packages/builders/__tests__/components/v2/container.test.ts b/packages/builders/__tests__/components/v2/container.test.ts new file mode 100644 index 000000000..f67ad2af0 --- /dev/null +++ b/packages/builders/__tests__/components/v2/container.test.ts @@ -0,0 +1,248 @@ +import { type APIContainerComponent, ComponentType, SeparatorSpacingSize } from 'discord-api-types/v10'; +import { describe, test, expect } from 'vitest'; +import { ActionRowBuilder } from '../../../src/components/ActionRow.js'; +import { createComponentBuilder } from '../../../src/components/Components.js'; +import { ButtonBuilder } from '../../../src/components/button/Button.js'; +import { ContainerBuilder } from '../../../src/components/v2/Container.js'; +import { FileBuilder } from '../../../src/components/v2/File.js'; +import { MediaGalleryBuilder } from '../../../src/components/v2/MediaGallery.js'; +import { SectionBuilder } from '../../../src/components/v2/Section.js'; +import { SeparatorBuilder } from '../../../src/components/v2/Separator.js'; +import { TextDisplayBuilder } from '../../../src/components/v2/TextDisplay.js'; + +const containerWithTextDisplay: APIContainerComponent = { + type: ComponentType.Container, + components: [ + { + type: ComponentType.TextDisplay, + content: 'test', + id: 123, + }, + ], +}; + +const containerWithSeparatorData: APIContainerComponent = { + type: ComponentType.Container, + components: [ + { + type: ComponentType.Separator, + id: 1_234, + spacing: SeparatorSpacingSize.Small, + divider: false, + }, + ], + accent_color: 0x00ff00, +}; + +const containerWithSeparatorDataNoColor: APIContainerComponent = { + type: ComponentType.Container, + components: [ + { + type: ComponentType.Separator, + id: 1_234, + spacing: SeparatorSpacingSize.Small, + divider: false, + }, + ], +}; + +describe('Container Components', () => { + describe('Assertion Tests', () => { + test('GIVEN valid components THEN do not throw', () => { + expect(() => + new ContainerBuilder().addActionRowComponents( + new ActionRowBuilder().addComponents(new ButtonBuilder()), + ), + ).not.toThrowError(); + expect(() => new ContainerBuilder().addFileComponents(new FileBuilder())).not.toThrowError(); + expect(() => new ContainerBuilder().addMediaGalleryComponents(new MediaGalleryBuilder())).not.toThrowError(); + expect(() => new ContainerBuilder().addSectionComponents(new SectionBuilder())).not.toThrowError(); + expect(() => new ContainerBuilder().addSeparatorComponents(new SeparatorBuilder())).not.toThrowError(); + expect(() => new ContainerBuilder().addTextDisplayComponents(new TextDisplayBuilder())).not.toThrowError(); + expect(() => new ContainerBuilder().spliceComponents(0, 0, new SeparatorBuilder())).not.toThrowError(); + expect(() => new ContainerBuilder().addSeparatorComponents([new SeparatorBuilder()])).not.toThrowError(); + expect(() => + new ContainerBuilder().spliceComponents(0, 0, [{ type: ComponentType.Separator }]), + ).not.toThrowError(); + }); + + test('GIVEN valid JSON input THEN valid JSON output is given', () => { + const containerData: APIContainerComponent = { + type: ComponentType.Container, + components: [ + { + type: ComponentType.TextDisplay, + content: 'test', + id: 3, + }, + { + type: ComponentType.Separator, + spacing: SeparatorSpacingSize.Large, + divider: true, + id: 4, + }, + { + type: ComponentType.File, + file: { + url: 'attachment://file.png', + }, + spoiler: false, + }, + ], + accent_color: 0xff00ff, + spoiler: true, + }; + + expect(new ContainerBuilder(containerData).toJSON()).toEqual(containerData); + expect(() => createComponentBuilder({ type: ComponentType.Container, components: [] })).not.toThrowError(); + }); + + test('GIVEN valid builder options THEN valid JSON output is given', () => { + const containerWithTextDisplay: APIContainerComponent = { + type: ComponentType.Container, + components: [ + { + type: ComponentType.TextDisplay, + content: 'test', + id: 123, + }, + ], + }; + + const containerWithSeparatorData: APIContainerComponent = { + type: ComponentType.Container, + components: [ + { + type: ComponentType.Separator, + id: 1_234, + spacing: SeparatorSpacingSize.Small, + divider: false, + }, + ], + accent_color: 0x00ff00, + }; + + expect(new ContainerBuilder(containerWithTextDisplay).toJSON()).toEqual(containerWithTextDisplay); + expect(new ContainerBuilder(containerWithSeparatorData).toJSON()).toEqual(containerWithSeparatorData); + expect(() => createComponentBuilder({ type: ComponentType.Container, components: [] })).not.toThrowError(); + }); + + test('GIVEN valid builder options THEN valid JSON output is given 2', () => { + const textDisplay = new TextDisplayBuilder().setContent('test').setId(123); + const separator = new SeparatorBuilder().setId(1_234).setSpacing(SeparatorSpacingSize.Small).setDivider(false); + + expect(new ContainerBuilder().addTextDisplayComponents(textDisplay).toJSON()).toEqual(containerWithTextDisplay); + expect(new ContainerBuilder().addSeparatorComponents(separator).toJSON()).toEqual( + containerWithSeparatorDataNoColor, + ); + expect(new ContainerBuilder().addTextDisplayComponents([textDisplay]).toJSON()).toEqual(containerWithTextDisplay); + expect(new ContainerBuilder().addSeparatorComponents([separator]).toJSON()).toEqual( + containerWithSeparatorDataNoColor, + ); + }); + + test('GIVEN valid accent color THEN valid JSON output is given', () => { + expect( + new ContainerBuilder({ + components: [ + { + type: ComponentType.TextDisplay, + content: 'test', + }, + ], + }) + .setAccentColor([255, 0, 255]) + .toJSON(), + ).toEqual({ + type: ComponentType.Container, + components: [ + { + type: ComponentType.TextDisplay, + content: 'test', + }, + ], + accent_color: 0xff00ff, + }); + expect( + new ContainerBuilder({ + components: [ + { + type: ComponentType.TextDisplay, + content: 'test', + }, + ], + }) + .setAccentColor(0xff00ff) + .toJSON(), + ).toEqual({ + type: ComponentType.Container, + components: [ + { + type: ComponentType.TextDisplay, + content: 'test', + }, + ], + accent_color: 0xff00ff, + }); + expect( + new ContainerBuilder({ + components: [ + { + type: ComponentType.TextDisplay, + content: 'test', + }, + ], + }) + .setAccentColor([255, 0, 255]) + .clearAccentColor() + .toJSON(), + ).toEqual({ + type: ComponentType.Container, + components: [ + { + type: ComponentType.TextDisplay, + content: 'test', + }, + ], + }); + expect(new ContainerBuilder(containerWithSeparatorData).clearAccentColor().toJSON()).toEqual( + containerWithSeparatorDataNoColor, + ); + }); + + test('GIVEN valid method parameters THEN valid JSON is given', () => { + expect( + new ContainerBuilder() + .addTextDisplayComponents(new TextDisplayBuilder().setId(3).clearId().setContent('test')) + .setSpoiler() + .toJSON(), + ).toEqual({ + type: ComponentType.Container, + components: [ + { + type: ComponentType.TextDisplay, + content: 'test', + }, + ], + spoiler: true, + }); + expect( + new ContainerBuilder() + .addTextDisplayComponents({ type: ComponentType.TextDisplay, content: 'test' }) + .setSpoiler(false) + .setId(5) + .toJSON(), + ).toEqual({ + type: ComponentType.Container, + components: [ + { + type: ComponentType.TextDisplay, + content: 'test', + }, + ], + spoiler: false, + id: 5, + }); + }); + }); +}); diff --git a/packages/builders/__tests__/components/v2/file.test.ts b/packages/builders/__tests__/components/v2/file.test.ts new file mode 100644 index 000000000..96bd7c250 --- /dev/null +++ b/packages/builders/__tests__/components/v2/file.test.ts @@ -0,0 +1,44 @@ +import { ComponentType } from 'discord-api-types/v10'; +import { describe, expect, test } from 'vitest'; +import { FileBuilder } from '../../../src/components/v2/File'; + +const dummy = { + type: ComponentType.File as const, + file: { url: 'attachment://owo.png' }, +}; + +describe('File', () => { + describe('File url', () => { + test('GIVEN a file with a pre-defined url THEN return valid toJSON data', () => { + const file = new FileBuilder({ file: { url: 'attachment://owo.png' } }); + expect(file.toJSON()).toEqual({ ...dummy, file: { url: 'attachment://owo.png' } }); + }); + + test('GIVEN a file using File#setURL THEN return valid toJSON data', () => { + const file = new FileBuilder(); + file.setURL('attachment://uwu.png'); + + expect(file.toJSON()).toEqual({ ...dummy, file: { url: 'attachment://uwu.png' } }); + }); + + test('GIVEN a file with an invalid url THEN throws error', () => { + const file = new FileBuilder(); + + expect(() => file.setURL('https://google.com')).toThrowError(); + }); + }); + + describe('File spoiler', () => { + test('GIVEN a file with a pre-defined spoiler status THEN return valid toJSON data', () => { + const file = new FileBuilder({ ...dummy, spoiler: true }); + expect(file.toJSON()).toEqual({ ...dummy, spoiler: true }); + }); + + test('GIVEN a file using File#setSpoiler THEN return valid toJSON data', () => { + const file = new FileBuilder({ ...dummy }); + file.setSpoiler(false); + + expect(file.toJSON()).toEqual({ ...dummy, spoiler: false }); + }); + }); +}); diff --git a/packages/builders/__tests__/components/v2/mediagallery.test.ts b/packages/builders/__tests__/components/v2/mediagallery.test.ts new file mode 100644 index 000000000..965059dc0 --- /dev/null +++ b/packages/builders/__tests__/components/v2/mediagallery.test.ts @@ -0,0 +1,150 @@ +import { type APIMediaGalleryItem, type APIMediaGalleryComponent, ComponentType } from 'discord-api-types/v10'; +import { describe, test, expect } from 'vitest'; +import { createComponentBuilder } from '../../../src/components/Components.js'; +import { MediaGalleryBuilder } from '../../../src/components/v2/MediaGallery.js'; +import { MediaGalleryItemBuilder } from '../../../src/components/v2/MediaGalleryItem.js'; + +const galleryHttpsDisplay: APIMediaGalleryComponent = { + type: ComponentType.MediaGallery, + items: [ + { + description: 'test', + spoiler: false, + media: { url: 'https://discord.com/logo.png' }, + }, + ], +}; + +const galleryAttachmentData: APIMediaGalleryComponent = { + type: ComponentType.MediaGallery, + items: [ + { + media: { url: 'attachment://file.png' }, + }, + ], + id: 123, +}; + +describe('Media Gallery Components', () => { + describe('Assertion Tests', () => { + test('GIVEN an empty media gallery THEN throws error', () => { + const gallery = new MediaGalleryBuilder(); + expect(() => gallery.toJSON()).toThrow(); + }); + + test('GIVEN valid items THEN do not throw', () => { + expect(() => new MediaGalleryBuilder().addItems(new MediaGalleryItemBuilder())).not.toThrowError(); + expect(() => new MediaGalleryBuilder().spliceItems(0, 0, new MediaGalleryItemBuilder())).not.toThrowError(); + expect(() => new MediaGalleryBuilder().addItems([new MediaGalleryItemBuilder()])).not.toThrowError(); + expect(() => new MediaGalleryBuilder().spliceItems(0, 0, [new MediaGalleryItemBuilder()])).not.toThrowError(); + }); + + test('GIVEN valid JSON input THEN valid JSON output is given', () => { + const mediaGalleryData: APIMediaGalleryComponent = { + type: ComponentType.MediaGallery, + items: [ + { + media: { url: 'attachment://file.png' }, + description: 'test', + spoiler: false, + }, + { + media: { url: 'https://discord.js.org/logo.jpg' }, + spoiler: true, + }, + ], + id: 1_234, + }; + + expect(new MediaGalleryBuilder(mediaGalleryData).toJSON()).toEqual(mediaGalleryData); + expect(() => createComponentBuilder({ type: ComponentType.MediaGallery, items: [] })).not.toThrowError(); + }); + + test('GIVEN valid builder options THEN valid JSON output is given', () => { + const galleryHttpsDisplay: APIMediaGalleryComponent = { + type: ComponentType.MediaGallery, + items: [ + { + description: 'test', + spoiler: false, + media: { url: 'https://discord.com/logo.png' }, + }, + ], + }; + + const galleryAttachmentData: APIMediaGalleryComponent = { + type: ComponentType.MediaGallery, + items: [ + { + media: { url: 'attachment://file.png' }, + }, + ], + id: 123, + }; + + expect(new MediaGalleryBuilder(galleryHttpsDisplay).toJSON()).toEqual(galleryHttpsDisplay); + expect(new MediaGalleryBuilder(galleryAttachmentData).toJSON()).toEqual(galleryAttachmentData); + expect(() => createComponentBuilder({ type: ComponentType.MediaGallery, items: [] })).not.toThrowError(); + }); + + test('GIVEN valid builder options THEN valid JSON output is given 2', () => { + const item1 = new MediaGalleryItemBuilder() + .setDescription('test') + .setSpoiler(false) + .setURL('https://discord.com/logo.png'); + const item2 = new MediaGalleryItemBuilder().setURL('attachment://file.png'); + + expect(new MediaGalleryBuilder().addItems(item1).toJSON()).toEqual(galleryHttpsDisplay); + expect(new MediaGalleryBuilder().addItems(item2).setId(123).toJSON()).toEqual(galleryAttachmentData); + expect(new MediaGalleryBuilder().addItems([item1]).toJSON()).toEqual(galleryHttpsDisplay); + expect(new MediaGalleryBuilder().addItems([item2]).setId(123).toJSON()).toEqual(galleryAttachmentData); + }); + + test('GIVEN valid JSON options THEN valid JSON output is given 2', () => { + const item1: APIMediaGalleryItem = { + description: 'test', + spoiler: false, + media: { url: 'https://discord.com/logo.png' }, + }; + const item2 = { + media: { url: 'attachment://file.png' }, + }; + + expect(new MediaGalleryBuilder().addItems(item1).toJSON()).toEqual(galleryHttpsDisplay); + expect(new MediaGalleryBuilder().addItems(item2).setId(123).toJSON()).toEqual(galleryAttachmentData); + expect(new MediaGalleryBuilder().addItems([item1]).toJSON()).toEqual(galleryHttpsDisplay); + expect(new MediaGalleryBuilder().addItems([item2]).setId(123).toJSON()).toEqual(galleryAttachmentData); + }); + + test('GIVEN valid builder callback THEN valid JSON output is given', () => { + const item1 = new MediaGalleryItemBuilder() + .setDescription('test') + .setSpoiler(false) + .setURL('https://discord.com/logo.png'); + const item2 = new MediaGalleryItemBuilder().setURL('attachment://file.png'); + + expect( + new MediaGalleryBuilder() + .addItems((item) => item.setDescription('test').setSpoiler(false).setURL('https://discord.com/logo.png')) + .toJSON(), + ).toEqual(galleryHttpsDisplay); + expect( + new MediaGalleryBuilder() + .spliceItems(0, 0, (item) => item.setURL('attachment://file.png')) + .setId(123) + .toJSON(), + ).toEqual(galleryAttachmentData); + expect( + new MediaGalleryBuilder() + .addItems([(item) => item.setDescription('test').setSpoiler(false).setURL('https://discord.com/logo.png')]) + .toJSON(), + ).toEqual(galleryHttpsDisplay); + expect( + new MediaGalleryBuilder() + .spliceItems(0, 0, [(item) => item.setDescription('test').clearDescription().setURL('attachment://file.png')]) + .setId(123) + .toJSON(), + ).toEqual(galleryAttachmentData); + }); + }); +}); diff --git a/packages/builders/__tests__/components/v2/section.test.ts b/packages/builders/__tests__/components/v2/section.test.ts new file mode 100644 index 000000000..05af792e0 --- /dev/null +++ b/packages/builders/__tests__/components/v2/section.test.ts @@ -0,0 +1,191 @@ +import { type APISectionComponent, ButtonStyle, ComponentType } from 'discord-api-types/v10'; +import { describe, test, expect } from 'vitest'; +import { createComponentBuilder } from '../../../src/components/Components.js'; +import { ButtonBuilder } from '../../../src/components/button/Button.js'; +import { SectionBuilder } from '../../../src/components/v2/Section.js'; +import { TextDisplayBuilder } from '../../../src/components/v2/TextDisplay.js'; +import { ThumbnailBuilder } from '../../../src/components/v2/Thumbnail.js'; + +const sectionWithButtonData: APISectionComponent = { + type: ComponentType.Section, + components: [ + { + type: ComponentType.TextDisplay, + content: 'test', + }, + ], + accessory: { + type: ComponentType.Button, + label: 'test', + custom_id: '123', + style: ButtonStyle.Primary, + }, +}; + +const sectionWithThumbnailData: APISectionComponent = { + type: ComponentType.Section, + components: [ + { + type: ComponentType.TextDisplay, + content: 'test', + }, + ], + accessory: { + type: ComponentType.Thumbnail, + media: { url: 'attachment://file.png' }, + spoiler: true, + description: 'test', + }, +}; + +describe('Section Components', () => { + describe('Assertion Tests', () => { + test('GIVEN valid components THEN do not throw', () => { + expect(() => new SectionBuilder().addTextDisplayComponents(new TextDisplayBuilder())).not.toThrowError(); + expect(() => new SectionBuilder().spliceTextDisplayComponents(0, 0, new TextDisplayBuilder())).not.toThrowError(); + expect(() => new SectionBuilder().addTextDisplayComponents([new TextDisplayBuilder()])).not.toThrowError(); + expect(() => + new SectionBuilder().spliceTextDisplayComponents(0, 0, [new TextDisplayBuilder()]), + ).not.toThrowError(); + }); + + test('GIVEN valid JSON input THEN valid JSON output is given', () => { + const sectionData: APISectionComponent = { + type: ComponentType.Section, + components: [ + { + type: ComponentType.TextDisplay, + content: 'test', + id: 123, + }, + { + type: ComponentType.TextDisplay, + content: 'test', + }, + { + type: ComponentType.TextDisplay, + content: 'test', + }, + ], + accessory: { + type: ComponentType.Thumbnail, + media: { url: 'attachment://file.png' }, + }, + }; + + expect(new SectionBuilder(sectionData).toJSON()).toEqual(sectionData); + expect(() => + createComponentBuilder({ + type: ComponentType.Section, + components: [], + accessory: { type: ComponentType.Thumbnail, media: { url: 'https://discord.com/logo.png' } }, + }), + ).not.toThrowError(); + }); + + test('GIVEN valid builder options THEN valid JSON output is given', () => { + const sectionWithButtonData: APISectionComponent = { + type: ComponentType.Section, + components: [ + { + type: ComponentType.TextDisplay, + content: 'test', + }, + ], + accessory: { + type: ComponentType.Button, + label: 'test', + custom_id: '123', + style: ButtonStyle.Primary, + }, + }; + + const sectionWithThumbnailData: APISectionComponent = { + type: ComponentType.Section, + components: [ + { + type: ComponentType.TextDisplay, + content: 'test', + }, + ], + accessory: { + type: ComponentType.Thumbnail, + media: { url: 'attachment://file.png' }, + spoiler: true, + description: 'test', + }, + }; + + expect(new SectionBuilder(sectionWithButtonData).toJSON()).toEqual(sectionWithButtonData); + expect(new SectionBuilder(sectionWithThumbnailData).toJSON()).toEqual(sectionWithThumbnailData); + expect(() => + createComponentBuilder({ + type: ComponentType.Section, + components: [], + accessory: { + type: ComponentType.Button, + label: 'test', + custom_id: '123', + style: ButtonStyle.Primary, + }, + }), + ).not.toThrowError(); + }); + + test('GIVEN valid builder options THEN valid JSON output is given 2', () => { + const button = new ButtonBuilder().setLabel('test').setStyle(ButtonStyle.Primary).setCustomId('123'); + const thumbnail = new ThumbnailBuilder().setDescription('test').setSpoiler().setURL('attachment://file.png'); + const textDisplay = new TextDisplayBuilder().setContent('test'); + + expect(new SectionBuilder().addTextDisplayComponents(textDisplay).setButtonAccessory(button).toJSON()).toEqual( + sectionWithButtonData, + ); + expect( + new SectionBuilder().addTextDisplayComponents(textDisplay).setThumbnailAccessory(thumbnail).toJSON(), + ).toEqual(sectionWithThumbnailData); + expect( + new SectionBuilder() + .addTextDisplayComponents([textDisplay]) + .setButtonAccessory((button) => button.setLabel('test').setStyle(ButtonStyle.Primary).setCustomId('123')) + .toJSON(), + ).toEqual(sectionWithButtonData); + expect( + new SectionBuilder() + .addTextDisplayComponents([textDisplay]) + .setThumbnailAccessory((thumbnail) => + thumbnail.setDescription('test').setSpoiler().setURL('attachment://file.png'), + ) + .toJSON(), + ).toEqual(sectionWithThumbnailData); + }); + + test('GIVEN valid builder callback THEN valid JSON output is given', () => { + const button = new ButtonBuilder().setLabel('test').setStyle(ButtonStyle.Primary).setCustomId('123'); + + expect( + new SectionBuilder() + .addTextDisplayComponents((textDisplay) => textDisplay.setContent('test')) + .setButtonAccessory(button) + .toJSON(), + ).toEqual(sectionWithButtonData); + expect( + new SectionBuilder() + .spliceTextDisplayComponents(0, 0, (textDisplay) => textDisplay.setContent('test')) + .setButtonAccessory(button) + .toJSON(), + ).toEqual(sectionWithButtonData); + expect( + new SectionBuilder() + .addTextDisplayComponents([(textDisplay) => textDisplay.setContent('test')]) + .setButtonAccessory(button) + .toJSON(), + ).toEqual(sectionWithButtonData); + expect( + new SectionBuilder() + .spliceTextDisplayComponents(0, 0, [(textDisplay) => textDisplay.setContent('test')]) + .setButtonAccessory(button) + .toJSON(), + ).toEqual(sectionWithButtonData); + }); + }); +}); diff --git a/packages/builders/__tests__/components/v2/separator.test.ts b/packages/builders/__tests__/components/v2/separator.test.ts new file mode 100644 index 000000000..737435484 --- /dev/null +++ b/packages/builders/__tests__/components/v2/separator.test.ts @@ -0,0 +1,35 @@ +import { ComponentType, SeparatorSpacingSize } from 'discord-api-types/v10'; +import { describe, expect, test } from 'vitest'; +import { SeparatorBuilder } from '../../../src/components/v2/Separator'; + +describe('Separator', () => { + describe('Divider', () => { + test('GIVEN a separator with a pre-defined divider THEN return valid toJSON data', () => { + const separator = new SeparatorBuilder({ divider: true }); + expect(separator.toJSON()).toEqual({ type: ComponentType.Separator, divider: true }); + }); + + test('GIVEN a separator with a set divider THEN return valid toJSON data', () => { + const separator = new SeparatorBuilder().setDivider(false); + expect(separator.toJSON()).toEqual({ type: ComponentType.Separator, divider: false }); + }); + }); + + describe('Spacing', () => { + test('GIVEN a separator with a pre-defined spacing THEN return valid toJSON data', () => { + const separator = new SeparatorBuilder({ spacing: SeparatorSpacingSize.Small }); + expect(separator.toJSON()).toEqual({ type: ComponentType.Separator, spacing: SeparatorSpacingSize.Small }); + }); + + test('GIVEN a separator with a set spacing THEN return valid toJSON data', () => { + const separator = new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Large); + expect(separator.toJSON()).toEqual({ type: ComponentType.Separator, spacing: SeparatorSpacingSize.Large }); + }); + + test('GIVEN a separator with a set spacing THEN clear spacing THEN return valid toJSON data', () => { + const separator = new SeparatorBuilder({ spacing: SeparatorSpacingSize.Small }); + separator.clearSpacing(); + expect(separator.toJSON()).toEqual({ type: ComponentType.Separator }); + }); + }); +}); diff --git a/packages/builders/__tests__/components/v2/textDisplay.test.ts b/packages/builders/__tests__/components/v2/textDisplay.test.ts new file mode 100644 index 000000000..01495c8af --- /dev/null +++ b/packages/builders/__tests__/components/v2/textDisplay.test.ts @@ -0,0 +1,23 @@ +import { ComponentType } from 'discord-api-types/v10'; +import { describe, expect, test } from 'vitest'; +import { TextDisplayBuilder } from '../../../src/components/v2/TextDisplay'; + +describe('TextDisplay', () => { + describe('TextDisplay content', () => { + test('GIVEN a text display with a pre-defined content THEN return valid toJSON data', () => { + const textDisplay = new TextDisplayBuilder({ content: 'foo' }); + expect(textDisplay.toJSON()).toEqual({ type: ComponentType.TextDisplay, content: 'foo' }); + }); + + test('GIVEN a text display with a set content THEN return valid toJSON data', () => { + const textDisplay = new TextDisplayBuilder().setContent('foo'); + expect(textDisplay.toJSON()).toEqual({ type: ComponentType.TextDisplay, content: 'foo' }); + }); + + test('GIVEN a text display with a pre-defined content THEN overwritten content THEN return valid toJSON data', () => { + const textDisplay = new TextDisplayBuilder({ content: 'foo' }); + textDisplay.setContent('bar'); + expect(textDisplay.toJSON()).toEqual({ type: ComponentType.TextDisplay, content: 'bar' }); + }); + }); +}); diff --git a/packages/builders/__tests__/components/v2/thumbnail.test.ts b/packages/builders/__tests__/components/v2/thumbnail.test.ts new file mode 100644 index 000000000..5bcd23af7 --- /dev/null +++ b/packages/builders/__tests__/components/v2/thumbnail.test.ts @@ -0,0 +1,69 @@ +import { ComponentType } from 'discord-api-types/v10'; +import { describe, expect, test } from 'vitest'; +import { ThumbnailBuilder } from '../../../src/components/v2/Thumbnail'; + +const dummy = { + type: ComponentType.Thumbnail as const, + media: { url: 'https://google.com' }, +}; + +describe('Thumbnail', () => { + describe('Thumbnail url', () => { + test('GIVEN a thumbnail with a pre-defined url THEN return valid toJSON data', () => { + const thumbnail = new ThumbnailBuilder({ media: { url: 'https://google.com' } }); + expect(thumbnail.toJSON()).toEqual({ type: ComponentType.Thumbnail, media: { url: 'https://google.com' } }); + }); + + test('GIVEN a thumbnail with a set url THEN return valid toJSON data', () => { + const thumbnail = new ThumbnailBuilder().setURL('https://google.com'); + expect(thumbnail.toJSON()).toEqual({ type: ComponentType.Thumbnail, media: { url: 'https://google.com' } }); + }); + + test.each(['owo', 'discord://user'])('GIVEN a thumbnail with an invalid URL (%s) THEN throws error', (input) => { + const thumbnail = new ThumbnailBuilder(); + + expect(() => thumbnail.setURL(input)).toThrowError(); + }); + }); + + describe('Thumbnail description', () => { + test('GIVEN a thumbnail with a pre-defined description THEN return valid toJSON data', () => { + const thumbnail = new ThumbnailBuilder({ ...dummy, description: 'foo' }); + expect(thumbnail.toJSON()).toEqual({ ...dummy, description: 'foo' }); + }); + + test('GIVEN a thumbnail with a set description THEN return valid toJSON data', () => { + const thumbnail = new ThumbnailBuilder({ ...dummy }); + thumbnail.setDescription('foo'); + + expect(thumbnail.toJSON()).toEqual({ ...dummy, description: 'foo' }); + }); + + test('GIVEN a thumbnail with a pre-defined description THEN unset description THEN return valid toJSON data', () => { + const thumbnail = new ThumbnailBuilder({ description: 'foo', ...dummy }); + thumbnail.clearDescription(); + + expect(thumbnail.toJSON()).toEqual({ ...dummy }); + }); + + test('GIVEN a thumbnail with an invalid description THEN throws error', () => { + const thumbnail = new ThumbnailBuilder(); + + expect(() => thumbnail.setDescription('a'.repeat(1_025))).toThrowError(); + }); + }); + + describe('Thumbnail spoiler', () => { + test('GIVEN a thumbnail with a pre-defined spoiler status THEN return valid toJSON data', () => { + const thumbnail = new ThumbnailBuilder({ ...dummy, spoiler: true }); + expect(thumbnail.toJSON()).toEqual({ ...dummy, spoiler: true }); + }); + + test('GIVEN a thumbnail with a set spoiler status THEN return valid toJSON data', () => { + const thumbnail = new ThumbnailBuilder({ ...dummy }); + thumbnail.setSpoiler(false); + + expect(thumbnail.toJSON()).toEqual({ ...dummy, spoiler: false }); + }); + }); +}); diff --git a/packages/builders/__tests__/interactions/ContextMenuCommands.test.ts b/packages/builders/__tests__/interactions/ContextMenuCommands.test.ts index 55a8b030a..fd126753a 100644 --- a/packages/builders/__tests__/interactions/ContextMenuCommands.test.ts +++ b/packages/builders/__tests__/interactions/ContextMenuCommands.test.ts @@ -16,8 +16,8 @@ describe('Context Menu Commands', () => { // Too short of a name expect(() => ContextMenuCommandAssertions.validateName('')).toThrowError(); - // Invalid characters used - expect(() => ContextMenuCommandAssertions.validateName('ABC123$%^&')).toThrowError(); + // This should be fine, even with trailing and leading spaces (API trims it). + expect(() => ContextMenuCommandAssertions.validateName(' 🩵 ABC 123 $%^& ')).not.toThrowError(); // Too long of a name expect(() => @@ -60,8 +60,6 @@ describe('Context Menu Commands', () => { }); test('GIVEN invalid name THEN throw error', () => { - expect(() => getBuilder().setName('$$$')).toThrowError(); - expect(() => getBuilder().setName(' ')).toThrowError(); }); @@ -166,7 +164,7 @@ describe('Context Menu Commands', () => { }); describe('integration types', () => { - test('GIVEN a builder with valid integration types THEN does not throw an error', () => { + test('GIVEN a builder with valid integraton types THEN does not throw an error', () => { expect(() => getBuilder().setIntegrationTypes([ ApplicationIntegrationType.GuildInstall, diff --git a/packages/builders/__tests__/interactions/SlashCommands/SlashCommands.test.ts b/packages/builders/__tests__/interactions/SlashCommands/SlashCommands.test.ts index 8efc62d41..64e9d97a7 100644 --- a/packages/builders/__tests__/interactions/SlashCommands/SlashCommands.test.ts +++ b/packages/builders/__tests__/interactions/SlashCommands/SlashCommands.test.ts @@ -565,7 +565,7 @@ describe('Slash Commands', () => { }); describe('integration types', () => { - test('GIVEN a builder with valid integration types THEN does not throw an error', () => { + test('GIVEN a builder with valid integraton types THEN does not throw an error', () => { expect(() => getBuilder().setIntegrationTypes([ ApplicationIntegrationType.GuildInstall, diff --git a/packages/builders/__tests__/messages/embed.test.ts b/packages/builders/__tests__/messages/embed.test.ts index 3a0ef2702..c4ed0f7e0 100644 --- a/packages/builders/__tests__/messages/embed.test.ts +++ b/packages/builders/__tests__/messages/embed.test.ts @@ -324,12 +324,16 @@ describe('Embed', () => { test('GIVEN an embed using Embed#addFields THEN returns valid toJSON data', () => { const embed = new EmbedBuilder(); embed.addFields({ name: 'foo', value: 'bar' }); - embed.addFields([{ name: 'foo', value: 'bar' }]); + embed.addFields([ + { name: 'foo', value: 'bar' }, + { name: '', value: '' }, + ]); expect(embed.toJSON()).toStrictEqual({ fields: [ { name: 'foo', value: 'bar' }, { name: 'foo', value: 'bar' }, + { name: '', value: '' }, ], }); }); @@ -381,38 +385,24 @@ describe('Embed', () => { expect(() => embed.setFields(Array.from({ length: 26 }, () => ({ name: 'foo', value: 'bar' })))).toThrowError(); }); - describe('GIVEN invalid field amount THEN throws error', () => { - test('1', () => { - const embed = new EmbedBuilder(); + test('GIVEN invalid field amount THEN throws error', () => { + const embed = new EmbedBuilder(); - expect(() => - embed.addFields(...Array.from({ length: 26 }, () => ({ name: 'foo', value: 'bar' }))), - ).toThrowError(); - }); + expect(() => + embed.addFields(...Array.from({ length: 26 }, () => ({ name: 'foo', value: 'bar' }))), + ).toThrowError(); }); - describe('GIVEN invalid field name THEN throws error', () => { - test('2', () => { - const embed = new EmbedBuilder(); + test('GIVEN invalid field name length THEN throws error', () => { + const embed = new EmbedBuilder(); - expect(() => embed.addFields({ name: '', value: 'bar' })).toThrowError(); - }); + expect(() => embed.addFields({ name: 'a'.repeat(257), value: 'bar' })).toThrowError(); }); - describe('GIVEN invalid field name length THEN throws error', () => { - test('3', () => { - const embed = new EmbedBuilder(); + test('GIVEN invalid field value length THEN throws error', () => { + const embed = new EmbedBuilder(); - expect(() => embed.addFields({ name: 'a'.repeat(257), value: 'bar' })).toThrowError(); - }); - }); - - describe('GIVEN invalid field value length THEN throws error', () => { - test('4', () => { - const embed = new EmbedBuilder(); - - expect(() => embed.addFields({ name: '', value: 'a'.repeat(1_025) })).toThrowError(); - }); + expect(() => embed.addFields({ name: '', value: 'a'.repeat(1_025) })).toThrowError(); }); }); }); diff --git a/packages/builders/package.json b/packages/builders/package.json index 538de52d3..bd307d43d 100644 --- a/packages/builders/package.json +++ b/packages/builders/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@discordjs/builders", - "version": "1.9.0", + "version": "1.13.0", "description": "A set of builders that you can use when creating your bot", "scripts": { "test": "vitest run", @@ -68,7 +68,7 @@ "@discordjs/formatters": "workspace:^", "@discordjs/util": "workspace:^", "@sapphire/shapeshift": "^4.0.0", - "discord-api-types": "^0.37.119", + "discord-api-types": "^0.38.32", "fast-deep-equal": "^3.1.3", "ts-mixer": "^6.0.4", "tslib": "^2.6.3" @@ -91,7 +91,7 @@ "vitest": "^2.0.5" }, "engines": { - "node": ">=18" + "node": ">=16.11.0" }, "publishConfig": { "access": "public", diff --git a/packages/builders/src/components/ActionRow.ts b/packages/builders/src/components/ActionRow.ts index ade84ac46..6953d5f8d 100644 --- a/packages/builders/src/components/ActionRow.ts +++ b/packages/builders/src/components/ActionRow.ts @@ -3,9 +3,9 @@ import { type APIActionRowComponent, ComponentType, - type APIMessageActionRowComponent, - type APIModalActionRowComponent, - type APIActionRowComponentTypes, + type APIComponentInMessageActionRow, + type APIComponentInModalActionRow, + type APIComponentInActionRow, } from 'discord-api-types/v10'; import { normalizeArray, type RestOrArray } from '../util/normalizeArray.js'; import { ComponentBuilder } from './Component.js'; @@ -18,13 +18,6 @@ import type { StringSelectMenuBuilder } from './selectMenu/StringSelectMenu.js'; import type { UserSelectMenuBuilder } from './selectMenu/UserSelectMenu.js'; import type { TextInputBuilder } from './textInput/TextInput.js'; -/** - * The builders that may be used for messages. - */ -export type MessageComponentBuilder = - | ActionRowBuilder - | MessageActionRowComponentBuilder; - /** * The builders that may be used for modals. */ @@ -57,7 +50,7 @@ export type AnyComponentBuilder = MessageActionRowComponentBuilder | ModalAction * @typeParam ComponentType - The types of components this action row holds */ export class ActionRowBuilder extends ComponentBuilder< - APIActionRowComponent + APIActionRowComponent > { /** * The components within this action row. @@ -98,7 +91,7 @@ export class ActionRowBuilder extends * .addComponents(button2, button3); * ``` */ - public constructor({ components, ...data }: Partial> = {}) { + public constructor({ components, ...data }: Partial> = {}) { super({ type: ComponentType.ActionRow, ...data }); this.components = (components?.map((component) => createComponentBuilder(component)) ?? []) as ComponentType[]; } diff --git a/packages/builders/src/components/Assertions.ts b/packages/builders/src/components/Assertions.ts index 926159eed..7165a5dd6 100644 --- a/packages/builders/src/components/Assertions.ts +++ b/packages/builders/src/components/Assertions.ts @@ -3,6 +3,13 @@ import { ButtonStyle, ChannelType, type APIMessageComponentEmoji } from 'discord import { isValidationEnabled } from '../util/validation.js'; import { StringSelectMenuOptionBuilder } from './selectMenu/StringSelectMenuOption.js'; +export const idValidator = s + .number() + .safeInt() + .greaterThanOrEqual(1) + .lessThan(4_294_967_296) // 2^32 - 1 + .setValidationEnabled(isValidationEnabled); + export const customIdValidator = s .string() .lengthGreaterThanOrEqual(1) diff --git a/packages/builders/src/components/Component.ts b/packages/builders/src/components/Component.ts index e5e59638d..b2e5cf884 100644 --- a/packages/builders/src/components/Component.ts +++ b/packages/builders/src/components/Component.ts @@ -1,15 +1,22 @@ import type { JSONEncodable } from '@discordjs/util'; import type { APIActionRowComponent, - APIActionRowComponentTypes, + APIComponentInActionRow, APIBaseComponent, ComponentType, + APIMessageComponent, + APIModalComponent, } from 'discord-api-types/v10'; +import { idValidator } from './Assertions'; /** * Any action row component data represented as an object. */ -export type AnyAPIActionRowComponent = APIActionRowComponent | APIActionRowComponentTypes; +export type AnyAPIActionRowComponent = + | APIActionRowComponent + | APIComponentInActionRow + | APIMessageComponent + | APIModalComponent; /** * The base component builder that contains common symbols for all sorts of components. @@ -42,4 +49,22 @@ export abstract class ComponentBuilder< public constructor(data: Partial) { this.data = data; } + + /** + * Sets the id (not the custom id) for this component. + * + * @param id - The id for this component + */ + public setId(id: number) { + this.data.id = idValidator.parse(id); + return this; + } + + /** + * Clears the id of this component, defaulting to a default incremented id. + */ + public clearId() { + this.data.id = undefined; + return this; + } } diff --git a/packages/builders/src/components/Components.ts b/packages/builders/src/components/Components.ts index 18b0dff6d..243694f06 100644 --- a/packages/builders/src/components/Components.ts +++ b/packages/builders/src/components/Components.ts @@ -1,18 +1,42 @@ +import type { JSONEncodable } from '@discordjs/util'; import { ComponentType, type APIMessageComponent, type APIModalComponent } from 'discord-api-types/v10'; import { ActionRowBuilder, + type MessageActionRowComponentBuilder, type AnyComponentBuilder, - type MessageComponentBuilder, type ModalComponentBuilder, } from './ActionRow.js'; import { ComponentBuilder } from './Component.js'; import { ButtonBuilder } from './button/Button.js'; +import { FileUploadBuilder } from './fileUpload/FileUpload.js'; +import { LabelBuilder } from './label/Label.js'; import { ChannelSelectMenuBuilder } from './selectMenu/ChannelSelectMenu.js'; import { MentionableSelectMenuBuilder } from './selectMenu/MentionableSelectMenu.js'; import { RoleSelectMenuBuilder } from './selectMenu/RoleSelectMenu.js'; import { StringSelectMenuBuilder } from './selectMenu/StringSelectMenu.js'; import { UserSelectMenuBuilder } from './selectMenu/UserSelectMenu.js'; import { TextInputBuilder } from './textInput/TextInput.js'; +import { ContainerBuilder } from './v2/Container.js'; +import { FileBuilder } from './v2/File.js'; +import { MediaGalleryBuilder } from './v2/MediaGallery.js'; +import { SectionBuilder } from './v2/Section.js'; +import { SeparatorBuilder } from './v2/Separator.js'; +import { TextDisplayBuilder } from './v2/TextDisplay.js'; +import { ThumbnailBuilder } from './v2/Thumbnail.js'; + +/** + * The builders that may be used for messages. + */ +export type MessageComponentBuilder = + | ActionRowBuilder + | ContainerBuilder + | FileBuilder + | MediaGalleryBuilder + | MessageActionRowComponentBuilder + | SectionBuilder + | SeparatorBuilder + | TextDisplayBuilder + | ThumbnailBuilder; /** * Components here are mapped to their respective builder. @@ -50,6 +74,42 @@ export interface MappedComponentTypes { * The channel select component type is associated with a {@link ChannelSelectMenuBuilder}. */ [ComponentType.ChannelSelect]: ChannelSelectMenuBuilder; + /** + * The file component type is associated with a {@link FileBuilder}. + */ + [ComponentType.File]: FileBuilder; + /** + * The separator component type is associated with a {@link SeparatorBuilder}. + */ + [ComponentType.Separator]: SeparatorBuilder; + /** + * The container component type is associated with a {@link ContainerBuilder}. + */ + [ComponentType.Container]: ContainerBuilder; + /** + * The text display component type is associated with a {@link TextDisplayBuilder}. + */ + [ComponentType.TextDisplay]: TextDisplayBuilder; + /** + * The thumbnail component type is associated with a {@link ThumbnailBuilder}. + */ + [ComponentType.Thumbnail]: ThumbnailBuilder; + /** + * The section component type is associated with a {@link SectionBuilder}. + */ + [ComponentType.Section]: SectionBuilder; + /** + * The media gallery component type is associated with a {@link MediaGalleryBuilder}. + */ + [ComponentType.MediaGallery]: MediaGalleryBuilder; + /** + * The label component type is associated with a {@link LabelBuilder}. + */ + [ComponentType.Label]: LabelBuilder; + /** + * The file upload component type is associated with a {@link FileUploadBuilder}. + */ + [ComponentType.FileUpload]: FileUploadBuilder; } /** @@ -97,8 +157,48 @@ export function createComponentBuilder( return new MentionableSelectMenuBuilder(data); case ComponentType.ChannelSelect: return new ChannelSelectMenuBuilder(data); + case ComponentType.File: + return new FileBuilder(data); + case ComponentType.Container: + return new ContainerBuilder(data); + case ComponentType.Section: + return new SectionBuilder(data); + case ComponentType.Separator: + return new SeparatorBuilder(data); + case ComponentType.TextDisplay: + return new TextDisplayBuilder(data); + case ComponentType.Thumbnail: + return new ThumbnailBuilder(data); + case ComponentType.MediaGallery: + return new MediaGalleryBuilder(data); + case ComponentType.Label: + return new LabelBuilder(data); + case ComponentType.FileUpload: + return new FileUploadBuilder(data); default: // @ts-expect-error This case can still occur if we get a newer unsupported component type throw new Error(`Cannot properly serialize component type: ${data.type}`); } } + +function isBuilder>( + builder: unknown, + Constructor: new () => Builder, +): builder is Builder { + return builder instanceof Constructor; +} + +export function resolveBuilder, Builder extends JSONEncodable>( + builder: Builder | ComponentType | ((builder: Builder) => Builder), + Constructor: new (data?: ComponentType) => Builder, +) { + if (isBuilder(builder, Constructor)) { + return builder; + } + + if (typeof builder === 'function') { + return builder(new Constructor()); + } + + return new Constructor(builder); +} diff --git a/packages/builders/src/components/fileUpload/Assertions.ts b/packages/builders/src/components/fileUpload/Assertions.ts new file mode 100644 index 000000000..8f3683d16 --- /dev/null +++ b/packages/builders/src/components/fileUpload/Assertions.ts @@ -0,0 +1,12 @@ +import { s } from '@sapphire/shapeshift'; +import { ComponentType } from 'discord-api-types/v10'; +import { customIdValidator, idValidator } from '../Assertions.js'; + +export const fileUploadPredicate = s.object({ + type: s.literal(ComponentType.FileUpload), + id: idValidator.optional(), + custom_id: customIdValidator, + min_values: s.number().greaterThanOrEqual(0).lessThanOrEqual(10).optional(), + max_values: s.number().greaterThanOrEqual(1).lessThanOrEqual(10).optional(), + required: s.boolean().optional(), +}); diff --git a/packages/builders/src/components/fileUpload/FileUpload.ts b/packages/builders/src/components/fileUpload/FileUpload.ts new file mode 100644 index 000000000..0985d2b0e --- /dev/null +++ b/packages/builders/src/components/fileUpload/FileUpload.ts @@ -0,0 +1,99 @@ +import { type APIFileUploadComponent, ComponentType } from 'discord-api-types/v10'; +import { ComponentBuilder } from '../Component.js'; +import { fileUploadPredicate } from './Assertions.js'; + +/** + * A builder that creates API-compatible JSON data for file uploads. + */ +export class FileUploadBuilder extends ComponentBuilder { + /** + * Creates a new file upload. + * + * @param data - The API data to create this file upload with + * @example + * Creating a file upload from an API data object: + * ```ts + * const fileUpload = new FileUploadBuilder({ + * custom_id: "file_upload", + * min_values: 2, + * max_values: 5, + * }); + * ``` + * @example + * Creating a file upload using setters and API data: + * ```ts + * const fileUpload = new FileUploadBuilder({ + * custom_id: "file_upload", + * min_values: 2, + * max_values: 5, + * }).setRequired(); + * ``` + */ + public constructor(data: Partial = {}) { + super({ type: ComponentType.FileUpload, ...data }); + } + + /** + * Sets the custom id for this file upload. + * + * @param customId - The custom id to use + */ + public setCustomId(customId: string) { + this.data.custom_id = customId; + return this; + } + + /** + * Sets the minimum number of file uploads required. + * + * @param minValues - The minimum values that must be uploaded + */ + public setMinValues(minValues: number) { + this.data.min_values = minValues; + return this; + } + + /** + * Clears the minimum values. + */ + public clearMinValues() { + this.data.min_values = undefined; + return this; + } + + /** + * Sets the maximum number of file uploads required. + * + * @param maxValues - The maximum values that can be uploaded + */ + public setMaxValues(maxValues: number) { + this.data.max_values = maxValues; + return this; + } + + /** + * Clears the maximum values. + */ + public clearMaxValues() { + this.data.max_values = undefined; + return this; + } + + /** + * Sets whether this file upload is required. + * + * @param required - Whether this file upload is required + */ + public setRequired(required = true) { + this.data.required = required; + return this; + } + + /** + * {@inheritDoc ComponentBuilder.toJSON} + */ + public toJSON(): APIFileUploadComponent { + fileUploadPredicate.parse(this.data); + return this.data as APIFileUploadComponent; + } +} diff --git a/packages/builders/src/components/label/Assertions.ts b/packages/builders/src/components/label/Assertions.ts new file mode 100644 index 000000000..8e17ca4c3 --- /dev/null +++ b/packages/builders/src/components/label/Assertions.ts @@ -0,0 +1,31 @@ +import { s } from '@sapphire/shapeshift'; +import { ComponentType } from 'discord-api-types/v10'; +import { isValidationEnabled } from '../../util/validation.js'; +import { idValidator } from '../Assertions.js'; +import { fileUploadPredicate } from '../fileUpload/Assertions.js'; +import { + selectMenuChannelPredicate, + selectMenuMentionablePredicate, + selectMenuRolePredicate, + selectMenuStringPredicate, + selectMenuUserPredicate, +} from '../selectMenu/Assertions.js'; +import { textInputPredicate } from '../textInput/Assertions.js'; + +export const labelPredicate = s + .object({ + id: idValidator.optional(), + type: s.literal(ComponentType.Label), + label: s.string().lengthGreaterThanOrEqual(1).lengthLessThanOrEqual(45), + description: s.string().lengthGreaterThanOrEqual(1).lengthLessThanOrEqual(100).optional(), + component: s.union([ + textInputPredicate, + selectMenuUserPredicate, + selectMenuRolePredicate, + selectMenuMentionablePredicate, + selectMenuChannelPredicate, + selectMenuStringPredicate, + fileUploadPredicate, + ]), + }) + .setValidationEnabled(isValidationEnabled); diff --git a/packages/builders/src/components/label/Label.ts b/packages/builders/src/components/label/Label.ts new file mode 100644 index 000000000..a68589d24 --- /dev/null +++ b/packages/builders/src/components/label/Label.ts @@ -0,0 +1,213 @@ +import type { + APIChannelSelectComponent, + APIFileUploadComponent, + APILabelComponent, + APIMentionableSelectComponent, + APIRoleSelectComponent, + APIStringSelectComponent, + APITextInputComponent, + APIUserSelectComponent, +} from 'discord-api-types/v10'; +import { ComponentType } from 'discord-api-types/v10'; +import { ComponentBuilder } from '../Component.js'; +import { createComponentBuilder, resolveBuilder } from '../Components.js'; +import { FileUploadBuilder } from '../fileUpload/FileUpload.js'; +import { ChannelSelectMenuBuilder } from '../selectMenu/ChannelSelectMenu.js'; +import { MentionableSelectMenuBuilder } from '../selectMenu/MentionableSelectMenu.js'; +import { RoleSelectMenuBuilder } from '../selectMenu/RoleSelectMenu.js'; +import { StringSelectMenuBuilder } from '../selectMenu/StringSelectMenu.js'; +import { UserSelectMenuBuilder } from '../selectMenu/UserSelectMenu.js'; +import { TextInputBuilder } from '../textInput/TextInput.js'; +import { labelPredicate } from './Assertions.js'; + +export interface LabelBuilderData extends Partial> { + component?: + | ChannelSelectMenuBuilder + | FileUploadBuilder + | MentionableSelectMenuBuilder + | RoleSelectMenuBuilder + | StringSelectMenuBuilder + | TextInputBuilder + | UserSelectMenuBuilder; +} + +/** + * A builder that creates API-compatible JSON data for labels. + */ +export class LabelBuilder extends ComponentBuilder { + /** + * @internal + */ + public override readonly data: LabelBuilderData; + + /** + * Creates a new label. + * + * @param data - The API data to create this label with + * @example + * Creating a label from an API data object: + * ```ts + * const label = new LabelBuilder({ + * label: "label", + * component, + * }); + * ``` + * @example + * Creating a label using setters and API data: + * ```ts + * const label = new LabelBuilder({ + * label: 'label', + * component, + * }).setLabel('new text'); + * ``` + */ + public constructor(data: Partial = {}) { + super({ type: ComponentType.Label }); + + const { component, ...rest } = data; + + this.data = { + ...rest, + component: component ? createComponentBuilder(component) : undefined, + type: ComponentType.Label, + }; + } + + /** + * Sets the label for this label. + * + * @param label - The label to use + */ + public setLabel(label: string) { + this.data.label = label; + return this; + } + + /** + * Sets the description for this label. + * + * @param description - The description to use + */ + public setDescription(description: string) { + this.data.description = description; + return this; + } + + /** + * Clears the description for this label. + */ + public clearDescription() { + this.data.description = undefined; + return this; + } + + /** + * Sets a string select menu component to this label. + * + * @param input - A function that returns a component builder or an already built builder + */ + public setStringSelectMenuComponent( + input: + | APIStringSelectComponent + | StringSelectMenuBuilder + | ((builder: StringSelectMenuBuilder) => StringSelectMenuBuilder), + ): this { + this.data.component = resolveBuilder(input, StringSelectMenuBuilder); + return this; + } + + /** + * Sets a user select menu component to this label. + * + * @param input - A function that returns a component builder or an already built builder + */ + public setUserSelectMenuComponent( + input: APIUserSelectComponent | UserSelectMenuBuilder | ((builder: UserSelectMenuBuilder) => UserSelectMenuBuilder), + ): this { + this.data.component = resolveBuilder(input, UserSelectMenuBuilder); + return this; + } + + /** + * Sets a role select menu component to this label. + * + * @param input - A function that returns a component builder or an already built builder + */ + public setRoleSelectMenuComponent( + input: APIRoleSelectComponent | RoleSelectMenuBuilder | ((builder: RoleSelectMenuBuilder) => RoleSelectMenuBuilder), + ): this { + this.data.component = resolveBuilder(input, RoleSelectMenuBuilder); + return this; + } + + /** + * Sets a mentionable select menu component to this label. + * + * @param input - A function that returns a component builder or an already built builder + */ + public setMentionableSelectMenuComponent( + input: + | APIMentionableSelectComponent + | MentionableSelectMenuBuilder + | ((builder: MentionableSelectMenuBuilder) => MentionableSelectMenuBuilder), + ): this { + this.data.component = resolveBuilder(input, MentionableSelectMenuBuilder); + return this; + } + + /** + * Sets a channel select menu component to this label. + * + * @param input - A function that returns a component builder or an already built builder + */ + public setChannelSelectMenuComponent( + input: + | APIChannelSelectComponent + | ChannelSelectMenuBuilder + | ((builder: ChannelSelectMenuBuilder) => ChannelSelectMenuBuilder), + ): this { + this.data.component = resolveBuilder(input, ChannelSelectMenuBuilder); + return this; + } + + /** + * Sets a text input component to this label. + * + * @param input - A function that returns a component builder or an already built builder + */ + public setTextInputComponent( + input: APITextInputComponent | TextInputBuilder | ((builder: TextInputBuilder) => TextInputBuilder), + ): this { + this.data.component = resolveBuilder(input, TextInputBuilder); + return this; + } + + /** + * Sets a file upload component to this label. + * + * @param input - A function that returns a component builder or an already built builder + */ + public setFileUploadComponent( + input: APIFileUploadComponent | FileUploadBuilder | ((builder: FileUploadBuilder) => FileUploadBuilder), + ): this { + this.data.component = resolveBuilder(input, FileUploadBuilder); + return this; + } + + /** + * {@inheritDoc ComponentBuilder.toJSON} + */ + public override toJSON(): APILabelComponent { + const { component, ...rest } = this.data; + + const data = { + ...rest, + // The label predicate validates the component. + component: component?.toJSON(), + }; + + labelPredicate.parse(data); + + return data as APILabelComponent; + } +} diff --git a/packages/builders/src/components/selectMenu/Assertions.ts b/packages/builders/src/components/selectMenu/Assertions.ts new file mode 100644 index 000000000..8aa0c0f1d --- /dev/null +++ b/packages/builders/src/components/selectMenu/Assertions.ts @@ -0,0 +1,92 @@ +import { Result, s } from '@sapphire/shapeshift'; +import { ChannelType, ComponentType, SelectMenuDefaultValueType } from 'discord-api-types/v10'; +import { isValidationEnabled } from '../../util/validation.js'; +import { customIdValidator, emojiValidator, idValidator } from '../Assertions.js'; +import { labelValidator } from '../textInput/Assertions.js'; + +const selectMenuBasePredicate = s.object({ + id: idValidator.optional(), + placeholder: s.string().lengthLessThanOrEqual(150).optional(), + min_values: s.number().greaterThanOrEqual(0).lessThanOrEqual(25).optional(), + max_values: s.number().greaterThanOrEqual(0).lessThanOrEqual(25).optional(), + custom_id: customIdValidator, + disabled: s.boolean().optional(), +}); + +export const selectMenuChannelPredicate = selectMenuBasePredicate + .extend({ + type: s.literal(ComponentType.ChannelSelect), + channel_types: s.nativeEnum(ChannelType).array().optional(), + default_values: s + .object({ id: s.string(), type: s.literal(SelectMenuDefaultValueType.Channel) }) + .array() + .lengthLessThanOrEqual(25) + .optional(), + }) + .setValidationEnabled(isValidationEnabled); + +export const selectMenuMentionablePredicate = selectMenuBasePredicate + .extend({ + type: s.literal(ComponentType.MentionableSelect), + default_values: s + .object({ + id: s.string(), + type: s.union([s.literal(SelectMenuDefaultValueType.Role), s.literal(SelectMenuDefaultValueType.User)]), + }) + .array() + .lengthLessThanOrEqual(25) + .optional(), + }) + .setValidationEnabled(isValidationEnabled); + +export const selectMenuRolePredicate = selectMenuBasePredicate + .extend({ + type: s.literal(ComponentType.RoleSelect), + default_values: s + .object({ id: s.string(), type: s.literal(SelectMenuDefaultValueType.Role) }) + .array() + .lengthLessThanOrEqual(25) + .optional(), + }) + .setValidationEnabled(isValidationEnabled); + +export const selectMenuUserPredicate = selectMenuBasePredicate + .extend({ + type: s.literal(ComponentType.UserSelect), + default_values: s + .object({ id: s.string(), type: s.literal(SelectMenuDefaultValueType.User) }) + .array() + .lengthLessThanOrEqual(25) + .optional(), + }) + .setValidationEnabled(isValidationEnabled); + +export const selectMenuStringOptionPredicate = s + .object({ + label: labelValidator, + value: s.string().lengthGreaterThanOrEqual(1).lengthLessThanOrEqual(100), + description: s.string().lengthGreaterThanOrEqual(1).lengthLessThanOrEqual(100).optional(), + emoji: emojiValidator.optional(), + default: s.boolean().optional(), + }) + .setValidationEnabled(isValidationEnabled); + +export const selectMenuStringPredicate = selectMenuBasePredicate + .extend({ + type: s.literal(ComponentType.StringSelect), + options: selectMenuStringOptionPredicate.array().lengthGreaterThanOrEqual(1).lengthLessThanOrEqual(25), + }) + .reshape((value) => { + if (value.min_values !== undefined && value.options.length < value.min_values) { + return Result.err(new RangeError(`The number of options must be greater than or equal to min_values`)); + } + + if (value.min_values !== undefined && value.max_values !== undefined && value.min_values > value.max_values) { + return Result.err( + new RangeError(`The maximum amount of options must be greater than or equal to the minimum amount of options`), + ); + } + + return Result.ok(value); + }) + .setValidationEnabled(isValidationEnabled); diff --git a/packages/builders/src/components/selectMenu/BaseSelectMenu.ts b/packages/builders/src/components/selectMenu/BaseSelectMenu.ts index 298d7dc5e..b97eec501 100644 --- a/packages/builders/src/components/selectMenu/BaseSelectMenu.ts +++ b/packages/builders/src/components/selectMenu/BaseSelectMenu.ts @@ -1,6 +1,7 @@ import type { APISelectMenuComponent } from 'discord-api-types/v10'; import { customIdValidator, disabledValidator, minMaxValidator, placeholderValidator } from '../Assertions.js'; import { ComponentBuilder } from '../Component.js'; +import { requiredValidator } from '../textInput/Assertions.js'; /** * The base select menu builder that contains common symbols for select menu builders. @@ -31,9 +32,9 @@ export abstract class BaseSelectMenuBuilder< } /** - * Sets the maximum values that must be selected in the select menu. + * Sets the maximum values that can be selected in the select menu. * - * @param maxValues - The maximum values that must be selected + * @param maxValues - The maximum values that can be selected */ public setMaxValues(maxValues: number) { this.data.max_values = minMaxValidator.parse(maxValues); @@ -60,6 +61,17 @@ export abstract class BaseSelectMenuBuilder< return this; } + /** + * Sets whether this select menu is required. + * + * @remarks Only for use in modals. + * @param required - Whether this select menu is required + */ + public setRequired(required = true) { + this.data.required = requiredValidator.parse(required); + return this; + } + /** * {@inheritDoc ComponentBuilder.toJSON} */ diff --git a/packages/builders/src/components/selectMenu/StringSelectMenu.ts b/packages/builders/src/components/selectMenu/StringSelectMenu.ts index 711bf6917..9c6542387 100644 --- a/packages/builders/src/components/selectMenu/StringSelectMenu.ts +++ b/packages/builders/src/components/selectMenu/StringSelectMenu.ts @@ -83,7 +83,7 @@ export class StringSelectMenuBuilder extends BaseSelectMenuBuilder( + input: unknown, + ExpectedInstanceOf: new () => ReturnType, +): asserts input is ReturnType { + s.instance(ExpectedInstanceOf).setValidationEnabled(isValidationEnabled).parse(input); +} + +export function validateComponentArray< + ReturnType extends ContainerComponentBuilder | MediaGalleryItemBuilder = ContainerComponentBuilder, +>(input: unknown, min: number, max: number, ExpectedInstanceOf?: new () => ReturnType): asserts input is ReturnType[] { + (ExpectedInstanceOf ? s.instance(ExpectedInstanceOf) : s.instance(ComponentBuilder)) + .array() + .lengthGreaterThanOrEqual(min) + .lengthLessThanOrEqual(max) + .setValidationEnabled(isValidationEnabled) + .parse(input); +} diff --git a/packages/builders/src/components/v2/Container.ts b/packages/builders/src/components/v2/Container.ts new file mode 100644 index 000000000..19b4bf1c0 --- /dev/null +++ b/packages/builders/src/components/v2/Container.ts @@ -0,0 +1,239 @@ +/* eslint-disable jsdoc/check-param-names */ + +import type { + APIActionRowComponent, + APIComponentInContainer, + APIComponentInMessageActionRow, + APIContainerComponent, + APIFileComponent, + APIMediaGalleryComponent, + APISectionComponent, + APISeparatorComponent, + APITextDisplayComponent, +} from 'discord-api-types/v10'; +import { ComponentType } from 'discord-api-types/v10'; +import type { RGBTuple } from '../../index.js'; +import { MediaGalleryBuilder, SectionBuilder } from '../../index.js'; +import { normalizeArray, type RestOrArray } from '../../util/normalizeArray.js'; +import type { AnyComponentBuilder, MessageActionRowComponentBuilder } from '../ActionRow.js'; +import { ActionRowBuilder } from '../ActionRow.js'; +import { ComponentBuilder } from '../Component.js'; +import { createComponentBuilder, resolveBuilder } from '../Components.js'; +import { containerColorPredicate, spoilerPredicate } from './Assertions.js'; +import { FileBuilder } from './File.js'; +import { SeparatorBuilder } from './Separator.js'; +import { TextDisplayBuilder } from './TextDisplay.js'; + +/** + * The builders that may be used within a container. + */ +export type ContainerComponentBuilder = + | ActionRowBuilder + | FileBuilder + | MediaGalleryBuilder + | SectionBuilder + | SeparatorBuilder + | TextDisplayBuilder; + +/** + * A builder that creates API-compatible JSON data for a container. + */ +export class ContainerBuilder extends ComponentBuilder { + /** + * The components within this container. + */ + public readonly components: ContainerComponentBuilder[]; + + /** + * Creates a new container from API data. + * + * @param data - The API data to create this container with + * @example + * Creating a container from an API data object: + * ```ts + * const container = new ContainerBuilder({ + * components: [ + * { + * content: "Some text here", + * type: ComponentType.TextDisplay, + * }, + * ], + * }); + * ``` + * @example + * Creating a container using setters and API data: + * ```ts + * const container = new ContainerBuilder({ + * components: [ + * { + * content: "# Heading", + * type: ComponentType.TextDisplay, + * }, + * ], + * }) + * .addComponents(separator, section); + * ``` + */ + public constructor({ components, ...data }: Partial = {}) { + super({ type: ComponentType.Container, ...data }); + this.components = (components?.map((component) => createComponentBuilder(component)) ?? + []) as ContainerComponentBuilder[]; + } + + /** + * Sets the accent color of this container. + * + * @param color - The color to use + */ + public setAccentColor(color?: RGBTuple | number): this { + // Data assertions + containerColorPredicate.parse(color); + + if (Array.isArray(color)) { + const [red, green, blue] = color; + this.data.accent_color = (red << 16) + (green << 8) + blue; + return this; + } + + this.data.accent_color = color; + return this; + } + + /** + * Clears the accent color of this container. + */ + public clearAccentColor() { + this.data.accent_color = undefined; + return this; + } + + /** + * Adds action row components to this container. + * + * @param components - The action row components to add + */ + public addActionRowComponents( + ...components: RestOrArray< + | ActionRowBuilder + | APIActionRowComponent + | ((builder: ActionRowBuilder) => ActionRowBuilder) + > + ) { + this.components.push( + ...normalizeArray(components).map((component) => resolveBuilder(component, ActionRowBuilder)), + ); + return this; + } + + /** + * Adds file components to this container. + * + * @param components - The file components to add + */ + public addFileComponents( + ...components: RestOrArray FileBuilder)> + ) { + this.components.push(...normalizeArray(components).map((component) => resolveBuilder(component, FileBuilder))); + return this; + } + + /** + * Adds media gallery components to this container. + * + * @param components - The media gallery components to add + */ + public addMediaGalleryComponents( + ...components: RestOrArray< + APIMediaGalleryComponent | MediaGalleryBuilder | ((builder: MediaGalleryBuilder) => MediaGalleryBuilder) + > + ) { + this.components.push( + ...normalizeArray(components).map((component) => resolveBuilder(component, MediaGalleryBuilder)), + ); + return this; + } + + /** + * Adds section components to this container. + * + * @param components - The section components to add + */ + public addSectionComponents( + ...components: RestOrArray SectionBuilder)> + ) { + this.components.push(...normalizeArray(components).map((component) => resolveBuilder(component, SectionBuilder))); + return this; + } + + /** + * Adds separator components to this container. + * + * @param components - The separator components to add + */ + public addSeparatorComponents( + ...components: RestOrArray< + APISeparatorComponent | SeparatorBuilder | ((builder: SeparatorBuilder) => SeparatorBuilder) + > + ) { + this.components.push(...normalizeArray(components).map((component) => resolveBuilder(component, SeparatorBuilder))); + return this; + } + + /** + * Adds text display components to this container. + * + * @param components - The text display components to add + */ + public addTextDisplayComponents( + ...components: RestOrArray< + APITextDisplayComponent | TextDisplayBuilder | ((builder: TextDisplayBuilder) => TextDisplayBuilder) + > + ) { + this.components.push( + ...normalizeArray(components).map((component) => resolveBuilder(component, TextDisplayBuilder)), + ); + return this; + } + + /** + * Removes, replaces, or inserts components for this container. + * + * @param index - The index to start removing, replacing or inserting components + * @param deleteCount - The amount of components to remove + * @param components - The components to set + */ + public spliceComponents( + index: number, + deleteCount: number, + ...components: RestOrArray + ) { + this.components.splice( + index, + deleteCount, + ...normalizeArray(components).map((component) => + component instanceof ComponentBuilder ? component : createComponentBuilder(component), + ), + ); + return this; + } + + /** + * Sets the spoiler status of this container. + * + * @param spoiler - The spoiler status to use + */ + public setSpoiler(spoiler = true) { + this.data.spoiler = spoilerPredicate.parse(spoiler); + return this; + } + + /** + * {@inheritDoc ComponentBuilder.toJSON} + */ + public toJSON(): APIContainerComponent { + return { + ...this.data, + components: this.components.map((component) => component.toJSON()), + } as APIContainerComponent; + } +} diff --git a/packages/builders/src/components/v2/File.ts b/packages/builders/src/components/v2/File.ts new file mode 100644 index 000000000..fb92a82d8 --- /dev/null +++ b/packages/builders/src/components/v2/File.ts @@ -0,0 +1,63 @@ +import { ComponentType, type APIFileComponent } from 'discord-api-types/v10'; +import { ComponentBuilder } from '../Component'; +import { filePredicate, spoilerPredicate } from './Assertions'; + +export class FileBuilder extends ComponentBuilder { + /** + * Creates a new file from API data. + * + * @param data - The API data to create this file with + * @example + * Creating a file from an API data object: + * ```ts + * const file = new FileBuilder({ + * spoiler: true, + * file: { + * url: 'attachment://file.png', + * }, + * }); + * ``` + * @example + * Creating a file using setters and API data: + * ```ts + * const file = new FileBuilder({ + * file: { + * url: 'attachment://image.jpg', + * }, + * }) + * .setSpoiler(false); + * ``` + */ + public constructor(data: Partial = {}) { + super({ type: ComponentType.File, ...data, file: data.file ? { url: data.file.url } : undefined }); + } + + /** + * Sets the spoiler status of this file. + * + * @param spoiler - The spoiler status to use + */ + public setSpoiler(spoiler = true) { + this.data.spoiler = spoilerPredicate.parse(spoiler); + return this; + } + + /** + * Sets the media URL of this file. + * + * @param url - The URL to use + */ + public setURL(url: string) { + this.data.file = filePredicate.parse({ url }); + return this; + } + + /** + * {@inheritDoc ComponentBuilder.toJSON} + */ + public override toJSON(): APIFileComponent { + filePredicate.parse(this.data.file); + + return { ...this.data, file: { ...this.data.file } } as APIFileComponent; + } +} diff --git a/packages/builders/src/components/v2/MediaGallery.ts b/packages/builders/src/components/v2/MediaGallery.ts new file mode 100644 index 000000000..cad2b5a22 --- /dev/null +++ b/packages/builders/src/components/v2/MediaGallery.ts @@ -0,0 +1,117 @@ +/* eslint-disable jsdoc/check-param-names */ + +import type { APIMediaGalleryComponent, APIMediaGalleryItem } from 'discord-api-types/v10'; +import { ComponentType } from 'discord-api-types/v10'; +import { normalizeArray, type RestOrArray } from '../../util/normalizeArray.js'; +import { ComponentBuilder } from '../Component.js'; +import { resolveBuilder } from '../Components.js'; +import { assertReturnOfBuilder, validateComponentArray } from './Assertions.js'; +import { MediaGalleryItemBuilder } from './MediaGalleryItem.js'; + +/** + * A builder that creates API-compatible JSON data for a container. + */ +export class MediaGalleryBuilder extends ComponentBuilder { + /** + * The components within this container. + */ + public readonly items: MediaGalleryItemBuilder[]; + + /** + * Creates a new media gallery from API data. + * + * @param data - The API data to create this media gallery with + * @example + * Creating a media gallery from an API data object: + * ```ts + * const mediaGallery = new MediaGalleryBuilder({ + * items: [ + * { + * description: "Some text here", + * media: { + * url: 'https://cdn.discordapp.com/embed/avatars/2.png', + * }, + * }, + * ], + * }); + * ``` + * @example + * Creating a media gallery using setters and API data: + * ```ts + * const mediaGallery = new MediaGalleryBuilder({ + * items: [ + * { + * description: "alt text", + * media: { + * url: 'https://cdn.discordapp.com/embed/avatars/5.png', + * }, + * }, + * ], + * }) + * .addItems(item2, item3); + * ``` + */ + public constructor({ items, ...data }: Partial = {}) { + super({ type: ComponentType.MediaGallery, ...data }); + this.items = items?.map((item) => new MediaGalleryItemBuilder(item)) ?? []; + } + + /** + * Adds items to this media gallery. + * + * @param items - The items to add + */ + public addItems( + ...items: RestOrArray< + APIMediaGalleryItem | MediaGalleryItemBuilder | ((builder: MediaGalleryItemBuilder) => MediaGalleryItemBuilder) + > + ) { + this.items.push( + ...normalizeArray(items).map((input) => { + const result = resolveBuilder(input, MediaGalleryItemBuilder); + + assertReturnOfBuilder(result, MediaGalleryItemBuilder); + return result; + }), + ); + return this; + } + + /** + * Removes, replaces, or inserts media gallery items for this media gallery. + * + * @param index - The index to start removing, replacing or inserting items + * @param deleteCount - The amount of items to remove + * @param items - The items to insert + */ + public spliceItems( + index: number, + deleteCount: number, + ...items: RestOrArray< + APIMediaGalleryItem | MediaGalleryItemBuilder | ((builder: MediaGalleryItemBuilder) => MediaGalleryItemBuilder) + > + ) { + this.items.splice( + index, + deleteCount, + ...normalizeArray(items).map((input) => { + const result = resolveBuilder(input, MediaGalleryItemBuilder); + + assertReturnOfBuilder(result, MediaGalleryItemBuilder); + return result; + }), + ); + return this; + } + + /** + * {@inheritDoc ComponentBuilder.toJSON} + */ + public toJSON(): APIMediaGalleryComponent { + validateComponentArray(this.items, 1, 10, MediaGalleryItemBuilder); + return { + ...this.data, + items: this.items.map((item) => item.toJSON()), + } as APIMediaGalleryComponent; + } +} diff --git a/packages/builders/src/components/v2/MediaGalleryItem.ts b/packages/builders/src/components/v2/MediaGalleryItem.ts new file mode 100644 index 000000000..9e33298b7 --- /dev/null +++ b/packages/builders/src/components/v2/MediaGalleryItem.ts @@ -0,0 +1,90 @@ +import type { JSONEncodable } from '@discordjs/util'; +import type { APIMediaGalleryItem } from 'discord-api-types/v10'; +import { descriptionPredicate, spoilerPredicate, unfurledMediaItemPredicate } from './Assertions'; + +export class MediaGalleryItemBuilder implements JSONEncodable { + /** + * The API data associated with this media gallery item. + */ + public readonly data: Partial; + + /** + * Creates a new media gallery item from API data. + * + * @param data - The API data to create this media gallery item with + * @example + * Creating a media gallery item from an API data object: + * ```ts + * const item = new MediaGalleryItemBuilder({ + * description: "Some text here", + * media: { + * url: 'https://cdn.discordapp.com/embed/avatars/2.png', + * }, + * }); + * ``` + * @example + * Creating a media gallery item using setters and API data: + * ```ts + * const item = new MediaGalleryItemBuilder({ + * media: { + * url: 'https://cdn.discordapp.com/embed/avatars/5.png', + * }, + * }) + * .setDescription("alt text"); + * ``` + */ + public constructor(data: Partial = {}) { + this.data = data; + } + + /** + * Sets the description of this media gallery item. + * + * @param description - The description to use + */ + public setDescription(description: string) { + this.data.description = descriptionPredicate.parse(description); + return this; + } + + /** + * Clears the description of this media gallery item. + */ + public clearDescription() { + this.data.description = undefined; + return this; + } + + /** + * Sets the spoiler status of this media gallery item. + * + * @param spoiler - The spoiler status to use + */ + public setSpoiler(spoiler = true) { + this.data.spoiler = spoilerPredicate.parse(spoiler); + return this; + } + + /** + * Sets the media URL of this media gallery item. + * + * @param url - The URL to use + */ + public setURL(url: string) { + this.data.media = unfurledMediaItemPredicate.parse({ url }); + return this; + } + + /** + * Serializes this builder to API-compatible JSON data. + * + * @remarks + * This method runs validations on the data before serializing it. + * As such, it may throw an error if the data is invalid. + */ + public toJSON(): APIMediaGalleryItem { + unfurledMediaItemPredicate.parse(this.data.media); + + return { ...this.data } as APIMediaGalleryItem; + } +} diff --git a/packages/builders/src/components/v2/Section.ts b/packages/builders/src/components/v2/Section.ts new file mode 100644 index 000000000..432d9ba2d --- /dev/null +++ b/packages/builders/src/components/v2/Section.ts @@ -0,0 +1,153 @@ +/* eslint-disable jsdoc/check-param-names */ + +import type { + APIButtonComponent, + APISectionComponent, + APITextDisplayComponent, + APIThumbnailComponent, +} from 'discord-api-types/v10'; +import { ComponentType } from 'discord-api-types/v10'; +import { ButtonBuilder, ThumbnailBuilder } from '../../index.js'; +import { normalizeArray, type RestOrArray } from '../../util/normalizeArray.js'; +import { ComponentBuilder } from '../Component.js'; +import { createComponentBuilder, resolveBuilder } from '../Components.js'; +import { accessoryPredicate, assertReturnOfBuilder, validateComponentArray } from './Assertions.js'; +import { TextDisplayBuilder } from './TextDisplay.js'; + +/** + * A builder that creates API-compatible JSON data for a section. + */ +export class SectionBuilder extends ComponentBuilder { + /** + * The components within this section. + */ + public readonly components: ComponentBuilder[]; + + /** + * The accessory of this section. + */ + public readonly accessory?: ButtonBuilder | ThumbnailBuilder; + + /** + * Creates a new section from API data. + * + * @param data - The API data to create this section with + * @example + * Creating a section from an API data object: + * ```ts + * const section = new SectionBuilder({ + * components: [ + * { + * content: "Some text here", + * type: ComponentType.TextDisplay, + * }, + * ], + * accessory: { + * media: { + * url: 'https://cdn.discordapp.com/embed/avatars/3.png', + * }, + * } + * }); + * ``` + * @example + * Creating a section using setters and API data: + * ```ts + * const section = new SectionBuilder({ + * components: [ + * { + * content: "# Heading", + * type: ComponentType.TextDisplay, + * }, + * ], + * }) + * .setPrimaryButtonAccessory(button); + * ``` + */ + public constructor({ components, accessory, ...data }: Partial = {}) { + super({ type: ComponentType.Section, ...data }); + this.components = (components?.map((component) => createComponentBuilder(component)) ?? []) as ComponentBuilder[]; + this.accessory = accessory ? createComponentBuilder(accessory) : undefined; + } + + /** + * Sets the accessory of this section to a button. + * + * @param accessory - The accessory to use + */ + public setButtonAccessory( + accessory: APIButtonComponent | ButtonBuilder | ((builder: ButtonBuilder) => ButtonBuilder), + ): this { + Reflect.set(this, 'accessory', accessoryPredicate.parse(resolveBuilder(accessory, ButtonBuilder))); + return this; + } + + /** + * Sets the accessory of this section to a thumbnail. + * + * @param accessory - The accessory to use + */ + public setThumbnailAccessory( + accessory: APIThumbnailComponent | ThumbnailBuilder | ((builder: ThumbnailBuilder) => ThumbnailBuilder), + ): this { + Reflect.set(this, 'accessory', accessoryPredicate.parse(resolveBuilder(accessory, ThumbnailBuilder))); + return this; + } + + /** + * Adds text display components to this section. + * + * @param components - The text display components to add + */ + public addTextDisplayComponents( + ...components: RestOrArray TextDisplayBuilder)> + ) { + this.components.push( + ...normalizeArray(components).map((input) => { + const result = resolveBuilder(input, TextDisplayBuilder); + + assertReturnOfBuilder(result, TextDisplayBuilder); + return result; + }), + ); + return this; + } + + /** + * Removes, replaces, or inserts text display components for this section. + * + * @param index - The index to start removing, replacing or inserting text display components + * @param deleteCount - The amount of text display components to remove + * @param components - The text display components to insert + */ + public spliceTextDisplayComponents( + index: number, + deleteCount: number, + ...components: RestOrArray< + APITextDisplayComponent | TextDisplayBuilder | ((builder: TextDisplayBuilder) => TextDisplayBuilder) + > + ) { + this.components.splice( + index, + deleteCount, + ...normalizeArray(components).map((input) => { + const result = resolveBuilder(input, TextDisplayBuilder); + + assertReturnOfBuilder(result, TextDisplayBuilder); + return result; + }), + ); + return this; + } + + /** + * {@inheritDoc ComponentBuilder.toJSON} + */ + public toJSON(): APISectionComponent { + validateComponentArray(this.components, 1, 3, TextDisplayBuilder); + return { + ...this.data, + components: this.components.map((component) => component.toJSON()), + accessory: accessoryPredicate.parse(this.accessory).toJSON(), + } as APISectionComponent; + } +} diff --git a/packages/builders/src/components/v2/Separator.ts b/packages/builders/src/components/v2/Separator.ts new file mode 100644 index 000000000..579921885 --- /dev/null +++ b/packages/builders/src/components/v2/Separator.ts @@ -0,0 +1,69 @@ +import type { SeparatorSpacingSize, APISeparatorComponent } from 'discord-api-types/v10'; +import { ComponentType } from 'discord-api-types/v10'; +import { ComponentBuilder } from '../Component'; +import { dividerPredicate, spacingPredicate } from './Assertions'; + +export class SeparatorBuilder extends ComponentBuilder { + /** + * Creates a new separator from API data. + * + * @param data - The API data to create this separator with + * @example + * Creating a separator from an API data object: + * ```ts + * const separator = new SeparatorBuilder({ + * spacing: SeparatorSpacingSize.Small, + * divider: true, + * }); + * ``` + * @example + * Creating a separator using setters and API data: + * ```ts + * const separator = new SeparatorBuilder({ + * spacing: SeparatorSpacingSize.Large, + * }) + * .setDivider(false); + * ``` + */ + public constructor(data: Partial = {}) { + super({ + type: ComponentType.Separator, + ...data, + }); + } + + /** + * Sets whether this separator should show a divider line. + * + * @param divider - Whether to show a divider line + */ + public setDivider(divider = true) { + this.data.divider = dividerPredicate.parse(divider); + return this; + } + + /** + * Sets the spacing of this separator. + * + * @param spacing - The spacing to use + */ + public setSpacing(spacing: SeparatorSpacingSize) { + this.data.spacing = spacingPredicate.parse(spacing); + return this; + } + + /** + * Clears the spacing of this separator. + */ + public clearSpacing() { + this.data.spacing = undefined; + return this; + } + + /** + * {@inheritDoc ComponentBuilder.toJSON} + */ + public override toJSON(): APISeparatorComponent { + return { ...this.data } as APISeparatorComponent; + } +} diff --git a/packages/builders/src/components/v2/TextDisplay.ts b/packages/builders/src/components/v2/TextDisplay.ts new file mode 100644 index 000000000..61bfefa4f --- /dev/null +++ b/packages/builders/src/components/v2/TextDisplay.ts @@ -0,0 +1,52 @@ +import type { APITextDisplayComponent } from 'discord-api-types/v10'; +import { ComponentType } from 'discord-api-types/v10'; +import { ComponentBuilder } from '../Component'; +import { textDisplayContentPredicate } from './Assertions'; + +export class TextDisplayBuilder extends ComponentBuilder { + /** + * Creates a new text display from API data. + * + * @param data - The API data to create this text display with + * @example + * Creating a text display from an API data object: + * ```ts + * const textDisplay = new TextDisplayBuilder({ + * content: 'some text', + * }); + * ``` + * @example + * Creating a text display using setters and API data: + * ```ts + * const textDisplay = new TextDisplayBuilder({ + * content: 'old text', + * }) + * .setContent('new text'); + * ``` + */ + public constructor(data: Partial = {}) { + super({ + type: ComponentType.TextDisplay, + ...data, + }); + } + + /** + * Sets the text of this text display. + * + * @param content - The text to use + */ + public setContent(content: string) { + this.data.content = textDisplayContentPredicate.parse(content); + return this; + } + + /** + * {@inheritDoc ComponentBuilder.toJSON} + */ + public override toJSON(): APITextDisplayComponent { + textDisplayContentPredicate.parse(this.data.content); + + return { ...this.data } as APITextDisplayComponent; + } +} diff --git a/packages/builders/src/components/v2/Thumbnail.ts b/packages/builders/src/components/v2/Thumbnail.ts new file mode 100644 index 000000000..f049733c0 --- /dev/null +++ b/packages/builders/src/components/v2/Thumbnail.ts @@ -0,0 +1,86 @@ +import type { APIThumbnailComponent } from 'discord-api-types/v10'; +import { ComponentType } from 'discord-api-types/v10'; +import { ComponentBuilder } from '../Component'; +import { descriptionPredicate, spoilerPredicate, unfurledMediaItemPredicate } from './Assertions'; + +export class ThumbnailBuilder extends ComponentBuilder { + /** + * Creates a new thumbnail from API data. + * + * @param data - The API data to create this thumbnail with + * @example + * Creating a thumbnail from an API data object: + * ```ts + * const thumbnail = new ThumbnailBuilder({ + * description: 'some text', + * media: { + * url: 'https://cdn.discordapp.com/embed/avatars/4.png', + * }, + * }); + * ``` + * @example + * Creating a thumbnail using setters and API data: + * ```ts + * const thumbnail = new ThumbnailBuilder({ + * media: { + * url: 'attachment://image.png', + * }, + * }) + * .setDescription('alt text'); + * ``` + */ + public constructor(data: Partial = {}) { + super({ + type: ComponentType.Thumbnail, + ...data, + media: data.media ? { url: data.media.url } : undefined, + }); + } + + /** + * Sets the description of this thumbnail. + * + * @param description - The description to use + */ + public setDescription(description: string) { + this.data.description = descriptionPredicate.parse(description); + return this; + } + + /** + * Clears the description of this thumbnail. + */ + public clearDescription() { + this.data.description = undefined; + return this; + } + + /** + * Sets the spoiler status of this thumbnail. + * + * @param spoiler - The spoiler status to use + */ + public setSpoiler(spoiler = true) { + this.data.spoiler = spoilerPredicate.parse(spoiler); + return this; + } + + /** + * Sets the media URL of this thumbnail. + * + * @param url - The URL to use + */ + public setURL(url: string) { + this.data.media = unfurledMediaItemPredicate.parse({ url }); + return this; + } + + /** + * {@inheritdoc ComponentBuilder.toJSON} + */ + public override toJSON(): APIThumbnailComponent { + unfurledMediaItemPredicate.parse(this.data.media); + + return { ...this.data } as APIThumbnailComponent; + } +} diff --git a/packages/builders/src/index.ts b/packages/builders/src/index.ts index 539086121..3fe81285e 100644 --- a/packages/builders/src/index.ts +++ b/packages/builders/src/index.ts @@ -34,6 +34,22 @@ export { export * from './components/selectMenu/StringSelectMenuOption.js'; export * from './components/selectMenu/UserSelectMenu.js'; +export * from './components/fileUpload/FileUpload.js'; +export * as FileUploadAssertions from './components/fileUpload/Assertions.js'; + +export * from './components/label/Label.js'; +export * as LabelAssertions from './components/label/Assertions.js'; + +export * as ComponentsV2Assertions from './components/v2/Assertions.js'; +export * from './components/v2/Container.js'; +export * from './components/v2/File.js'; +export * from './components/v2/MediaGallery.js'; +export * from './components/v2/MediaGalleryItem.js'; +export * from './components/v2/Section.js'; +export * from './components/v2/Separator.js'; +export * from './components/v2/TextDisplay.js'; +export * from './components/v2/Thumbnail.js'; + export * as SlashCommandAssertions from './interactions/slashCommands/Assertions.js'; export * from './interactions/slashCommands/SlashCommandBuilder.js'; export * from './interactions/slashCommands/SlashCommandSubcommands.js'; diff --git a/packages/builders/src/interactions/contextMenuCommands/Assertions.ts b/packages/builders/src/interactions/contextMenuCommands/Assertions.ts index 72d6c50f0..7dc6d4beb 100644 --- a/packages/builders/src/interactions/contextMenuCommands/Assertions.ts +++ b/packages/builders/src/interactions/contextMenuCommands/Assertions.ts @@ -7,8 +7,7 @@ const namePredicate = s .string() .lengthGreaterThanOrEqual(1) .lengthLessThanOrEqual(32) - // eslint-disable-next-line prefer-named-capture-group - .regex(/^( *[\p{P}\p{L}\p{N}\p{sc=Devanagari}\p{sc=Thai}]+ *)+$/u) + .regex(/\S/) .setValidationEnabled(isValidationEnabled); const typePredicate = s .union([s.literal(ApplicationCommandType.User), s.literal(ApplicationCommandType.Message)]) diff --git a/packages/builders/src/interactions/modals/Assertions.ts b/packages/builders/src/interactions/modals/Assertions.ts index 79597ff47..6a3cc2645 100644 --- a/packages/builders/src/interactions/modals/Assertions.ts +++ b/packages/builders/src/interactions/modals/Assertions.ts @@ -1,6 +1,8 @@ import { s } from '@sapphire/shapeshift'; import { ActionRowBuilder, type ModalActionRowComponentBuilder } from '../../components/ActionRow.js'; import { customIdValidator } from '../../components/Assertions.js'; +import { LabelBuilder } from '../../components/label/Label.js'; +import { TextDisplayBuilder } from '../../components/v2/TextDisplay.js'; import { isValidationEnabled } from '../../util/validation.js'; export const titleValidator = s @@ -9,7 +11,7 @@ export const titleValidator = s .lengthLessThanOrEqual(45) .setValidationEnabled(isValidationEnabled); export const componentsValidator = s - .instance(ActionRowBuilder) + .union([s.instance(ActionRowBuilder), s.instance(LabelBuilder), s.instance(TextDisplayBuilder)]) .array() .lengthGreaterThanOrEqual(1) .setValidationEnabled(isValidationEnabled); @@ -17,7 +19,7 @@ export const componentsValidator = s export function validateRequiredParameters( customId?: string, title?: string, - components?: ActionRowBuilder[], + components?: (ActionRowBuilder | LabelBuilder | TextDisplayBuilder)[], ) { customIdValidator.parse(customId); titleValidator.parse(title); diff --git a/packages/builders/src/interactions/modals/Modal.ts b/packages/builders/src/interactions/modals/Modal.ts index 948d774df..58c31653b 100644 --- a/packages/builders/src/interactions/modals/Modal.ts +++ b/packages/builders/src/interactions/modals/Modal.ts @@ -2,13 +2,20 @@ import type { JSONEncodable } from '@discordjs/util'; import type { + APITextInputComponent, APIActionRowComponent, - APIModalActionRowComponent, + APIComponentInModalActionRow, + APILabelComponent, APIModalInteractionResponseCallbackData, + APITextDisplayComponent, } from 'discord-api-types/v10'; +import { ComponentType } from 'discord-api-types/v10'; import { ActionRowBuilder, type ModalActionRowComponentBuilder } from '../../components/ActionRow.js'; import { customIdValidator } from '../../components/Assertions.js'; -import { createComponentBuilder } from '../../components/Components.js'; +import { createComponentBuilder, resolveBuilder } from '../../components/Components.js'; +import { LabelBuilder } from '../../components/label/Label.js'; +import { TextInputBuilder } from '../../components/textInput/TextInput.js'; +import { TextDisplayBuilder } from '../../components/v2/TextDisplay.js'; import { normalizeArray, type RestOrArray } from '../../util/normalizeArray.js'; import { titleValidator, validateRequiredParameters } from './Assertions.js'; @@ -24,7 +31,8 @@ export class ModalBuilder implements JSONEncodable[] = []; + public readonly components: (ActionRowBuilder | LabelBuilder | TextDisplayBuilder)[] = + []; /** * Creates a new modal from API data. @@ -33,8 +41,10 @@ export class ModalBuilder implements JSONEncodable = {}) { this.data = { ...data }; - this.components = (components?.map((component) => createComponentBuilder(component)) ?? - []) as ActionRowBuilder[]; + this.components = (components?.map((component) => createComponentBuilder(component)) ?? []) as ( + | ActionRowBuilder + | LabelBuilder + )[]; } /** @@ -61,28 +71,182 @@ export class ModalBuilder implements JSONEncodable | APIActionRowComponent + | ActionRowBuilder + | APIActionRowComponent + | APILabelComponent + | APITextDisplayComponent + | APITextInputComponent + | LabelBuilder + | TextDisplayBuilder + | TextInputBuilder > ) { this.components.push( - ...normalizeArray(components).map((component) => - component instanceof ActionRowBuilder - ? component - : new ActionRowBuilder(component), - ), + ...normalizeArray(components).map((component, idx) => { + if ( + component instanceof ActionRowBuilder || + component instanceof LabelBuilder || + component instanceof TextDisplayBuilder + ) { + return component; + } + + // Deprecated support + if (component instanceof TextInputBuilder) { + return new ActionRowBuilder().addComponents(component); + } + + if ('type' in component) { + if (component.type === ComponentType.ActionRow) { + return new ActionRowBuilder(component); + } + + if (component.type === ComponentType.Label) { + return new LabelBuilder(component); + } + + if (component.type === ComponentType.TextDisplay) { + return new TextDisplayBuilder(component); + } + + // Deprecated, should go in a label component + if (component.type === ComponentType.TextInput) { + return new ActionRowBuilder().addComponents( + new TextInputBuilder(component), + ); + } + } + + throw new TypeError(`Invalid component passed in ModalBuilder.addComponents at index ${idx}!`); + }), ); return this; } + /** + * Adds label components to this modal. + * + * @param components - The components to add + */ + public addLabelComponents( + ...components: RestOrArray LabelBuilder)> + ) { + const normalized = normalizeArray(components); + const resolved = normalized.map((label) => resolveBuilder(label, LabelBuilder)); + + this.components.push(...resolved); + + return this; + } + + /** + * Adds text display components to this modal. + * + * @param components - The components to add + */ + public addTextDisplayComponents( + ...components: RestOrArray< + APITextDisplayComponent | TextDisplayBuilder | ((builder: TextDisplayBuilder) => TextDisplayBuilder) + > + ) { + const normalized = normalizeArray(components); + const resolved = normalized.map((row) => resolveBuilder(row, TextDisplayBuilder)); + + this.components.push(...resolved); + + return this; + } + + /** + * Adds action rows to this modal. + * + * @param components - The components to add + * @deprecated Use {@link ModalBuilder.addLabelComponents} instead + */ + public addActionRowComponents( + ...components: RestOrArray< + | ActionRowBuilder + | APIActionRowComponent + | (( + builder: ActionRowBuilder, + ) => ActionRowBuilder) + > + ) { + const normalized = normalizeArray(components); + const resolved = normalized.map((row) => resolveBuilder(row, ActionRowBuilder)); + + this.components.push(...resolved); + + return this; + } + + /** + * Sets the labels for this modal. + * + * @param components - The components to set + */ + public setLabelComponents( + ...components: RestOrArray LabelBuilder)> + ) { + const normalized = normalizeArray(components); + this.spliceLabelComponents(0, this.components.length, ...normalized); + + return this; + } + + /** + * Removes, replaces, or inserts labels for this modal. + * + * @remarks + * This method behaves similarly + * to {@link https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array/splice | Array.prototype.splice()}. + * The maximum amount of labels that can be added is 5. + * + * It's useful for modifying and adjusting order of the already-existing labels of a modal. + * @example + * Remove the first label: + * ```ts + * modal.spliceLabelComponents(0, 1); + * ``` + * @example + * Remove the first n labels: + * ```ts + * const n = 4; + * modal.spliceLabelComponents(0, n); + * ``` + * @example + * Remove the last label: + * ```ts + * modal.spliceLabelComponents(-1, 1); + * ``` + * @param index - The index to start at + * @param deleteCount - The number of labels to remove + * @param labels - The replacing label objects + */ + public spliceLabelComponents( + index: number, + deleteCount: number, + ...labels: (APILabelComponent | LabelBuilder | ((builder: LabelBuilder) => LabelBuilder))[] + ): this { + const resolved = labels.map((label) => resolveBuilder(label, LabelBuilder)); + this.components.splice(index, deleteCount, ...resolved); + + return this; + } + /** * Sets components for this modal. * * @param components - The components to set + * @deprecated Use {@link ModalBuilder.setLabelComponents} instead */ - public setComponents(...components: RestOrArray>) { + public setComponents( + ...components: RestOrArray | LabelBuilder | TextDisplayBuilder> + ) { this.components.splice(0, this.components.length, ...normalizeArray(components)); return this; } diff --git a/packages/builders/src/messages/embed/Assertions.ts b/packages/builders/src/messages/embed/Assertions.ts index 8bf9b3eef..243c6155f 100644 --- a/packages/builders/src/messages/embed/Assertions.ts +++ b/packages/builders/src/messages/embed/Assertions.ts @@ -2,17 +2,9 @@ import { s } from '@sapphire/shapeshift'; import type { APIEmbedField } from 'discord-api-types/v10'; import { isValidationEnabled } from '../../util/validation.js'; -export const fieldNamePredicate = s - .string() - .lengthGreaterThanOrEqual(1) - .lengthLessThanOrEqual(256) - .setValidationEnabled(isValidationEnabled); +export const fieldNamePredicate = s.string().lengthLessThanOrEqual(256).setValidationEnabled(isValidationEnabled); -export const fieldValuePredicate = s - .string() - .lengthGreaterThanOrEqual(1) - .lengthLessThanOrEqual(1_024) - .setValidationEnabled(isValidationEnabled); +export const fieldValuePredicate = s.string().lengthLessThanOrEqual(1_024).setValidationEnabled(isValidationEnabled); export const fieldInlinePredicate = s.boolean().optional(); @@ -32,7 +24,10 @@ export function validateFieldLength(amountAdding: number, fields?: APIEmbedField fieldLengthPredicate.parse((fields?.length ?? 0) + amountAdding); } -export const authorNamePredicate = fieldNamePredicate.nullable().setValidationEnabled(isValidationEnabled); +export const authorNamePredicate = fieldNamePredicate + .lengthGreaterThanOrEqual(1) + .nullable() + .setValidationEnabled(isValidationEnabled); export const imageURLPredicate = s .string() @@ -96,4 +91,7 @@ export const embedFooterPredicate = s export const timestampPredicate = s.union([s.number(), s.date()]).nullable().setValidationEnabled(isValidationEnabled); -export const titlePredicate = fieldNamePredicate.nullable().setValidationEnabled(isValidationEnabled); +export const titlePredicate = fieldNamePredicate + .lengthGreaterThanOrEqual(1) + .nullable() + .setValidationEnabled(isValidationEnabled); diff --git a/packages/core/package.json b/packages/core/package.json index e844cf5ee..09b646273 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -70,7 +70,7 @@ "@discordjs/ws": "workspace:^", "@sapphire/snowflake": "^3.5.3", "@vladfrangu/async_event_emitter": "^2.4.6", - "discord-api-types": "^0.38.31" + "discord-api-types": "^0.38.32" }, "devDependencies": { "@discordjs/api-extractor": "workspace:^", diff --git a/packages/discord.js/package.json b/packages/discord.js/package.json index 3bcfde958..c3a7269ac 100644 --- a/packages/discord.js/package.json +++ b/packages/discord.js/package.json @@ -66,14 +66,14 @@ "homepage": "https://discord.js.org", "funding": "https://github.com/discordjs/discord.js?sponsor", "dependencies": { - "@discordjs/builders": "^1.13.0", + "@discordjs/builders": "workspace:^", "@discordjs/collection": "1.5.3", - "@discordjs/formatters": "^0.6.1", + "@discordjs/formatters": "workspace:^", "@discordjs/rest": "workspace:^", "@discordjs/util": "workspace:^", "@discordjs/ws": "^1.2.3", "@sapphire/snowflake": "3.5.3", - "discord-api-types": "^0.38.31", + "discord-api-types": "^0.38.32", "fast-deep-equal": "3.1.3", "lodash.snakecase": "4.1.1", "magic-bytes.js": "^1.10.0", diff --git a/packages/formatters/README.md b/packages/formatters/README.md index dd92b5928..f4da77421 100644 --- a/packages/formatters/README.md +++ b/packages/formatters/README.md @@ -24,7 +24,7 @@ ## Installation -**Node.js 18 or newer is required.** +**Node.js 16.11.0 or newer is required.** ```sh npm install @discordjs/formatters diff --git a/packages/formatters/package.json b/packages/formatters/package.json index 8bdcee10f..00274ddec 100644 --- a/packages/formatters/package.json +++ b/packages/formatters/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@discordjs/formatters", - "version": "0.5.0", + "version": "0.6.1", "description": "A set of functions to format strings for Discord.", "scripts": { "test": "vitest run", @@ -55,7 +55,7 @@ "homepage": "https://discord.js.org", "funding": "https://github.com/discordjs/discord.js?sponsor", "dependencies": { - "discord-api-types": "^0.38.24" + "discord-api-types": "^0.38.32" }, "devDependencies": { "@discordjs/api-extractor": "workspace:^", @@ -75,7 +75,7 @@ "vitest": "^2.0.5" }, "engines": { - "node": ">=18" + "node": ">=16.11.0" }, "publishConfig": { "access": "public", diff --git a/packages/next/package.json b/packages/next/package.json index 440f284e1..4459ec12a 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -72,7 +72,7 @@ "@discordjs/rest": "workspace:^", "@discordjs/util": "workspace:^", "@discordjs/ws": "workspace:^", - "discord-api-types": "^0.38.24" + "discord-api-types": "^0.38.32" }, "devDependencies": { "@discordjs/api-extractor": "workspace:^", diff --git a/packages/rest/package.json b/packages/rest/package.json index 9eb615c75..62725a446 100644 --- a/packages/rest/package.json +++ b/packages/rest/package.json @@ -88,7 +88,7 @@ "@sapphire/async-queue": "^1.5.3", "@sapphire/snowflake": "^3.5.3", "@vladfrangu/async_event_emitter": "^2.4.6", - "discord-api-types": "^0.38.24", + "discord-api-types": "^0.38.32", "magic-bytes.js": "^1.10.0", "tslib": "^2.6.3", "undici": "6.21.3" diff --git a/packages/voice/package.json b/packages/voice/package.json index 6b97f1283..fcaabfa3f 100644 --- a/packages/voice/package.json +++ b/packages/voice/package.json @@ -64,7 +64,7 @@ "funding": "https://github.com/discordjs/discord.js?sponsor", "dependencies": { "@types/ws": "^8.5.12", - "discord-api-types": "^0.38.24", + "discord-api-types": "^0.38.32", "prism-media": "^1.3.5", "tslib": "^2.6.3", "ws": "^8.18.0" diff --git a/packages/ws/package.json b/packages/ws/package.json index 2ae44a48a..155436202 100644 --- a/packages/ws/package.json +++ b/packages/ws/package.json @@ -79,7 +79,7 @@ "@sapphire/async-queue": "^1.5.3", "@types/ws": "^8.5.12", "@vladfrangu/async_event_emitter": "^2.4.6", - "discord-api-types": "^0.38.24", + "discord-api-types": "^0.38.32", "tslib": "^2.6.3", "ws": "^8.18.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c367dd4e1..91435d589 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -692,8 +692,8 @@ importers: specifier: ^4.0.0 version: 4.0.0 discord-api-types: - specifier: ^0.37.119 - version: 0.37.119 + specifier: ^0.38.32 + version: 0.38.32 fast-deep-equal: specifier: ^3.1.3 version: 3.1.3 @@ -816,8 +816,8 @@ importers: specifier: ^2.4.6 version: 2.4.6 discord-api-types: - specifier: ^0.38.31 - version: 0.38.31 + specifier: ^0.38.32 + version: 0.38.32 devDependencies: '@discordjs/api-extractor': specifier: workspace:^ @@ -932,14 +932,14 @@ importers: packages/discord.js: dependencies: '@discordjs/builders': - specifier: ^1.13.0 - version: 1.13.0 + specifier: workspace:^ + version: link:../builders '@discordjs/collection': specifier: 1.5.3 version: 1.5.3 '@discordjs/formatters': - specifier: ^0.6.1 - version: 0.6.1 + specifier: workspace:^ + version: link:../formatters '@discordjs/rest': specifier: workspace:^ version: link:../rest @@ -953,8 +953,8 @@ importers: specifier: 3.5.3 version: 3.5.3 discord-api-types: - specifier: ^0.38.31 - version: 0.38.31 + specifier: ^0.38.32 + version: 0.38.32 fast-deep-equal: specifier: 3.1.3 version: 3.1.3 @@ -1075,8 +1075,8 @@ importers: packages/formatters: dependencies: discord-api-types: - specifier: ^0.38.24 - version: 0.38.24 + specifier: ^0.38.32 + version: 0.38.32 devDependencies: '@discordjs/api-extractor': specifier: workspace:^ @@ -1148,8 +1148,8 @@ importers: specifier: workspace:^ version: link:../ws discord-api-types: - specifier: ^0.38.24 - version: 0.38.24 + specifier: ^0.38.32 + version: 0.38.32 devDependencies: '@discordjs/api-extractor': specifier: workspace:^ @@ -1322,8 +1322,8 @@ importers: specifier: ^2.4.6 version: 2.4.6 discord-api-types: - specifier: ^0.38.24 - version: 0.38.24 + specifier: ^0.38.32 + version: 0.38.32 magic-bytes.js: specifier: ^1.10.0 version: 1.10.0 @@ -1619,8 +1619,8 @@ importers: specifier: ^8.5.12 version: 8.5.12 discord-api-types: - specifier: ^0.38.24 - version: 0.38.24 + specifier: ^0.38.32 + version: 0.38.32 prism-media: specifier: ^1.3.5 version: 1.3.5 @@ -1716,8 +1716,8 @@ importers: specifier: ^2.4.6 version: 2.4.6 discord-api-types: - specifier: ^0.38.24 - version: 0.38.24 + specifier: ^0.38.32 + version: 0.38.32 tslib: specifier: ^2.6.3 version: 2.6.3 @@ -2789,10 +2789,6 @@ packages: resolution: {integrity: sha512-4JINx4Rttha29f50PBsJo48xZXx/He5yaIWJRwVarhYAN947+S84YciHl+AIhQNRPAFkg8+5qFngEGtKxQDWXA==} engines: {node: '>=18.18.0'} - '@discordjs/builders@1.13.0': - resolution: {integrity: sha512-COK0uU6ZaJI+LA67H/rp8IbEkYwlZf3mAoBI5wtPh5G5cbEQGNhVpzINg2f/6+q/YipnNIKy6fJDg6kMUKUw4Q==} - engines: {node: '>=16.11.0'} - '@discordjs/collection@1.5.3': resolution: {integrity: sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==} engines: {node: '>=16.11.0'} @@ -2801,10 +2797,6 @@ packages: resolution: {integrity: sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==} engines: {node: '>=18'} - '@discordjs/formatters@0.6.1': - resolution: {integrity: sha512-5cnX+tASiPCqCWtFcFslxBVUaCetB0thvM/JyavhbXInP1HJIEU+Qv/zMrnuwSsX3yWH2lVXNJZeDK3EiP4HHg==} - engines: {node: '>=16.11.0'} - '@discordjs/rest@2.5.1': resolution: {integrity: sha512-Tg9840IneBcbrAjcGaQzHUJWFNq1MMWZjTdjJ0WS/89IffaNKc++iOvffucPxQTF/gviO9+9r8kEPea1X5J2Dw==} engines: {node: '>=18'} @@ -8354,14 +8346,8 @@ packages: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} - discord-api-types@0.37.119: - resolution: {integrity: sha512-WasbGFXEB+VQWXlo6IpW3oUv73Yuau1Ig4AZF/m13tXcTKnMpc/mHjpztIlz4+BM9FG9BHQkEXiPto3bKduQUg==} - - discord-api-types@0.38.24: - resolution: {integrity: sha512-P7/DkcFIiIoaBogStnhhcGRX7KR+gIFp0SpmwsZUIM0bgDkYMEUx+8l+t3quYc/KSgg92wvE9w/4mabO57EMug==} - - discord-api-types@0.38.31: - resolution: {integrity: sha512-kC94ANsk8ackj8ENTuO8joTNEL0KtymVhHy9dyEC/s4QAZ7GCx40dYEzQaadyo8w+oP0X8QydE/nzAWRylTGtQ==} + discord-api-types@0.38.32: + resolution: {integrity: sha512-UhIqkFuUVwBzejLPPWF18qixYPucMf718RnGh1NxZYNS7czXUmcUsWWkzWR7lRWj5pjfj4LwrnN9McvpfLvGqQ==} dlv@1.1.3: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} @@ -16321,24 +16307,10 @@ snapshots: tar-stream: 3.1.7 which: 4.0.0 - '@discordjs/builders@1.13.0': - dependencies: - '@discordjs/formatters': 0.6.1 - '@discordjs/util': 1.1.1 - '@sapphire/shapeshift': 4.0.0 - discord-api-types: 0.38.31 - fast-deep-equal: 3.1.3 - ts-mixer: 6.0.4 - tslib: 2.8.1 - '@discordjs/collection@1.5.3': {} '@discordjs/collection@2.1.1': {} - '@discordjs/formatters@0.6.1': - dependencies: - discord-api-types: 0.38.31 - '@discordjs/rest@2.5.1': dependencies: '@discordjs/collection': 2.1.1 @@ -16346,7 +16318,7 @@ snapshots: '@sapphire/async-queue': 1.5.3 '@sapphire/snowflake': 3.5.3 '@vladfrangu/async_event_emitter': 2.4.6 - discord-api-types: 0.38.31 + discord-api-types: 0.38.32 magic-bytes.js: 1.10.0 tslib: 2.8.1 undici: 6.21.3 @@ -16361,7 +16333,7 @@ snapshots: '@sapphire/async-queue': 1.5.3 '@types/ws': 8.5.12 '@vladfrangu/async_event_emitter': 2.4.6 - discord-api-types: 0.38.31 + discord-api-types: 0.38.32 tslib: 2.8.1 ws: 8.18.0(bufferutil@4.0.8)(utf-8-validate@6.0.4) transitivePeerDependencies: @@ -23712,11 +23684,7 @@ snapshots: dependencies: path-type: 4.0.0 - discord-api-types@0.37.119: {} - - discord-api-types@0.38.24: {} - - discord-api-types@0.38.31: {} + discord-api-types@0.38.32: {} dlv@1.1.3: {}