diff --git a/packages/builders/__tests__/components/components.test.ts b/packages/builders/__tests__/components/components.test.ts index 5e46e136538c..38f38a75aa0d 100644 --- a/packages/builders/__tests__/components/components.test.ts +++ b/packages/builders/__tests__/components/components.test.ts @@ -3,10 +3,10 @@ import { ComponentType, TextInputStyle, type APIButtonComponent, - type APIComponentInMessageActionRow, type APISelectMenuComponent, type APITextInputComponent, type APIActionRowComponent, + type APIComponentInMessageActionRow, } from 'discord-api-types/v10'; import { describe, test, expect } from 'vitest'; import { 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 000000000000..86cad4fab5ad --- /dev/null +++ b/packages/builders/__tests__/components/v2/container.test.ts @@ -0,0 +1,237 @@ +import { + type APIActionRowComponent, + type APIButtonComponent, + type APIContainerComponent, + ButtonStyle, + ComponentType, + SeparatorSpacingSize, +} from 'discord-api-types/v10'; +import { describe, test, expect } from 'vitest'; +import { createComponentBuilder } from '../../../src/components/Components.js'; +import { ContainerBuilder } from '../../../src/components/v2/Container.js'; +import { SeparatorBuilder } from '../../../src/components/v2/Separator.js'; +import { TextDisplayBuilder } from '../../../src/components/v2/TextDisplay.js'; +import { MediaGalleryBuilder, SectionBuilder } from '../../../src/index.js'; + +const containerWithTextDisplay: APIContainerComponent = { + type: ComponentType.Container, + components: [ + { + type: ComponentType.TextDisplay, + content: 'test', + id: 123, + }, + ], +}; + +const button = { + type: ComponentType.Button as const, + style: ButtonStyle.Primary as const, + custom_id: 'test', + label: 'test', +}; + +const actionRow: APIActionRowComponent = { + type: ComponentType.ActionRow, + components: [button], +}; + +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().addSeparatorComponents(new SeparatorBuilder())).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(0xff00ff) + .toJSON(), + ).toEqual({ + type: ComponentType.Container, + components: [ + { + type: ComponentType.TextDisplay, + content: 'test', + }, + ], + accent_color: 0xff00ff, + }); + expect(new ContainerBuilder(containerWithSeparatorData).clearAccentColor().toJSON()).toEqual( + containerWithSeparatorDataNoColor, + ); + }); + + test('GIVEN valid method parameters THEN valid JSON is given', () => { + expect( + new ContainerBuilder() + .addMediaGalleryComponents( + new MediaGalleryBuilder() + .addItems({ media: { url: 'https://discord.com' } }) + .setId(3) + .clearId(), + ) + .setSpoiler() + .toJSON(), + ).toEqual({ + type: ComponentType.Container, + components: [ + { + type: ComponentType.MediaGallery, + items: [{ media: { url: 'https://discord.com' } }], + }, + ], + spoiler: true, + }); + expect( + new ContainerBuilder() + .addSectionComponents( + new SectionBuilder() + .addTextDisplayComponents({ type: ComponentType.TextDisplay, content: 'test' }) + .setPrimaryButtonAccessory(button), + ) + .addFileComponents({ type: ComponentType.File, file: { url: 'attachment://discord.png' } }) + .setSpoiler(false) + .setId(5) + .toJSON(), + ).toEqual({ + type: ComponentType.Container, + components: [ + { + type: ComponentType.Section, + components: [ + { + type: ComponentType.TextDisplay, + content: 'test', + }, + ], + accessory: button, + }, + { + type: ComponentType.File, + file: { url: 'attachment://discord.png' }, + }, + ], + spoiler: false, + id: 5, + }); + expect(new ContainerBuilder().addActionRowComponents(actionRow).setSpoiler(true).toJSON()).toEqual({ + type: ComponentType.Container, + components: [actionRow], + spoiler: true, + }); + }); + }); +}); 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 000000000000..37acfc21ae1b --- /dev/null +++ b/packages/builders/__tests__/components/v2/file.test.ts @@ -0,0 +1,45 @@ +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(); + file.setURL('https://google.com'); + + expect(() => file.toJSON()).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 000000000000..6b042dc43770 --- /dev/null +++ b/packages/builders/__tests__/components/v2/mediaGallery.test.ts @@ -0,0 +1,117 @@ +import { ComponentType } from 'discord-api-types/v10'; +import { describe, expect, test } from 'vitest'; +import { MediaGalleryBuilder } from '../../../src/components/v2/MediaGallery'; +import { MediaGalleryItemBuilder } from '../../../src/components/v2/MediaGalleryItem'; + +describe('MediaGallery', () => { + test('GIVEN an empty media gallery THEN throws error', () => { + const gallery = new MediaGalleryBuilder(); + expect(() => gallery.toJSON()).toThrow(); + }); + + describe('MediaGallery items', () => { + test('GIVEN a media gallery with pre-defined items THEN return valid toJSON data', () => { + const items = [ + { media: { url: 'https://google.com' } }, + { media: { url: 'https://discord.com' }, description: 'Discord' }, + ]; + + const gallery = new MediaGalleryBuilder({ + type: ComponentType.MediaGallery, + items, + }); + + expect(gallery.toJSON()).toEqual({ + type: ComponentType.MediaGallery, + items, + }); + }); + + test('GIVEN a media gallery with items added via addItems THEN return valid toJSON data', () => { + const gallery = new MediaGalleryBuilder(); + const item1 = new MediaGalleryItemBuilder().setURL('https://google.com'); + const item2 = new MediaGalleryItemBuilder().setURL('https://discord.com').setDescription('Discord'); + + gallery.addItems(item1, item2); + + expect(gallery.toJSON()).toEqual({ + type: ComponentType.MediaGallery, + items: [ + { media: { url: 'https://google.com' } }, + { media: { url: 'https://discord.com' }, description: 'Discord' }, + ], + }); + }); + + test('GIVEN a media gallery with items added via addItems with raw objects THEN return valid toJSON data', () => { + const gallery = new MediaGalleryBuilder(); + + gallery.addItems( + { media: { url: 'https://google.com' } }, + { media: { url: 'https://discord.com' }, description: 'Discord' }, + ); + + expect(gallery.toJSON()).toEqual({ + type: ComponentType.MediaGallery, + items: [ + { media: { url: 'https://google.com' } }, + { media: { url: 'https://discord.com' }, description: 'Discord' }, + ], + }); + }); + + test('GIVEN a media gallery with items added via addItems with builder functions THEN return valid toJSON data', () => { + const gallery = new MediaGalleryBuilder(); + + gallery.addItems( + (builder) => builder.setURL('https://google.com'), + (builder) => builder.setURL('https://discord.com').setDescription('Discord'), + ); + + expect(gallery.toJSON()).toEqual({ + type: ComponentType.MediaGallery, + items: [ + { media: { url: 'https://google.com' } }, + { media: { url: 'https://discord.com' }, description: 'Discord' }, + ], + }); + }); + + test('GIVEN a media gallery with array of items passed to addItems THEN return valid toJSON data', () => { + const gallery = new MediaGalleryBuilder(); + const items = [ + new MediaGalleryItemBuilder().setURL('https://google.com'), + new MediaGalleryItemBuilder().setURL('https://discord.com').setDescription('Discord'), + ]; + + gallery.addItems(items); + + expect(gallery.toJSON()).toEqual({ + type: ComponentType.MediaGallery, + items: [ + { media: { url: 'https://google.com' } }, + { media: { url: 'https://discord.com' }, description: 'Discord' }, + ], + }); + }); + + test('GIVEN a media gallery with items added via addItems with builder functions THEN return valid toJSON data', () => { + const gallery = new MediaGalleryBuilder(); + + gallery + .addItems( + new MediaGalleryItemBuilder().setURL('https://google.com'), + new MediaGalleryItemBuilder().setURL('https://discord.com').setDescription('Discord'), + ) + .spliceItems(1, 1, new MediaGalleryItemBuilder().setURL('https://discord.js.org').setDescription('Discord.JS')); + + expect(gallery.toJSON()).toEqual({ + type: ComponentType.MediaGallery, + items: [ + { media: { url: 'https://google.com' } }, + { media: { url: 'https://discord.js.org' }, description: 'Discord.JS' }, + ], + }); + }); + }); +}); diff --git a/packages/builders/__tests__/components/v2/mediaGalleryItem.test.ts b/packages/builders/__tests__/components/v2/mediaGalleryItem.test.ts new file mode 100644 index 000000000000..c161946c941b --- /dev/null +++ b/packages/builders/__tests__/components/v2/mediaGalleryItem.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from 'vitest'; +import { MediaGalleryItemBuilder } from '../../../src/components/v2/MediaGalleryItem'; + +const dummy = { + media: { url: 'https://google.com' }, +}; + +describe('MediaGalleryItem', () => { + describe('MediaGalleryItem url', () => { + test('GIVEN a media gallery item with a pre-defined url THEN return valid toJSON data', () => { + const item = new MediaGalleryItemBuilder({ media: { url: 'https://google.com' } }); + expect(item.toJSON()).toEqual({ media: { url: 'https://google.com' } }); + }); + + test('GIVEN a media gallery item with a set url THEN return valid toJSON data', () => { + const item = new MediaGalleryItemBuilder().setURL('https://google.com'); + expect(item.toJSON()).toEqual({ media: { url: 'https://google.com' } }); + }); + + test.each(['owo', 'discord://user'])( + 'GIVEN a media gallery item with an invalid URL (%s) THEN throws error', + (input) => { + const item = new MediaGalleryItemBuilder(); + + item.setURL(input); + expect(() => item.toJSON()).toThrowError(); + }, + ); + }); + + describe('MediaGalleryItem description', () => { + test('GIVEN a media gallery item with a pre-defined description THEN return valid toJSON data', () => { + const item = new MediaGalleryItemBuilder({ ...dummy, description: 'foo' }); + expect(item.toJSON()).toEqual({ ...dummy, description: 'foo' }); + }); + + test('GIVEN a media gallery item with a set description THEN return valid toJSON data', () => { + const item = new MediaGalleryItemBuilder({ ...dummy }); + item.setDescription('foo'); + + expect(item.toJSON()).toEqual({ ...dummy, description: 'foo' }); + }); + + test('GIVEN a media gallery item with a pre-defined description THEN unset description THEN return valid toJSON data', () => { + const item = new MediaGalleryItemBuilder({ description: 'foo', ...dummy }); + item.clearDescription(); + + expect(item.toJSON()).toEqual({ ...dummy }); + }); + + test('GIVEN a media gallery item with an invalid description THEN throws error', () => { + const item = new MediaGalleryItemBuilder(); + + item.setDescription('a'.repeat(1_025)); + expect(() => item.toJSON()).toThrowError(); + }); + }); + + describe('MediaGalleryItem spoiler', () => { + test('GIVEN a media gallery item with a pre-defined spoiler status THEN return valid toJSON data', () => { + const item = new MediaGalleryItemBuilder({ ...dummy, spoiler: true }); + expect(item.toJSON()).toEqual({ ...dummy, spoiler: true }); + }); + + test('GIVEN a media gallery item with a set spoiler status THEN return valid toJSON data', () => { + const item = new MediaGalleryItemBuilder({ ...dummy }); + item.setSpoiler(false); + + expect(item.toJSON()).toEqual({ ...dummy, spoiler: false }); + }); + }); +}); 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 000000000000..e019063d46aa --- /dev/null +++ b/packages/builders/__tests__/components/v2/section.test.ts @@ -0,0 +1,166 @@ +import { ButtonStyle, ComponentType } from 'discord-api-types/v10'; +import { describe, expect, test } from 'vitest'; +import { PrimaryButtonBuilder } from '../../../src/components/button/CustomIdButton'; +import { SectionBuilder } from '../../../src/components/v2/Section'; +import { TextDisplayBuilder } from '../../../src/components/v2/TextDisplay'; +import { ThumbnailBuilder } from '../../../src/components/v2/Thumbnail'; + +describe('Section', () => { + describe('Validation', () => { + test('GIVEN empty section builder THEN throws error on toJSON', () => { + const section = new SectionBuilder(); + expect(() => section.toJSON()).toThrowError(); + }); + + test('GIVEN section with text components but no accessory THEN throws error on toJSON', () => { + const section = new SectionBuilder().addTextDisplayComponents(new TextDisplayBuilder().setContent('Hello world')); + expect(() => section.toJSON()).toThrowError(); + }); + + test('GIVEN section with accessory but no text components THEN throws error on toJSON', () => { + const section = new SectionBuilder().setThumbnailAccessory( + new ThumbnailBuilder().setURL('https://example.com/image.png'), + ); + expect(() => section.toJSON()).toThrowError(); + }); + }); + + describe('Text display components', () => { + test('GIVEN section with predefined text components THEN returns valid toJSON data', () => { + const section = new SectionBuilder({ + components: [{ type: ComponentType.TextDisplay, content: 'Hello world' }], + accessory: { type: ComponentType.Thumbnail, media: { url: 'https://example.com/image.png' } }, + }); + + expect(section.toJSON()).toEqual({ + type: ComponentType.Section, + components: [{ type: ComponentType.TextDisplay, content: 'Hello world' }], + accessory: { type: ComponentType.Thumbnail, media: { url: 'https://example.com/image.png' } }, + }); + }); + + test('GIVEN section with added text components THEN returns valid toJSON data', () => { + const section = new SectionBuilder() + .addTextDisplayComponents(new TextDisplayBuilder().setContent('Hello world')) + .setThumbnailAccessory(new ThumbnailBuilder().setURL('https://example.com/image.png')); + + expect(section.toJSON()).toEqual({ + type: ComponentType.Section, + components: [{ type: ComponentType.TextDisplay, content: 'Hello world' }], + accessory: { type: ComponentType.Thumbnail, media: { url: 'https://example.com/image.png' } }, + }); + }); + + test('GIVEN section with multiple text components THEN returns valid toJSON data', () => { + const section = new SectionBuilder() + .addTextDisplayComponents( + new TextDisplayBuilder().setContent('Line 1'), + new TextDisplayBuilder().setContent('Line 2'), + new TextDisplayBuilder().setContent('Line 3'), + ) + .setThumbnailAccessory(new ThumbnailBuilder().setURL('https://example.com/image.png')); + + expect(section.toJSON()).toEqual({ + type: ComponentType.Section, + components: [ + { type: ComponentType.TextDisplay, content: 'Line 1' }, + { type: ComponentType.TextDisplay, content: 'Line 2' }, + { type: ComponentType.TextDisplay, content: 'Line 3' }, + ], + accessory: { type: ComponentType.Thumbnail, media: { url: 'https://example.com/image.png' } }, + }); + }); + + test('GIVEN section with spliced text components THEN returns valid toJSON data', () => { + const section = new SectionBuilder() + .addTextDisplayComponents( + new TextDisplayBuilder().setContent('Original 1'), + new TextDisplayBuilder().setContent('Will be removed'), + new TextDisplayBuilder().setContent('Original 3'), + ) + .spliceTextDisplayComponents(1, 1, new TextDisplayBuilder().setContent('Replacement')) + .setThumbnailAccessory(new ThumbnailBuilder().setURL('https://example.com/image.png')); + + expect(section.toJSON()).toEqual({ + type: ComponentType.Section, + components: [ + { type: ComponentType.TextDisplay, content: 'Original 1' }, + { type: ComponentType.TextDisplay, content: 'Replacement' }, + { type: ComponentType.TextDisplay, content: 'Original 3' }, + ], + accessory: { type: ComponentType.Thumbnail, media: { url: 'https://example.com/image.png' } }, + }); + }); + }); + + describe('Accessory components', () => { + test('GIVEN section with thumbnail accessory THEN returns valid toJSON data', () => { + const section = new SectionBuilder() + .addTextDisplayComponents(new TextDisplayBuilder().setContent('Hello world')) + .setThumbnailAccessory(new ThumbnailBuilder().setURL('https://example.com/image.png')); + + expect(section.toJSON()).toEqual({ + type: ComponentType.Section, + components: [{ type: ComponentType.TextDisplay, content: 'Hello world' }], + accessory: { type: ComponentType.Thumbnail, media: { url: 'https://example.com/image.png' } }, + }); + }); + + test('GIVEN section with primary button accessory THEN returns valid toJSON data', () => { + const section = new SectionBuilder() + .addTextDisplayComponents(new TextDisplayBuilder().setContent('Hello world')) + .setPrimaryButtonAccessory(new PrimaryButtonBuilder().setCustomId('click_me').setLabel('Click me')); + + expect(section.toJSON()).toEqual({ + type: ComponentType.Section, + components: [{ type: ComponentType.TextDisplay, content: 'Hello world' }], + accessory: { + type: ComponentType.Button, + style: 1, + custom_id: 'click_me', + label: 'Click me', + }, + }); + }); + + test('GIVEN section with primary button accessory JSON THEN returns valid toJSON data', () => { + const section = new SectionBuilder() + .addTextDisplayComponents(new TextDisplayBuilder().setContent('Hello world')) + .setPrimaryButtonAccessory({ + type: ComponentType.Button, + style: ButtonStyle.Primary, + custom_id: 'click_me', + label: 'Click me', + }); + + expect(section.toJSON()).toEqual({ + type: ComponentType.Section, + components: [{ type: ComponentType.TextDisplay, content: 'Hello world' }], + accessory: { + type: ComponentType.Button, + style: 1, + custom_id: 'click_me', + label: 'Click me', + }, + }); + }); + + test('GIVEN changing accessory type THEN returns the latest accessory in toJSON', () => { + const section = new SectionBuilder() + .addTextDisplayComponents(new TextDisplayBuilder().setContent('Hello world')) + .setThumbnailAccessory(new ThumbnailBuilder().setURL('https://example.com/image.png')) + .setPrimaryButtonAccessory(new PrimaryButtonBuilder().setCustomId('click_me').setLabel('Click me')); + + expect(section.toJSON()).toEqual({ + type: ComponentType.Section, + components: [{ type: ComponentType.TextDisplay, content: 'Hello world' }], + accessory: { + type: ComponentType.Button, + style: 1, + custom_id: 'click_me', + label: 'Click me', + }, + }); + }); + }); +}); 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 000000000000..73743548463f --- /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 000000000000..01495c8af9a7 --- /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 000000000000..384fe1709436 --- /dev/null +++ b/packages/builders/__tests__/components/v2/thumbnail.test.ts @@ -0,0 +1,71 @@ +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 an embed with an invalid URL (%s) THEN throws error', (input) => { + const thumbnail = new ThumbnailBuilder(); + + thumbnail.setURL(input); + expect(() => thumbnail.toJSON()).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(); + + thumbnail.setDescription('a'.repeat(1_025)); + expect(() => thumbnail.toJSON()).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__/messages/message.test.ts b/packages/builders/__tests__/messages/message.test.ts index 48a064073df0..eda225d00b58 100644 --- a/packages/builders/__tests__/messages/message.test.ts +++ b/packages/builders/__tests__/messages/message.test.ts @@ -18,7 +18,7 @@ describe('Message', () => { }); test('GIVEN bad action row THEN it throws', () => { - const message = new MessageBuilder().setComponents((row) => + const message = new MessageBuilder().addActionRowComponents((row) => row.addTextInputComponent((input) => input.setCustomId('abc').setLabel('def')), ); expect(() => message.toJSON()).toThrow(); @@ -32,7 +32,9 @@ describe('Message', () => { .addEmbeds(new EmbedBuilder().setTitle('foo').setDescription('bar')) .setAllowedMentions({ parse: [AllowedMentionsTypes.Role], roles: ['123'] }) .setMessageReference({ channel_id: '123', message_id: '123' }) - .setComponents((row) => row.addPrimaryButtonComponents((button) => button.setCustomId('abc').setLabel('def'))) + .addActionRowComponents((row) => + row.addPrimaryButtonComponents((button) => button.setCustomId('abc').setLabel('def')), + ) .setStickerIds('123', '456') .addAttachments((attachment) => attachment.setId('hi!').setFilename('abc')) .setFlags(MessageFlags.Ephemeral) diff --git a/packages/builders/src/components/ActionRow.ts b/packages/builders/src/components/ActionRow.ts index d81019ec64b4..2c27250c3b8a 100644 --- a/packages/builders/src/components/ActionRow.ts +++ b/packages/builders/src/components/ActionRow.ts @@ -47,7 +47,7 @@ export interface ActionRowBuilderData * @typeParam ComponentType - The types of components this action row holds */ export class ActionRowBuilder extends ComponentBuilder> { - private readonly data: ActionRowBuilderData; + protected readonly data: ActionRowBuilderData; /** * The components within this action row. diff --git a/packages/builders/src/components/Component.ts b/packages/builders/src/components/Component.ts index 7009842d44ad..47ea1ad18295 100644 --- a/packages/builders/src/components/Component.ts +++ b/packages/builders/src/components/Component.ts @@ -1,17 +1,38 @@ import type { JSONEncodable } from '@discordjs/util'; -import type { APIActionRowComponent, APIComponentInActionRow } from 'discord-api-types/v10'; +import type { APIBaseComponent, ComponentType } from 'discord-api-types/v10'; -/** - * Any action row component data represented as an object. - */ -export type AnyAPIActionRowComponent = APIActionRowComponent | APIComponentInActionRow; +export interface ComponentBuilderBaseData { + id?: number | undefined; +} /** * The base component builder that contains common symbols for all sorts of components. * * @typeParam Component - The type of API data that is stored within the builder */ -export abstract class ComponentBuilder implements JSONEncodable { +export abstract class ComponentBuilder> + implements JSONEncodable +{ + protected abstract readonly data: ComponentBuilderBaseData; + + /** + * Sets the id of this component. + * + * @param id - The id to use + */ + public setId(id: number) { + this.data.id = id; + return this; + } + + /** + * Clears the id of this component, defaulting to a default incremented id. + */ + public clearId() { + this.data.id = undefined; + return this; + } + /** * Serializes this builder to API-compatible JSON data. * diff --git a/packages/builders/src/components/Components.ts b/packages/builders/src/components/Components.ts index 8b4475fca1c7..f16f993331ef 100644 --- a/packages/builders/src/components/Components.ts +++ b/packages/builders/src/components/Components.ts @@ -1,7 +1,12 @@ -import type { APIButtonComponent, APIMessageComponent, APIModalComponent } from 'discord-api-types/v10'; +import type { + APIBaseComponent, + APIButtonComponent, + APIMessageComponent, + APIModalComponent, + APISectionAccessoryComponent, +} from 'discord-api-types/v10'; import { ButtonStyle, ComponentType } from 'discord-api-types/v10'; import { ActionRowBuilder } from './ActionRow.js'; -import type { AnyAPIActionRowComponent } from './Component.js'; import { ComponentBuilder } from './Component.js'; import type { BaseButtonBuilder } from './button/Button.js'; import { @@ -18,11 +23,33 @@ 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 as top-level components on messages + */ +export type MessageTopLevelComponentBuilder = + | ActionRowBuilder + | ContainerBuilder + | FileBuilder + | MediaGalleryBuilder + | SectionBuilder + | SeparatorBuilder + | TextDisplayBuilder; /** * The builders that may be used for messages. */ -export type MessageComponentBuilder = ActionRowBuilder | MessageActionRowComponentBuilder; +export type MessageComponentBuilder = + | MessageActionRowComponentBuilder + | MessageTopLevelComponentBuilder + | ThumbnailBuilder; /** * The builders that may be used for modals. @@ -97,6 +124,34 @@ export interface MappedComponentTypes { * The channel select component type is associated with a {@link ChannelSelectMenuBuilder}. */ [ComponentType.ChannelSelect]: ChannelSelectMenuBuilder; + /** + * The thumbnail component type is associated with a {@link ThumbnailBuilder}. + */ + [ComponentType.Thumbnail]: ThumbnailBuilder; + /** + * 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 text display component type is associated with a {@link TextDisplayBuilder}. + */ + [ComponentType.TextDisplay]: TextDisplayBuilder; + /** + * The media gallery component type is associated with a {@link MediaGalleryBuilder}. + */ + [ComponentType.MediaGallery]: MediaGalleryBuilder; + /** + * The section component type is associated with a {@link SectionBuilder}. + */ + [ComponentType.Section]: SectionBuilder; + /** + * The container component type is associated with a {@link ContainerBuilder}. + */ + [ComponentType.Container]: ContainerBuilder; } /** @@ -122,7 +177,7 @@ export function createComponentBuilder { +): ComponentBuilder> { if (data instanceof ComponentBuilder) { return data; } @@ -144,36 +199,20 @@ export function createComponentBuilder( return new MentionableSelectMenuBuilder(data); case ComponentType.ChannelSelect: return new ChannelSelectMenuBuilder(data); - - // Will be handled later - case ComponentType.Section: { - throw new Error('Not implemented yet: ComponentType.Section case'); - } - - case ComponentType.TextDisplay: { - throw new Error('Not implemented yet: ComponentType.TextDisplay case'); - } - - case ComponentType.Thumbnail: { - throw new Error('Not implemented yet: ComponentType.Thumbnail case'); - } - - case ComponentType.MediaGallery: { - throw new Error('Not implemented yet: ComponentType.MediaGallery case'); - } - - case ComponentType.File: { - throw new Error('Not implemented yet: ComponentType.File case'); - } - - case ComponentType.Separator: { - throw new Error('Not implemented yet: ComponentType.Separator case'); - } - - case ComponentType.Container: { - throw new Error('Not implemented yet: ComponentType.Container case'); - } - + case ComponentType.Thumbnail: + return new ThumbnailBuilder(data); + case ComponentType.File: + return new FileBuilder(data); + case ComponentType.Separator: + return new SeparatorBuilder(data); + case ComponentType.TextDisplay: + return new TextDisplayBuilder(data); + case ComponentType.MediaGallery: + return new MediaGalleryBuilder(data); + case ComponentType.Section: + return new SectionBuilder(data); + case ComponentType.Container: + return new ContainerBuilder(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}`); @@ -199,3 +238,15 @@ function createButtonBuilder(data: APIButtonComponent): ButtonBuilder { throw new Error(`Cannot properly serialize button with style: ${data.style}`); } } + +export function resolveAccessoryComponent(component: APISectionAccessoryComponent) { + switch (component.type) { + case ComponentType.Button: + return createButtonBuilder(component); + case ComponentType.Thumbnail: + return new ThumbnailBuilder(component); + default: + // @ts-expect-error This case can still occur if we get a newer unsupported component type + throw new Error(`Cannot properly serialize section accessory component: ${component.type}`); + } +} diff --git a/packages/builders/src/components/selectMenu/BaseSelectMenu.ts b/packages/builders/src/components/selectMenu/BaseSelectMenu.ts index 75e34d28e50d..6ebc646d6053 100644 --- a/packages/builders/src/components/selectMenu/BaseSelectMenu.ts +++ b/packages/builders/src/components/selectMenu/BaseSelectMenu.ts @@ -11,8 +11,8 @@ export abstract class BaseSelectMenuBuilder extends ComponentBuilder implements JSONEncodable { - protected abstract readonly data: Partial< - Pick + protected abstract override readonly data: Partial< + Pick >; /** diff --git a/packages/builders/src/components/textInput/TextInput.ts b/packages/builders/src/components/textInput/TextInput.ts index e6aa77b9b07a..8fcb34fb79a4 100644 --- a/packages/builders/src/components/textInput/TextInput.ts +++ b/packages/builders/src/components/textInput/TextInput.ts @@ -7,7 +7,7 @@ import { textInputPredicate } from './Assertions.js'; * A builder that creates API-compatible JSON data for text inputs. */ export class TextInputBuilder extends ComponentBuilder { - private readonly data: Partial; + protected readonly data: Partial; /** * Creates a new text input from API data. diff --git a/packages/builders/src/components/v2/Assertions.ts b/packages/builders/src/components/v2/Assertions.ts new file mode 100644 index 000000000000..338aabd34265 --- /dev/null +++ b/packages/builders/src/components/v2/Assertions.ts @@ -0,0 +1,78 @@ +import { ComponentType, SeparatorSpacingSize } from 'discord-api-types/v10'; +import { z } from 'zod'; +import { refineURLPredicate } from '../../Assertions.js'; +import { actionRowPredicate } from '../Assertions.js'; + +const unfurledMediaItemPredicate = z.object({ + url: z + .string() + .url() + .refine(refineURLPredicate(['http:', 'https:', 'attachment:']), { + message: 'Invalid protocol for media URL. Must be http:, https:, or attachment:', + }), +}); + +export const thumbnailPredicate = z.object({ + media: unfurledMediaItemPredicate, + description: z.string().min(1).max(1_024).nullish(), + spoiler: z.boolean().optional(), +}); + +const unfurledMediaItemAttachmentOnlyPredicate = z.object({ + url: z + .string() + .url() + .refine(refineURLPredicate(['attachment:']), { + message: 'Invalid protocol for file URL. Must be attachment:', + }), +}); + +export const filePredicate = z.object({ + file: unfurledMediaItemAttachmentOnlyPredicate, + spoiler: z.boolean().optional(), +}); + +export const separatorPredicate = z.object({ + divider: z.boolean().optional(), + spacing: z.nativeEnum(SeparatorSpacingSize).optional(), +}); + +export const textDisplayPredicate = z.object({ + content: z.string().min(1).max(4_000), +}); + +export const mediaGalleryItemPredicate = z.object({ + media: unfurledMediaItemPredicate, + description: z.string().min(1).max(1_024).nullish(), + spoiler: z.boolean().optional(), +}); + +export const mediaGalleryPredicate = z.object({ + items: z.array(mediaGalleryItemPredicate).min(1).max(10), +}); + +export const sectionPredicate = z.object({ + components: z.array(textDisplayPredicate).min(1).max(3), + accessory: z.union([ + z.object({ type: z.literal(ComponentType.Button) }), + z.object({ type: z.literal(ComponentType.Thumbnail) }), + ]), +}); + +export const containerPredicate = z.object({ + components: z + .array( + z.union([ + actionRowPredicate, + filePredicate, + mediaGalleryPredicate, + sectionPredicate, + separatorPredicate, + textDisplayPredicate, + ]), + ) + .min(1) + .max(10), + spoiler: z.boolean().optional(), + accent_color: z.number().int().min(0).max(0xffffff).nullish(), +}); diff --git a/packages/builders/src/components/v2/Container.ts b/packages/builders/src/components/v2/Container.ts new file mode 100644 index 000000000000..244abce5d85e --- /dev/null +++ b/packages/builders/src/components/v2/Container.ts @@ -0,0 +1,232 @@ +import type { + APIActionRowComponent, + APIFileComponent, + APITextDisplayComponent, + APIContainerComponent, + APIComponentInContainer, + APIMediaGalleryComponent, + APISectionComponent, +} from 'discord-api-types/v10'; +import { ComponentType } from 'discord-api-types/v10'; +import type { APIComponentInMessageActionRow, APISeparatorComponent } from 'discord-api-types/v9'; +import { normalizeArray, type RestOrArray } from '../../util/normalizeArray'; +import { resolveBuilder } from '../../util/resolveBuilder'; +import { validate } from '../../util/validation'; +import { ActionRowBuilder } from '../ActionRow.js'; +import { ComponentBuilder } from '../Component.js'; +import { createComponentBuilder } from '../Components'; +import { containerPredicate } from './Assertions'; +import { FileBuilder } from './File.js'; +import { MediaGalleryBuilder } from './MediaGallery'; +import { SectionBuilder } from './Section'; +import { SeparatorBuilder } from './Separator.js'; +import { TextDisplayBuilder } from './TextDisplay'; + +export type ContainerComponentBuilders = + | ActionRowBuilder + | FileBuilder + | MediaGalleryBuilder + | SectionBuilder + | SeparatorBuilder + | TextDisplayBuilder; + +export interface ContainerBuilderData extends Partial> { + components: ContainerComponentBuilders[]; +} + +export class ContainerBuilder extends ComponentBuilder { + protected readonly data: ContainerBuilderData; + + public constructor({ components = [], ...rest }: Partial = {}) { + super(); + this.data = { + ...structuredClone(rest), + components: components.map((component) => createComponentBuilder(component)), + type: ComponentType.Container, + }; + } + + /** + * Sets the accent color of this container. + * + * @param color - The color to use + */ + public setAccentColor(color: number) { + this.data.accent_color = color; + return this; + } + + /** + * Clears the accent color of this container. + */ + public clearAccentColor() { + this.data.accent_color = undefined; + return this; + } + + /** + * Sets the spoiler status of this container. + * + * @param spoiler - The spoiler status to use + */ + public setSpoiler(spoiler = true) { + this.data.spoiler = spoiler; + return this; + } + + /** + * Adds action row components to this container. + * + * @param input - The action row to add + */ + public addActionRowComponents( + ...input: RestOrArray< + | ActionRowBuilder + | APIActionRowComponent + | ((builder: ActionRowBuilder) => ActionRowBuilder) + > + ): this { + const normalized = normalizeArray(input); + const resolved = normalized.map((component) => resolveBuilder(component, ActionRowBuilder)); + + this.data.components.push(...resolved); + return this; + } + + /** + * Adds file components to this container. + * + * @param input - The file components to add + */ + public addFileComponents( + ...input: RestOrArray FileBuilder)> + ): this { + const normalized = normalizeArray(input); + const resolved = normalized.map((component) => resolveBuilder(component, FileBuilder)); + + this.data.components.push(...resolved); + return this; + } + + /** + * Adds media gallery components to this container. + * + * @param input - The media gallery components to add + */ + public addMediaGalleryComponents( + ...input: RestOrArray< + APIMediaGalleryComponent | MediaGalleryBuilder | ((builder: MediaGalleryBuilder) => MediaGalleryBuilder) + > + ): this { + const normalized = normalizeArray(input); + const resolved = normalized.map((component) => resolveBuilder(component, MediaGalleryBuilder)); + + this.data.components.push(...resolved); + return this; + } + + /** + * Adds section components to this container. + * + * @param input - The section components to add + */ + public addSectionComponents( + ...input: RestOrArray SectionBuilder)> + ): this { + const normalized = normalizeArray(input); + const resolved = normalized.map((component) => resolveBuilder(component, SectionBuilder)); + + this.data.components.push(...resolved); + return this; + } + + /** + * Adds separator components to this container. + * + * @param input - The separator components to add + */ + public addSeparatorComponents( + ...input: RestOrArray SeparatorBuilder)> + ): this { + const normalized = normalizeArray(input); + const resolved = normalized.map((component) => resolveBuilder(component, SeparatorBuilder)); + + this.data.components.push(...resolved); + return this; + } + + /** + * Adds text display components to this container. + * + * @param input - The text display components to add + */ + public addTextDisplayComponents( + ...input: RestOrArray< + APITextDisplayComponent | TextDisplayBuilder | ((builder: TextDisplayBuilder) => TextDisplayBuilder) + > + ): this { + const normalized = normalizeArray(input); + const resolved = normalized.map((component) => resolveBuilder(component, TextDisplayBuilder)); + + this.data.components.push(...resolved); + return this; + } + + /** + * Removes, replaces, or inserts components for this container + * + * @remarks + * This method behaves similarly + * to {@link https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array/splice | Array.prototype.splice()}. + * + * It's useful for modifying and adjusting order of the already-existing components of a container. + * @example + * Remove the first component: + * ```ts + * container.spliceComponents(0, 1); + * ``` + * @example + * Remove the first n components: + * ```ts + * const n = 4; + * container.spliceComponents(0, n); + * ``` + * @example + * Remove the last component: + * ```ts + * container.spliceComponents(-1, 1); + * ``` + * @param index - The index to start at + * @param deleteCount - The number of components to remove + * @param components - The replacing component objects + */ + public spliceComponents( + index: number, + deleteCount: number, + ...components: RestOrArray + ): this { + const normalized = normalizeArray(components); + const resolved = normalized.map((component) => + component instanceof ComponentBuilder ? component : createComponentBuilder(component), + ); + + this.data.components.splice(index, deleteCount, ...resolved); + return this; + } + + /** + * {@inheritDoc ComponentBuilder.toJSON} + */ + public override toJSON(validationOverride?: boolean): APIContainerComponent { + const { components, ...rest } = this.data; + + const data = { + ...structuredClone(rest), + components: components.map((component) => component.toJSON(false)), + }; + + validate(containerPredicate, data, validationOverride); + + return data 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 000000000000..d6fdf1673158 --- /dev/null +++ b/packages/builders/src/components/v2/File.ts @@ -0,0 +1,72 @@ +import { ComponentType, type APIFileComponent } from 'discord-api-types/v10'; +import { validate } from '../../util/validation.js'; +import { ComponentBuilder } from '../Component.js'; +import { filePredicate } from './Assertions.js'; + +export class FileBuilder extends ComponentBuilder { + protected readonly data: Partial; + + /** + * 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(); + this.data = { + ...structuredClone(data), + file: data.file ? { url: data.file.url } : undefined, + type: ComponentType.File, + }; + } + + /** + * Sets the spoiler status of this file. + * + * @param spoiler - The spoiler status to use + */ + public setSpoiler(spoiler = true) { + this.data.spoiler = spoiler; + return this; + } + + /** + * Sets the media URL of this file. + * + * @param url - The URL to use + */ + public setURL(url: string) { + this.data.file = { url }; + return this; + } + + /** + * {@inheritDoc ComponentBuilder.toJSON} + */ + public override toJSON(validationOverride?: boolean): APIFileComponent { + const clone = structuredClone(this.data); + validate(filePredicate, clone, validationOverride); + + return clone 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 000000000000..832bfc872e17 --- /dev/null +++ b/packages/builders/src/components/v2/MediaGallery.ts @@ -0,0 +1,118 @@ +import { type APIMediaGalleryItem, type APIMediaGalleryComponent, ComponentType } from 'discord-api-types/v10'; +import { normalizeArray, type RestOrArray } from '../../util/normalizeArray.js'; +import { resolveBuilder } from '../../util/resolveBuilder.js'; +import { validate } from '../../util/validation.js'; +import { ComponentBuilder } from '../Component.js'; +import { mediaGalleryPredicate } from './Assertions.js'; +import { MediaGalleryItemBuilder } from './MediaGalleryItem.js'; + +export interface MediaGalleryBuilderData extends Partial> { + items: MediaGalleryItemBuilder[]; +} + +export class MediaGalleryBuilder extends ComponentBuilder { + protected readonly data: MediaGalleryBuilderData; + + /** + * Creates a new media gallery from API data. + * + * @param data - The API data to create this container 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(data: Partial = {}) { + super(); + this.data = { + items: data?.items?.map((item) => new MediaGalleryItemBuilder(item)) ?? [], + type: ComponentType.MediaGallery, + }; + } + + /** + * The items in this media gallery. + */ + public get items(): readonly MediaGalleryItemBuilder[] { + return this.data.items; + } + + /** + * Adds a media gallery item to this media gallery. + * + * @param input - The items to add + */ + public addItems( + ...input: RestOrArray< + APIMediaGalleryItem | MediaGalleryItemBuilder | ((builder: MediaGalleryItemBuilder) => MediaGalleryItemBuilder) + > + ): this { + const normalized = normalizeArray(input); + const resolved = normalized.map((item) => resolveBuilder(item, MediaGalleryItemBuilder)); + + this.data.items.push(...resolved); + 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) + > + ) { + const normalized = normalizeArray(items); + const resolved = normalized.map((item) => resolveBuilder(item, MediaGalleryItemBuilder)); + + this.data.items.splice(index, deleteCount, ...resolved); + return this; + } + + /** + * {@inheritDoc ComponentBuilder.toJSON} + */ + public override toJSON(validationOverride?: boolean): APIMediaGalleryComponent { + const { items, ...rest } = this.data; + + const data = { + ...structuredClone(rest), + items: items.map((item) => item.toJSON(false)), + }; + + validate(mediaGalleryPredicate, data, validationOverride); + + return data 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 000000000000..015be98ba811 --- /dev/null +++ b/packages/builders/src/components/v2/MediaGalleryItem.ts @@ -0,0 +1,87 @@ +import type { JSONEncodable } from '@discordjs/util'; +import type { APIMediaGalleryItem } from 'discord-api-types/v10'; +import { validate } from '../../util/validation.js'; +import { mediaGalleryItemPredicate } from './Assertions.js'; + +export class MediaGalleryItemBuilder implements JSONEncodable { + private 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 = { + ...structuredClone(data), + }; + } + + /** + * Sets the source URL of this media gallery item. + * + * @param url - The URL to use + */ + public setURL(url: string) { + this.data.media = { url }; + return this; + } + + /** + * Sets the description of this thumbnail. + * + * @param description - The description to use + */ + public setDescription(description: string) { + this.data.description = 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 = spoiler; + return this; + } + + /** + * Transforms this object to its JSON format + */ + public toJSON(validationOverride?: boolean): APIMediaGalleryItem { + const clone = structuredClone(this.data); + validate(mediaGalleryItemPredicate, clone, validationOverride); + + return clone 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 000000000000..7fa616500b5d --- /dev/null +++ b/packages/builders/src/components/v2/Section.ts @@ -0,0 +1,257 @@ +import type { + APITextDisplayComponent, + APISectionComponent, + APIButtonComponentWithCustomId, + APIThumbnailComponent, + APIButtonComponentWithSKUId, + APIButtonComponentWithURL, + ButtonStyle, +} from 'discord-api-types/v10'; +import { ComponentType } from 'discord-api-types/v10'; +import { normalizeArray, type RestOrArray } from '../../util/normalizeArray.js'; +import { resolveBuilder } from '../../util/resolveBuilder.js'; +import { validate } from '../../util/validation.js'; +import { ComponentBuilder } from '../Component.js'; +import { resolveAccessoryComponent, type ButtonBuilder } from '../Components.js'; +import { + DangerButtonBuilder, + PrimaryButtonBuilder, + SecondaryButtonBuilder, + SuccessButtonBuilder, +} from '../button/CustomIdButton.js'; +import { LinkButtonBuilder } from '../button/LinkButton.js'; +import { PremiumButtonBuilder } from '../button/PremiumButton.js'; +import { sectionPredicate } from './Assertions.js'; +import { TextDisplayBuilder } from './TextDisplay.js'; +import { ThumbnailBuilder } from './Thumbnail.js'; + +export type SectionBuilderAccessory = ButtonBuilder | ThumbnailBuilder; + +export interface SectionBuilderData extends Partial> { + accessory?: SectionBuilderAccessory; + components: TextDisplayBuilder[]; +} + +export class SectionBuilder extends ComponentBuilder { + protected readonly data: SectionBuilderData; + + public get components(): readonly TextDisplayBuilder[] { + return this.data.components; + } + + /** + * 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(data: Partial = {}) { + super(); + + const { components = [], accessory, ...rest } = data; + + this.data = { + ...structuredClone(rest), + accessory: accessory ? resolveAccessoryComponent(accessory) : undefined, + components: components.map((component) => new TextDisplayBuilder(component)), + type: ComponentType.Section, + }; + } + + /** + * Adds text display components to this section. + * + * @param input - The text display components to add + */ + public addTextDisplayComponents( + ...input: RestOrArray< + APITextDisplayComponent | TextDisplayBuilder | ((builder: TextDisplayBuilder) => TextDisplayBuilder) + > + ): this { + const normalized = normalizeArray(input); + const resolved = normalized.map((component) => resolveBuilder(component, TextDisplayBuilder)); + + this.data.components.push(...resolved); + return this; + } + + /** + * Sets a primary button component to be the accessory of this section. + * + * @param input - The button to set as the accessory + */ + public setPrimaryButtonAccessory( + input: + | PrimaryButtonBuilder + | ((builder: PrimaryButtonBuilder) => PrimaryButtonBuilder) + | (APIButtonComponentWithCustomId & { style: ButtonStyle.Primary }), + ): this { + const builder = resolveBuilder(input, PrimaryButtonBuilder); + + this.data.accessory = builder; + return this; + } + + /** + * Sets a secondary button component to be the accessory of this section. + * + * @param input - The button to set as the accessory + */ + public setSecondaryButtonAccessory( + input: + | SecondaryButtonBuilder + | ((builder: SecondaryButtonBuilder) => SecondaryButtonBuilder) + | (APIButtonComponentWithCustomId & { style: ButtonStyle.Secondary }), + ): this { + const builder = resolveBuilder(input, SecondaryButtonBuilder); + + this.data.accessory = builder; + return this; + } + + /** + * Sets a success button component to be the accessory of this section. + * + * @param input - The button to set as the accessory + */ + public setSuccessButtonAccessory( + input: + | SuccessButtonBuilder + | ((builder: SuccessButtonBuilder) => SuccessButtonBuilder) + | (APIButtonComponentWithCustomId & { style: ButtonStyle.Success }), + ): this { + const builder = resolveBuilder(input, SuccessButtonBuilder); + + this.data.accessory = builder; + return this; + } + + /** + * Sets a danger button component to be the accessory of this section. + * + * @param input - The button to set as the accessory + */ + public setDangerButtonAccessory( + input: + | DangerButtonBuilder + | ((builder: DangerButtonBuilder) => DangerButtonBuilder) + | (APIButtonComponentWithCustomId & { style: ButtonStyle.Danger }), + ): this { + const builder = resolveBuilder(input, DangerButtonBuilder); + + this.data.accessory = builder; + return this; + } + + /** + * Sets a SKU id button component to be the accessory of this section. + * + * @param input - The button to set as the accessory + */ + public setPremiumButtonAccessory( + input: + | APIButtonComponentWithSKUId + | PremiumButtonBuilder + | ((builder: PremiumButtonBuilder) => PremiumButtonBuilder), + ): this { + const builder = resolveBuilder(input, PremiumButtonBuilder); + + this.data.accessory = builder; + return this; + } + + /** + * Sets a URL button component to be the accessory of this section. + * + * @param input - The button to set as the accessory + */ + public setLinkButtonAccessory( + input: APIButtonComponentWithURL | LinkButtonBuilder | ((builder: LinkButtonBuilder) => LinkButtonBuilder), + ): this { + const builder = resolveBuilder(input, LinkButtonBuilder); + + this.data.accessory = builder; + return this; + } + + /** + * Sets a thumbnail component to be the accessory of this section. + * + * @param input - The thumbnail to set as the accessory + */ + public setThumbnailAccessory( + input: APIThumbnailComponent | ThumbnailBuilder | ((builder: ThumbnailBuilder) => ThumbnailBuilder), + ): this { + const builder = resolveBuilder(input, ThumbnailBuilder); + + this.data.accessory = builder; + 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 { + const normalized = normalizeArray(components); + const resolved = normalized.map((component) => resolveBuilder(component, TextDisplayBuilder)); + + this.data.components.splice(index, deleteCount, ...resolved); + return this; + } + + /** + * {@inheritDoc ComponentBuilder.toJSON} + */ + public override toJSON(validationOverride?: boolean): APISectionComponent { + const { components, accessory, ...rest } = this.data; + + const data = { + ...structuredClone(rest), + components: components.map((component) => component.toJSON(false)), + accessory: accessory?.toJSON(validationOverride), + }; + + validate(sectionPredicate, data, validationOverride); + + return data 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 000000000000..de8af7683fd4 --- /dev/null +++ b/packages/builders/src/components/v2/Separator.ts @@ -0,0 +1,76 @@ +import type { SeparatorSpacingSize, APISeparatorComponent } from 'discord-api-types/v10'; +import { ComponentType } from 'discord-api-types/v10'; +import { validate } from '../../util/validation.js'; +import { ComponentBuilder } from '../Component.js'; +import { separatorPredicate } from './Assertions.js'; + +export class SeparatorBuilder extends ComponentBuilder { + protected readonly data: Partial; + + /** + * 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(); + this.data = { + ...structuredClone(data), + type: ComponentType.Separator, + }; + } + + /** + * Sets whether this separator should show a divider line. + * + * @param divider - Whether to show a divider line + */ + public setDivider(divider = true) { + this.data.divider = divider; + return this; + } + + /** + * Sets the spacing of this separator. + * + * @param spacing - The spacing to use + */ + public setSpacing(spacing: SeparatorSpacingSize) { + this.data.spacing = spacing; + return this; + } + + /** + * Clears the spacing of this separator. + */ + public clearSpacing() { + this.data.spacing = undefined; + return this; + } + + /** + * {@inheritDoc ComponentBuilder.toJSON} + */ + public override toJSON(validationOverride?: boolean): APISeparatorComponent { + const clone = structuredClone(this.data); + validate(separatorPredicate, clone, validationOverride); + + return clone 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 000000000000..1ad756976188 --- /dev/null +++ b/packages/builders/src/components/v2/TextDisplay.ts @@ -0,0 +1,57 @@ +import type { APITextDisplayComponent } from 'discord-api-types/v10'; +import { ComponentType } from 'discord-api-types/v10'; +import { validate } from '../../util/validation.js'; +import { ComponentBuilder } from '../Component.js'; +import { textDisplayPredicate } from './Assertions.js'; + +export class TextDisplayBuilder extends ComponentBuilder { + protected readonly data: Partial; + + /** + * 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(); + this.data = { + ...structuredClone(data), + type: ComponentType.TextDisplay, + }; + } + + /** + * Sets the text of this text display. + * + * @param content - The text to use + */ + public setContent(content: string) { + this.data.content = content; + return this; + } + + /** + * {@inheritDoc ComponentBuilder.toJSON} + */ + public override toJSON(validationOverride?: boolean): APITextDisplayComponent { + const clone = structuredClone(this.data); + validate(textDisplayPredicate, clone, validationOverride); + + return clone 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 000000000000..15c6937ac088 --- /dev/null +++ b/packages/builders/src/components/v2/Thumbnail.ts @@ -0,0 +1,91 @@ +import type { APIThumbnailComponent } from 'discord-api-types/v10'; +import { ComponentType } from 'discord-api-types/v10'; +import { validate } from '../../util/validation.js'; +import { ComponentBuilder } from '../Component.js'; +import { thumbnailPredicate } from './Assertions.js'; + +export class ThumbnailBuilder extends ComponentBuilder { + protected readonly data: Partial; + + /** + * 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(); + this.data = { + ...structuredClone(data), + media: data.media ? { url: data.media.url } : undefined, + type: ComponentType.Thumbnail, + }; + } + + /** + * Sets the description of this thumbnail. + * + * @param description - The description to use + */ + public setDescription(description: string) { + this.data.description = 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 = spoiler; + return this; + } + + /** + * Sets the media URL of this thumbnail. + * + * @param url - The URL to use + */ + public setURL(url: string) { + this.data.media = { url }; + return this; + } + + /** + * {@inheritdoc ComponentBuilder.toJSON} + */ + public override toJSON(validationOverride?: boolean): APIThumbnailComponent { + const clone = structuredClone(this.data); + validate(thumbnailPredicate, clone, validationOverride); + + return clone as APIThumbnailComponent; + } +} diff --git a/packages/builders/src/index.ts b/packages/builders/src/index.ts index 1e6c779b52f5..03a589680277 100644 --- a/packages/builders/src/index.ts +++ b/packages/builders/src/index.ts @@ -20,6 +20,15 @@ export * from './components/Assertions.js'; export * from './components/Component.js'; export * from './components/Components.js'; +export * from './components/v2/Assertions.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 * from './interactions/commands/chatInput/mixins/ApplicationCommandNumericOptionMinMaxValueMixin.js'; export * from './interactions/commands/chatInput/mixins/ApplicationCommandOptionChannelTypesMixin.js'; export * from './interactions/commands/chatInput/mixins/ApplicationCommandOptionWithAutocompleteMixin.js'; diff --git a/packages/builders/src/messages/Assertions.ts b/packages/builders/src/messages/Assertions.ts index f5ff7340f55e..a26c20a96d6f 100644 --- a/packages/builders/src/messages/Assertions.ts +++ b/packages/builders/src/messages/Assertions.ts @@ -1,4 +1,4 @@ -import { AllowedMentionsTypes, ComponentType, MessageReferenceType } from 'discord-api-types/v10'; +import { AllowedMentionsTypes, ComponentType, MessageFlags, MessageReferenceType } from 'discord-api-types/v10'; import { z } from 'zod'; import { embedPredicate } from './embed/Assertions.js'; import { pollPredicate } from './poll/Assertions.js'; @@ -27,40 +27,49 @@ export const messageReferencePredicate = z.object({ type: z.nativeEnum(MessageReferenceType).optional(), }); -export const messagePredicate = z - .object({ +const baseMessagePredicate = z.object({ + nonce: z.union([z.string().max(25), z.number()]).optional(), + tts: z.boolean().optional(), + allowed_mentions: allowedMentionPredicate.optional(), + message_reference: messageReferencePredicate.optional(), + attachments: attachmentPredicate.array().max(10).optional(), + enforce_nonce: z.boolean().optional(), +}); + +const basicActionRowPredicate = z.object({ + type: z.literal(ComponentType.ActionRow), + components: z + .object({ + type: z.union([ + z.literal(ComponentType.Button), + z.literal(ComponentType.ChannelSelect), + z.literal(ComponentType.MentionableSelect), + z.literal(ComponentType.RoleSelect), + z.literal(ComponentType.StringSelect), + z.literal(ComponentType.UserSelect), + ]), + }) + .array(), +}); + +const messageNoComponentsV2Predicate = baseMessagePredicate + .extend({ content: z.string().optional(), - nonce: z.union([z.string().max(25), z.number()]).optional(), - tts: z.boolean().optional(), embeds: embedPredicate.array().max(10).optional(), - allowed_mentions: allowedMentionPredicate.optional(), - message_reference: messageReferencePredicate.optional(), - // Partial validation here to ensure the components are valid, - // rest of the validation is done in the action row predicate - components: z - .object({ - type: z.literal(ComponentType.ActionRow), - components: z - .object({ - type: z.union([ - z.literal(ComponentType.Button), - z.literal(ComponentType.ChannelSelect), - z.literal(ComponentType.MentionableSelect), - z.literal(ComponentType.RoleSelect), - z.literal(ComponentType.StringSelect), - z.literal(ComponentType.UserSelect), - ]), - }) - .array(), - }) - .array() - .max(5) - .optional(), sticker_ids: z.array(z.string()).min(0).max(3).optional(), - attachments: attachmentPredicate.array().max(10).optional(), - flags: z.number().optional(), - enforce_nonce: z.boolean().optional(), poll: pollPredicate.optional(), + components: basicActionRowPredicate.array().max(5).optional(), + flags: z + .number() + .optional() + .refine((flags) => { + // If we have flags, ensure we don't have the ComponentsV2 flag + if (flags) { + return (flags & MessageFlags.IsComponentsV2) === 0; + } + + return true; + }), }) .refine( (data) => @@ -70,5 +79,37 @@ export const messagePredicate = z (data.attachments !== undefined && data.attachments.length > 0) || (data.components !== undefined && data.components.length > 0) || (data.sticker_ids !== undefined && data.sticker_ids.length > 0), - { message: 'Messages must have content, embeds, a poll, attachments, components, or stickers' }, + { message: 'Messages must have content, embeds, a poll, attachments, components or stickers' }, ); + +const allTopLevelComponentsPredicate = z + .union([ + basicActionRowPredicate, + z.object({ + type: z.union([ + // Components v2 + z.literal(ComponentType.Container), + z.literal(ComponentType.File), + z.literal(ComponentType.MediaGallery), + z.literal(ComponentType.Section), + z.literal(ComponentType.Separator), + z.literal(ComponentType.TextDisplay), + z.literal(ComponentType.Thumbnail), + ]), + }), + ]) + .array() + .min(1) + .max(10); + +const messageComponentsV2Predicate = baseMessagePredicate.extend({ + components: allTopLevelComponentsPredicate, + flags: z.number().refine((flags) => (flags & MessageFlags.IsComponentsV2) === MessageFlags.IsComponentsV2), + // These fields cannot be set + content: z.string().length(0).nullish(), + embeds: z.array(z.never()).nullish(), + sticker_ids: z.array(z.never()).nullish(), + poll: z.null().optional(), +}); + +export const messagePredicate = z.union([messageNoComponentsV2Predicate, messageComponentsV2Predicate]); diff --git a/packages/builders/src/messages/Message.ts b/packages/builders/src/messages/Message.ts index 415975263cdf..304bd7eccd31 100644 --- a/packages/builders/src/messages/Message.ts +++ b/packages/builders/src/messages/Message.ts @@ -10,9 +10,24 @@ import type { RESTPostAPIChannelMessageJSONBody, Snowflake, MessageFlags, - APIComponentInActionRow, + APIContainerComponent, + APIFileComponent, + APIMediaGalleryComponent, + APISectionComponent, + APISeparatorComponent, + APITextDisplayComponent, + APIMessageTopLevelComponent, } from 'discord-api-types/v10'; import { ActionRowBuilder } from '../components/ActionRow.js'; +import { ComponentBuilder } from '../components/Component.js'; +import type { MessageTopLevelComponentBuilder } from '../components/Components.js'; +import { createComponentBuilder } from '../components/Components.js'; +import { ContainerBuilder } from '../components/v2/Container.js'; +import { FileBuilder } from '../components/v2/File.js'; +import { MediaGalleryBuilder } from '../components/v2/MediaGallery.js'; +import { SectionBuilder } from '../components/v2/Section.js'; +import { SeparatorBuilder } from '../components/v2/Separator.js'; +import { TextDisplayBuilder } from '../components/v2/TextDisplay.js'; import { normalizeArray, type RestOrArray } from '../util/normalizeArray.js'; import { resolveBuilder } from '../util/resolveBuilder.js'; import { validate } from '../util/validation.js'; @@ -32,7 +47,7 @@ export interface MessageBuilderData > { allowed_mentions?: AllowedMentionsBuilder; attachments: AttachmentBuilder[]; - components: ActionRowBuilder[]; + components: MessageTopLevelComponentBuilder[]; embeds: EmbedBuilder[]; message_reference?: MessageReferenceBuilder; poll?: PollBuilder; @@ -54,7 +69,7 @@ export class MessageBuilder implements JSONEncodable new AttachmentBuilder(attachment)) ?? [], embeds: data.embeds?.map((embed) => new EmbedBuilder(embed)) ?? [], poll: data.poll ? new PollBuilder(data.poll) : undefined, - components: - data.components?.map( - (component) => new ActionRowBuilder(component as unknown as APIActionRowComponent), - ) ?? [], + components: data.components?.map((component) => createComponentBuilder(component)) ?? [], message_reference: data.message_reference ? new MessageReferenceBuilder(data.message_reference) : undefined, }; } @@ -268,11 +280,11 @@ export class MessageBuilder implements JSONEncodable @@ -287,6 +299,110 @@ export class MessageBuilder implements JSONEncodable ContainerBuilder) + > + ): this { + this.data.components ??= []; + + const resolved = normalizeArray(components).map((component) => resolveBuilder(component, ContainerBuilder)); + this.data.components.push(...resolved); + + return this; + } + + /** + * Adds file components to this message. + * + * @param components - The file components to add + */ + public addFileComponents( + ...components: RestOrArray FileBuilder)> + ): this { + this.data.components ??= []; + + const resolved = normalizeArray(components).map((component) => resolveBuilder(component, FileBuilder)); + this.data.components.push(...resolved); + + return this; + } + + /** + * Adds media gallery components to this message. + * + * @param components - The media gallery components to add + */ + public addMediaGalleryComponents( + ...components: RestOrArray< + APIMediaGalleryComponent | MediaGalleryBuilder | ((builder: MediaGalleryBuilder) => MediaGalleryBuilder) + > + ): this { + this.data.components ??= []; + + const resolved = normalizeArray(components).map((component) => resolveBuilder(component, MediaGalleryBuilder)); + this.data.components.push(...resolved); + + return this; + } + + /** + * Adds section components to this message. + * + * @param components - The section components to add + */ + public addSectionComponents( + ...components: RestOrArray SectionBuilder)> + ): this { + this.data.components ??= []; + + const resolved = normalizeArray(components).map((component) => resolveBuilder(component, SectionBuilder)); + this.data.components.push(...resolved); + + return this; + } + + /** + * Adds separator components to this message. + * + * @param components - The separator components to add + */ + public addSeparatorComponents( + ...components: RestOrArray< + APISeparatorComponent | SeparatorBuilder | ((builder: SeparatorBuilder) => SeparatorBuilder) + > + ): this { + this.data.components ??= []; + + const resolved = normalizeArray(components).map((component) => resolveBuilder(component, SeparatorBuilder)); + this.data.components.push(...resolved); + + return this; + } + + /** + * Adds text display components to this message. + * + * @param components - The text display components to add + */ + public addTextDisplayComponents( + ...components: RestOrArray< + APITextDisplayComponent | TextDisplayBuilder | ((builder: TextDisplayBuilder) => TextDisplayBuilder) + > + ): this { + this.data.components ??= []; + + const resolved = normalizeArray(components).map((component) => resolveBuilder(component, TextDisplayBuilder)); + this.data.components.push(...resolved); + + return this; + } + /** * Removes, replaces, or inserts components for this message. * @@ -318,35 +434,17 @@ export class MessageBuilder implements JSONEncodable - | ((builder: ActionRowBuilder) => ActionRowBuilder) - > + ...components: RestOrArray ): this { this.data.components ??= []; - const resolved = normalizeArray(components).map((component) => resolveBuilder(component, ActionRowBuilder)); + const resolved = normalizeArray(components).map((component) => + component instanceof ComponentBuilder ? component : createComponentBuilder(component), + ); this.data.components.splice(start, deleteCount, ...resolved); return this; } - /** - * Sets the components of this message. - * - * @param components - The components to set - */ - public setComponents( - ...components: RestOrArray< - | ActionRowBuilder - | APIActionRowComponent - | ((builder: ActionRowBuilder) => ActionRowBuilder) - > - ): this { - this.data.components = normalizeArray(components).map((component) => resolveBuilder(component, ActionRowBuilder)); - return this; - } - /** * Sets the sticker ids of this message. * diff --git a/packages/builders/src/messages/embed/EmbedAuthor.ts b/packages/builders/src/messages/embed/EmbedAuthor.ts index 5eb9df58cdee..990ecadadc62 100644 --- a/packages/builders/src/messages/embed/EmbedAuthor.ts +++ b/packages/builders/src/messages/embed/EmbedAuthor.ts @@ -1,3 +1,4 @@ +import type { JSONEncodable } from '@discordjs/util'; import type { APIEmbedAuthor } from 'discord-api-types/v10'; import { validate } from '../../util/validation.js'; import { embedAuthorPredicate } from './Assertions.js'; @@ -5,7 +6,7 @@ import { embedAuthorPredicate } from './Assertions.js'; /** * A builder that creates API-compatible JSON data for the embed author. */ -export class EmbedAuthorBuilder { +export class EmbedAuthorBuilder implements JSONEncodable { private readonly data: Partial; /** diff --git a/packages/builders/src/messages/embed/EmbedField.ts b/packages/builders/src/messages/embed/EmbedField.ts index 5025fec0ecf5..b71520242484 100644 --- a/packages/builders/src/messages/embed/EmbedField.ts +++ b/packages/builders/src/messages/embed/EmbedField.ts @@ -1,3 +1,4 @@ +import type { JSONEncodable } from '@discordjs/util'; import type { APIEmbedField } from 'discord-api-types/v10'; import { validate } from '../../util/validation.js'; import { embedFieldPredicate } from './Assertions.js'; @@ -5,7 +6,7 @@ import { embedFieldPredicate } from './Assertions.js'; /** * A builder that creates API-compatible JSON data for embed fields. */ -export class EmbedFieldBuilder { +export class EmbedFieldBuilder implements JSONEncodable { private readonly data: Partial; /** diff --git a/packages/builders/src/messages/embed/EmbedFooter.ts b/packages/builders/src/messages/embed/EmbedFooter.ts index 8d75b77f6df8..3304d2dec708 100644 --- a/packages/builders/src/messages/embed/EmbedFooter.ts +++ b/packages/builders/src/messages/embed/EmbedFooter.ts @@ -1,3 +1,4 @@ +import type { JSONEncodable } from '@discordjs/util'; import type { APIEmbedFooter } from 'discord-api-types/v10'; import { validate } from '../../util/validation.js'; import { embedFooterPredicate } from './Assertions.js'; @@ -5,7 +6,7 @@ import { embedFooterPredicate } from './Assertions.js'; /** * A builder that creates API-compatible JSON data for the embed footer. */ -export class EmbedFooterBuilder { +export class EmbedFooterBuilder implements JSONEncodable { private readonly data: Partial; /** diff --git a/packages/builders/src/messages/poll/PollAnswer.ts b/packages/builders/src/messages/poll/PollAnswer.ts index 071ed97fd8ae..e869e634aaca 100644 --- a/packages/builders/src/messages/poll/PollAnswer.ts +++ b/packages/builders/src/messages/poll/PollAnswer.ts @@ -1,3 +1,4 @@ +import type { JSONEncodable } from '@discordjs/util'; import type { APIPollAnswer, APIPollMedia } from 'discord-api-types/v10'; import { resolveBuilder } from '../../util/resolveBuilder'; import { validate } from '../../util/validation'; @@ -8,11 +9,11 @@ export interface PollAnswerData extends Omit> { /** * The API data associated with this poll answer. */ - protected readonly data: PollAnswerData; + private readonly data: PollAnswerData; /** * Creates a new poll answer from API data.