From 680cc5ff8d2557720f5ec8dd2809851b9dfd6317 Mon Sep 17 00:00:00 2001 From: didinele Date: Thu, 15 Aug 2024 23:18:38 +0300 Subject: [PATCH] refactor: builders Co-authored-by: Vlad Frangu Co-authored-by: Almeida --- .../__tests__/components/actionRow.test.ts | 72 ++- .../__tests__/components/button.test.ts | 127 +--- .../__tests__/components/components.test.ts | 6 +- .../__tests__/components/selectMenu.test.ts | 115 ++-- .../__tests__/components/textInput.test.ts | 62 +- .../interactions/ContextMenuCommands.test.ts | 100 ++- .../SlashCommands/ChatInputCommands.test.ts | 563 +++++++++++++++++ .../SlashCommands/Options.test.ts | 39 +- .../SlashCommands/SlashCommands.test.ts | 593 ------------------ .../__tests__/interactions/modal.test.ts | 125 +--- .../builders/__tests__/messages/embed.test.ts | 220 ++++--- packages/builders/__tests__/types.test.ts | 14 +- packages/builders/package.json | 8 +- packages/builders/src/Assertions.ts | 20 + packages/builders/src/components/ActionRow.ts | 243 +++++-- .../builders/src/components/Assertions.ts | 277 ++++---- packages/builders/src/components/Component.ts | 34 +- .../builders/src/components/Components.ts | 77 ++- .../builders/src/components/button/Button.ts | 143 +---- .../src/components/button/CustomIdButton.ts | 43 ++ .../src/components/button/SKUIdButton.ts | 26 + .../src/components/button/URLButton.ts | 34 + .../button/mixins/URLOrCustomIdButtonMixin.ts | 44 ++ .../components/selectMenu/BaseSelectMenu.ts | 41 +- .../selectMenu/ChannelSelectMenu.ts | 30 +- .../selectMenu/MentionableSelectMenu.ts | 27 +- .../components/selectMenu/RoleSelectMenu.ts | 25 +- .../components/selectMenu/StringSelectMenu.ts | 102 +-- .../selectMenu/StringSelectMenuOption.ts | 54 +- .../components/selectMenu/UserSelectMenu.ts | 27 +- .../src/components/textInput/Assertions.ts | 45 +- .../src/components/textInput/TextInput.ts | 94 +-- packages/builders/src/index.ts | 101 +-- .../src/interactions/commands/Command.ts | 83 +++ .../src/interactions/commands/SharedName.ts | 69 ++ .../commands/SharedNameAndDescription.ts | 72 +++ .../commands/chatInput/Assertions.ts | 139 ++++ .../commands/chatInput/ChatInputCommand.ts | 37 ++ .../chatInput/ChatInputCommandSubcommands.ts | 107 ++++ ...ionCommandNumericOptionMinMaxValueMixin.ts | 47 ++ .../mixins/ApplicationCommandOptionBase.ts | 59 ++ ...plicationCommandOptionChannelTypesMixin.ts | 42 ++ ...ationCommandOptionWithAutocompleteMixin.ts | 29 + ...pplicationCommandOptionWithChoicesMixin.ts | 38 ++ .../mixins/SharedChatInputCommandOptions.ts | 161 +++++ .../chatInput/mixins/SharedSubcommands.ts | 60 ++ .../commands/chatInput/options/attachment.ts | 11 + .../commands/chatInput/options/boolean.ts | 11 + .../commands/chatInput/options/channel.ts | 19 + .../commands/chatInput/options/integer.ts | 23 + .../commands/chatInput/options/mentionable.ts | 11 + .../commands/chatInput/options/number.ts | 23 + .../commands/chatInput/options/role.ts | 11 + .../commands/chatInput/options/string.ts | 65 ++ .../commands/chatInput/options/user.ts | 11 + .../commands/contextMenu/Assertions.ts | 34 + .../contextMenu/ContextMenuCommand.ts | 29 + .../commands/contextMenu/MessageCommand.ts | 19 + .../commands/contextMenu/UserCommand.ts | 19 + .../contextMenuCommands/Assertions.ts | 65 -- .../ContextMenuCommandBuilder.ts | 239 ------- .../src/interactions/modals/Assertions.ts | 42 +- .../builders/src/interactions/modals/Modal.ts | 124 +++- .../interactions/slashCommands/Assertions.ts | 123 ---- .../slashCommands/SlashCommandBuilder.ts | 110 ---- .../slashCommands/SlashCommandSubcommands.ts | 131 ---- ...ionCommandNumericOptionMinMaxValueMixin.ts | 28 - .../mixins/ApplicationCommandOptionBase.ts | 57 -- ...plicationCommandOptionChannelTypesMixin.ts | 54 -- ...ationCommandOptionWithAutocompleteMixin.ts | 39 -- ...pplicationCommandOptionWithChoicesMixin.ts | 83 --- .../mixins/NameAndDescription.ts | 142 ----- .../mixins/SharedSlashCommand.ts | 162 ----- .../mixins/SharedSlashCommandOptions.ts | 145 ----- .../slashCommands/mixins/SharedSubcommands.ts | 66 -- .../slashCommands/options/attachment.ts | 21 - .../slashCommands/options/boolean.ts | 21 - .../slashCommands/options/channel.ts | 26 - .../slashCommands/options/integer.ts | 67 -- .../slashCommands/options/mentionable.ts | 21 - .../slashCommands/options/number.ts | 67 -- .../slashCommands/options/role.ts | 21 - .../slashCommands/options/string.ts | 73 --- .../slashCommands/options/user.ts | 21 - .../builders/src/messages/embed/Assertions.ts | 155 ++--- packages/builders/src/messages/embed/Embed.ts | 306 +++++---- .../src/messages/embed/EmbedAuthor.ts | 82 +++ .../builders/src/messages/embed/EmbedField.ts | 66 ++ .../src/messages/embed/EmbedFooter.ts | 64 ++ packages/builders/src/util/resolveBuilder.ts | 40 ++ pnpm-lock.yaml | 194 +++--- 91 files changed, 3724 insertions(+), 3891 deletions(-) create mode 100644 packages/builders/__tests__/interactions/SlashCommands/ChatInputCommands.test.ts delete mode 100644 packages/builders/__tests__/interactions/SlashCommands/SlashCommands.test.ts create mode 100644 packages/builders/src/Assertions.ts create mode 100644 packages/builders/src/components/button/CustomIdButton.ts create mode 100644 packages/builders/src/components/button/SKUIdButton.ts create mode 100644 packages/builders/src/components/button/URLButton.ts create mode 100644 packages/builders/src/components/button/mixins/URLOrCustomIdButtonMixin.ts create mode 100644 packages/builders/src/interactions/commands/Command.ts create mode 100644 packages/builders/src/interactions/commands/SharedName.ts create mode 100644 packages/builders/src/interactions/commands/SharedNameAndDescription.ts create mode 100644 packages/builders/src/interactions/commands/chatInput/Assertions.ts create mode 100644 packages/builders/src/interactions/commands/chatInput/ChatInputCommand.ts create mode 100644 packages/builders/src/interactions/commands/chatInput/ChatInputCommandSubcommands.ts create mode 100644 packages/builders/src/interactions/commands/chatInput/mixins/ApplicationCommandNumericOptionMinMaxValueMixin.ts create mode 100644 packages/builders/src/interactions/commands/chatInput/mixins/ApplicationCommandOptionBase.ts create mode 100644 packages/builders/src/interactions/commands/chatInput/mixins/ApplicationCommandOptionChannelTypesMixin.ts create mode 100644 packages/builders/src/interactions/commands/chatInput/mixins/ApplicationCommandOptionWithAutocompleteMixin.ts create mode 100644 packages/builders/src/interactions/commands/chatInput/mixins/ApplicationCommandOptionWithChoicesMixin.ts create mode 100644 packages/builders/src/interactions/commands/chatInput/mixins/SharedChatInputCommandOptions.ts create mode 100644 packages/builders/src/interactions/commands/chatInput/mixins/SharedSubcommands.ts create mode 100644 packages/builders/src/interactions/commands/chatInput/options/attachment.ts create mode 100644 packages/builders/src/interactions/commands/chatInput/options/boolean.ts create mode 100644 packages/builders/src/interactions/commands/chatInput/options/channel.ts create mode 100644 packages/builders/src/interactions/commands/chatInput/options/integer.ts create mode 100644 packages/builders/src/interactions/commands/chatInput/options/mentionable.ts create mode 100644 packages/builders/src/interactions/commands/chatInput/options/number.ts create mode 100644 packages/builders/src/interactions/commands/chatInput/options/role.ts create mode 100644 packages/builders/src/interactions/commands/chatInput/options/string.ts create mode 100644 packages/builders/src/interactions/commands/chatInput/options/user.ts create mode 100644 packages/builders/src/interactions/commands/contextMenu/Assertions.ts create mode 100644 packages/builders/src/interactions/commands/contextMenu/ContextMenuCommand.ts create mode 100644 packages/builders/src/interactions/commands/contextMenu/MessageCommand.ts create mode 100644 packages/builders/src/interactions/commands/contextMenu/UserCommand.ts delete mode 100644 packages/builders/src/interactions/contextMenuCommands/Assertions.ts delete mode 100644 packages/builders/src/interactions/contextMenuCommands/ContextMenuCommandBuilder.ts delete mode 100644 packages/builders/src/interactions/slashCommands/Assertions.ts delete mode 100644 packages/builders/src/interactions/slashCommands/SlashCommandBuilder.ts delete mode 100644 packages/builders/src/interactions/slashCommands/SlashCommandSubcommands.ts delete mode 100644 packages/builders/src/interactions/slashCommands/mixins/ApplicationCommandNumericOptionMinMaxValueMixin.ts delete mode 100644 packages/builders/src/interactions/slashCommands/mixins/ApplicationCommandOptionBase.ts delete mode 100644 packages/builders/src/interactions/slashCommands/mixins/ApplicationCommandOptionChannelTypesMixin.ts delete mode 100644 packages/builders/src/interactions/slashCommands/mixins/ApplicationCommandOptionWithAutocompleteMixin.ts delete mode 100644 packages/builders/src/interactions/slashCommands/mixins/ApplicationCommandOptionWithChoicesMixin.ts delete mode 100644 packages/builders/src/interactions/slashCommands/mixins/NameAndDescription.ts delete mode 100644 packages/builders/src/interactions/slashCommands/mixins/SharedSlashCommand.ts delete mode 100644 packages/builders/src/interactions/slashCommands/mixins/SharedSlashCommandOptions.ts delete mode 100644 packages/builders/src/interactions/slashCommands/mixins/SharedSubcommands.ts delete mode 100644 packages/builders/src/interactions/slashCommands/options/attachment.ts delete mode 100644 packages/builders/src/interactions/slashCommands/options/boolean.ts delete mode 100644 packages/builders/src/interactions/slashCommands/options/channel.ts delete mode 100644 packages/builders/src/interactions/slashCommands/options/integer.ts delete mode 100644 packages/builders/src/interactions/slashCommands/options/mentionable.ts delete mode 100644 packages/builders/src/interactions/slashCommands/options/number.ts delete mode 100644 packages/builders/src/interactions/slashCommands/options/role.ts delete mode 100644 packages/builders/src/interactions/slashCommands/options/string.ts delete mode 100644 packages/builders/src/interactions/slashCommands/options/user.ts create mode 100644 packages/builders/src/messages/embed/EmbedAuthor.ts create mode 100644 packages/builders/src/messages/embed/EmbedField.ts create mode 100644 packages/builders/src/messages/embed/EmbedFooter.ts create mode 100644 packages/builders/src/util/resolveBuilder.ts diff --git a/packages/builders/__tests__/components/actionRow.test.ts b/packages/builders/__tests__/components/actionRow.test.ts index b9f63b501529..5a00e2301682 100644 --- a/packages/builders/__tests__/components/actionRow.test.ts +++ b/packages/builders/__tests__/components/actionRow.test.ts @@ -9,6 +9,7 @@ import { ActionRowBuilder, ButtonBuilder, createComponentBuilder, + CustomIdButtonBuilder, StringSelectMenuBuilder, StringSelectMenuOptionBuilder, } from '../../src/index.js'; @@ -41,21 +42,14 @@ const rowWithSelectMenuData: APIActionRowComponent value: 'two', }, ], - max_values: 10, - min_values: 12, + max_values: 2, + min_values: 2, }, ], }; describe('Action Row Components', () => { describe('Assertion Tests', () => { - test('GIVEN valid components THEN do not throw', () => { - expect(() => new ActionRowBuilder().addComponents(new ButtonBuilder())).not.toThrowError(); - expect(() => new ActionRowBuilder().setComponents(new ButtonBuilder())).not.toThrowError(); - expect(() => new ActionRowBuilder().addComponents([new ButtonBuilder()])).not.toThrowError(); - expect(() => new ActionRowBuilder().setComponents([new ButtonBuilder()])).not.toThrowError(); - }); - test('GIVEN valid JSON input THEN valid JSON output is given', () => { const actionRowData: APIActionRowComponent = { type: ComponentType.ActionRow, @@ -72,22 +66,10 @@ describe('Action Row Components', () => { style: ButtonStyle.Link, url: 'https://google.com', }, - { - type: ComponentType.StringSelect, - placeholder: 'test', - custom_id: 'test', - options: [ - { - label: 'option', - value: 'option', - }, - ], - }, ], }; expect(new ActionRowBuilder(actionRowData).toJSON()).toEqual(actionRowData); - expect(new ActionRowBuilder().toJSON()).toEqual({ type: ComponentType.ActionRow, components: [] }); expect(() => createComponentBuilder({ type: ComponentType.ActionRow, components: [] })).not.toThrowError(); }); @@ -120,24 +102,23 @@ describe('Action Row Components', () => { value: 'two', }, ], - max_values: 10, - min_values: 12, + max_values: 1, + min_values: 1, }, ], }; expect(new ActionRowBuilder(rowWithButtonData).toJSON()).toEqual(rowWithButtonData); expect(new ActionRowBuilder(rowWithSelectMenuData).toJSON()).toEqual(rowWithSelectMenuData); - expect(new ActionRowBuilder().toJSON()).toEqual({ type: ComponentType.ActionRow, components: [] }); expect(() => createComponentBuilder({ type: ComponentType.ActionRow, components: [] })).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 button = new CustomIdButtonBuilder().setLabel('test').setStyle(ButtonStyle.Primary).setCustomId('123'); const selectMenu = new StringSelectMenuBuilder() .setCustomId('1234') - .setMaxValues(10) - .setMinValues(12) + .setMaxValues(2) + .setMinValues(2) .setOptions( new StringSelectMenuOptionBuilder().setLabel('one').setValue('one'), new StringSelectMenuOptionBuilder().setLabel('two').setValue('two'), @@ -147,10 +128,39 @@ describe('Action Row Components', () => { new StringSelectMenuOptionBuilder().setLabel('two').setValue('two'), ]); - expect(new ActionRowBuilder().addComponents(button).toJSON()).toEqual(rowWithButtonData); - expect(new ActionRowBuilder().addComponents(selectMenu).toJSON()).toEqual(rowWithSelectMenuData); - expect(new ActionRowBuilder().addComponents([button]).toJSON()).toEqual(rowWithButtonData); - expect(new ActionRowBuilder().addComponents([selectMenu]).toJSON()).toEqual(rowWithSelectMenuData); + expect(new ActionRowBuilder().addCustomIdButtonComponents(button).toJSON()).toEqual(rowWithButtonData); + expect(new ActionRowBuilder().addStringSelectMenuComponent(selectMenu).toJSON()).toEqual(rowWithSelectMenuData); + expect(new ActionRowBuilder().addCustomIdButtonComponents([button]).toJSON()).toEqual(rowWithButtonData); + }); + + test('GIVEN 2 select menus THEN it throws', () => { + const selectMenu = new StringSelectMenuBuilder() + .setCustomId('1234') + .setOptions( + new StringSelectMenuOptionBuilder().setLabel('one').setValue('one'), + new StringSelectMenuOptionBuilder().setLabel('two').setValue('two'), + ); + + expect(() => + new ActionRowBuilder() + .addStringSelectMenuComponent(selectMenu) + .addStringSelectMenuComponent(selectMenu) + .toJSON(), + ).toThrowError(); + }); + + test('GIVEN a button and a select menu THEN it throws', () => { + const button = new CustomIdButtonBuilder().setLabel('test').setStyle(ButtonStyle.Primary).setCustomId('123'); + const selectMenu = new StringSelectMenuBuilder() + .setCustomId('1234') + .setOptions( + new StringSelectMenuOptionBuilder().setLabel('one').setValue('one'), + new StringSelectMenuOptionBuilder().setLabel('two').setValue('two'), + ); + + expect(() => + new ActionRowBuilder().addStringSelectMenuComponent(selectMenu).addCustomIdButtonComponents(button).toJSON(), + ).toThrowError(); }); }); }); diff --git a/packages/builders/__tests__/components/button.test.ts b/packages/builders/__tests__/components/button.test.ts index 0eb5134d4312..994446ad3bcc 100644 --- a/packages/builders/__tests__/components/button.test.ts +++ b/packages/builders/__tests__/components/button.test.ts @@ -5,44 +5,23 @@ import { type APIButtonComponentWithURL, } from 'discord-api-types/v10'; import { describe, test, expect } from 'vitest'; -import { buttonLabelValidator, buttonStyleValidator } from '../../src/components/Assertions.js'; import { ButtonBuilder } from '../../src/components/button/Button.js'; - -const buttonComponent = () => new ButtonBuilder(); +import { CustomIdButtonBuilder, SKUIdButtonBuilder, URLButtonBuilder } from '../../src/index.js'; const longStr = 'looooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong'; describe('Button Components', () => { describe('Assertion Tests', () => { - test('GIVEN valid label THEN validator does not throw', () => { - expect(() => buttonLabelValidator.parse('foobar')).not.toThrowError(); - }); - - test('GIVEN invalid label THEN validator does throw', () => { - expect(() => buttonLabelValidator.parse(null)).toThrowError(); - expect(() => buttonLabelValidator.parse('')).toThrowError(); - - expect(() => buttonLabelValidator.parse(longStr)).toThrowError(); - }); - - test('GIVEN valid style THEN validator does not throw', () => { - expect(() => buttonStyleValidator.parse(3)).not.toThrowError(); - expect(() => buttonStyleValidator.parse(ButtonStyle.Secondary)).not.toThrowError(); - }); - - test('GIVEN invalid style THEN validator does throw', () => { - expect(() => buttonStyleValidator.parse(7)).toThrowError(); - }); - test('GIVEN valid fields THEN builder does not throw', () => { expect(() => - buttonComponent().setCustomId('custom').setStyle(ButtonStyle.Primary).setLabel('test'), + new CustomIdButtonBuilder().setCustomId('custom').setStyle(ButtonStyle.Primary).setLabel('test'), ).not.toThrowError(); expect(() => { - const button = buttonComponent() + const button = new CustomIdButtonBuilder() .setCustomId('custom') + .setLabel('test') .setStyle(ButtonStyle.Primary) .setDisabled(true) .setEmoji({ name: 'test' }); @@ -51,111 +30,41 @@ describe('Button Components', () => { }).not.toThrowError(); expect(() => { - const button = buttonComponent().setSKUId('123456789012345678').setStyle(ButtonStyle.Premium); + const button = new SKUIdButtonBuilder().setSKUId('123456789012345678'); button.toJSON(); }).not.toThrowError(); - expect(() => buttonComponent().setURL('https://google.com')).not.toThrowError(); + expect(() => new URLButtonBuilder().setURL('https://google.com')).not.toThrowError(); }); test('GIVEN invalid fields THEN build does throw', () => { expect(() => { - buttonComponent().setCustomId(longStr); - }).toThrowError(); - - expect(() => { - const button = buttonComponent() - .setCustomId('custom') - .setStyle(ButtonStyle.Primary) - .setDisabled(true) - .setLabel('test') - .setURL('https://google.com') - .setEmoji({ name: 'test' }); - - button.toJSON(); + new CustomIdButtonBuilder().setCustomId(longStr).toJSON(); }).toThrowError(); expect(() => { // @ts-expect-error: Invalid emoji - const button = buttonComponent().setEmoji('test'); + const button = new CustomIdButtonBuilder().setEmoji('test'); button.toJSON(); }).toThrowError(); expect(() => { - const button = buttonComponent().setStyle(ButtonStyle.Primary); + const button = new CustomIdButtonBuilder().setStyle(ButtonStyle.Primary); button.toJSON(); }).toThrowError(); expect(() => { - const button = buttonComponent().setStyle(ButtonStyle.Primary).setCustomId('test'); - button.toJSON(); - }).toThrowError(); - - expect(() => { - const button = buttonComponent().setStyle(ButtonStyle.Link); - button.toJSON(); - }).toThrowError(); - - expect(() => { - const button = buttonComponent().setStyle(ButtonStyle.Primary).setLabel('test').setURL('https://google.com'); - button.toJSON(); - }).toThrowError(); - - expect(() => { - const button = buttonComponent().setStyle(ButtonStyle.Link).setLabel('test'); - button.toJSON(); - }).toThrowError(); - - expect(() => { - const button = buttonComponent().setStyle(ButtonStyle.Primary).setSKUId('123456789012345678'); - button.toJSON(); - }).toThrowError(); - - expect(() => { - const button = buttonComponent() - .setStyle(ButtonStyle.Secondary) - .setLabel('button') - .setSKUId('123456789012345678'); - - button.toJSON(); - }).toThrowError(); - - expect(() => { - const button = buttonComponent() - .setStyle(ButtonStyle.Success) - .setEmoji({ name: '😇' }) - .setSKUId('123456789012345678'); - - button.toJSON(); - }).toThrowError(); - - expect(() => { - const button = buttonComponent() - .setStyle(ButtonStyle.Danger) - .setCustomId('test') - .setSKUId('123456789012345678'); - - button.toJSON(); - }).toThrowError(); - - expect(() => { - const button = buttonComponent() - .setStyle(ButtonStyle.Link) - .setURL('https://google.com') - .setSKUId('123456789012345678'); - + const button = new CustomIdButtonBuilder().setStyle(ButtonStyle.Primary).setCustomId('test'); button.toJSON(); }).toThrowError(); // @ts-expect-error: Invalid style - expect(() => buttonComponent().setStyle(24)).toThrowError(); - expect(() => buttonComponent().setLabel(longStr)).toThrowError(); + expect(() => new CustomIdButtonBuilder().setCustomId('hi').setStyle(24).toJSON()).toThrowError(); + expect(() => new CustomIdButtonBuilder().setCustomId('hi').setLabel(longStr).toJSON()).toThrowError(); // @ts-expect-error: Invalid parameter for disabled - expect(() => buttonComponent().setDisabled(0)).toThrowError(); + expect(() => new CustomIdButtonBuilder().setCustomId('hi').setDisabled(0).toJSON()).toThrowError(); // @ts-expect-error: Invalid emoji - expect(() => buttonComponent().setEmoji('foo')).toThrowError(); - - expect(() => buttonComponent().setURL('foobar')).toThrowError(); + expect(() => new CustomIdButtonBuilder().setCustomId('hi').setEmoji('foo').toJSON()).toThrowError(); }); test('GiVEN valid input THEN valid JSON outputs are given', () => { @@ -167,10 +76,10 @@ describe('Button Components', () => { disabled: true, }; - expect(new ButtonBuilder(interactionData).toJSON()).toEqual(interactionData); + expect(new CustomIdButtonBuilder(interactionData).toJSON()).toEqual(interactionData); expect( - buttonComponent() + new CustomIdButtonBuilder() .setCustomId(interactionData.custom_id) .setLabel(interactionData.label!) .setStyle(interactionData.style) @@ -186,9 +95,7 @@ describe('Button Components', () => { url: 'https://google.com', }; - expect(new ButtonBuilder(linkData).toJSON()).toEqual(linkData); - - expect(buttonComponent().setLabel(linkData.label!).setDisabled(true).setURL(linkData.url)); + expect(new URLButtonBuilder(linkData).toJSON()).toEqual(linkData); }); }); }); diff --git a/packages/builders/__tests__/components/components.test.ts b/packages/builders/__tests__/components/components.test.ts index fa0bd4607f65..0612d5a20ed2 100644 --- a/packages/builders/__tests__/components/components.test.ts +++ b/packages/builders/__tests__/components/components.test.ts @@ -11,14 +11,14 @@ import { import { describe, test, expect } from 'vitest'; import { ActionRowBuilder, - ButtonBuilder, createComponentBuilder, + CustomIdButtonBuilder, StringSelectMenuBuilder, TextInputBuilder, } from '../../src/index.js'; describe('createComponentBuilder', () => { - test.each([ButtonBuilder, StringSelectMenuBuilder, TextInputBuilder])( + test.each([StringSelectMenuBuilder, TextInputBuilder])( 'passing an instance of %j should return itself', (Builder) => { const builder = new Builder(); @@ -42,7 +42,7 @@ describe('createComponentBuilder', () => { type: ComponentType.Button, }; - expect(createComponentBuilder(button)).toBeInstanceOf(ButtonBuilder); + expect(createComponentBuilder(button)).toBeInstanceOf(CustomIdButtonBuilder); }); test('GIVEN a select menu component THEN returns a StringSelectMenuBuilder', () => { diff --git a/packages/builders/__tests__/components/selectMenu.test.ts b/packages/builders/__tests__/components/selectMenu.test.ts index 6e0c887dc274..455813c21b5e 100644 --- a/packages/builders/__tests__/components/selectMenu.test.ts +++ b/packages/builders/__tests__/components/selectMenu.test.ts @@ -3,6 +3,7 @@ import { describe, test, expect } from 'vitest'; import { StringSelectMenuBuilder, StringSelectMenuOptionBuilder } from '../../src/index.js'; const selectMenu = () => new StringSelectMenuBuilder(); +const selectMenuWithId = () => new StringSelectMenuBuilder({ custom_id: 'hi' }); const selectMenuOption = () => new StringSelectMenuOptionBuilder(); const longStr = 'a'.repeat(256); @@ -16,10 +17,10 @@ const selectMenuOptionData: APISelectMenuOption = { }; const selectMenuDataWithoutOptions = { - type: ComponentType.SelectMenu, + type: ComponentType.StringSelect, custom_id: 'test', - max_values: 10, - min_values: 3, + max_values: 1, + min_values: 1, disabled: true, placeholder: 'test', } as const; @@ -109,49 +110,87 @@ describe('Select Menu Components', () => { }); test('GIVEN invalid inputs THEN Select Menu does throw', () => { - expect(() => selectMenu().setCustomId(longStr)).toThrowError(); - expect(() => selectMenu().setMaxValues(30)).toThrowError(); - expect(() => selectMenu().setMinValues(-20)).toThrowError(); + expect(() => selectMenu().setCustomId(longStr).toJSON()).toThrowError(); + expect(() => selectMenuWithId().setMaxValues(30).toJSON()).toThrowError(); + expect(() => selectMenuWithId().setMinValues(-20).toJSON()).toThrowError(); // @ts-expect-error: Invalid disabled value - expect(() => selectMenu().setDisabled(0)).toThrowError(); - expect(() => selectMenu().setPlaceholder(longStr)).toThrowError(); + expect(() => selectMenuWithId().setDisabled(0).toJSON()).toThrowError(); + expect(() => selectMenuWithId().setPlaceholder(longStr).toJSON()).toThrowError(); // @ts-expect-error: Invalid option - expect(() => selectMenu().addOptions({ label: 'test' })).toThrowError(); - expect(() => selectMenu().addOptions({ label: longStr, value: 'test' })).toThrowError(); - expect(() => selectMenu().addOptions({ value: longStr, label: 'test' })).toThrowError(); - expect(() => selectMenu().addOptions({ label: 'test', value: 'test', description: longStr })).toThrowError(); - // @ts-expect-error: Invalid option - expect(() => selectMenu().addOptions({ label: 'test', value: 'test', default: 100 })).toThrowError(); - // @ts-expect-error: Invalid option - expect(() => selectMenu().addOptions({ value: 'test' })).toThrowError(); - // @ts-expect-error: Invalid option - expect(() => selectMenu().addOptions({ default: true })).toThrowError(); - // @ts-expect-error: Invalid option - expect(() => selectMenu().addOptions([{ label: 'test' }])).toThrowError(); - expect(() => selectMenu().addOptions([{ label: longStr, value: 'test' }])).toThrowError(); - expect(() => selectMenu().addOptions([{ value: longStr, label: 'test' }])).toThrowError(); - expect(() => selectMenu().addOptions([{ label: 'test', value: 'test', description: longStr }])).toThrowError(); - // @ts-expect-error: Invalid option - expect(() => selectMenu().addOptions([{ label: 'test', value: 'test', default: 100 }])).toThrowError(); + expect(() => selectMenuWithId().addOptions({ label: 'test' }).toJSON()).toThrowError(); + expect(() => selectMenuWithId().addOptions({ label: longStr, value: 'test' }).toJSON()).toThrowError(); + expect(() => selectMenuWithId().addOptions({ value: longStr, label: 'test' }).toJSON()).toThrowError(); + expect(() => + selectMenuWithId().addOptions({ label: 'test', value: 'test', description: longStr }).toJSON(), + ).toThrowError(); + expect(() => + // @ts-expect-error: Invalid option + selectMenuWithId().addOptions({ label: 'test', value: 'test', default: 100 }).toJSON(), + ).toThrowError(); // @ts-expect-error: Invalid option - expect(() => selectMenu().addOptions([{ value: 'test' }])).toThrowError(); + expect(() => selectMenuWithId().addOptions({ value: 'test' }).toJSON()).toThrowError(); // @ts-expect-error: Invalid option - expect(() => selectMenu().addOptions([{ default: true }])).toThrowError(); + expect(() => selectMenuWithId().addOptions({ default: true }).toJSON()).toThrowError(); + expect(() => + selectMenuWithId() + // @ts-expect-error: Invalid option + .addOptions([{ label: 'test' }]) + .toJSON(), + ).toThrowError(); + expect(() => + selectMenuWithId() + .addOptions([{ label: longStr, value: 'test' }]) + .toJSON(), + ).toThrowError(); + expect(() => + selectMenuWithId() + .addOptions([{ value: longStr, label: 'test' }]) + .toJSON(), + ).toThrowError(); + expect(() => + selectMenuWithId() + .addOptions([{ label: 'test', value: 'test', description: longStr }]) + .toJSON(), + ).toThrowError(); + expect(() => + selectMenuWithId() + // @ts-expect-error: Invalid option + .addOptions([{ label: 'test', value: 'test', default: 100 }]) + .toJSON(), + ).toThrowError(); + expect(() => + selectMenuWithId() + // @ts-expect-error: Invalid option + .addOptions([{ value: 'test' }]) + .toJSON(), + ).toThrowError(); + expect(() => + selectMenuWithId() + // @ts-expect-error: Invalid option + .addOptions([{ default: true }]) + .toJSON(), + ).toThrowError(); const tooManyOptions = Array.from({ length: 26 }).fill({ label: 'test', value: 'test' }); - expect(() => selectMenu().setOptions(...tooManyOptions)).toThrowError(); - expect(() => selectMenu().setOptions(tooManyOptions)).toThrowError(); + expect(() => + selectMenu() + .setOptions(...tooManyOptions) + .toJSON(), + ).toThrowError(); + expect(() => selectMenu().setOptions(tooManyOptions).toJSON()).toThrowError(); expect(() => selectMenu() .addOptions({ label: 'test', value: 'test' }) - .addOptions(...tooManyOptions), + .addOptions(...tooManyOptions) + .toJSON(), ).toThrowError(); expect(() => selectMenu() .addOptions([{ label: 'test', value: 'test' }]) - .addOptions(tooManyOptions), + .addOptions(tooManyOptions) + .toJSON(), ).toThrowError(); expect(() => { @@ -162,7 +201,8 @@ describe('Select Menu Components', () => { .setDefault(-1) // @ts-expect-error: Invalid emoji .setEmoji({ name: 1 }) - .setDescription(longStr); + .setDescription(longStr) + .toJSON(); }).toThrowError(); }); @@ -212,17 +252,16 @@ describe('Select Menu Components', () => { ).toStrictEqual([selectMenuOptionData]); expect(() => - makeStringSelectMenuWithOptions().spliceOptions( - 0, - 0, - ...Array.from({ length: 26 }, () => selectMenuOptionData), - ), + makeStringSelectMenuWithOptions() + .spliceOptions(0, 0, ...Array.from({ length: 26 }, () => selectMenuOptionData)) + .toJSON(), ).toThrowError(); expect(() => makeStringSelectMenuWithOptions() .setOptions(Array.from({ length: 25 }, () => selectMenuOptionData)) - .spliceOptions(-1, 2, selectMenuOptionData, selectMenuOptionData), + .spliceOptions(-1, 2, selectMenuOptionData, selectMenuOptionData) + .toJSON(), ).toThrowError(); }); }); diff --git a/packages/builders/__tests__/components/textInput.test.ts b/packages/builders/__tests__/components/textInput.test.ts index ab09ffe009b4..162bd7249f08 100644 --- a/packages/builders/__tests__/components/textInput.test.ts +++ b/packages/builders/__tests__/components/textInput.test.ts @@ -1,13 +1,5 @@ import { ComponentType, TextInputStyle, type APITextInputComponent } from 'discord-api-types/v10'; import { describe, test, expect } from 'vitest'; -import { - labelValidator, - maxLengthValidator, - minLengthValidator, - placeholderValidator, - valueValidator, - textInputStyleValidator, -} from '../../src/components/textInput/Assertions.js'; import { TextInputBuilder } from '../../src/components/textInput/TextInput.js'; const superLongStr = 'a'.repeat(5_000); @@ -16,56 +8,6 @@ const textInputComponent = () => new TextInputBuilder(); describe('Text Input Components', () => { describe('Assertion Tests', () => { - test('GIVEN valid label THEN validator does not throw', () => { - expect(() => labelValidator.parse('foobar')).not.toThrowError(); - }); - - test('GIVEN invalid label THEN validator does throw', () => { - expect(() => labelValidator.parse(24)).toThrowError(); - expect(() => labelValidator.parse(undefined)).toThrowError(); - }); - - test('GIVEN valid style THEN validator does not throw', () => { - expect(() => textInputStyleValidator.parse(TextInputStyle.Paragraph)).not.toThrowError(); - expect(() => textInputStyleValidator.parse(TextInputStyle.Short)).not.toThrowError(); - }); - - test('GIVEN invalid style THEN validator does throw', () => { - expect(() => textInputStyleValidator.parse(24)).toThrowError(); - }); - - test('GIVEN valid min length THEN validator does not throw', () => { - expect(() => minLengthValidator.parse(10)).not.toThrowError(); - }); - - test('GIVEN invalid min length THEN validator does throw', () => { - expect(() => minLengthValidator.parse(-1)).toThrowError(); - }); - - test('GIVEN valid max length THEN validator does not throw', () => { - expect(() => maxLengthValidator.parse(10)).not.toThrowError(); - }); - - test('GIVEN invalid min length THEN validator does throw 2', () => { - expect(() => maxLengthValidator.parse(4_001)).toThrowError(); - }); - - test('GIVEN valid value THEN validator does not throw', () => { - expect(() => valueValidator.parse('foobar')).not.toThrowError(); - }); - - test('GIVEN invalid value THEN validator does throw', () => { - expect(() => valueValidator.parse(superLongStr)).toThrowError(); - }); - - test('GIVEN valid placeholder THEN validator does not throw', () => { - expect(() => placeholderValidator.parse('foobar')).not.toThrowError(); - }); - - test('GIVEN invalid value THEN validator does throw 2', () => { - expect(() => placeholderValidator.parse(superLongStr)).toThrowError(); - }); - test('GIVEN valid fields THEN builder does not throw', () => { expect(() => { textInputComponent().setCustomId('foobar').setLabel('test').setStyle(TextInputStyle.Paragraph).toJSON(); @@ -84,9 +26,7 @@ describe('Text Input Components', () => { }).not.toThrowError(); expect(() => { - // Issue #8107 - // @ts-expect-error: Shapeshift maps the enum key to the value when parsing - textInputComponent().setCustomId('Custom').setLabel('Guess').setStyle('Short').toJSON(); + textInputComponent().setCustomId('Custom').setLabel('Guess').setStyle(TextInputStyle.Short).toJSON(); }).not.toThrowError(); }); }); diff --git a/packages/builders/__tests__/interactions/ContextMenuCommands.test.ts b/packages/builders/__tests__/interactions/ContextMenuCommands.test.ts index ecec8c1b0d69..c59ef56e0abd 100644 --- a/packages/builders/__tests__/interactions/ContextMenuCommands.test.ts +++ b/packages/builders/__tests__/interactions/ContextMenuCommands.test.ts @@ -1,51 +1,44 @@ -import { ApplicationIntegrationType, InteractionContextType, PermissionFlagsBits } from 'discord-api-types/v10'; +import { + ApplicationCommandType, + ApplicationIntegrationType, + InteractionContextType, + PermissionFlagsBits, +} from 'discord-api-types/v10'; import { describe, test, expect } from 'vitest'; -import { ContextMenuCommandAssertions, ContextMenuCommandBuilder } from '../../src/index.js'; +import { ContextMenuCommandAssertions, MessageContextCommandBuilder } from '../../src/index.js'; -const getBuilder = () => new ContextMenuCommandBuilder(); +const getBuilder = () => new MessageContextCommandBuilder(); describe('Context Menu Commands', () => { describe('Assertions tests', () => { test('GIVEN valid name THEN does not throw error', () => { - expect(() => ContextMenuCommandAssertions.validateName('ping')).not.toThrowError(); + expect(() => ContextMenuCommandAssertions.namePredicate.parse('ping')).not.toThrowError(); }); test('GIVEN invalid name THEN throw error', () => { - expect(() => ContextMenuCommandAssertions.validateName(null)).toThrowError(); + expect(() => ContextMenuCommandAssertions.namePredicate.parse(null)).toThrowError(); // Too short of a name - expect(() => ContextMenuCommandAssertions.validateName('')).toThrowError(); + expect(() => ContextMenuCommandAssertions.namePredicate.parse('')).toThrowError(); // Invalid characters used - expect(() => ContextMenuCommandAssertions.validateName('ABC123$%^&')).toThrowError(); + expect(() => ContextMenuCommandAssertions.namePredicate.parse('ABC123$%^&')).toThrowError(); // Too long of a name expect(() => - ContextMenuCommandAssertions.validateName('qwertyuiopasdfghjklzxcvbnmqwertyuiopasdfghjklzxcvbnm'), + ContextMenuCommandAssertions.namePredicate.parse('qwertyuiopasdfghjklzxcvbnmqwertyuiopasdfghjklzxcvbnm'), ).toThrowError(); }); test('GIVEN valid type THEN does not throw error', () => { - expect(() => ContextMenuCommandAssertions.validateType(3)).not.toThrowError(); + expect(() => ContextMenuCommandAssertions.typePredicate.parse(3)).not.toThrowError(); }); test('GIVEN invalid type THEN throw error', () => { - expect(() => ContextMenuCommandAssertions.validateType(null)).toThrowError(); + expect(() => ContextMenuCommandAssertions.typePredicate.parse(null)).toThrowError(); // Out of range - expect(() => ContextMenuCommandAssertions.validateType(1)).toThrowError(); - }); - - test('GIVEN valid required parameters THEN does not throw error', () => { - expect(() => ContextMenuCommandAssertions.validateRequiredParameters('owo', 2)).not.toThrowError(); - }); - - test('GIVEN valid default_permission THEN does not throw error', () => { - expect(() => ContextMenuCommandAssertions.validateDefaultPermission(true)).not.toThrowError(); - }); - - test('GIVEN invalid default_permission THEN throw error', () => { - expect(() => ContextMenuCommandAssertions.validateDefaultPermission(null)).toThrowError(); + expect(() => ContextMenuCommandAssertions.typePredicate.parse(1)).toThrowError(); }); }); @@ -55,20 +48,16 @@ describe('Context Menu Commands', () => { expect(() => getBuilder().toJSON()).toThrowError(); }); - test('GIVEN valid builder THEN does not throw error', () => { - expect(() => getBuilder().setName('example').setType(3).toJSON()).not.toThrowError(); - }); - test('GIVEN invalid name THEN throw error', () => { - expect(() => getBuilder().setName('$$$')).toThrowError(); + expect(() => getBuilder().setName('$$$').toJSON()).toThrowError(); - expect(() => getBuilder().setName(' ')).toThrowError(); + expect(() => getBuilder().setName(' ').toJSON()).toThrowError(); }); test('GIVEN valid names THEN does not throw error', () => { - expect(() => getBuilder().setName('hi_there')).not.toThrowError(); + expect(() => getBuilder().setName('hi_there').toJSON()).not.toThrowError(); - expect(() => getBuilder().setName('A COMMAND')).not.toThrowError(); + expect(() => getBuilder().setName('A COMMAND').toJSON()).not.toThrowError(); // Translation: a_command expect(() => getBuilder().setName('o_comandă')).not.toThrowError(); @@ -76,20 +65,6 @@ describe('Context Menu Commands', () => { // Translation: thx (according to GTranslate) expect(() => getBuilder().setName('どうも')).not.toThrowError(); }); - - test('GIVEN valid types THEN does not throw error', () => { - expect(() => getBuilder().setType(2)).not.toThrowError(); - - expect(() => getBuilder().setType(3)).not.toThrowError(); - }); - - test('GIVEN valid builder with defaultPermission false THEN does not throw error', () => { - expect(() => getBuilder().setName('foo').setDefaultPermission(false)).not.toThrowError(); - }); - - test('GIVEN valid builder with dmPermission false THEN does not throw error', () => { - expect(() => getBuilder().setName('foo').setDMPermission(false)).not.toThrowError(); - }); }); describe('Context menu command localizations', () => { @@ -106,19 +81,22 @@ describe('Context Menu Commands', () => { test('GIVEN invalid name localizations THEN does throw error', () => { // @ts-expect-error: Invalid localization - expect(() => getBuilder().setNameLocalization('en-U', 'foobar')).toThrowError(); + expect(() => getBuilder().setNameLocalization('en-U', 'foobar').toJSON()).toThrowError(); // @ts-expect-error: Invalid localization - expect(() => getBuilder().setNameLocalizations({ 'en-U': 'foobar' })).toThrowError(); + expect(() => getBuilder().setNameLocalizations({ 'en-U': 'foobar' }).toJSON()).toThrowError(); }); test('GIVEN valid name localizations THEN valid data is stored', () => { - expect(getBuilder().setNameLocalization('en-US', 'foobar').name_localizations).toEqual(expectedSingleLocale); - expect(getBuilder().setNameLocalizations({ 'en-US': 'foobar', bg: 'test' }).name_localizations).toEqual( - expectedMultipleLocales, + expect(getBuilder().setName('hi').setNameLocalization('en-US', 'foobar').toJSON().name_localizations).toEqual( + expectedSingleLocale, ); - expect(getBuilder().setNameLocalizations(null).name_localizations).toBeNull(); - expect(getBuilder().setNameLocalization('en-US', null).name_localizations).toEqual({ - 'en-US': null, + expect( + getBuilder().setName('hi').setNameLocalizations({ 'en-US': 'foobar', bg: 'test' }).toJSON() + .name_localizations, + ).toEqual(expectedMultipleLocales); + expect(getBuilder().setName('hi').clearNameLocalizations().toJSON().name_localizations).toBeUndefined(); + expect(getBuilder().setName('hi').clearNameLocalization('en-US').toJSON().name_localizations).toEqual({ + 'en-US': undefined, }); }); }); @@ -134,14 +112,10 @@ describe('Context Menu Commands', () => { ).not.toThrowError(); }); - test('GIVEN null permissions THEN does not throw error', () => { - expect(() => getBuilder().setDefaultMemberPermissions(null)).not.toThrowError(); - }); - test('GIVEN invalid inputs THEN does throw error', () => { - expect(() => getBuilder().setDefaultMemberPermissions('1.1')).toThrowError(); + expect(() => getBuilder().setName('hi').setDefaultMemberPermissions('1.1').toJSON()).toThrowError(); - expect(() => getBuilder().setDefaultMemberPermissions(1.1)).toThrowError(); + expect(() => getBuilder().setName('hi').setDefaultMemberPermissions(1.1).toJSON()).toThrowError(); }); }); @@ -158,10 +132,10 @@ describe('Context Menu Commands', () => { test('GIVEN a builder with invalid contexts THEN does throw an error', () => { // @ts-expect-error: Invalid contexts - expect(() => getBuilder().setContexts(999)).toThrowError(); + expect(() => getBuilder().setName('hi').setContexts(999).toJSON()).toThrowError(); // @ts-expect-error: Invalid contexts - expect(() => getBuilder().setContexts([999, 998])).toThrowError(); + expect(() => getBuilder().setName('hi').setContexts([999, 998]).toJSON()).toThrowError(); }); }); @@ -184,10 +158,10 @@ describe('Context Menu Commands', () => { test('GIVEN a builder with invalid integration types THEN does throw an error', () => { // @ts-expect-error: Invalid integration types - expect(() => getBuilder().setIntegrationTypes(999)).toThrowError(); + expect(() => getBuilder().setName('hi').setIntegrationTypes(999).toJSON()).toThrowError(); // @ts-expect-error: Invalid integration types - expect(() => getBuilder().setIntegrationTypes([999, 998])).toThrowError(); + expect(() => getBuilder().setName('hi').setIntegrationTypes([999, 998]).toJSON()).toThrowError(); }); }); }); diff --git a/packages/builders/__tests__/interactions/SlashCommands/ChatInputCommands.test.ts b/packages/builders/__tests__/interactions/SlashCommands/ChatInputCommands.test.ts new file mode 100644 index 000000000000..b4f1440d79f8 --- /dev/null +++ b/packages/builders/__tests__/interactions/SlashCommands/ChatInputCommands.test.ts @@ -0,0 +1,563 @@ +import { + ApplicationCommandType, + ApplicationIntegrationType, + ChannelType, + InteractionContextType, + PermissionFlagsBits, +} from 'discord-api-types/v10'; +import { describe, test, expect } from 'vitest'; +import { + ChatInputCommandAssertions, + ChatInputCommandBooleanOption, + ChatInputCommandBuilder, + ChatInputCommandChannelOption, + ChatInputCommandIntegerOption, + ChatInputCommandMentionableOption, + ChatInputCommandNumberOption, + ChatInputCommandRoleOption, + ChatInputCommandAttachmentOption, + ChatInputCommandStringOption, + ChatInputCommandSubcommandBuilder, + ChatInputCommandSubcommandGroupBuilder, + ChatInputCommandUserOption, +} from '../../../src/index.js'; + +const getBuilder = () => new ChatInputCommandBuilder(); +const getNamedBuilder = () => getBuilder().setName('example').setDescription('Example command'); +const getStringOption = () => new ChatInputCommandStringOption().setName('owo').setDescription('Testing 123'); +const getIntegerOption = () => new ChatInputCommandIntegerOption().setName('owo').setDescription('Testing 123'); +const getNumberOption = () => new ChatInputCommandNumberOption().setName('owo').setDescription('Testing 123'); +const getBooleanOption = () => new ChatInputCommandBooleanOption().setName('owo').setDescription('Testing 123'); +const getUserOption = () => new ChatInputCommandUserOption().setName('owo').setDescription('Testing 123'); +const getChannelOption = () => new ChatInputCommandChannelOption().setName('owo').setDescription('Testing 123'); +const getRoleOption = () => new ChatInputCommandRoleOption().setName('owo').setDescription('Testing 123'); +const getAttachmentOption = () => new ChatInputCommandAttachmentOption().setName('owo').setDescription('Testing 123'); +const getMentionableOption = () => new ChatInputCommandMentionableOption().setName('owo').setDescription('Testing 123'); +const getSubcommandGroup = () => + new ChatInputCommandSubcommandGroupBuilder().setName('owo').setDescription('Testing 123'); +const getSubcommand = () => new ChatInputCommandSubcommandBuilder().setName('owo').setDescription('Testing 123'); + +class Collection { + public readonly [Symbol.toStringTag] = 'Map'; +} + +describe('ChatInput Commands', () => { + describe('Assertions tests', () => { + test('GIVEN valid name THEN does not throw error', () => { + expect(() => ChatInputCommandAssertions.namePredicate.parse('ping')).not.toThrowError(); + expect(() => ChatInputCommandAssertions.namePredicate.parse('hello-world_command')).not.toThrowError(); + expect(() => ChatInputCommandAssertions.namePredicate.parse('aˇ㐆1٢〣²अก')).not.toThrowError(); + }); + + test('GIVEN invalid name THEN throw error', () => { + expect(() => ChatInputCommandAssertions.namePredicate.parse(null)).toThrowError(); + + // Too short of a name + expect(() => ChatInputCommandAssertions.namePredicate.parse('')).toThrowError(); + + // Invalid characters used + expect(() => ChatInputCommandAssertions.namePredicate.parse('ABC')).toThrowError(); + expect(() => ChatInputCommandAssertions.namePredicate.parse('ABC123$%^&')).toThrowError(); + expect(() => ChatInputCommandAssertions.namePredicate.parse('help ping')).toThrowError(); + expect(() => ChatInputCommandAssertions.namePredicate.parse('🦦')).toThrowError(); + + // Too long of a name + expect(() => + ChatInputCommandAssertions.numberOptionPredicate.parse('qwertyuiopasdfghjklzxcvbnmqwertyuiopasdfghjklzxcvbnm'), + ).toThrowError(); + }); + + test('GIVEN valid description THEN does not throw error', () => { + expect(() => + ChatInputCommandAssertions.descriptionPredicate.parse('This is an OwO moment fur sure!~'), + ).not.toThrowError(); + }); + + test('GIVEN invalid description THEN throw error', () => { + expect(() => ChatInputCommandAssertions.descriptionPredicate.parse(null)).toThrowError(); + + // Too short of a description + expect(() => ChatInputCommandAssertions.descriptionPredicate.parse('')).toThrowError(); + + // Too long of a description + expect(() => + ChatInputCommandAssertions.descriptionPredicate.parse( + 'Lorem ipsum dolor sit amet, consectetur adipisicing elit. Magnam autem libero expedita vitae accusamus nostrum ipsam tempore repudiandae deserunt ipsum facilis, velit fugiat facere accusantium, explicabo corporis aliquam non quos.', + ), + ).toThrowError(); + }); + }); + + describe('ChatInputCommandBuilder', () => { + describe('Builder with no options', () => { + test('GIVEN empty builder THEN throw error when calling toJSON', () => { + expect(() => getBuilder().toJSON()).toThrowError(); + }); + + test('GIVEN valid builder THEN does not throw error', () => { + expect(() => getBuilder().setName('example').setDescription('Example command').toJSON()).not.toThrowError(); + }); + }); + + describe('Builder with simple options', () => { + test('GIVEN valid builder THEN returns type included', () => { + expect(getNamedBuilder().toJSON()).includes({ type: ApplicationCommandType.ChatInput }); + }); + + test('GIVEN valid builder with options THEN does not throw error', () => { + expect(() => + getBuilder() + .setName('example') + .setDescription('Example command') + .addBooleanOptions((boolean) => + boolean.setName('iscool').setDescription('Are we cool or what?').setRequired(true), + ) + .addChannelOptions((channel) => channel.setName('iscool').setDescription('Are we cool or what?')) + .addMentionableOptions((mentionable) => + mentionable.setName('iscool').setDescription('Are we cool or what?'), + ) + .addRoleOptions((role) => role.setName('iscool').setDescription('Are we cool or what?')) + .addUserOptions((user) => user.setName('iscool').setDescription('Are we cool or what?')) + .addIntegerOptions((integer) => + integer + .setName('iscool') + .setDescription('Are we cool or what?') + .addChoices({ name: 'Very cool', value: 1_000 }) + .addChoices([{ name: 'Even cooler', value: 2_000 }]), + ) + .addNumberOptions((number) => + number + .setName('iscool') + .setDescription('Are we cool or what?') + .addChoices({ name: 'Very cool', value: 1.5 }) + .addChoices([{ name: 'Even cooler', value: 2.5 }]), + ) + .addStringOptions((string) => + string + .setName('iscool') + .setDescription('Are we cool or what?') + .addChoices({ name: 'Fancy Pants', value: 'fp_1' }, { name: 'Fancy Shoes', value: 'fs_1' }) + .addChoices([{ name: 'The Whole shebang', value: 'all' }]), + ) + .addIntegerOptions((integer) => + integer.setName('iscool').setDescription('Are we cool or what?').setAutocomplete(true), + ) + .addNumberOptions((number) => + number.setName('iscool').setDescription('Are we cool or what?').setAutocomplete(true), + ) + .addStringOptions((string) => + string.setName('iscool').setDescription('Are we cool or what?').setAutocomplete(true), + ) + .toJSON(), + ).not.toThrowError(); + }); + + test('GIVEN a builder with invalid autocomplete THEN does throw an error', () => { + expect(() => + // @ts-expect-error: Checking if not providing anything, or an invalid return type causes an error + getNamedBuilder().addStringOptions(getStringOption().setAutocomplete('not a boolean')).toJSON(), + ).toThrowError(); + }); + + test('GIVEN a builder with both choices and autocomplete THEN does throw an error', () => { + expect(() => + getNamedBuilder() + .addStringOptions( + getStringOption().setAutocomplete(true).addChoices({ name: 'Fancy Pants', value: 'fp_1' }), + ) + .toJSON(), + ).toThrowError(); + + expect(() => + getNamedBuilder() + .addStringOptions( + getStringOption() + .setAutocomplete(true) + .addChoices( + { name: 'Fancy Pants', value: 'fp_1' }, + { name: 'Fancy Shoes', value: 'fs_1' }, + { name: 'The Whole shebang', value: 'all' }, + ), + ) + .toJSON(), + ).toThrowError(); + + expect(() => + getNamedBuilder() + .addStringOptions( + getStringOption().addChoices({ name: 'Fancy Pants', value: 'fp_1' }).setAutocomplete(true), + ) + .toJSON(), + ).toThrowError(); + }); + + test('GIVEN a builder with valid channel options and channel_types THEN does not throw an error', () => { + expect(() => + getNamedBuilder() + .addChannelOptions( + getChannelOption().addChannelTypes(ChannelType.GuildText).addChannelTypes([ChannelType.GuildVoice]), + ) + .toJSON(), + ).not.toThrowError(); + + expect(() => { + getNamedBuilder() + .addChannelOptions(getChannelOption().addChannelTypes(ChannelType.GuildAnnouncement, ChannelType.GuildText)) + .toJSON(); + }).not.toThrowError(); + }); + + test('GIVEN a builder with valid channel options and channel_types THEN does throw an error', () => { + expect(() => + // @ts-expect-error: Invalid channel type + getNamedBuilder().addChannelOptions(getChannelOption().addChannelTypes(100)).toJSON(), + ).toThrowError(); + + expect(() => + // @ts-expect-error: Invalid channel types + getNamedBuilder().addChannelOptions(getChannelOption().addChannelTypes(100, 200)).toJSON(), + ).toThrowError(); + }); + + test('GIVEN a builder with invalid number min/max options THEN does throw an error', () => { + // @ts-expect-error: Invalid max value + expect(() => getNamedBuilder().addNumberOptions(getNumberOption().setMaxValue('test')).toJSON()).toThrowError(); + + expect(() => + // @ts-expect-error: Invalid max value + getNamedBuilder().addIntegerOptions(getIntegerOption().setMaxValue('test')).toJSON(), + ).toThrowError(); + + // @ts-expect-error: Invalid min value + expect(() => getNamedBuilder().addNumberOptions(getNumberOption().setMinValue('test')).toJSON()).toThrowError(); + + expect(() => + // @ts-expect-error: Invalid min value + getNamedBuilder().addIntegerOptions(getIntegerOption().setMinValue('test')).toJSON(), + ).toThrowError(); + + expect(() => getNamedBuilder().addIntegerOptions(getIntegerOption().setMinValue(1.5)).toJSON()).toThrowError(); + }); + + test('GIVEN a builder with valid number min/max options THEN does not throw an error', () => { + expect(() => + getNamedBuilder().addIntegerOptions(getIntegerOption().setMinValue(1)).toJSON(), + ).not.toThrowError(); + + expect(() => + getNamedBuilder().addNumberOptions(getNumberOption().setMinValue(1.5)).toJSON(), + ).not.toThrowError(); + + expect(() => + getNamedBuilder().addIntegerOptions(getIntegerOption().setMaxValue(1)).toJSON(), + ).not.toThrowError(); + + expect(() => + getNamedBuilder().addNumberOptions(getNumberOption().setMaxValue(1.5)).toJSON(), + ).not.toThrowError(); + }); + + test('GIVEN an already built builder THEN does not throw an error', () => { + expect(() => getNamedBuilder().addStringOptions(getStringOption()).toJSON()).not.toThrowError(); + + expect(() => getNamedBuilder().addIntegerOptions(getIntegerOption()).toJSON()).not.toThrowError(); + + expect(() => getNamedBuilder().addNumberOptions(getNumberOption()).toJSON()).not.toThrowError(); + + expect(() => getNamedBuilder().addBooleanOptions(getBooleanOption()).toJSON()).not.toThrowError(); + + expect(() => getNamedBuilder().addUserOptions(getUserOption()).toJSON()).not.toThrowError(); + + expect(() => getNamedBuilder().addChannelOptions(getChannelOption()).toJSON()).not.toThrowError(); + + expect(() => getNamedBuilder().addRoleOptions(getRoleOption()).toJSON()).not.toThrowError(); + + expect(() => getNamedBuilder().addAttachmentOptions(getAttachmentOption()).toJSON()).not.toThrowError(); + + expect(() => getNamedBuilder().addMentionableOptions(getMentionableOption()).toJSON()).not.toThrowError(); + }); + + test('GIVEN invalid name THEN throw error', () => { + expect(() => getBuilder().setName('TEST_COMMAND').setDescription(':3').toJSON()).toThrowError(); + expect(() => getBuilder().setName('ĂĂĂĂĂĂ').setDescription(':3').toJSON()).toThrowError(); + }); + + test('GIVEN valid names THEN does not throw error', () => { + expect(() => getBuilder().setName('hi_there').setDescription(':3')).not.toThrowError(); + expect(() => getBuilder().setName('o_comandă').setDescription(':3')).not.toThrowError(); + expect(() => getBuilder().setName('どうも').setDescription(':3')).not.toThrowError(); + }); + + test('GIVEN invalid returns for builder THEN throw error', () => { + // @ts-expect-error: Checking if not providing anything, or an invalid return type causes an error + expect(() => getNamedBuilder().addBooleanOptions(true).toJSON()).toThrowError(); + + // @ts-expect-error: Checking if not providing anything, or an invalid return type causes an error + expect(() => getNamedBuilder().addBooleanOptions(null).toJSON()).toThrowError(); + + // @ts-expect-error: Checking if not providing anything, or an invalid return type causes an error + expect(() => getNamedBuilder().addBooleanOptions(undefined).toJSON()).toThrowError(); + + expect(() => + getNamedBuilder() + // @ts-expect-error: Checking if not providing anything, or an invalid return type causes an error + .addBooleanOptions(() => ChatInputCommandStringOption) + .toJSON(), + ).toThrowError(); + expect(() => + getNamedBuilder() + // @ts-expect-error: Checking if not providing anything, or an invalid return type causes an error + .addBooleanOptions(() => new Collection()) + .toJSON(), + ).toThrowError(); + }); + + test('GIVEN an option that is autocompletable and has choices, THEN passing nothing to setChoices should not throw an error', () => { + expect(() => + getNamedBuilder().addStringOptions(getStringOption().setAutocomplete(true).setChoices()).toJSON(), + ).not.toThrowError(); + }); + + test('GIVEN an option that is autocompletable, THEN setting choices should throw an error', () => { + expect(() => + getNamedBuilder() + .addStringOptions(getStringOption().setAutocomplete(true).setChoices({ name: 'owo', value: 'uwu' })) + .toJSON(), + ).toThrowError(); + }); + + test('GIVEN an option, THEN setting choices should not throw an error', () => { + expect(() => + getNamedBuilder() + .addStringOptions(getStringOption().setChoices({ name: 'owo', value: 'uwu' })) + .toJSON(), + ).not.toThrowError(); + }); + + test('GIVEN valid builder with NSFW, THEN does not throw error', () => { + expect(() => getNamedBuilder().setName('foo').setDescription('foo').setNSFW(true).toJSON()).not.toThrowError(); + }); + }); + + describe('Builder with subcommand (group) options', () => { + test('GIVEN builder with subcommand group THEN does not throw error', () => { + expect(() => + getNamedBuilder() + .addSubcommandGroups((group) => + group.setName('group').setDescription('Group us together!').addSubcommands(getSubcommand()), + ) + .toJSON(), + ).not.toThrowError(); + }); + + test('GIVEN builder with subcommand THEN does not throw error', () => { + expect(() => + getNamedBuilder() + .addSubcommands((subcommand) => subcommand.setName('boop').setDescription('Boops a fellow nerd (you)')) + .toJSON(), + ).not.toThrowError(); + }); + + test('GIVEN builder with subcommand THEN has regular ChatInput command fields', () => { + expect(() => + getBuilder() + .setName('name') + .setDescription('description') + .addSubcommands((option) => option.setName('ye').setDescription('ye')) + .addSubcommands((option) => option.setName('no').setDescription('no')) + .setDefaultMemberPermissions(1n) + .toJSON(), + ).not.toThrowError(); + }); + + test('GIVEN builder with already built subcommand group THEN does not throw error', () => { + expect(() => + getNamedBuilder().addSubcommandGroups(getSubcommandGroup().addSubcommands(getSubcommand())).toJSON(), + ).not.toThrowError(); + }); + + test('GIVEN builder with already built subcommand THEN does not throw error', () => { + expect(() => getNamedBuilder().addSubcommands(getSubcommand()).toJSON()).not.toThrowError(); + }); + + test('GIVEN builder with already built subcommand with options THEN does not throw error', () => { + expect(() => + getNamedBuilder().addSubcommands(getSubcommand().addBooleanOptions(getBooleanOption())).toJSON(), + ).not.toThrowError(); + }); + + test('GIVEN builder with a subcommand that tries to add an invalid result THEN throw error', () => { + expect(() => + // @ts-expect-error: Checking if check works JS-side too + getNamedBuilder().addSubcommands(getSubcommand()).addIntegerOptions(getInteger()).toJSON(), + ).toThrowError(); + }); + + test('GIVEN no valid return for an addSubcommand(Group) method THEN throw error', () => { + // @ts-expect-error: Checking if not providing anything, or an invalid return type causes an error + expect(() => getNamedBuilder().addSubcommands(getSubcommandGroup()).toJSON()).toThrowError(); + }); + }); + + describe('Subcommand group builder', () => { + test('GIVEN no valid subcommand THEN throw error', () => { + expect(() => getSubcommandGroup().addSubcommands().toJSON()).toThrowError(); + + // @ts-expect-error: Checking if not providing anything, or an invalid return type causes an error + expect(() => getSubcommandGroup().addSubcommands(getSubcommandGroup()).toJSON()).toThrowError(); + }); + + test('GIVEN a valid subcommand THEN does not throw an error', () => { + expect(() => + getSubcommandGroup() + .addSubcommands((sub) => sub.setName('sub').setDescription('Testing 123')) + .toJSON(), + ).not.toThrowError(); + }); + }); + + describe('Subcommand builder', () => { + test('GIVEN a valid subcommand with options THEN does not throw error', () => { + expect(() => getSubcommand().addBooleanOptions(getBooleanOption()).toJSON()).not.toThrowError(); + }); + }); + + describe('ChatInput command localizations', () => { + const expectedSingleLocale = { 'en-US': 'foobar' }; + const expectedMultipleLocales = { + ...expectedSingleLocale, + bg: 'test', + }; + + test('GIVEN valid name localizations THEN does not throw error', () => { + expect(() => getBuilder().setNameLocalization('en-US', 'foobar')).not.toThrowError(); + expect(() => getBuilder().setNameLocalizations({ 'en-US': 'foobar' })).not.toThrowError(); + }); + + test('GIVEN invalid name localizations THEN does throw error', () => { + // @ts-expect-error: Invalid localization + expect(() => getNamedBuilder().setNameLocalization('en-U', 'foobar').toJSON()).toThrowError(); + // @ts-expect-error: Invalid localization + expect(() => getNamedBuilder().setNameLocalizations({ 'en-U': 'foobar' }).toJSON()).toThrowError(); + }); + + test('GIVEN valid name localizations THEN valid data is stored', () => { + expect(getNamedBuilder().setNameLocalization('en-US', 'foobar').toJSON().name_localizations).toEqual( + expectedSingleLocale, + ); + expect( + getNamedBuilder().setNameLocalizations({ 'en-US': 'foobar', bg: 'test' }).toJSON().name_localizations, + ).toEqual(expectedMultipleLocales); + expect(getNamedBuilder().clearNameLocalizations().toJSON().name_localizations).toBeUndefined(); + expect(getNamedBuilder().clearNameLocalization('en-US').toJSON().name_localizations).toEqual({ + 'en-US': undefined, + }); + }); + + test('GIVEN valid description localizations THEN does not throw error', () => { + expect(() => getNamedBuilder().setDescriptionLocalization('en-US', 'foobar').toJSON()).not.toThrowError(); + expect(() => getNamedBuilder().setDescriptionLocalizations({ 'en-US': 'foobar' }).toJSON()).not.toThrowError(); + }); + + test('GIVEN invalid description localizations THEN does throw error', () => { + // @ts-expect-error: Invalid localization description + expect(() => getNamedBuilder().setDescriptionLocalization('en-U', 'foobar').toJSON()).toThrowError(); + // @ts-expect-error: Invalid localization description + expect(() => getNamedBuilder().setDescriptionLocalizations({ 'en-U': 'foobar' }).toJSON()).toThrowError(); + }); + + test('GIVEN valid description localizations THEN valid data is stored', () => { + expect( + getNamedBuilder().setDescriptionLocalization('en-US', 'foobar').toJSON(false).description_localizations, + ).toEqual(expectedSingleLocale); + expect( + getNamedBuilder().setDescriptionLocalizations({ 'en-US': 'foobar', bg: 'test' }).toJSON(false) + .description_localizations, + ).toEqual(expectedMultipleLocales); + expect( + getNamedBuilder().clearDescriptionLocalizations().toJSON(false).description_localizations, + ).toBeUndefined(); + expect(getNamedBuilder().clearDescriptionLocalization('en-US').toJSON(false).description_localizations).toEqual( + { + 'en-US': undefined, + }, + ); + }); + }); + + describe('permissions', () => { + test('GIVEN valid permission string THEN does not throw error', () => { + expect(() => getNamedBuilder().setDefaultMemberPermissions('1')).not.toThrowError(); + }); + + test('GIVEN valid permission bitfield THEN does not throw error', () => { + expect(() => + getNamedBuilder().setDefaultMemberPermissions( + PermissionFlagsBits.AddReactions | PermissionFlagsBits.AttachFiles, + ), + ).not.toThrowError(); + }); + + test('GIVEN null permissions THEN does not throw error', () => { + expect(() => getNamedBuilder().clearDefaultMemberPermissions()).not.toThrowError(); + }); + + test('GIVEN invalid inputs THEN does throw error', () => { + expect(() => getNamedBuilder().setDefaultMemberPermissions('1.1').toJSON()).toThrowError(); + expect(() => getNamedBuilder().setDefaultMemberPermissions(1.1).toJSON()).toThrowError(); + }); + + test('GIVEN valid permission with options THEN does not throw error', () => { + expect(() => + getNamedBuilder().addBooleanOptions(getBooleanOption()).setDefaultMemberPermissions('1').toJSON(), + ).not.toThrowError(); + + expect(() => getNamedBuilder().addChannelOptions(getChannelOption())).not.toThrowError(); + }); + }); + + describe('contexts', () => { + test('GIVEN a builder with valid contexts THEN does not throw an error', () => { + expect(() => + getNamedBuilder().setContexts([InteractionContextType.Guild, InteractionContextType.BotDM]).toJSON(), + ).not.toThrowError(); + + expect(() => + getNamedBuilder().setContexts(InteractionContextType.Guild, InteractionContextType.BotDM).toJSON(), + ).not.toThrowError(); + }); + + test('GIVEN a builder with invalid contexts THEN does throw an error', () => { + // @ts-expect-error: Invalid contexts + expect(() => getNamedBuilder().setContexts(999).toJSON()).toThrowError(); + + // @ts-expect-error: Invalid contexts + expect(() => getNamedBuilder().setContexts([999, 998]).toJSON()).toThrowError(); + }); + }); + + describe('integration types', () => { + test('GIVEN a builder with valid integraton types THEN does not throw an error', () => { + expect(() => + getNamedBuilder() + .setIntegrationTypes([ApplicationIntegrationType.GuildInstall, ApplicationIntegrationType.UserInstall]) + .toJSON(), + ).not.toThrowError(); + + expect(() => + getNamedBuilder() + .setIntegrationTypes(ApplicationIntegrationType.GuildInstall, ApplicationIntegrationType.UserInstall) + .toJSON(), + ).not.toThrowError(); + }); + + test('GIVEN a builder with invalid integration types THEN does throw an error', () => { + // @ts-expect-error: Invalid integration types + expect(() => getNamedBuilder().setIntegrationTypes(999).toJSON()).toThrowError(); + + // @ts-expect-error: Invalid integration types + expect(() => getNamedBuilder().setIntegrationTypes([999, 998]).toJSON()).toThrowError(); + }); + }); + }); +}); diff --git a/packages/builders/__tests__/interactions/SlashCommands/Options.test.ts b/packages/builders/__tests__/interactions/SlashCommands/Options.test.ts index 8c985bb11f9e..66fe99457865 100644 --- a/packages/builders/__tests__/interactions/SlashCommands/Options.test.ts +++ b/packages/builders/__tests__/interactions/SlashCommands/Options.test.ts @@ -13,32 +13,32 @@ import { } from 'discord-api-types/v10'; import { describe, test, expect } from 'vitest'; import { - SlashCommandAttachmentOption, - SlashCommandBooleanOption, - SlashCommandChannelOption, - SlashCommandIntegerOption, - SlashCommandMentionableOption, - SlashCommandNumberOption, - SlashCommandRoleOption, - SlashCommandStringOption, - SlashCommandUserOption, + ChatInputCommandAttachmentOption, + ChatInputCommandBooleanOption, + ChatInputCommandChannelOption, + ChatInputCommandIntegerOption, + ChatInputCommandMentionableOption, + ChatInputCommandNumberOption, + ChatInputCommandRoleOption, + ChatInputCommandStringOption, + ChatInputCommandUserOption, } from '../../../src/index.js'; const getBooleanOption = () => - new SlashCommandBooleanOption().setName('owo').setDescription('Testing 123').setRequired(true); + new ChatInputCommandBooleanOption().setName('owo').setDescription('Testing 123').setRequired(true); const getChannelOption = () => - new SlashCommandChannelOption() + new ChatInputCommandChannelOption() .setName('owo') .setDescription('Testing 123') .setRequired(true) .addChannelTypes(ChannelType.GuildText); const getStringOption = () => - new SlashCommandStringOption().setName('owo').setDescription('Testing 123').setRequired(true); + new ChatInputCommandStringOption().setName('owo').setDescription('Testing 123').setRequired(true); const getIntegerOption = () => - new SlashCommandIntegerOption() + new ChatInputCommandIntegerOption() .setName('owo') .setDescription('Testing 123') .setRequired(true) @@ -46,22 +46,24 @@ const getIntegerOption = () => .setMaxValue(10); const getNumberOption = () => - new SlashCommandNumberOption() + new ChatInputCommandNumberOption() .setName('owo') .setDescription('Testing 123') .setRequired(true) .setMinValue(-1.23) .setMaxValue(10); -const getUserOption = () => new SlashCommandUserOption().setName('owo').setDescription('Testing 123').setRequired(true); +const getUserOption = () => + new ChatInputCommandUserOption().setName('owo').setDescription('Testing 123').setRequired(true); -const getRoleOption = () => new SlashCommandRoleOption().setName('owo').setDescription('Testing 123').setRequired(true); +const getRoleOption = () => + new ChatInputCommandRoleOption().setName('owo').setDescription('Testing 123').setRequired(true); const getMentionableOption = () => - new SlashCommandMentionableOption().setName('owo').setDescription('Testing 123').setRequired(true); + new ChatInputCommandMentionableOption().setName('owo').setDescription('Testing 123').setRequired(true); const getAttachmentOption = () => - new SlashCommandAttachmentOption().setName('attachment').setDescription('attachment').setRequired(true); + new ChatInputCommandAttachmentOption().setName('attachment').setDescription('attachment').setRequired(true); describe('Application Command toJSON() results', () => { test('GIVEN a boolean option THEN calling toJSON should return a valid JSON', () => { @@ -101,7 +103,6 @@ describe('Application Command toJSON() results', () => { max_value: 10, min_value: -1, autocomplete: true, - // TODO choices: [], }); diff --git a/packages/builders/__tests__/interactions/SlashCommands/SlashCommands.test.ts b/packages/builders/__tests__/interactions/SlashCommands/SlashCommands.test.ts deleted file mode 100644 index 64e9d97a710d..000000000000 --- a/packages/builders/__tests__/interactions/SlashCommands/SlashCommands.test.ts +++ /dev/null @@ -1,593 +0,0 @@ -import { - ApplicationCommandType, - ApplicationIntegrationType, - ChannelType, - InteractionContextType, - PermissionFlagsBits, - type APIApplicationCommandOptionChoice, -} from 'discord-api-types/v10'; -import { describe, test, expect } from 'vitest'; -import { - SlashCommandAssertions, - SlashCommandBooleanOption, - SlashCommandBuilder, - SlashCommandChannelOption, - SlashCommandIntegerOption, - SlashCommandMentionableOption, - SlashCommandNumberOption, - SlashCommandRoleOption, - SlashCommandAttachmentOption, - SlashCommandStringOption, - SlashCommandSubcommandBuilder, - SlashCommandSubcommandGroupBuilder, - SlashCommandUserOption, -} from '../../../src/index.js'; - -const largeArray = Array.from({ length: 26 }, () => 1 as unknown as APIApplicationCommandOptionChoice); - -const getBuilder = () => new SlashCommandBuilder(); -const getNamedBuilder = () => getBuilder().setName('example').setDescription('Example command'); -const getStringOption = () => new SlashCommandStringOption().setName('owo').setDescription('Testing 123'); -const getIntegerOption = () => new SlashCommandIntegerOption().setName('owo').setDescription('Testing 123'); -const getNumberOption = () => new SlashCommandNumberOption().setName('owo').setDescription('Testing 123'); -const getBooleanOption = () => new SlashCommandBooleanOption().setName('owo').setDescription('Testing 123'); -const getUserOption = () => new SlashCommandUserOption().setName('owo').setDescription('Testing 123'); -const getChannelOption = () => new SlashCommandChannelOption().setName('owo').setDescription('Testing 123'); -const getRoleOption = () => new SlashCommandRoleOption().setName('owo').setDescription('Testing 123'); -const getAttachmentOption = () => new SlashCommandAttachmentOption().setName('owo').setDescription('Testing 123'); -const getMentionableOption = () => new SlashCommandMentionableOption().setName('owo').setDescription('Testing 123'); -const getSubcommandGroup = () => new SlashCommandSubcommandGroupBuilder().setName('owo').setDescription('Testing 123'); -const getSubcommand = () => new SlashCommandSubcommandBuilder().setName('owo').setDescription('Testing 123'); - -class Collection { - public readonly [Symbol.toStringTag] = 'Map'; -} - -describe('Slash Commands', () => { - describe('Assertions tests', () => { - test('GIVEN valid name THEN does not throw error', () => { - expect(() => SlashCommandAssertions.validateName('ping')).not.toThrowError(); - expect(() => SlashCommandAssertions.validateName('hello-world_command')).not.toThrowError(); - expect(() => SlashCommandAssertions.validateName('aˇ㐆1٢〣²अก')).not.toThrowError(); - }); - - test('GIVEN invalid name THEN throw error', () => { - expect(() => SlashCommandAssertions.validateName(null)).toThrowError(); - - // Too short of a name - expect(() => SlashCommandAssertions.validateName('')).toThrowError(); - - // Invalid characters used - expect(() => SlashCommandAssertions.validateName('ABC')).toThrowError(); - expect(() => SlashCommandAssertions.validateName('ABC123$%^&')).toThrowError(); - expect(() => SlashCommandAssertions.validateName('help ping')).toThrowError(); - expect(() => SlashCommandAssertions.validateName('🦦')).toThrowError(); - - // Too long of a name - expect(() => - SlashCommandAssertions.validateName('qwertyuiopasdfghjklzxcvbnmqwertyuiopasdfghjklzxcvbnm'), - ).toThrowError(); - }); - - test('GIVEN valid description THEN does not throw error', () => { - expect(() => SlashCommandAssertions.validateDescription('This is an OwO moment fur sure!~')).not.toThrowError(); - }); - - test('GIVEN invalid description THEN throw error', () => { - expect(() => SlashCommandAssertions.validateDescription(null)).toThrowError(); - - // Too short of a description - expect(() => SlashCommandAssertions.validateDescription('')).toThrowError(); - - // Too long of a description - expect(() => - SlashCommandAssertions.validateDescription( - 'Lorem ipsum dolor sit amet, consectetur adipisicing elit. Magnam autem libero expedita vitae accusamus nostrum ipsam tempore repudiandae deserunt ipsum facilis, velit fugiat facere accusantium, explicabo corporis aliquam non quos.', - ), - ).toThrowError(); - }); - - test('GIVEN valid default_permission THEN does not throw error', () => { - expect(() => SlashCommandAssertions.validateDefaultPermission(true)).not.toThrowError(); - }); - - test('GIVEN invalid default_permission THEN throw error', () => { - expect(() => SlashCommandAssertions.validateDefaultPermission(null)).toThrowError(); - }); - - test('GIVEN valid array of options or choices THEN does not throw error', () => { - expect(() => SlashCommandAssertions.validateMaxOptionsLength([])).not.toThrowError(); - - expect(() => SlashCommandAssertions.validateChoicesLength(25)).not.toThrowError(); - expect(() => SlashCommandAssertions.validateChoicesLength(25, [])).not.toThrowError(); - }); - - test('GIVEN invalid options or choices THEN throw error', () => { - expect(() => SlashCommandAssertions.validateMaxOptionsLength(null)).toThrowError(); - - // Given an array that's too big - expect(() => SlashCommandAssertions.validateMaxOptionsLength(largeArray)).toThrowError(); - - expect(() => SlashCommandAssertions.validateChoicesLength(1, largeArray)).toThrowError(); - }); - - test('GIVEN valid required parameters THEN does not throw error', () => { - expect(() => - SlashCommandAssertions.validateRequiredParameters( - 'owo', - 'My fancy command that totally exists, to test assertions', - [], - ), - ).not.toThrowError(); - }); - }); - - describe('SlashCommandBuilder', () => { - describe('Builder with no options', () => { - test('GIVEN empty builder THEN throw error when calling toJSON', () => { - expect(() => getBuilder().toJSON()).toThrowError(); - }); - - test('GIVEN valid builder THEN does not throw error', () => { - expect(() => getBuilder().setName('example').setDescription('Example command').toJSON()).not.toThrowError(); - }); - }); - - describe('Builder with simple options', () => { - test('GIVEN valid builder THEN returns type included', () => { - expect(getNamedBuilder().toJSON()).includes({ type: ApplicationCommandType.ChatInput }); - }); - - test('GIVEN valid builder with options THEN does not throw error', () => { - expect(() => - getBuilder() - .setName('example') - .setDescription('Example command') - .setDMPermission(false) - .addBooleanOption((boolean) => - boolean.setName('iscool').setDescription('Are we cool or what?').setRequired(true), - ) - .addChannelOption((channel) => channel.setName('iscool').setDescription('Are we cool or what?')) - .addMentionableOption((mentionable) => mentionable.setName('iscool').setDescription('Are we cool or what?')) - .addRoleOption((role) => role.setName('iscool').setDescription('Are we cool or what?')) - .addUserOption((user) => user.setName('iscool').setDescription('Are we cool or what?')) - .addIntegerOption((integer) => - integer - .setName('iscool') - .setDescription('Are we cool or what?') - .addChoices({ name: 'Very cool', value: 1_000 }) - .addChoices([{ name: 'Even cooler', value: 2_000 }]), - ) - .addNumberOption((number) => - number - .setName('iscool') - .setDescription('Are we cool or what?') - .addChoices({ name: 'Very cool', value: 1.5 }) - .addChoices([{ name: 'Even cooler', value: 2.5 }]), - ) - .addStringOption((string) => - string - .setName('iscool') - .setDescription('Are we cool or what?') - .addChoices({ name: 'Fancy Pants', value: 'fp_1' }, { name: 'Fancy Shoes', value: 'fs_1' }) - .addChoices([{ name: 'The Whole shebang', value: 'all' }]), - ) - .addIntegerOption((integer) => - integer.setName('iscool').setDescription('Are we cool or what?').setAutocomplete(true), - ) - .addNumberOption((number) => - number.setName('iscool').setDescription('Are we cool or what?').setAutocomplete(true), - ) - .addStringOption((string) => - string.setName('iscool').setDescription('Are we cool or what?').setAutocomplete(true), - ) - .toJSON(), - ).not.toThrowError(); - }); - - test('GIVEN a builder with invalid autocomplete THEN does throw an error', () => { - // @ts-expect-error: Checking if not providing anything, or an invalid return type causes an error - expect(() => getBuilder().addStringOption(getStringOption().setAutocomplete('not a boolean'))).toThrowError(); - }); - - test('GIVEN a builder with both choices and autocomplete THEN does throw an error', () => { - expect(() => - getBuilder().addStringOption( - getStringOption().setAutocomplete(true).addChoices({ name: 'Fancy Pants', value: 'fp_1' }), - ), - ).toThrowError(); - - expect(() => - getBuilder().addStringOption( - getStringOption() - .setAutocomplete(true) - .addChoices( - { name: 'Fancy Pants', value: 'fp_1' }, - { name: 'Fancy Shoes', value: 'fs_1' }, - { name: 'The Whole shebang', value: 'all' }, - ), - ), - ).toThrowError(); - - expect(() => - getBuilder().addStringOption( - getStringOption().addChoices({ name: 'Fancy Pants', value: 'fp_1' }).setAutocomplete(true), - ), - ).toThrowError(); - - expect(() => { - const option = getStringOption(); - Reflect.set(option, 'autocomplete', true); - Reflect.set(option, 'choices', [{ name: 'Fancy Pants', value: 'fp_1' }]); - return option.toJSON(); - }).toThrowError(); - - expect(() => { - const option = getNumberOption(); - Reflect.set(option, 'autocomplete', true); - Reflect.set(option, 'choices', [{ name: 'Fancy Pants', value: 'fp_1' }]); - return option.toJSON(); - }).toThrowError(); - - expect(() => { - const option = getIntegerOption(); - Reflect.set(option, 'autocomplete', true); - Reflect.set(option, 'choices', [{ name: 'Fancy Pants', value: 'fp_1' }]); - return option.toJSON(); - }).toThrowError(); - }); - - test('GIVEN a builder with valid channel options and channel_types THEN does not throw an error', () => { - expect(() => - getBuilder().addChannelOption( - getChannelOption().addChannelTypes(ChannelType.GuildText).addChannelTypes([ChannelType.GuildVoice]), - ), - ).not.toThrowError(); - - expect(() => { - getBuilder().addChannelOption( - getChannelOption().addChannelTypes(ChannelType.GuildAnnouncement, ChannelType.GuildText), - ); - }).not.toThrowError(); - }); - - test('GIVEN a builder with valid channel options and channel_types THEN does throw an error', () => { - // @ts-expect-error: Invalid channel type - expect(() => getBuilder().addChannelOption(getChannelOption().addChannelTypes(100))).toThrowError(); - - // @ts-expect-error: Invalid channel types - expect(() => getBuilder().addChannelOption(getChannelOption().addChannelTypes(100, 200))).toThrowError(); - }); - - test('GIVEN a builder with invalid number min/max options THEN does throw an error', () => { - // @ts-expect-error: Invalid max value - expect(() => getBuilder().addNumberOption(getNumberOption().setMaxValue('test'))).toThrowError(); - - // @ts-expect-error: Invalid max value - expect(() => getBuilder().addIntegerOption(getIntegerOption().setMaxValue('test'))).toThrowError(); - - // @ts-expect-error: Invalid min value - expect(() => getBuilder().addNumberOption(getNumberOption().setMinValue('test'))).toThrowError(); - - // @ts-expect-error: Invalid min value - expect(() => getBuilder().addIntegerOption(getIntegerOption().setMinValue('test'))).toThrowError(); - - expect(() => getBuilder().addIntegerOption(getIntegerOption().setMinValue(1.5))).toThrowError(); - }); - - test('GIVEN a builder with valid number min/max options THEN does not throw an error', () => { - expect(() => getBuilder().addIntegerOption(getIntegerOption().setMinValue(1))).not.toThrowError(); - - expect(() => getBuilder().addNumberOption(getNumberOption().setMinValue(1.5))).not.toThrowError(); - - expect(() => getBuilder().addIntegerOption(getIntegerOption().setMaxValue(1))).not.toThrowError(); - - expect(() => getBuilder().addNumberOption(getNumberOption().setMaxValue(1.5))).not.toThrowError(); - }); - - test('GIVEN an already built builder THEN does not throw an error', () => { - expect(() => getBuilder().addStringOption(getStringOption())).not.toThrowError(); - - expect(() => getBuilder().addIntegerOption(getIntegerOption())).not.toThrowError(); - - expect(() => getBuilder().addNumberOption(getNumberOption())).not.toThrowError(); - - expect(() => getBuilder().addBooleanOption(getBooleanOption())).not.toThrowError(); - - expect(() => getBuilder().addUserOption(getUserOption())).not.toThrowError(); - - expect(() => getBuilder().addChannelOption(getChannelOption())).not.toThrowError(); - - expect(() => getBuilder().addRoleOption(getRoleOption())).not.toThrowError(); - - expect(() => getBuilder().addAttachmentOption(getAttachmentOption())).not.toThrowError(); - - expect(() => getBuilder().addMentionableOption(getMentionableOption())).not.toThrowError(); - }); - - test('GIVEN no valid return for an addOption method THEN throw error', () => { - // @ts-expect-error: Checking if not providing anything, or an invalid return type causes an error - expect(() => getBuilder().addBooleanOption()).toThrowError(); - - // @ts-expect-error: Checking if not providing anything, or an invalid return type causes an error - expect(() => getBuilder().addBooleanOption(getRoleOption())).toThrowError(); - }); - - test('GIVEN invalid name THEN throw error', () => { - expect(() => getBuilder().setName('TEST_COMMAND')).toThrowError(); - - expect(() => getBuilder().setName('ĂĂĂĂĂĂ')).toThrowError(); - }); - - test('GIVEN valid names THEN does not throw error', () => { - expect(() => getBuilder().setName('hi_there')).not.toThrowError(); - - // Translation: a_command - expect(() => getBuilder().setName('o_comandă')).not.toThrowError(); - - // Translation: thx (according to GTranslate) - expect(() => getBuilder().setName('どうも')).not.toThrowError(); - }); - - test('GIVEN invalid returns for builder THEN throw error', () => { - // @ts-expect-error: Checking if not providing anything, or an invalid return type causes an error - expect(() => getBuilder().addBooleanOption(true)).toThrowError(); - - // @ts-expect-error: Checking if not providing anything, or an invalid return type causes an error - expect(() => getBuilder().addBooleanOption(null)).toThrowError(); - - // @ts-expect-error: Checking if not providing anything, or an invalid return type causes an error - expect(() => getBuilder().addBooleanOption(undefined)).toThrowError(); - - // @ts-expect-error: Checking if not providing anything, or an invalid return type causes an error - expect(() => getBuilder().addBooleanOption(() => SlashCommandStringOption)).toThrowError(); - // @ts-expect-error: Checking if not providing anything, or an invalid return type causes an error - expect(() => getBuilder().addBooleanOption(() => new Collection())).toThrowError(); - }); - - test('GIVEN valid builder with defaultPermission false THEN does not throw error', () => { - expect(() => getBuilder().setName('foo').setDescription('foo').setDefaultPermission(false)).not.toThrowError(); - }); - - test('GIVEN an option that is autocompletable and has choices, THEN passing nothing to setChoices should not throw an error', () => { - expect(() => - getBuilder().addStringOption(getStringOption().setAutocomplete(true).setChoices()), - ).not.toThrowError(); - }); - - test('GIVEN an option that is autocompletable, THEN setting choices should throw an error', () => { - expect(() => - getBuilder().addStringOption( - getStringOption().setAutocomplete(true).setChoices({ name: 'owo', value: 'uwu' }), - ), - ).toThrowError(); - }); - - test('GIVEN an option, THEN setting choices should not throw an error', () => { - expect(() => - getBuilder().addStringOption(getStringOption().setChoices({ name: 'owo', value: 'uwu' })), - ).not.toThrowError(); - }); - - test('GIVEN valid builder with NSFW, THEN does not throw error', () => { - expect(() => getBuilder().setName('foo').setDescription('foo').setNSFW(true)).not.toThrowError(); - }); - }); - - describe('Builder with subcommand (group) options', () => { - test('GIVEN builder with subcommand group THEN does not throw error', () => { - expect(() => - getNamedBuilder().addSubcommandGroup((group) => group.setName('group').setDescription('Group us together!')), - ).not.toThrowError(); - }); - - test('GIVEN builder with subcommand THEN does not throw error', () => { - expect(() => - getNamedBuilder().addSubcommand((subcommand) => - subcommand.setName('boop').setDescription('Boops a fellow nerd (you)'), - ), - ).not.toThrowError(); - }); - - test('GIVEN builder with subcommand THEN has regular slash command fields', () => { - expect(() => - getBuilder() - .setName('name') - .setDescription('description') - .addSubcommand((option) => option.setName('ye').setDescription('ye')) - .addSubcommand((option) => option.setName('no').setDescription('no')) - .setDMPermission(false) - .setDefaultMemberPermissions(1n), - ).not.toThrowError(); - }); - - test('GIVEN builder with already built subcommand group THEN does not throw error', () => { - expect(() => getNamedBuilder().addSubcommandGroup(getSubcommandGroup())).not.toThrowError(); - }); - - test('GIVEN builder with already built subcommand THEN does not throw error', () => { - expect(() => getNamedBuilder().addSubcommand(getSubcommand())).not.toThrowError(); - }); - - test('GIVEN builder with already built subcommand with options THEN does not throw error', () => { - expect(() => - getNamedBuilder().addSubcommand(getSubcommand().addBooleanOption(getBooleanOption())), - ).not.toThrowError(); - }); - - test('GIVEN builder with a subcommand that tries to add an invalid result THEN throw error', () => { - expect(() => - // @ts-expect-error: Checking if check works JS-side too - getNamedBuilder().addSubcommand(getSubcommand()).addInteger(getInteger()), - ).toThrowError(); - }); - - test('GIVEN no valid return for an addSubcommand(Group) method THEN throw error', () => { - // @ts-expect-error: Checking if not providing anything, or an invalid return type causes an error - expect(() => getBuilder().addSubcommandGroup()).toThrowError(); - - // @ts-expect-error: Checking if not providing anything, or an invalid return type causes an error - expect(() => getBuilder().addSubcommand()).toThrowError(); - - // @ts-expect-error: Checking if not providing anything, or an invalid return type causes an error - expect(() => getBuilder().addSubcommand(getSubcommandGroup())).toThrowError(); - }); - }); - - describe('Subcommand group builder', () => { - test('GIVEN no valid subcommand THEN throw error', () => { - // @ts-expect-error: Checking if not providing anything, or an invalid return type causes an error - expect(() => getSubcommandGroup().addSubcommand()).toThrowError(); - - // @ts-expect-error: Checking if not providing anything, or an invalid return type causes an error - expect(() => getSubcommandGroup().addSubcommand(getSubcommandGroup())).toThrowError(); - }); - - test('GIVEN a valid subcommand THEN does not throw an error', () => { - expect(() => - getSubcommandGroup() - .addSubcommand((sub) => sub.setName('sub').setDescription('Testing 123')) - .toJSON(), - ).not.toThrowError(); - }); - }); - - describe('Subcommand builder', () => { - test('GIVEN a valid subcommand with options THEN does not throw error', () => { - expect(() => getSubcommand().addBooleanOption(getBooleanOption()).toJSON()).not.toThrowError(); - }); - }); - - describe('Slash command localizations', () => { - const expectedSingleLocale = { 'en-US': 'foobar' }; - const expectedMultipleLocales = { - ...expectedSingleLocale, - bg: 'test', - }; - - test('GIVEN valid name localizations THEN does not throw error', () => { - expect(() => getBuilder().setNameLocalization('en-US', 'foobar')).not.toThrowError(); - expect(() => getBuilder().setNameLocalizations({ 'en-US': 'foobar' })).not.toThrowError(); - }); - - test('GIVEN invalid name localizations THEN does throw error', () => { - // @ts-expect-error: Invalid localization - expect(() => getBuilder().setNameLocalization('en-U', 'foobar')).toThrowError(); - // @ts-expect-error: Invalid localization - expect(() => getBuilder().setNameLocalizations({ 'en-U': 'foobar' })).toThrowError(); - }); - - test('GIVEN valid name localizations THEN valid data is stored', () => { - expect(getBuilder().setNameLocalization('en-US', 'foobar').name_localizations).toEqual(expectedSingleLocale); - expect(getBuilder().setNameLocalizations({ 'en-US': 'foobar', bg: 'test' }).name_localizations).toEqual( - expectedMultipleLocales, - ); - expect(getBuilder().setNameLocalizations(null).name_localizations).toBeNull(); - expect(getBuilder().setNameLocalization('en-US', null).name_localizations).toEqual({ - 'en-US': null, - }); - }); - - test('GIVEN valid description localizations THEN does not throw error', () => { - expect(() => getBuilder().setDescriptionLocalization('en-US', 'foobar')).not.toThrowError(); - expect(() => getBuilder().setDescriptionLocalizations({ 'en-US': 'foobar' })).not.toThrowError(); - }); - - test('GIVEN invalid description localizations THEN does throw error', () => { - // @ts-expect-error: Invalid localization description - expect(() => getBuilder().setDescriptionLocalization('en-U', 'foobar')).toThrowError(); - // @ts-expect-error: Invalid localization description - expect(() => getBuilder().setDescriptionLocalizations({ 'en-U': 'foobar' })).toThrowError(); - }); - - test('GIVEN valid description localizations THEN valid data is stored', () => { - expect(getBuilder().setDescriptionLocalization('en-US', 'foobar').description_localizations).toEqual( - expectedSingleLocale, - ); - expect( - getBuilder().setDescriptionLocalizations({ 'en-US': 'foobar', bg: 'test' }).description_localizations, - ).toEqual(expectedMultipleLocales); - expect(getBuilder().setDescriptionLocalizations(null).description_localizations).toBeNull(); - expect(getBuilder().setDescriptionLocalization('en-US', null).description_localizations).toEqual({ - 'en-US': null, - }); - }); - }); - - describe('permissions', () => { - test('GIVEN valid permission string THEN does not throw error', () => { - expect(() => getBuilder().setDefaultMemberPermissions('1')).not.toThrowError(); - }); - - test('GIVEN valid permission bitfield THEN does not throw error', () => { - expect(() => - getBuilder().setDefaultMemberPermissions(PermissionFlagsBits.AddReactions | PermissionFlagsBits.AttachFiles), - ).not.toThrowError(); - }); - - test('GIVEN null permissions THEN does not throw error', () => { - expect(() => getBuilder().setDefaultMemberPermissions(null)).not.toThrowError(); - }); - - test('GIVEN invalid inputs THEN does throw error', () => { - expect(() => getBuilder().setDefaultMemberPermissions('1.1')).toThrowError(); - - expect(() => getBuilder().setDefaultMemberPermissions(1.1)).toThrowError(); - }); - - test('GIVEN valid permission with options THEN does not throw error', () => { - expect(() => - getBuilder().addBooleanOption(getBooleanOption()).setDefaultMemberPermissions('1'), - ).not.toThrowError(); - - expect(() => getBuilder().addChannelOption(getChannelOption()).setDMPermission(false)).not.toThrowError(); - }); - }); - - describe('contexts', () => { - test('GIVEN a builder with valid contexts THEN does not throw an error', () => { - expect(() => - getBuilder().setContexts([InteractionContextType.Guild, InteractionContextType.BotDM]), - ).not.toThrowError(); - - expect(() => - getBuilder().setContexts(InteractionContextType.Guild, InteractionContextType.BotDM), - ).not.toThrowError(); - }); - - test('GIVEN a builder with invalid contexts THEN does throw an error', () => { - // @ts-expect-error: Invalid contexts - expect(() => getBuilder().setContexts(999)).toThrowError(); - - // @ts-expect-error: Invalid contexts - expect(() => getBuilder().setContexts([999, 998])).toThrowError(); - }); - }); - - describe('integration types', () => { - test('GIVEN a builder with valid integraton types THEN does not throw an error', () => { - expect(() => - getBuilder().setIntegrationTypes([ - ApplicationIntegrationType.GuildInstall, - ApplicationIntegrationType.UserInstall, - ]), - ).not.toThrowError(); - - expect(() => - getBuilder().setIntegrationTypes( - ApplicationIntegrationType.GuildInstall, - ApplicationIntegrationType.UserInstall, - ), - ).not.toThrowError(); - }); - - test('GIVEN a builder with invalid integration types THEN does throw an error', () => { - // @ts-expect-error: Invalid integration types - expect(() => getBuilder().setIntegrationTypes(999)).toThrowError(); - - // @ts-expect-error: Invalid integration types - expect(() => getBuilder().setIntegrationTypes([999, 998])).toThrowError(); - }); - }); - }); -}); diff --git a/packages/builders/__tests__/interactions/modal.test.ts b/packages/builders/__tests__/interactions/modal.test.ts index 17bdfefb4bf5..7adfcf8445f9 100644 --- a/packages/builders/__tests__/interactions/modal.test.ts +++ b/packages/builders/__tests__/interactions/modal.test.ts @@ -1,71 +1,21 @@ -import { - ComponentType, - TextInputStyle, - type APIModalInteractionResponseCallbackData, - type APITextInputComponent, -} from 'discord-api-types/v10'; +import { ComponentType, TextInputStyle, type APIModalInteractionResponseCallbackData } from 'discord-api-types/v10'; import { describe, test, expect } from 'vitest'; -import { - ActionRowBuilder, - ButtonBuilder, - ModalBuilder, - TextInputBuilder, - type ModalActionRowComponentBuilder, -} from '../../src/index.js'; -import { - componentsValidator, - titleValidator, - validateRequiredParameters, -} from '../../src/interactions/modals/Assertions.js'; +import { ActionRowBuilder, ModalBuilder, TextInputBuilder } from '../../src/index.js'; const modal = () => new ModalBuilder(); +const textInput = () => + new ActionRowBuilder().addTextInputComponent( + new TextInputBuilder().setCustomId('text').setLabel(':3').setStyle(TextInputStyle.Short), + ); describe('Modals', () => { - describe('Assertion Tests', () => { - test('GIVEN valid title THEN validator does not throw', () => { - expect(() => titleValidator.parse('foobar')).not.toThrowError(); - }); - - test('GIVEN invalid title THEN validator does throw', () => { - expect(() => titleValidator.parse(42)).toThrowError(); - }); - - test('GIVEN valid components THEN validator does not throw', () => { - expect(() => componentsValidator.parse([new ActionRowBuilder(), new ActionRowBuilder()])).not.toThrowError(); - }); - - test('GIVEN invalid components THEN validator does throw', () => { - expect(() => componentsValidator.parse([new ButtonBuilder(), new TextInputBuilder()])).toThrowError(); - }); - - test('GIVEN valid required parameters THEN validator does not throw', () => { - expect(() => - validateRequiredParameters('123', 'title', [new ActionRowBuilder(), new ActionRowBuilder()]), - ).not.toThrowError(); - }); - - test('GIVEN invalid required parameters THEN validator does throw', () => { - expect(() => - // @ts-expect-error: Missing required parameter - validateRequiredParameters('123', undefined, [new ActionRowBuilder(), new ButtonBuilder()]), - ).toThrowError(); - }); - }); - test('GIVEN valid fields THEN builder does not throw', () => { - expect(() => - modal().setTitle('test').setCustomId('foobar').setComponents(new ActionRowBuilder()), - ).not.toThrowError(); - - expect(() => - // @ts-expect-error: You can pass a TextInputBuilder and it will add it to an action row - modal().setTitle('test').setCustomId('foobar').addComponents(new TextInputBuilder()), - ).not.toThrowError(); + expect(() => modal().setTitle('test').setCustomId('foobar').setActionRows(textInput()).toJSON()).not.toThrowError(); + expect(() => modal().setTitle('test').setCustomId('foobar').addActionRows(textInput()).toJSON()).not.toThrowError(); }); test('GIVEN invalid fields THEN builder does throw', () => { expect(() => modal().setTitle('test').setCustomId('foobar').toJSON()).toThrowError(); - // @ts-expect-error: CustomId is invalid expect(() => modal().setTitle('test').setCustomId(42).toJSON()).toThrowError(); }); @@ -106,68 +56,17 @@ describe('Modals', () => { modal() .setTitle(modalData.title) .setCustomId('custom id') - .setComponents( - new ActionRowBuilder().addComponents( + .setActionRows( + new ActionRowBuilder().addTextInputComponent( new TextInputBuilder().setCustomId('custom id').setLabel('label').setStyle(TextInputStyle.Paragraph), ), ) - .addComponents([ - new ActionRowBuilder().addComponents( + .addActionRows([ + new ActionRowBuilder().addTextInputComponent( new TextInputBuilder().setCustomId('custom id').setLabel('label').setStyle(TextInputStyle.Paragraph), ), ]) .toJSON(), ).toEqual(modalData); }); - - describe('equals()', () => { - const textInput1 = new TextInputBuilder() - .setCustomId('custom id') - .setLabel('label') - .setStyle(TextInputStyle.Paragraph); - - const textInput2: APITextInputComponent = { - type: ComponentType.TextInput, - custom_id: 'custom id', - label: 'label', - style: TextInputStyle.Paragraph, - }; - - test('GIVEN equal builders THEN returns true', () => { - const equalTextInput = new TextInputBuilder() - .setCustomId('custom id') - .setLabel('label') - .setStyle(TextInputStyle.Paragraph); - - expect(textInput1.equals(equalTextInput)).toBeTruthy(); - }); - - test('GIVEN the same builder THEN returns true', () => { - expect(textInput1.equals(textInput1)).toBeTruthy(); - }); - - test('GIVEN equal builder and data THEN returns true', () => { - expect(textInput1.equals(textInput2)).toBeTruthy(); - }); - - test('GIVEN different builders THEN returns false', () => { - const diffTextInput = new TextInputBuilder() - .setCustomId('custom id') - .setLabel('label 2') - .setStyle(TextInputStyle.Paragraph); - - expect(textInput1.equals(diffTextInput)).toBeFalsy(); - }); - - test('GIVEN different text input builder and data THEN returns false', () => { - const diffTextInputData: APITextInputComponent = { - type: ComponentType.TextInput, - custom_id: 'custom id', - label: 'label 2', - style: TextInputStyle.Short, - }; - - expect(textInput1.equals(diffTextInputData)).toBeFalsy(); - }); - }); }); diff --git a/packages/builders/__tests__/messages/embed.test.ts b/packages/builders/__tests__/messages/embed.test.ts index 3a0ef27024a9..caee2e9e8303 100644 --- a/packages/builders/__tests__/messages/embed.test.ts +++ b/packages/builders/__tests__/messages/embed.test.ts @@ -3,6 +3,16 @@ import { EmbedBuilder, embedLength } from '../../src/index.js'; const alpha = 'abcdefghijklmnopqrstuvwxyz'; +const dummy = { + title: 'ooooo aaaaa uuuuuu aaaa', +}; + +const base = { + author: undefined, + fields: [], + footer: undefined, +}; + describe('Embed', () => { describe('Embed getters', () => { test('GIVEN an embed with specific amount of characters THEN returns amount of characters', () => { @@ -14,127 +24,136 @@ describe('Embed', () => { footer: { text: alpha }, }); - expect(embedLength(embed.data)).toEqual(alpha.length * 6); + expect(embedLength(embed.toJSON())).toEqual(alpha.length * 6); }); test('GIVEN an embed with zero characters THEN returns amount of characters', () => { const embed = new EmbedBuilder(); - expect(embedLength(embed.data)).toEqual(0); + expect(embedLength(embed.toJSON(false))).toEqual(0); }); }); describe('Embed title', () => { test('GIVEN an embed with a pre-defined title THEN return valid toJSON data', () => { const embed = new EmbedBuilder({ title: 'foo' }); - expect(embed.toJSON()).toStrictEqual({ title: 'foo' }); + expect(embed.toJSON()).toStrictEqual({ ...base, title: 'foo' }); }); test('GIVEN an embed using Embed#setTitle THEN return valid toJSON data', () => { const embed = new EmbedBuilder(); embed.setTitle('foo'); - expect(embed.toJSON()).toStrictEqual({ title: 'foo' }); + expect(embed.toJSON()).toStrictEqual({ ...base, title: 'foo' }); }); test('GIVEN an embed with a pre-defined title THEN unset title THEN return valid toJSON data', () => { - const embed = new EmbedBuilder({ title: 'foo' }); - embed.setTitle(null); + const embed = new EmbedBuilder({ title: 'foo', description: ':3' }); + embed.clearTitle(); - expect(embed.toJSON()).toStrictEqual({ title: undefined }); + expect(embed.toJSON()).toStrictEqual({ ...base, description: ':3', title: undefined }); }); test('GIVEN an embed with an invalid title THEN throws error', () => { const embed = new EmbedBuilder(); - expect(() => embed.setTitle('a'.repeat(257))).toThrowError(); + embed.setTitle('a'.repeat(257)); + + expect(() => embed.toJSON()).toThrowError(); }); }); describe('Embed description', () => { test('GIVEN an embed with a pre-defined description THEN return valid toJSON data', () => { const embed = new EmbedBuilder({ description: 'foo' }); - expect(embed.toJSON()).toStrictEqual({ description: 'foo' }); + expect(embed.toJSON()).toStrictEqual({ ...base, description: 'foo' }); }); test('GIVEN an embed using Embed#setDescription THEN return valid toJSON data', () => { const embed = new EmbedBuilder(); embed.setDescription('foo'); - expect(embed.toJSON()).toStrictEqual({ description: 'foo' }); + expect(embed.toJSON()).toStrictEqual({ ...base, description: 'foo' }); }); test('GIVEN an embed with a pre-defined description THEN unset description THEN return valid toJSON data', () => { - const embed = new EmbedBuilder({ description: 'foo' }); - embed.setDescription(null); + const embed = new EmbedBuilder({ description: 'foo', ...dummy }); + embed.clearDescription(); - expect(embed.toJSON()).toStrictEqual({ description: undefined }); + expect(embed.toJSON()).toStrictEqual({ ...base, ...dummy, description: undefined }); }); test('GIVEN an embed with an invalid description THEN throws error', () => { const embed = new EmbedBuilder(); - expect(() => embed.setDescription('a'.repeat(4_097))).toThrowError(); + embed.setDescription('a'.repeat(4_097)); + expect(() => embed.toJSON()).toThrowError(); }); }); describe('Embed URL', () => { test('GIVEN an embed with a pre-defined url THEN returns valid toJSON data', () => { - const embed = new EmbedBuilder({ url: 'https://discord.js.org/' }); + const embed = new EmbedBuilder({ url: 'https://discord.js.org/', ...dummy }); expect(embed.toJSON()).toStrictEqual({ + ...base, + ...dummy, url: 'https://discord.js.org/', }); }); test('GIVEN an embed using Embed#setURL THEN returns valid toJSON data', () => { - const embed = new EmbedBuilder(); + const embed = new EmbedBuilder(dummy); embed.setURL('https://discord.js.org/'); expect(embed.toJSON()).toStrictEqual({ + ...base, + ...dummy, url: 'https://discord.js.org/', }); }); test('GIVEN an embed with a pre-defined title THEN unset title THEN return valid toJSON data', () => { - const embed = new EmbedBuilder({ url: 'https://discord.js.org' }); - embed.setURL(null); + const embed = new EmbedBuilder({ url: 'https://discord.js.org', ...dummy }); + embed.clearURL(); - expect(embed.toJSON()).toStrictEqual({ url: undefined }); + expect(embed.toJSON()).toStrictEqual({ ...base, ...dummy, url: undefined }); }); test.each(['owo', 'discord://user'])('GIVEN an embed with an invalid URL THEN throws error', (input) => { const embed = new EmbedBuilder(); - expect(() => embed.setURL(input)).toThrowError(); + embed.setURL(input); + expect(() => embed.toJSON()).toThrowError(); }); }); describe('Embed Color', () => { test('GIVEN an embed with a pre-defined color THEN returns valid toJSON data', () => { - const embed = new EmbedBuilder({ color: 0xff0000 }); - expect(embed.toJSON()).toStrictEqual({ color: 0xff0000 }); + const embed = new EmbedBuilder({ color: 0xff0000, ...dummy }); + expect(embed.toJSON()).toStrictEqual({ ...base, ...dummy, color: 0xff0000 }); }); test('GIVEN an embed using Embed#setColor THEN returns valid toJSON data', () => { - expect(new EmbedBuilder().setColor(0xff0000).toJSON()).toStrictEqual({ color: 0xff0000 }); - expect(new EmbedBuilder().setColor([242, 66, 245]).toJSON()).toStrictEqual({ color: 0xf242f5 }); + expect(new EmbedBuilder(dummy).setColor(0xff0000).toJSON()).toStrictEqual({ ...base, ...dummy, color: 0xff0000 }); }); test('GIVEN an embed with a pre-defined color THEN unset color THEN return valid toJSON data', () => { - const embed = new EmbedBuilder({ color: 0xff0000 }); - embed.setColor(null); + const embed = new EmbedBuilder({ ...dummy, color: 0xff0000 }); + embed.clearColor(); - expect(embed.toJSON()).toStrictEqual({ color: undefined }); + expect(embed.toJSON()).toStrictEqual({ ...base, ...dummy, color: undefined }); }); test('GIVEN an embed with an invalid color THEN throws error', () => { const embed = new EmbedBuilder(); // @ts-expect-error: Invalid color - expect(() => embed.setColor('RED')).toThrowError(); + embed.setColor('RED'); + expect(() => embed.toJSON()).toThrowError(); + // @ts-expect-error: Invalid color - expect(() => embed.setColor([42, 36])).toThrowError(); - expect(() => embed.setColor([42, 36, 1_000])).toThrowError(); + embed.setColor([42, 36]); + expect(() => embed.toJSON()).toThrowError(); }); }); @@ -142,98 +161,92 @@ describe('Embed', () => { const now = new Date(); test('GIVEN an embed with a pre-defined timestamp THEN returns valid toJSON data', () => { - const embed = new EmbedBuilder({ timestamp: now.toISOString() }); - expect(embed.toJSON()).toStrictEqual({ timestamp: now.toISOString() }); + const embed = new EmbedBuilder({ timestamp: now.toISOString(), ...dummy }); + expect(embed.toJSON()).toStrictEqual({ ...base, ...dummy, timestamp: now.toISOString() }); }); - test('given an embed using Embed#setTimestamp (with Date) THEN returns valid toJSON data', () => { - const embed = new EmbedBuilder(); + test('GIVEN an embed using Embed#setTimestamp (with Date) THEN returns valid toJSON data', () => { + const embed = new EmbedBuilder(dummy); embed.setTimestamp(now); - expect(embed.toJSON()).toStrictEqual({ timestamp: now.toISOString() }); + expect(embed.toJSON()).toStrictEqual({ ...base, ...dummy, timestamp: now.toISOString() }); }); test('GIVEN an embed using Embed#setTimestamp (with int) THEN returns valid toJSON data', () => { - const embed = new EmbedBuilder(); + const embed = new EmbedBuilder(dummy); embed.setTimestamp(now.getTime()); - expect(embed.toJSON()).toStrictEqual({ timestamp: now.toISOString() }); + expect(embed.toJSON()).toStrictEqual({ ...base, ...dummy, timestamp: now.toISOString() }); }); test('GIVEN an embed using Embed#setTimestamp (default) THEN returns valid toJSON data', () => { - const embed = new EmbedBuilder(); + const embed = new EmbedBuilder(dummy); embed.setTimestamp(); - expect(embed.toJSON()).toStrictEqual({ timestamp: embed.data.timestamp }); + expect(embed.toJSON()).toStrictEqual({ ...base, ...dummy, timestamp: embed.toJSON().timestamp }); }); test('GIVEN an embed with a pre-defined timestamp THEN unset timestamp THEN return valid toJSON data', () => { - const embed = new EmbedBuilder({ timestamp: now.toISOString() }); - embed.setTimestamp(null); + const embed = new EmbedBuilder({ timestamp: now.toISOString(), ...dummy }); + embed.clearTimestamp(); - expect(embed.toJSON()).toStrictEqual({ timestamp: undefined }); + expect(embed.toJSON()).toStrictEqual({ ...base, ...dummy, timestamp: undefined }); }); }); describe('Embed Thumbnail', () => { test('GIVEN an embed with a pre-defined thumbnail THEN returns valid toJSON data', () => { const embed = new EmbedBuilder({ thumbnail: { url: 'https://discord.js.org/static/logo.svg' } }); - expect(embed.toJSON()).toStrictEqual({ - thumbnail: { url: 'https://discord.js.org/static/logo.svg' }, - }); + expect(embed.toJSON()).toStrictEqual({ ...base, thumbnail: { url: 'https://discord.js.org/static/logo.svg' } }); }); test('GIVEN an embed using Embed#setThumbnail THEN returns valid toJSON data', () => { const embed = new EmbedBuilder(); embed.setThumbnail('https://discord.js.org/static/logo.svg'); - expect(embed.toJSON()).toStrictEqual({ - thumbnail: { url: 'https://discord.js.org/static/logo.svg' }, - }); + expect(embed.toJSON()).toStrictEqual({ ...base, thumbnail: { url: 'https://discord.js.org/static/logo.svg' } }); }); test('GIVEN an embed with a pre-defined thumbnail THEN unset thumbnail THEN return valid toJSON data', () => { - const embed = new EmbedBuilder({ thumbnail: { url: 'https://discord.js.org/static/logo.svg' } }); - embed.setThumbnail(null); + const embed = new EmbedBuilder({ thumbnail: { url: 'https://discord.js.org/static/logo.svg' }, ...dummy }); + embed.clearThumbnail(); - expect(embed.toJSON()).toStrictEqual({ thumbnail: undefined }); + expect(embed.toJSON()).toStrictEqual({ ...base, ...dummy, thumbnail: undefined }); }); test('GIVEN an embed with an invalid thumbnail THEN throws error', () => { const embed = new EmbedBuilder(); - expect(() => embed.setThumbnail('owo')).toThrowError(); + embed.setThumbnail('owo'); + expect(() => embed.toJSON()).toThrowError(); }); }); describe('Embed Image', () => { test('GIVEN an embed with a pre-defined image THEN returns valid toJSON data', () => { const embed = new EmbedBuilder({ image: { url: 'https://discord.js.org/static/logo.svg' } }); - expect(embed.toJSON()).toStrictEqual({ - image: { url: 'https://discord.js.org/static/logo.svg' }, - }); + expect(embed.toJSON()).toStrictEqual({ ...base, image: { url: 'https://discord.js.org/static/logo.svg' } }); }); test('GIVEN an embed using Embed#setImage THEN returns valid toJSON data', () => { const embed = new EmbedBuilder(); embed.setImage('https://discord.js.org/static/logo.svg'); - expect(embed.toJSON()).toStrictEqual({ - image: { url: 'https://discord.js.org/static/logo.svg' }, - }); + expect(embed.toJSON()).toStrictEqual({ ...base, image: { url: 'https://discord.js.org/static/logo.svg' } }); }); test('GIVEN an embed with a pre-defined image THEN unset image THEN return valid toJSON data', () => { - const embed = new EmbedBuilder({ image: { url: 'https://discord.js/org/static/logo.svg' } }); - embed.setImage(null); + const embed = new EmbedBuilder({ image: { url: 'https://discord.js/org/static/logo.svg' }, ...dummy }); + embed.clearImage(); - expect(embed.toJSON()).toStrictEqual({ image: undefined }); + expect(embed.toJSON()).toStrictEqual({ ...base, ...dummy, image: undefined }); }); test('GIVEN an embed with an invalid image THEN throws error', () => { const embed = new EmbedBuilder(); - expect(() => embed.setImage('owo')).toThrowError(); + embed.setImage('owo'); + expect(() => embed.toJSON()).toThrowError(); }); }); @@ -243,19 +256,19 @@ describe('Embed', () => { author: { name: 'Wumpus', icon_url: 'https://discord.js.org/static/logo.svg', url: 'https://discord.js.org' }, }); expect(embed.toJSON()).toStrictEqual({ + ...base, author: { name: 'Wumpus', icon_url: 'https://discord.js.org/static/logo.svg', url: 'https://discord.js.org' }, }); }); test('GIVEN an embed using Embed#setAuthor THEN returns valid toJSON data', () => { const embed = new EmbedBuilder(); - embed.setAuthor({ - name: 'Wumpus', - iconURL: 'https://discord.js.org/static/logo.svg', - url: 'https://discord.js.org', - }); + embed.setAuthor((author) => + author.setName('Wumpus').setIconURL('https://discord.js.org/static/logo.svg').setURL('https://discord.js.org'), + ); expect(embed.toJSON()).toStrictEqual({ + ...base, author: { name: 'Wumpus', icon_url: 'https://discord.js.org/static/logo.svg', url: 'https://discord.js.org' }, }); }); @@ -263,16 +276,18 @@ describe('Embed', () => { test('GIVEN an embed with a pre-defined author THEN unset author THEN return valid toJSON data', () => { const embed = new EmbedBuilder({ author: { name: 'Wumpus', icon_url: 'https://discord.js.org/static/logo.svg', url: 'https://discord.js.org' }, + ...dummy, }); - embed.setAuthor(null); + embed.clearAuthor(); - expect(embed.toJSON()).toStrictEqual({ author: undefined }); + expect(embed.toJSON()).toStrictEqual({ ...base, ...dummy, author: undefined }); }); test('GIVEN an embed with an invalid author name THEN throws error', () => { const embed = new EmbedBuilder(); - expect(() => embed.setAuthor({ name: 'a'.repeat(257) })).toThrowError(); + embed.setAuthor({ name: 'a'.repeat(257) }); + expect(() => embed.toJSON()).toThrowError(); }); }); @@ -282,32 +297,36 @@ describe('Embed', () => { footer: { text: 'Wumpus', icon_url: 'https://discord.js.org/static/logo.svg' }, }); expect(embed.toJSON()).toStrictEqual({ + ...base, footer: { text: 'Wumpus', icon_url: 'https://discord.js.org/static/logo.svg' }, }); }); test('GIVEN an embed using Embed#setAuthor THEN returns valid toJSON data', () => { const embed = new EmbedBuilder(); - embed.setFooter({ text: 'Wumpus', iconURL: 'https://discord.js.org/static/logo.svg' }); + embed.setFooter({ text: 'Wumpus', icon_url: 'https://discord.js.org/static/logo.svg' }); expect(embed.toJSON()).toStrictEqual({ + ...base, footer: { text: 'Wumpus', icon_url: 'https://discord.js.org/static/logo.svg' }, }); }); test('GIVEN an embed with a pre-defined footer THEN unset footer THEN return valid toJSON data', () => { const embed = new EmbedBuilder({ + ...dummy, footer: { text: 'Wumpus', icon_url: 'https://discord.js.org/static/logo.svg' }, }); - embed.setFooter(null); + embed.clearFooter(); - expect(embed.toJSON()).toStrictEqual({ footer: undefined }); + expect(embed.toJSON()).toStrictEqual({ ...base, ...dummy, footer: undefined }); }); test('GIVEN an embed with invalid footer text THEN throws error', () => { const embed = new EmbedBuilder(); - expect(() => embed.setFooter({ text: 'a'.repeat(2_049) })).toThrowError(); + embed.setFooter({ text: 'a'.repeat(2_049) }); + expect(() => embed.toJSON()).toThrowError(); }); }); @@ -316,9 +335,7 @@ describe('Embed', () => { const embed = new EmbedBuilder({ fields: [{ name: 'foo', value: 'bar' }], }); - expect(embed.toJSON()).toStrictEqual({ - fields: [{ name: 'foo', value: 'bar' }], - }); + expect(embed.toJSON()).toStrictEqual({ ...base, fields: [{ name: 'foo', value: 'bar' }] }); }); test('GIVEN an embed using Embed#addFields THEN returns valid toJSON data', () => { @@ -327,6 +344,7 @@ describe('Embed', () => { embed.addFields([{ name: 'foo', value: 'bar' }]); expect(embed.toJSON()).toStrictEqual({ + ...base, fields: [ { name: 'foo', value: 'bar' }, { name: 'foo', value: 'bar' }, @@ -338,56 +356,51 @@ describe('Embed', () => { const embed = new EmbedBuilder(); embed.addFields({ name: 'foo', value: 'bar' }, { name: 'foo', value: 'baz' }); - expect(embed.spliceFields(0, 1).toJSON()).toStrictEqual({ - fields: [{ name: 'foo', value: 'baz' }], - }); + expect(embed.spliceFields(0, 1).toJSON()).toStrictEqual({ ...base, fields: [{ name: 'foo', value: 'baz' }] }); }); test('GIVEN an embed using Embed#spliceFields THEN returns valid toJSON data 2', () => { const embed = new EmbedBuilder(); embed.addFields(...Array.from({ length: 23 }, () => ({ name: 'foo', value: 'bar' }))); - expect(() => - embed.spliceFields(0, 3, ...Array.from({ length: 5 }, () => ({ name: 'foo', value: 'bar' }))), - ).not.toThrowError(); + embed.spliceFields(0, 3, ...Array.from({ length: 5 }, () => ({ name: 'foo', value: 'bar' }))); + expect(() => embed.toJSON()).not.toThrowError(); }); test('GIVEN an embed using Embed#spliceFields that adds additional fields resulting in fields > 25 THEN throws error', () => { const embed = new EmbedBuilder(); embed.addFields(...Array.from({ length: 23 }, () => ({ name: 'foo', value: 'bar' }))); - expect(() => - embed.spliceFields(0, 3, ...Array.from({ length: 8 }, () => ({ name: 'foo', value: 'bar' }))), - ).toThrowError(); + embed.spliceFields(0, 3, ...Array.from({ length: 8 }, () => ({ name: 'foo', value: 'bar' }))); + expect(() => embed.toJSON()).toThrowError(); }); test('GIVEN an embed using Embed#setFields THEN returns valid toJSON data', () => { const embed = new EmbedBuilder(); - expect(() => - embed.setFields(...Array.from({ length: 25 }, () => ({ name: 'foo', value: 'bar' }))), - ).not.toThrowError(); - expect(() => - embed.setFields(Array.from({ length: 25 }, () => ({ name: 'foo', value: 'bar' }))), - ).not.toThrowError(); + embed.setFields(...Array.from({ length: 25 }, () => ({ name: 'foo', value: 'bar' }))); + expect(() => embed.toJSON()).not.toThrowError(); + + embed.setFields(Array.from({ length: 25 }, () => ({ name: 'foo', value: 'bar' }))); + expect(() => embed.toJSON()).not.toThrowError(); }); test('GIVEN an embed using Embed#setFields that sets more than 25 fields THEN throws error', () => { const embed = new EmbedBuilder(); - expect(() => - embed.setFields(...Array.from({ length: 26 }, () => ({ name: 'foo', value: 'bar' }))), - ).toThrowError(); - expect(() => embed.setFields(Array.from({ length: 26 }, () => ({ name: 'foo', value: 'bar' })))).toThrowError(); + embed.setFields(...Array.from({ length: 26 }, () => ({ name: 'foo', value: 'bar' }))); + expect(() => embed.toJSON()).toThrowError(); + + embed.setFields(Array.from({ length: 26 }, () => ({ name: 'foo', value: 'bar' }))); + expect(() => embed.toJSON()).toThrowError(); }); describe('GIVEN invalid field amount THEN throws error', () => { test('1', () => { const embed = new EmbedBuilder(); - expect(() => - embed.addFields(...Array.from({ length: 26 }, () => ({ name: 'foo', value: 'bar' }))), - ).toThrowError(); + embed.addFields(...Array.from({ length: 26 }, () => ({ name: 'foo', value: 'bar' }))); + expect(() => embed.toJSON()).toThrowError(); }); }); @@ -395,7 +408,8 @@ describe('Embed', () => { test('2', () => { const embed = new EmbedBuilder(); - expect(() => embed.addFields({ name: '', value: 'bar' })).toThrowError(); + embed.addFields({ name: '', value: 'bar' }); + expect(() => embed.toJSON()).toThrowError(); }); }); @@ -403,7 +417,8 @@ describe('Embed', () => { test('3', () => { const embed = new EmbedBuilder(); - expect(() => embed.addFields({ name: 'a'.repeat(257), value: 'bar' })).toThrowError(); + embed.addFields({ name: 'a'.repeat(257), value: 'bar' }); + expect(() => embed.toJSON()).toThrowError(); }); }); @@ -411,7 +426,8 @@ describe('Embed', () => { test('4', () => { const embed = new EmbedBuilder(); - expect(() => embed.addFields({ name: '', value: 'a'.repeat(1_025) })).toThrowError(); + embed.addFields({ name: '', value: 'a'.repeat(1_025) }); + expect(() => embed.toJSON()).toThrowError(); }); }); }); diff --git a/packages/builders/__tests__/types.test.ts b/packages/builders/__tests__/types.test.ts index dfd6abef35f8..de94dbbda9b0 100644 --- a/packages/builders/__tests__/types.test.ts +++ b/packages/builders/__tests__/types.test.ts @@ -1,11 +1,15 @@ import { expectTypeOf } from 'vitest'; -import { SlashCommandBuilder, SlashCommandStringOption, SlashCommandSubcommandBuilder } from '../src/index.js'; +import { + ChatInputCommandBuilder, + ChatInputCommandStringOption, + ChatInputCommandSubcommandBuilder, +} from '../src/index.js'; -const getBuilder = () => new SlashCommandBuilder(); -const getStringOption = () => new SlashCommandStringOption().setName('owo').setDescription('Testing 123'); -const getSubcommand = () => new SlashCommandSubcommandBuilder().setName('owo').setDescription('Testing 123'); +const getBuilder = () => new ChatInputCommandBuilder(); +const getStringOption = () => new ChatInputCommandStringOption().setName('owo').setDescription('Testing 123'); +const getSubcommand = () => new ChatInputCommandSubcommandBuilder().setName('owo').setDescription('Testing 123'); -type BuilderPropsOnly = Pick< +type BuilderPropsOnly = Pick< Type, keyof { [Key in keyof Type as Type[Key] extends (...args: any) => any ? never : Key]: any; diff --git a/packages/builders/package.json b/packages/builders/package.json index 9e5d39f20122..38d4a980f509 100644 --- a/packages/builders/package.json +++ b/packages/builders/package.json @@ -65,19 +65,17 @@ "homepage": "https://discord.js.org", "funding": "https://github.com/discordjs/discord.js?sponsor", "dependencies": { - "@discordjs/formatters": "workspace:^", "@discordjs/util": "workspace:^", - "@sapphire/shapeshift": "^4.0.0", "discord-api-types": "0.37.97", - "fast-deep-equal": "^3.1.3", "ts-mixer": "^6.0.4", - "tslib": "^2.6.3" + "tslib": "^2.6.3", + "zod": "^3.23.8" }, "devDependencies": { "@discordjs/api-extractor": "workspace:^", "@discordjs/scripts": "workspace:^", "@favware/cliff-jumper": "^4.1.0", - "@types/node": "^16.18.105", + "@types/node": "^18.19.44", "@vitest/coverage-v8": "^2.0.5", "cross-env": "^7.0.3", "esbuild-plugin-version-injector": "^1.2.1", diff --git a/packages/builders/src/Assertions.ts b/packages/builders/src/Assertions.ts new file mode 100644 index 000000000000..1176cdc4abdc --- /dev/null +++ b/packages/builders/src/Assertions.ts @@ -0,0 +1,20 @@ +import { Locale } from 'discord-api-types/v10'; +import { z } from 'zod'; + +export const customIdPredicate = z.string().min(1).max(100); + +export const memberPermissionsPredicate = z.coerce.bigint(); + +export const localeMapPredicate = z + .object( + Object.fromEntries(Object.values(Locale).map((loc) => [loc, z.string().optional()])) as Record< + Locale, + z.ZodOptional + >, + ) + .strict(); + +export const refineURLPredicate = (allowedProtocols: string[]) => (value: string) => { + const url = new URL(value); + return allowedProtocols.includes(url.protocol); +}; diff --git a/packages/builders/src/components/ActionRow.ts b/packages/builders/src/components/ActionRow.ts index ade84ac4690c..d4a0b641baba 100644 --- a/packages/builders/src/components/ActionRow.ts +++ b/packages/builders/src/components/ActionRow.ts @@ -1,68 +1,55 @@ /* eslint-disable jsdoc/check-param-names */ -import { - type APIActionRowComponent, - ComponentType, - type APIMessageActionRowComponent, - type APIModalActionRowComponent, - type APIActionRowComponentTypes, +import type { + APITextInputComponent, + APIActionRowComponent, + APIActionRowComponentTypes, + APIChannelSelectComponent, + APIMentionableSelectComponent, + APIRoleSelectComponent, + APIStringSelectComponent, + APIUserSelectComponent, + APIButtonComponentWithCustomId, + APIButtonComponentWithSKUId, + APIButtonComponentWithURL, } 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 { isValidationEnabled } from '../util/validation.js'; +import { actionRowPredicate } from './Assertions.js'; import { ComponentBuilder } from './Component.js'; +import type { AnyActionRowComponentBuilder } from './Components.js'; import { createComponentBuilder } from './Components.js'; -import type { ButtonBuilder } from './button/Button.js'; -import type { ChannelSelectMenuBuilder } from './selectMenu/ChannelSelectMenu.js'; -import type { MentionableSelectMenuBuilder } from './selectMenu/MentionableSelectMenu.js'; -import type { RoleSelectMenuBuilder } from './selectMenu/RoleSelectMenu.js'; -import type { StringSelectMenuBuilder } from './selectMenu/StringSelectMenu.js'; -import type { UserSelectMenuBuilder } from './selectMenu/UserSelectMenu.js'; -import type { TextInputBuilder } from './textInput/TextInput.js'; +import { CustomIdButtonBuilder } from './button/CustomIdButton.js'; +import { SKUIdButtonBuilder } from './button/SKUIdButton.js'; +import { URLButtonBuilder } from './button/URLButton.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'; -/** - * The builders that may be used for messages. - */ -export type MessageComponentBuilder = - | ActionRowBuilder - | MessageActionRowComponentBuilder; - -/** - * The builders that may be used for modals. - */ -export type ModalComponentBuilder = ActionRowBuilder | ModalActionRowComponentBuilder; - -/** - * The builders that may be used within an action row for messages. - */ -export type MessageActionRowComponentBuilder = - | ButtonBuilder - | ChannelSelectMenuBuilder - | MentionableSelectMenuBuilder - | RoleSelectMenuBuilder - | StringSelectMenuBuilder - | UserSelectMenuBuilder; - -/** - * The builders that may be used within an action row for modals. - */ -export type ModalActionRowComponentBuilder = TextInputBuilder; - -/** - * Any builder. - */ -export type AnyComponentBuilder = MessageActionRowComponentBuilder | ModalActionRowComponentBuilder; +export interface ActionRowBuilderData + extends Partial, 'components'>> { + components: AnyActionRowComponentBuilder[]; +} /** * A builder that creates API-compatible JSON data for action rows. * * @typeParam ComponentType - The types of components this action row holds */ -export class ActionRowBuilder extends ComponentBuilder< - APIActionRowComponent -> { +export class ActionRowBuilder extends ComponentBuilder> { + private readonly data: ActionRowBuilderData; + /** * The components within this action row. */ - public readonly components: ComponentType[]; + public get components(): readonly AnyActionRowComponentBuilder[] { + return this.data.components; + } /** * Creates a new action row from API data. @@ -98,38 +85,164 @@ export class ActionRowBuilder extends * .addComponents(button2, button3); * ``` */ - public constructor({ components, ...data }: Partial> = {}) { - super({ type: ComponentType.ActionRow, ...data }); - this.components = (components?.map((component) => createComponentBuilder(component)) ?? []) as ComponentType[]; + public constructor({ components = [], ...data }: Partial> = {}) { + super(); + this.data = { + ...structuredClone(data), + type: ComponentType.ActionRow, + components: components.map((component) => createComponentBuilder(component)) ?? [], + }; } /** - * Adds components to this action row. + * Adds custom id button components to this action row. * - * @param components - The components to add + * @param input - The buttons to add */ - public addComponents(...components: RestOrArray) { - this.components.push(...normalizeArray(components)); + public addCustomIdButtonComponents( + ...input: RestOrArray< + | APIButtonComponentWithCustomId + | CustomIdButtonBuilder + | ((builder: CustomIdButtonBuilder) => CustomIdButtonBuilder) + > + ): this { + const normalized = normalizeArray(input); + const resolved = normalized.map((component) => resolveBuilder(component, CustomIdButtonBuilder)); + + this.data.components.push(...resolved); + return this; + } + + /** + * Adds SKU id button components to this action row. + * + * @param input - The buttons to add + */ + public addSKUIDButtonComponents( + ...input: RestOrArray< + APIButtonComponentWithSKUId | SKUIdButtonBuilder | ((builder: SKUIdButtonBuilder) => SKUIdButtonBuilder) + > + ): this { + const normalized = normalizeArray(input); + const resolved = normalized.map((component) => resolveBuilder(component, SKUIdButtonBuilder)); + + this.data.components.push(...resolved); return this; } /** - * Sets components for this action row. + * Adds URL button components to this action row. * - * @param components - The components to set + * @param input - The buttons to add */ - public setComponents(...components: RestOrArray) { - this.components.splice(0, this.components.length, ...normalizeArray(components)); + public addURLButtonComponents( + ...input: RestOrArray< + APIButtonComponentWithURL | URLButtonBuilder | ((builder: URLButtonBuilder) => URLButtonBuilder) + > + ): this { + const normalized = normalizeArray(input); + const resolved = normalized.map((component) => resolveBuilder(component, URLButtonBuilder)); + + this.data.components.push(...resolved); + return this; + } + + /** + * Adds a channel select menu component to this action row. + * + * @param input - A function that returns a component builder or an already built builder + */ + public addChannelSelectMenuComponent( + input: + | APIChannelSelectComponent + | ChannelSelectMenuBuilder + | ((builder: ChannelSelectMenuBuilder) => ChannelSelectMenuBuilder), + ): this { + this.data.components.push(resolveBuilder(input, ChannelSelectMenuBuilder)); + return this; + } + + /** + * Adds a mentionable select menu component to this action row. + * + * @param input - A function that returns a component builder or an already built builder + */ + public addMentionableSelectMenuComponent( + input: + | APIMentionableSelectComponent + | MentionableSelectMenuBuilder + | ((builder: MentionableSelectMenuBuilder) => MentionableSelectMenuBuilder), + ): this { + this.data.components.push(resolveBuilder(input, MentionableSelectMenuBuilder)); + return this; + } + + /** + * Adds a role select menu component to this action row. + * + * @param input - A function that returns a component builder or an already built builder + */ + public addRoleSelectMenuComponent( + input: APIRoleSelectComponent | RoleSelectMenuBuilder | ((builder: RoleSelectMenuBuilder) => RoleSelectMenuBuilder), + ): this { + this.data.components.push(resolveBuilder(input, RoleSelectMenuBuilder)); + return this; + } + + /** + * Adds a string select menu component to this action row. + * + * @param input - A function that returns a component builder or an already built builder + */ + public addStringSelectMenuComponent( + input: + | APIStringSelectComponent + | StringSelectMenuBuilder + | ((builder: StringSelectMenuBuilder) => StringSelectMenuBuilder), + ): this { + this.data.components.push(resolveBuilder(input, StringSelectMenuBuilder)); + return this; + } + + /** + * Adds a user select menu component to this action row. + * + * @param input - A function that returns a component builder or an already built builder + */ + public addUserSelectMenuComponent( + input: APIUserSelectComponent | UserSelectMenuBuilder | ((builder: UserSelectMenuBuilder) => UserSelectMenuBuilder), + ): this { + this.data.components.push(resolveBuilder(input, UserSelectMenuBuilder)); + return this; + } + + /** + * Adds a text input component to this action row. + * + * @param input - A function that returns a component builder or an already built builder + */ + public addTextInputComponent( + input: APITextInputComponent | TextInputBuilder | ((builder: TextInputBuilder) => TextInputBuilder), + ): this { + this.data.components.push(resolveBuilder(input, TextInputBuilder)); return this; } /** * {@inheritDoc ComponentBuilder.toJSON} */ - public toJSON(): APIActionRowComponent> { - return { - ...this.data, - components: this.components.map((component) => component.toJSON()), - } as APIActionRowComponent>; + public override toJSON(validationOverride?: boolean): APIActionRowComponent { + const { components, ...rest } = this.data; + + const data = { + ...structuredClone(rest), + components: components.map((component) => component.toJSON(validationOverride)), + }; + + if (validationOverride ?? isValidationEnabled()) { + actionRowPredicate.parse(data); + } + + return data as APIActionRowComponent; } } diff --git a/packages/builders/src/components/Assertions.ts b/packages/builders/src/components/Assertions.ts index 926159eedc08..4b8c020665e3 100644 --- a/packages/builders/src/components/Assertions.ts +++ b/packages/builders/src/components/Assertions.ts @@ -1,127 +1,168 @@ -import { s } from '@sapphire/shapeshift'; -import { ButtonStyle, ChannelType, type APIMessageComponentEmoji } from 'discord-api-types/v10'; -import { isValidationEnabled } from '../util/validation.js'; -import { StringSelectMenuOptionBuilder } from './selectMenu/StringSelectMenuOption.js'; - -export const customIdValidator = s - .string() - .lengthGreaterThanOrEqual(1) - .lengthLessThanOrEqual(100) - .setValidationEnabled(isValidationEnabled); - -export const emojiValidator = s - .object({ - id: s.string(), - name: s.string(), - animated: s.boolean(), - }) - .partial() - .strict() - .setValidationEnabled(isValidationEnabled); - -export const disabledValidator = s.boolean(); +import { ButtonStyle, ChannelType, ComponentType, SelectMenuDefaultValueType } from 'discord-api-types/v10'; +import { z } from 'zod'; +import { customIdPredicate, refineURLPredicate } from '../Assertions.js'; -export const buttonLabelValidator = s - .string() - .lengthGreaterThanOrEqual(1) - .lengthLessThanOrEqual(80) - .setValidationEnabled(isValidationEnabled); +const labelPredicate = z.string().min(1).max(80); -export const buttonStyleValidator = s.nativeEnum(ButtonStyle); - -export const placeholderValidator = s.string().lengthLessThanOrEqual(150).setValidationEnabled(isValidationEnabled); -export const minMaxValidator = s - .number() - .int() - .greaterThanOrEqual(0) - .lessThanOrEqual(25) - .setValidationEnabled(isValidationEnabled); - -export const labelValueDescriptionValidator = s - .string() - .lengthGreaterThanOrEqual(1) - .lengthLessThanOrEqual(100) - .setValidationEnabled(isValidationEnabled); - -export const jsonOptionValidator = s +export const emojiPredicate = z .object({ - label: labelValueDescriptionValidator, - value: labelValueDescriptionValidator, - description: labelValueDescriptionValidator.optional(), - emoji: emojiValidator.optional(), - default: s.boolean().optional(), + id: z.string().optional(), + name: z.string().min(2).max(32).optional(), + animated: z.boolean().optional(), }) - .setValidationEnabled(isValidationEnabled); - -export const optionValidator = s.instance(StringSelectMenuOptionBuilder).setValidationEnabled(isValidationEnabled); - -export const optionsValidator = optionValidator - .array() - .lengthGreaterThanOrEqual(0) - .setValidationEnabled(isValidationEnabled); -export const optionsLengthValidator = s - .number() - .int() - .greaterThanOrEqual(0) - .lessThanOrEqual(25) - .setValidationEnabled(isValidationEnabled); - -export function validateRequiredSelectMenuParameters(options: StringSelectMenuOptionBuilder[], customId?: string) { - customIdValidator.parse(customId); - optionsValidator.parse(options); -} - -export const defaultValidator = s.boolean(); - -export function validateRequiredSelectMenuOptionParameters(label?: string, value?: string) { - labelValueDescriptionValidator.parse(label); - labelValueDescriptionValidator.parse(value); -} - -export const channelTypesValidator = s.nativeEnum(ChannelType).array().setValidationEnabled(isValidationEnabled); - -export const urlValidator = s - .string() - .url({ - allowedProtocols: ['http:', 'https:', 'discord:'], + .strict() + .refine((data) => data.id !== undefined || data.name !== undefined, { + message: "Either 'id' or 'name' must be provided", + }); + +const buttonPredicateBase = z.object({ + type: z.literal(ComponentType.Button), + disabled: z.boolean().optional(), +}); + +const buttonCustomIdPredicateBase = buttonPredicateBase.extend({ + custom_id: customIdPredicate, + emoji: emojiPredicate.optional(), + label: labelPredicate, +}); + +const buttonPrimaryPredicate = buttonCustomIdPredicateBase.extend({ style: z.literal(ButtonStyle.Primary) }).strict(); +const buttonSecondaryPredicate = buttonCustomIdPredicateBase + .extend({ style: z.literal(ButtonStyle.Secondary) }) + .strict(); +const buttonSuccessPredicate = buttonCustomIdPredicateBase.extend({ style: z.literal(ButtonStyle.Success) }).strict(); +const buttonDangerPredicate = buttonCustomIdPredicateBase.extend({ style: z.literal(ButtonStyle.Danger) }).strict(); + +const buttonLinkPredicate = buttonPredicateBase + .extend({ + style: z.literal(ButtonStyle.Link), + url: z + .string() + .url() + .refine(refineURLPredicate(['http:', 'https:', 'discord:'])), + emoji: emojiPredicate.optional(), + label: labelPredicate, }) - .setValidationEnabled(isValidationEnabled); - -export function validateRequiredButtonParameters( - style?: ButtonStyle, - label?: string, - emoji?: APIMessageComponentEmoji, - customId?: string, - skuId?: string, - url?: string, -) { - if (style === ButtonStyle.Premium) { - if (!skuId) { - throw new RangeError('Premium buttons must have an SKU id.'); - } - - if (customId || label || url || emoji) { - throw new RangeError('Premium buttons cannot have a custom id, label, URL, or emoji.'); - } - } else { - if (skuId) { - throw new RangeError('Non-premium buttons must not have an SKU id.'); - } + .strict(); - if (url && customId) { - throw new RangeError('URL and custom id are mutually exclusive.'); - } - - if (!label && !emoji) { - throw new RangeError('Non-premium buttons must have a label and/or an emoji.'); +const buttonPremiumPredicate = buttonPredicateBase + .extend({ + style: z.literal(ButtonStyle.Premium), + sku_id: z.string(), + }) + .strict(); + +export const buttonPredicate = z.discriminatedUnion('style', [ + buttonLinkPredicate, + buttonPrimaryPredicate, + buttonSecondaryPredicate, + buttonSuccessPredicate, + buttonDangerPredicate, + buttonPremiumPredicate, +]); + +const selectMenuBasePredicate = z.object({ + placeholder: z.string().max(150).optional(), + min_values: z.number().min(0).max(25).optional(), + max_values: z.number().min(0).max(25).optional(), + custom_id: customIdPredicate, + disabled: z.boolean().optional(), +}); + +export const selectMenuChannelPredicate = selectMenuBasePredicate.extend({ + type: z.literal(ComponentType.ChannelSelect), + channel_types: z.nativeEnum(ChannelType).array().optional(), + default_values: z + .object({ id: z.string(), type: z.literal(SelectMenuDefaultValueType.Channel) }) + .array() + .max(25) + .optional(), +}); + +export const selectMenuMentionablePredicate = selectMenuBasePredicate.extend({ + type: z.literal(ComponentType.MentionableSelect), + default_values: z + .object({ + id: z.string(), + type: z.union([z.literal(SelectMenuDefaultValueType.Role), z.literal(SelectMenuDefaultValueType.User)]), + }) + .array() + .max(25) + .optional(), +}); + +export const selectMenuRolePredicate = selectMenuBasePredicate.extend({ + type: z.literal(ComponentType.RoleSelect), + default_values: z + .object({ id: z.string(), type: z.literal(SelectMenuDefaultValueType.Role) }) + .array() + .max(25) + .optional(), +}); + +export const selectMenuStringOptionPredicate = z.object({ + label: labelPredicate, + value: z.string().min(1).max(100), + description: z.string().min(1).max(100).optional(), + emoji: emojiPredicate.optional(), + default: z.boolean().optional(), +}); + +export const selectMenuStringPredicate = selectMenuBasePredicate + .extend({ + type: z.literal(ComponentType.StringSelect), + options: selectMenuStringOptionPredicate.array().min(1).max(25), + }) + .superRefine((menu, ctx) => { + const addIssue = (name: string, minimum: number) => + ctx.addIssue({ + code: 'too_small', + message: `The number of options must be greater than or equal to ${name}`, + inclusive: true, + minimum, + type: 'number', + path: ['options'], + }); + + if (menu.max_values !== undefined && menu.options.length < menu.max_values) { + addIssue('max_values', menu.max_values); } - if (style === ButtonStyle.Link) { - if (!url) { - throw new RangeError('Link buttons must have a URL.'); - } - } else if (url) { - throw new RangeError('Non-premium and non-link buttons cannot have a URL.'); + if (menu.min_values !== undefined && menu.options.length < menu.min_values) { + addIssue('min_values', menu.min_values); } - } -} + }); + +export const selectMenuUserPredicate = selectMenuBasePredicate.extend({ + type: z.literal(ComponentType.UserSelect), + default_values: z + .object({ id: z.string(), type: z.literal(SelectMenuDefaultValueType.User) }) + .array() + .max(25) + .optional(), +}); + +export const actionRowPredicate = z.object({ + type: z.literal(ComponentType.ActionRow), + components: z.union([ + z + .object({ type: z.literal(ComponentType.Button) }) + .array() + .min(1) + .max(5), + z + .object({ + type: z.union([ + z.literal(ComponentType.ChannelSelect), + z.literal(ComponentType.MentionableSelect), + z.literal(ComponentType.RoleSelect), + z.literal(ComponentType.StringSelect), + z.literal(ComponentType.UserSelect), + // And this! + z.literal(ComponentType.TextInput), + ]), + }) + .array() + .length(1), + ]), +}); diff --git a/packages/builders/src/components/Component.ts b/packages/builders/src/components/Component.ts index e5e59638dfb9..29bab02df41b 100644 --- a/packages/builders/src/components/Component.ts +++ b/packages/builders/src/components/Component.ts @@ -1,10 +1,5 @@ import type { JSONEncodable } from '@discordjs/util'; -import type { - APIActionRowComponent, - APIActionRowComponentTypes, - APIBaseComponent, - ComponentType, -} from 'discord-api-types/v10'; +import type { APIActionRowComponent, APIActionRowComponentTypes } from 'discord-api-types/v10'; /** * Any action row component data represented as an object. @@ -14,32 +9,15 @@ export type AnyAPIActionRowComponent = APIActionRowComponent> = APIBaseComponent, -> implements JSONEncodable -{ - /** - * The API data associated with this component. - */ - public readonly data: Partial; - +export abstract class ComponentBuilder implements JSONEncodable { /** * 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 abstract toJSON(): AnyAPIActionRowComponent; - - /** - * Constructs a new kind of component. + * Note that by disabling validation, there is no guarantee that the resulting object will be valid. * - * @param data - The data to construct a component out of + * @param validationOverride - Force validation to run/not run regardless of your global preference */ - public constructor(data: Partial) { - this.data = data; - } + public abstract toJSON(validationOverride?: boolean): Component; } diff --git a/packages/builders/src/components/Components.ts b/packages/builders/src/components/Components.ts index 18b0dff6dd77..6ce718e963d2 100644 --- a/packages/builders/src/components/Components.ts +++ b/packages/builders/src/components/Components.ts @@ -1,12 +1,12 @@ -import { ComponentType, type APIMessageComponent, type APIModalComponent } from 'discord-api-types/v10'; -import { - ActionRowBuilder, - type AnyComponentBuilder, - type MessageComponentBuilder, - type ModalComponentBuilder, -} from './ActionRow.js'; +import type { APIButtonComponent, APIMessageComponent, APIModalComponent } 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 { ButtonBuilder } from './button/Button.js'; +import type { ButtonBuilder } from './button/Button.js'; +import { CustomIdButtonBuilder } from './button/CustomIdButton.js'; +import { SKUIdButtonBuilder } from './button/SKUIdButton.js'; +import { URLButtonBuilder } from './button/URLButton.js'; import { ChannelSelectMenuBuilder } from './selectMenu/ChannelSelectMenu.js'; import { MentionableSelectMenuBuilder } from './selectMenu/MentionableSelectMenu.js'; import { RoleSelectMenuBuilder } from './selectMenu/RoleSelectMenu.js'; @@ -14,6 +14,42 @@ import { StringSelectMenuBuilder } from './selectMenu/StringSelectMenu.js'; import { UserSelectMenuBuilder } from './selectMenu/UserSelectMenu.js'; import { 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. + */ +export type ModalComponentBuilder = ActionRowBuilder | ModalActionRowComponentBuilder; + +/** + * Any button builder + */ +export type AnyButtonBuilder = CustomIdButtonBuilder | SKUIdButtonBuilder | URLButtonBuilder; + +/** + * The builders that may be used within an action row for messages. + */ +export type MessageActionRowComponentBuilder = + | AnyButtonBuilder + | ChannelSelectMenuBuilder + | MentionableSelectMenuBuilder + | RoleSelectMenuBuilder + | StringSelectMenuBuilder + | UserSelectMenuBuilder; + +/** + * The builders that may be used within an action row for modals. + */ +export type ModalActionRowComponentBuilder = TextInputBuilder; + +/** + * Any action row component builder. + */ +export type AnyActionRowComponentBuilder = MessageActionRowComponentBuilder | ModalActionRowComponentBuilder; + /** * Components here are mapped to their respective builder. */ @@ -21,11 +57,11 @@ export interface MappedComponentTypes { /** * The action row component type is associated with an {@link ActionRowBuilder}. */ - [ComponentType.ActionRow]: ActionRowBuilder; + [ComponentType.ActionRow]: ActionRowBuilder; /** * The button component type is associated with a {@link ButtonBuilder}. */ - [ComponentType.Button]: ButtonBuilder; + [ComponentType.Button]: AnyButtonBuilder; /** * The string select component type is associated with a {@link StringSelectMenuBuilder}. */ @@ -75,7 +111,7 @@ export function createComponentBuilder { if (data instanceof ComponentBuilder) { return data; } @@ -84,7 +120,7 @@ export function createComponentBuilder( case ComponentType.ActionRow: return new ActionRowBuilder(data); case ComponentType.Button: - return new ButtonBuilder(data); + return createButtonBuilder(data); case ComponentType.StringSelect: return new StringSelectMenuBuilder(data); case ComponentType.TextInput: @@ -102,3 +138,20 @@ export function createComponentBuilder( throw new Error(`Cannot properly serialize component type: ${data.type}`); } } + +function createButtonBuilder(data: APIButtonComponent): AnyButtonBuilder { + switch (data.style) { + case ButtonStyle.Primary: + case ButtonStyle.Secondary: + case ButtonStyle.Success: + case ButtonStyle.Danger: + return new CustomIdButtonBuilder(data); + case ButtonStyle.Link: + return new URLButtonBuilder(data); + case ButtonStyle.Premium: + return new SKUIdButtonBuilder(data); + default: + // @ts-expect-error This case can still occur if we get a newer unsupported button style + throw new Error(`Cannot properly serialize button with style: ${data.style}`); + } +} diff --git a/packages/builders/src/components/button/Button.ts b/packages/builders/src/components/button/Button.ts index cc36d80dabcb..0e3b177fcaf4 100644 --- a/packages/builders/src/components/button/Button.ts +++ b/packages/builders/src/components/button/Button.ts @@ -1,115 +1,13 @@ -import { - ComponentType, - type APIButtonComponent, - type APIButtonComponentWithCustomId, - type APIButtonComponentWithSKUId, - type APIButtonComponentWithURL, - type APIMessageComponentEmoji, - type ButtonStyle, - type Snowflake, -} from 'discord-api-types/v10'; -import { - buttonLabelValidator, - buttonStyleValidator, - customIdValidator, - disabledValidator, - emojiValidator, - urlValidator, - validateRequiredButtonParameters, -} from '../Assertions.js'; +import type { APIButtonComponent } from 'discord-api-types/v10'; +import { isValidationEnabled } from '../../util/validation.js'; +import { buttonPredicate } from '../Assertions.js'; import { ComponentBuilder } from '../Component.js'; /** * A builder that creates API-compatible JSON data for buttons. */ -export class ButtonBuilder extends ComponentBuilder { - /** - * Creates a new button from API data. - * - * @param data - The API data to create this button with - * @example - * Creating a button from an API data object: - * ```ts - * const button = new ButtonBuilder({ - * custom_id: 'a cool button', - * style: ButtonStyle.Primary, - * label: 'Click Me', - * emoji: { - * name: 'smile', - * id: '123456789012345678', - * }, - * }); - * ``` - * @example - * Creating a button using setters and API data: - * ```ts - * const button = new ButtonBuilder({ - * style: ButtonStyle.Secondary, - * label: 'Click Me', - * }) - * .setEmoji({ name: '🙂' }) - * .setCustomId('another cool button'); - * ``` - */ - public constructor(data?: Partial) { - super({ type: ComponentType.Button, ...data }); - } - - /** - * Sets the style of this button. - * - * @param style - The style to use - */ - public setStyle(style: ButtonStyle) { - this.data.style = buttonStyleValidator.parse(style); - return this; - } - - /** - * Sets the URL for this button. - * - * @remarks - * This method is only available to buttons using the `Link` button style. - * Only three types of URL schemes are currently supported: `https://`, `http://`, and `discord://`. - * @param url - The URL to use - */ - public setURL(url: string) { - (this.data as APIButtonComponentWithURL).url = urlValidator.parse(url); - return this; - } - - /** - * Sets the custom id for this button. - * - * @remarks - * This method is only applicable to buttons that are not using the `Link` button style. - * @param customId - The custom id to use - */ - public setCustomId(customId: string) { - (this.data as APIButtonComponentWithCustomId).custom_id = customIdValidator.parse(customId); - return this; - } - - /** - * Sets the SKU id that represents a purchasable SKU for this button. - * - * @remarks Only available when using premium-style buttons. - * @param skuId - The SKU id to use - */ - public setSKUId(skuId: Snowflake) { - (this.data as APIButtonComponentWithSKUId).sku_id = skuId; - return this; - } - - /** - * Sets the emoji to display on this button. - * - * @param emoji - The emoji to use - */ - public setEmoji(emoji: APIMessageComponentEmoji) { - (this.data as Exclude).emoji = emojiValidator.parse(emoji); - return this; - } +export abstract class ButtonBuilder extends ComponentBuilder { + protected declare readonly data: Partial; /** * Sets whether this button is disabled. @@ -117,35 +15,20 @@ export class ButtonBuilder extends ComponentBuilder { * @param disabled - Whether to disable this button */ public setDisabled(disabled = true) { - this.data.disabled = disabledValidator.parse(disabled); - return this; - } - - /** - * Sets the label for this button. - * - * @param label - The label to use - */ - public setLabel(label: string) { - (this.data as Exclude).label = buttonLabelValidator.parse(label); + this.data.disabled = disabled; return this; } /** * {@inheritDoc ComponentBuilder.toJSON} */ - public toJSON(): APIButtonComponent { - validateRequiredButtonParameters( - this.data.style, - (this.data as Exclude).label, - (this.data as Exclude).emoji, - (this.data as APIButtonComponentWithCustomId).custom_id, - (this.data as APIButtonComponentWithSKUId).sku_id, - (this.data as APIButtonComponentWithURL).url, - ); + public override toJSON(validationOverride?: boolean): ButtonData { + const clone = structuredClone(this.data); + + if (validationOverride ?? isValidationEnabled()) { + buttonPredicate.parse(clone); + } - return { - ...this.data, - } as APIButtonComponent; + return clone as ButtonData; } } diff --git a/packages/builders/src/components/button/CustomIdButton.ts b/packages/builders/src/components/button/CustomIdButton.ts new file mode 100644 index 000000000000..0ca41174b029 --- /dev/null +++ b/packages/builders/src/components/button/CustomIdButton.ts @@ -0,0 +1,43 @@ +import { ComponentType, type APIButtonComponentWithCustomId } from 'discord-api-types/v10'; +import { Mixin } from 'ts-mixer'; +import { ButtonBuilder } from './Button.js'; +import { URLOrCustomIdButtonMixin } from './mixins/URLOrCustomIdButtonMixin.js'; + +export type CustomIdButtonStyle = APIButtonComponentWithCustomId['style']; + +/** + * A builder that creates API-compatible JSON data for buttons with custom IDs. + */ +export class CustomIdButtonBuilder extends Mixin( + ButtonBuilder, + URLOrCustomIdButtonMixin, +) { + protected override readonly data: Partial; + + public constructor(data: Partial = {}) { + super(); + this.data = { ...structuredClone(data), type: ComponentType.Button }; + } + + /** + * Sets the style of this button. + * + * @param style - The style to use + */ + public setStyle(style: CustomIdButtonStyle) { + this.data.style = style; + return this; + } + + /** + * Sets the custom id for this button. + * + * @remarks + * This method is only applicable to buttons that are not using the `Link` button style. + * @param customId - The custom id to use + */ + public setCustomId(customId: string) { + this.data.custom_id = customId; + return this; + } +} diff --git a/packages/builders/src/components/button/SKUIdButton.ts b/packages/builders/src/components/button/SKUIdButton.ts new file mode 100644 index 000000000000..a4a9b9adc643 --- /dev/null +++ b/packages/builders/src/components/button/SKUIdButton.ts @@ -0,0 +1,26 @@ +import type { APIButtonComponentWithSKUId, Snowflake } from 'discord-api-types/v10'; +import { ButtonStyle, ComponentType } from 'discord-api-types/v10'; +import { ButtonBuilder } from './Button.js'; + +/** + * A builder that creates API-compatible JSON data for SKU ID buttons. + */ +export class SKUIdButtonBuilder extends ButtonBuilder { + protected override readonly data: Partial; + + public constructor(data: Partial = {}) { + super(); + this.data = { ...structuredClone(data), type: ComponentType.Button, style: ButtonStyle.Premium }; + } + + /** + * Sets the SKU id that represents a purchasable SKU for this button. + * + * @remarks Only available when using premium-style buttons. + * @param skuId - The SKU id to use + */ + public setSKUId(skuId: Snowflake) { + this.data.sku_id = skuId; + return this; + } +} diff --git a/packages/builders/src/components/button/URLButton.ts b/packages/builders/src/components/button/URLButton.ts new file mode 100644 index 000000000000..6ffa5aa99a9b --- /dev/null +++ b/packages/builders/src/components/button/URLButton.ts @@ -0,0 +1,34 @@ +import { + ButtonStyle, + ComponentType, + type APIButtonComponent, + type APIButtonComponentWithURL, +} from 'discord-api-types/v10'; +import { Mixin } from 'ts-mixer'; +import { ButtonBuilder } from './Button.js'; +import { URLOrCustomIdButtonMixin } from './mixins/URLOrCustomIdButtonMixin.js'; + +/** + * A builder that creates API-compatible JSON data for buttons with URLs. + */ +export class URLButtonBuilder extends Mixin(ButtonBuilder, URLOrCustomIdButtonMixin) { + protected override readonly data: Partial; + + public constructor(data: Partial = {}) { + super(); + this.data = { ...structuredClone(data), type: ComponentType.Button, style: ButtonStyle.Link }; + } + + /** + * Sets the URL for this button. + * + * @remarks + * This method is only available to buttons using the `Link` button style. + * Only three types of URL schemes are currently supported: `https://`, `http://`, and `discord://`. + * @param url - The URL to use + */ + public setURL(url: string) { + this.data.url = url; + return this; + } +} diff --git a/packages/builders/src/components/button/mixins/URLOrCustomIdButtonMixin.ts b/packages/builders/src/components/button/mixins/URLOrCustomIdButtonMixin.ts new file mode 100644 index 000000000000..30826d67339b --- /dev/null +++ b/packages/builders/src/components/button/mixins/URLOrCustomIdButtonMixin.ts @@ -0,0 +1,44 @@ +import type { APIButtonComponent, APIButtonComponentWithSKUId, APIMessageComponentEmoji } from 'discord-api-types/v10'; + +export interface URLOrCustomIdButtonData + extends Pick, 'emoji' | 'label'> {} + +export class URLOrCustomIdButtonMixin { + protected declare readonly data: URLOrCustomIdButtonData; + + /** + * Sets the emoji to display on this button. + * + * @param emoji - The emoji to use + */ + public setEmoji(emoji: APIMessageComponentEmoji) { + this.data.emoji = emoji; + return this; + } + + /** + * Clears the emoji on this button. + */ + public clearEmoji() { + this.data.emoji = undefined; + return this; + } + + /** + * Sets the label for this button. + * + * @param label - The label to use + */ + public setLabel(label: string) { + this.data.label = label; + return this; + } + + /** + * Clears the label on this button. + */ + public clearLabel() { + this.data.label = undefined; + return this; + } +} diff --git a/packages/builders/src/components/selectMenu/BaseSelectMenu.ts b/packages/builders/src/components/selectMenu/BaseSelectMenu.ts index 298d7dc5e1fd..75e34d28e50d 100644 --- a/packages/builders/src/components/selectMenu/BaseSelectMenu.ts +++ b/packages/builders/src/components/selectMenu/BaseSelectMenu.ts @@ -1,5 +1,5 @@ +import type { JSONEncodable } from '@discordjs/util'; import type { APISelectMenuComponent } from 'discord-api-types/v10'; -import { customIdValidator, disabledValidator, minMaxValidator, placeholderValidator } from '../Assertions.js'; import { ComponentBuilder } from '../Component.js'; /** @@ -7,16 +7,29 @@ import { ComponentBuilder } from '../Component.js'; * * @typeParam SelectMenuType - The type of select menu this would be instantiated for. */ -export abstract class BaseSelectMenuBuilder< - SelectMenuType extends APISelectMenuComponent, -> extends ComponentBuilder { +export abstract class BaseSelectMenuBuilder + extends ComponentBuilder + implements JSONEncodable +{ + protected abstract readonly data: Partial< + Pick + >; + /** * Sets the placeholder for this select menu. * * @param placeholder - The placeholder to use */ public setPlaceholder(placeholder: string) { - this.data.placeholder = placeholderValidator.parse(placeholder); + this.data.placeholder = placeholder; + return this; + } + + /** + * Clears the placeholder for this select menu. + */ + public clearPlaceholder() { + this.data.placeholder = undefined; return this; } @@ -26,7 +39,7 @@ export abstract class BaseSelectMenuBuilder< * @param minValues - The minimum values that must be selected */ public setMinValues(minValues: number) { - this.data.min_values = minMaxValidator.parse(minValues); + this.data.min_values = minValues; return this; } @@ -36,7 +49,7 @@ export abstract class BaseSelectMenuBuilder< * @param maxValues - The maximum values that must be selected */ public setMaxValues(maxValues: number) { - this.data.max_values = minMaxValidator.parse(maxValues); + this.data.max_values = maxValues; return this; } @@ -46,7 +59,7 @@ export abstract class BaseSelectMenuBuilder< * @param customId - The custom id to use */ public setCustomId(customId: string) { - this.data.custom_id = customIdValidator.parse(customId); + this.data.custom_id = customId; return this; } @@ -56,17 +69,7 @@ export abstract class BaseSelectMenuBuilder< * @param disabled - Whether this select menu is disabled */ public setDisabled(disabled = true) { - this.data.disabled = disabledValidator.parse(disabled); + this.data.disabled = disabled; return this; } - - /** - * {@inheritDoc ComponentBuilder.toJSON} - */ - public toJSON(): SelectMenuType { - customIdValidator.parse(this.data.custom_id); - return { - ...this.data, - } as SelectMenuType; - } } diff --git a/packages/builders/src/components/selectMenu/ChannelSelectMenu.ts b/packages/builders/src/components/selectMenu/ChannelSelectMenu.ts index 204dcf84a178..913d61592e4e 100644 --- a/packages/builders/src/components/selectMenu/ChannelSelectMenu.ts +++ b/packages/builders/src/components/selectMenu/ChannelSelectMenu.ts @@ -6,13 +6,16 @@ import { SelectMenuDefaultValueType, } from 'discord-api-types/v10'; import { type RestOrArray, normalizeArray } from '../../util/normalizeArray.js'; -import { channelTypesValidator, customIdValidator, optionsLengthValidator } from '../Assertions.js'; +import { isValidationEnabled } from '../../util/validation.js'; +import { selectMenuChannelPredicate } from '../Assertions.js'; import { BaseSelectMenuBuilder } from './BaseSelectMenu.js'; /** * A builder that creates API-compatible JSON data for channel select menus. */ export class ChannelSelectMenuBuilder extends BaseSelectMenuBuilder { + protected override readonly data: Partial; + /** * Creates a new select menu from API data. * @@ -36,8 +39,9 @@ export class ChannelSelectMenuBuilder extends BaseSelectMenuBuilder) { - super({ ...data, type: ComponentType.ChannelSelect }); + public constructor(data: Partial = {}) { + super(); + this.data = { ...structuredClone(data), type: ComponentType.ChannelSelect }; } /** @@ -48,7 +52,7 @@ export class ChannelSelectMenuBuilder extends BaseSelectMenuBuilder) { const normalizedTypes = normalizeArray(types); this.data.channel_types ??= []; - this.data.channel_types.push(...channelTypesValidator.parse(normalizedTypes)); + this.data.channel_types.push(...normalizedTypes); return this; } @@ -60,7 +64,7 @@ export class ChannelSelectMenuBuilder extends BaseSelectMenuBuilder) { const normalizedTypes = normalizeArray(types); this.data.channel_types ??= []; - this.data.channel_types.splice(0, this.data.channel_types.length, ...channelTypesValidator.parse(normalizedTypes)); + this.data.channel_types.splice(0, this.data.channel_types.length, ...normalizedTypes); return this; } @@ -71,7 +75,6 @@ export class ChannelSelectMenuBuilder extends BaseSelectMenuBuilder) { const normalizedValues = normalizeArray(channels); - optionsLengthValidator.parse((this.data.default_values?.length ?? 0) + normalizedValues.length); this.data.default_values ??= []; this.data.default_values.push( @@ -91,7 +94,6 @@ export class ChannelSelectMenuBuilder extends BaseSelectMenuBuilder) { const normalizedValues = normalizeArray(channels); - optionsLengthValidator.parse(normalizedValues.length); this.data.default_values = normalizedValues.map((id) => ({ id, @@ -102,13 +104,15 @@ export class ChannelSelectMenuBuilder extends BaseSelectMenuBuilder { + protected override readonly data: Partial; + /** * Creates a new select menu from API data. * @@ -35,8 +38,9 @@ export class MentionableSelectMenuBuilder extends BaseSelectMenuBuilder) { - super({ ...data, type: ComponentType.MentionableSelect }); + public constructor(data: Partial = {}) { + super(); + this.data = { ...structuredClone(data), type: ComponentType.MentionableSelect }; } /** @@ -46,7 +50,6 @@ export class MentionableSelectMenuBuilder extends BaseSelectMenuBuilder) { const normalizedValues = normalizeArray(roles); - optionsLengthValidator.parse((this.data.default_values?.length ?? 0) + normalizedValues.length); this.data.default_values ??= []; this.data.default_values.push( @@ -66,7 +69,6 @@ export class MentionableSelectMenuBuilder extends BaseSelectMenuBuilder) { const normalizedValues = normalizeArray(users); - optionsLengthValidator.parse((this.data.default_values?.length ?? 0) + normalizedValues.length); this.data.default_values ??= []; this.data.default_values.push( @@ -91,7 +93,6 @@ export class MentionableSelectMenuBuilder extends BaseSelectMenuBuilder ) { const normalizedValues = normalizeArray(values); - optionsLengthValidator.parse((this.data.default_values?.length ?? 0) + normalizedValues.length); this.data.default_values ??= []; this.data.default_values.push(...normalizedValues); return this; @@ -109,8 +110,20 @@ export class MentionableSelectMenuBuilder extends BaseSelectMenuBuilder ) { const normalizedValues = normalizeArray(values); - optionsLengthValidator.parse(normalizedValues.length); this.data.default_values = normalizedValues; return this; } + + /** + * {@inheritDoc ComponentBuilder.toJSON} + */ + public override toJSON(validationOverride?: boolean): APIMentionableSelectComponent { + const clone = structuredClone(this.data); + + if (validationOverride ?? isValidationEnabled()) { + selectMenuMentionablePredicate.parse(clone); + } + + return clone as APIMentionableSelectComponent; + } } diff --git a/packages/builders/src/components/selectMenu/RoleSelectMenu.ts b/packages/builders/src/components/selectMenu/RoleSelectMenu.ts index 640be8f81539..3da65696fdbc 100644 --- a/packages/builders/src/components/selectMenu/RoleSelectMenu.ts +++ b/packages/builders/src/components/selectMenu/RoleSelectMenu.ts @@ -5,13 +5,16 @@ import { SelectMenuDefaultValueType, } from 'discord-api-types/v10'; import { type RestOrArray, normalizeArray } from '../../util/normalizeArray.js'; -import { optionsLengthValidator } from '../Assertions.js'; +import { isValidationEnabled } from '../../util/validation.js'; +import { selectMenuRolePredicate } from '../Assertions.js'; import { BaseSelectMenuBuilder } from './BaseSelectMenu.js'; /** * A builder that creates API-compatible JSON data for role select menus. */ export class RoleSelectMenuBuilder extends BaseSelectMenuBuilder { + protected override readonly data: Partial; + /** * Creates a new select menu from API data. * @@ -34,8 +37,9 @@ export class RoleSelectMenuBuilder extends BaseSelectMenuBuilder) { - super({ ...data, type: ComponentType.RoleSelect }); + public constructor(data: Partial = {}) { + super(); + this.data = { ...structuredClone(data), type: ComponentType.RoleSelect }; } /** @@ -45,7 +49,6 @@ export class RoleSelectMenuBuilder extends BaseSelectMenuBuilder) { const normalizedValues = normalizeArray(roles); - optionsLengthValidator.parse((this.data.default_values?.length ?? 0) + normalizedValues.length); this.data.default_values ??= []; this.data.default_values.push( @@ -65,7 +68,6 @@ export class RoleSelectMenuBuilder extends BaseSelectMenuBuilder) { const normalizedValues = normalizeArray(roles); - optionsLengthValidator.parse(normalizedValues.length); this.data.default_values = normalizedValues.map((id) => ({ id, @@ -74,4 +76,17 @@ export class RoleSelectMenuBuilder extends BaseSelectMenuBuilder> { + options: StringSelectMenuOptionBuilder[]; +} + /** * A builder that creates API-compatible JSON data for string select menus. */ export class StringSelectMenuBuilder extends BaseSelectMenuBuilder { + protected override readonly data: StringSelectMenuData; + /** - * The options within this select menu. + * The options for this select menu. */ - public readonly options: StringSelectMenuOptionBuilder[]; + public get options(): readonly StringSelectMenuOptionBuilder[] { + return this.data.options; + } /** * Creates a new select menu from API data. @@ -45,10 +57,13 @@ export class StringSelectMenuBuilder extends BaseSelectMenuBuilder) { - const { options, ...initData } = data ?? {}; - super({ ...initData, type: ComponentType.StringSelect }); - this.options = options?.map((option: APISelectMenuOption) => new StringSelectMenuOptionBuilder(option)) ?? []; + public constructor({ options = [], ...data }: Partial = {}) { + super(); + this.data = { + ...structuredClone(data), + options: options.map((option) => new StringSelectMenuOptionBuilder(option)), + type: ComponentType.StringSelect, + }; } /** @@ -56,16 +71,18 @@ export class StringSelectMenuBuilder extends BaseSelectMenuBuilder) { + public addOptions( + ...options: RestOrArray< + | APISelectMenuOption + | StringSelectMenuOptionBuilder + | ((builder: StringSelectMenuOptionBuilder) => StringSelectMenuOptionBuilder) + > + ) { const normalizedOptions = normalizeArray(options); - optionsLengthValidator.parse(this.options.length + normalizedOptions.length); - this.options.push( - ...normalizedOptions.map((normalizedOption) => - normalizedOption instanceof StringSelectMenuOptionBuilder - ? normalizedOption - : new StringSelectMenuOptionBuilder(jsonOptionValidator.parse(normalizedOption)), - ), - ); + const resolved = normalizedOptions.map((option) => resolveBuilder(option, StringSelectMenuOptionBuilder)); + + this.data.options.push(...resolved); + return this; } @@ -74,8 +91,14 @@ export class StringSelectMenuBuilder extends BaseSelectMenuBuilder) { - return this.spliceOptions(0, this.options.length, ...options); + public setOptions( + ...options: RestOrArray< + | APISelectMenuOption + | StringSelectMenuOptionBuilder + | ((builder: StringSelectMenuOptionBuilder) => StringSelectMenuOptionBuilder) + > + ) { + return this.spliceOptions(0, this.options.length, ...normalizeArray(options)); } /** @@ -108,36 +131,35 @@ export class StringSelectMenuBuilder extends BaseSelectMenuBuilder + ...options: ( + | APISelectMenuOption + | StringSelectMenuOptionBuilder + | ((builder: StringSelectMenuOptionBuilder) => StringSelectMenuOptionBuilder) + )[] ) { - const normalizedOptions = normalizeArray(options); - - const clone = [...this.options]; + const resolved = options.map((option) => resolveBuilder(option, StringSelectMenuOptionBuilder)); - clone.splice( - index, - deleteCount, - ...normalizedOptions.map((normalizedOption) => - normalizedOption instanceof StringSelectMenuOptionBuilder - ? normalizedOption - : new StringSelectMenuOptionBuilder(jsonOptionValidator.parse(normalizedOption)), - ), - ); + this.data.options ??= []; + this.data.options.splice(index, deleteCount, ...resolved); - optionsLengthValidator.parse(clone.length); - this.options.splice(0, this.options.length, ...clone); return this; } /** - * {@inheritDoc BaseSelectMenuBuilder.toJSON} + * {@inheritDoc ComponentBuilder.toJSON} */ - public override toJSON(): APIStringSelectComponent { - validateRequiredSelectMenuParameters(this.options, this.data.custom_id); + public override toJSON(validationOverride?: boolean): APIStringSelectComponent { + const { options, ...rest } = this.data; + const data = { + ...(structuredClone(rest) as APIStringSelectComponent), + // selectMenuStringPredicate covers the validation of options + options: options.map((option) => option.toJSON(false)), + }; + + if (validationOverride ?? isValidationEnabled()) { + selectMenuStringPredicate.parse(data); + } - return { - ...this.data, - options: this.options.map((option) => option.toJSON()), - } as APIStringSelectComponent; + return data as APIStringSelectComponent; } } diff --git a/packages/builders/src/components/selectMenu/StringSelectMenuOption.ts b/packages/builders/src/components/selectMenu/StringSelectMenuOption.ts index 3e45970878e2..c2faa5361934 100644 --- a/packages/builders/src/components/selectMenu/StringSelectMenuOption.ts +++ b/packages/builders/src/components/selectMenu/StringSelectMenuOption.ts @@ -1,16 +1,14 @@ import type { JSONEncodable } from '@discordjs/util'; import type { APIMessageComponentEmoji, APISelectMenuOption } from 'discord-api-types/v10'; -import { - defaultValidator, - emojiValidator, - labelValueDescriptionValidator, - validateRequiredSelectMenuOptionParameters, -} from '../Assertions.js'; +import { isValidationEnabled } from '../../util/validation.js'; +import { selectMenuStringOptionPredicate } from '../Assertions.js'; /** * A builder that creates API-compatible JSON data for string select menu options. */ export class StringSelectMenuOptionBuilder implements JSONEncodable { + private readonly data: Partial; + /** * Creates a new string select menu option from API data. * @@ -33,7 +31,9 @@ export class StringSelectMenuOptionBuilder implements JSONEncodable = {}) {} + public constructor(data: Partial = {}) { + this.data = structuredClone(data); + } /** * Sets the label for this option. @@ -41,7 +41,7 @@ export class StringSelectMenuOptionBuilder implements JSONEncodable { + protected override readonly data: Partial; + /** * Creates a new select menu from API data. * @@ -34,8 +37,9 @@ export class UserSelectMenuBuilder extends BaseSelectMenuBuilder) { - super({ ...data, type: ComponentType.UserSelect }); + public constructor(data: Partial = {}) { + super(); + this.data = { ...structuredClone(data), type: ComponentType.UserSelect }; } /** @@ -45,9 +49,8 @@ export class UserSelectMenuBuilder extends BaseSelectMenuBuilder) { const normalizedValues = normalizeArray(users); - optionsLengthValidator.parse((this.data.default_values?.length ?? 0) + normalizedValues.length); - this.data.default_values ??= []; + this.data.default_values ??= []; this.data.default_values.push( ...normalizedValues.map((id) => ({ id, @@ -65,7 +68,6 @@ export class UserSelectMenuBuilder extends BaseSelectMenuBuilder) { const normalizedValues = normalizeArray(users); - optionsLengthValidator.parse(normalizedValues.length); this.data.default_values = normalizedValues.map((id) => ({ id, @@ -74,4 +76,17 @@ export class UserSelectMenuBuilder extends BaseSelectMenuBuilder - implements Equatable> -{ +export class TextInputBuilder extends ComponentBuilder { + private readonly data: Partial; + /** * Creates a new text input from API data. * @@ -44,8 +32,9 @@ export class TextInputBuilder * .setStyle(TextInputStyle.Paragraph); * ``` */ - public constructor(data?: APITextInputComponent & { type?: ComponentType.TextInput }) { - super({ type: ComponentType.TextInput, ...data }); + public constructor(data: Partial = {}) { + super(); + this.data = { ...structuredClone(data), type: ComponentType.TextInput }; } /** @@ -54,7 +43,7 @@ export class TextInputBuilder * @param customId - The custom id to use */ public setCustomId(customId: string) { - this.data.custom_id = customIdValidator.parse(customId); + this.data.custom_id = customId; return this; } @@ -64,7 +53,7 @@ export class TextInputBuilder * @param label - The label to use */ public setLabel(label: string) { - this.data.label = labelValidator.parse(label); + this.data.label = label; return this; } @@ -74,7 +63,7 @@ export class TextInputBuilder * @param style - The style to use */ public setStyle(style: TextInputStyle) { - this.data.style = textInputStyleValidator.parse(style); + this.data.style = style; return this; } @@ -84,7 +73,15 @@ export class TextInputBuilder * @param minLength - The minimum length of text for this text input */ public setMinLength(minLength: number) { - this.data.min_length = minLengthValidator.parse(minLength); + this.data.min_length = minLength; + return this; + } + + /** + * Clears the minimum length of text for this text input. + */ + public clearMinLength() { + this.data.min_length = undefined; return this; } @@ -94,7 +91,15 @@ export class TextInputBuilder * @param maxLength - The maximum length of text for this text input */ public setMaxLength(maxLength: number) { - this.data.max_length = maxLengthValidator.parse(maxLength); + this.data.max_length = maxLength; + return this; + } + + /** + * Clears the maximum length of text for this text input. + */ + public clearMaxLength() { + this.data.max_length = undefined; return this; } @@ -104,7 +109,15 @@ export class TextInputBuilder * @param placeholder - The placeholder to use */ public setPlaceholder(placeholder: string) { - this.data.placeholder = placeholderValidator.parse(placeholder); + this.data.placeholder = placeholder; + return this; + } + + /** + * Clears the placeholder for this text input. + */ + public clearPlaceholder() { + this.data.placeholder = undefined; return this; } @@ -114,7 +127,15 @@ export class TextInputBuilder * @param value - The value to use */ public setValue(value: string) { - this.data.value = valueValidator.parse(value); + this.data.value = value; + return this; + } + + /** + * Clears the value for this text input. + */ + public clearValue() { + this.data.value = undefined; return this; } @@ -124,29 +145,20 @@ export class TextInputBuilder * @param required - Whether this text input is required */ public setRequired(required = true) { - this.data.required = requiredValidator.parse(required); + this.data.required = required; return this; } /** * {@inheritDoc ComponentBuilder.toJSON} */ - public toJSON(): APITextInputComponent { - validateRequiredParameters(this.data.custom_id, this.data.style, this.data.label); - - return { - ...this.data, - } as APITextInputComponent; - } + public toJSON(validationOverride?: boolean): APITextInputComponent { + const clone = structuredClone(this.data); - /** - * Whether this is equal to another structure. - */ - public equals(other: APITextInputComponent | JSONEncodable): boolean { - if (isJSONEncodable(other)) { - return isEqual(other.toJSON(), this.data); + if (validationOverride ?? isValidationEnabled()) { + textInputPredicate.parse(clone); } - return isEqual(other, this.data); + return clone as APITextInputComponent; } } diff --git a/packages/builders/src/index.ts b/packages/builders/src/index.ts index 53908612197a..2fd4a00b3db7 100644 --- a/packages/builders/src/index.ts +++ b/packages/builders/src/index.ts @@ -1,68 +1,71 @@ -export * as EmbedAssertions from './messages/embed/Assertions.js'; -export * from './messages/embed/Embed.js'; -// TODO: Consider removing this dep in the next major version -export * from '@discordjs/formatters'; - -export * as ComponentAssertions from './components/Assertions.js'; -export * from './components/ActionRow.js'; +export * from './components/button/mixins/URLOrCustomIdButtonMixin.js'; export * from './components/button/Button.js'; -export * from './components/Component.js'; -export * from './components/Components.js'; -export * from './components/textInput/TextInput.js'; -export * as TextInputAssertions from './components/textInput/Assertions.js'; -export * from './interactions/modals/Modal.js'; -export * as ModalAssertions from './interactions/modals/Assertions.js'; +export * from './components/button/CustomIdButton.js'; +export * from './components/button/SKUIdButton.js'; +export * from './components/button/URLButton.js'; export * from './components/selectMenu/BaseSelectMenu.js'; export * from './components/selectMenu/ChannelSelectMenu.js'; export * from './components/selectMenu/MentionableSelectMenu.js'; export * from './components/selectMenu/RoleSelectMenu.js'; export * from './components/selectMenu/StringSelectMenu.js'; -// TODO: Remove those aliases in v2 -export { - /** - * @deprecated Will be removed in the next major version, use {@link StringSelectMenuBuilder} instead. - */ - StringSelectMenuBuilder as SelectMenuBuilder, -} from './components/selectMenu/StringSelectMenu.js'; -export { - /** - * @deprecated Will be removed in the next major version, use {@link StringSelectMenuOptionBuilder} instead. - */ - StringSelectMenuOptionBuilder as SelectMenuOptionBuilder, -} from './components/selectMenu/StringSelectMenuOption.js'; export * from './components/selectMenu/StringSelectMenuOption.js'; export * from './components/selectMenu/UserSelectMenu.js'; -export * as SlashCommandAssertions from './interactions/slashCommands/Assertions.js'; -export * from './interactions/slashCommands/SlashCommandBuilder.js'; -export * from './interactions/slashCommands/SlashCommandSubcommands.js'; -export * from './interactions/slashCommands/options/boolean.js'; -export * from './interactions/slashCommands/options/channel.js'; -export * from './interactions/slashCommands/options/integer.js'; -export * from './interactions/slashCommands/options/mentionable.js'; -export * from './interactions/slashCommands/options/number.js'; -export * from './interactions/slashCommands/options/role.js'; -export * from './interactions/slashCommands/options/attachment.js'; -export * from './interactions/slashCommands/options/string.js'; -export * from './interactions/slashCommands/options/user.js'; -export * from './interactions/slashCommands/mixins/ApplicationCommandNumericOptionMinMaxValueMixin.js'; -export * from './interactions/slashCommands/mixins/ApplicationCommandOptionBase.js'; -export * from './interactions/slashCommands/mixins/ApplicationCommandOptionChannelTypesMixin.js'; -export * from './interactions/slashCommands/mixins/ApplicationCommandOptionWithAutocompleteMixin.js'; -export * from './interactions/slashCommands/mixins/ApplicationCommandOptionWithChoicesMixin.js'; -export * from './interactions/slashCommands/mixins/NameAndDescription.js'; -export * from './interactions/slashCommands/mixins/SharedSlashCommandOptions.js'; -export * from './interactions/slashCommands/mixins/SharedSubcommands.js'; -export * from './interactions/slashCommands/mixins/SharedSlashCommand.js'; +export * from './components/textInput/TextInput.js'; +export * as TextInputAssertions from './components/textInput/Assertions.js'; -export * as ContextMenuCommandAssertions from './interactions/contextMenuCommands/Assertions.js'; -export * from './interactions/contextMenuCommands/ContextMenuCommandBuilder.js'; +export * from './components/ActionRow.js'; +export * as ComponentAssertions from './components/Assertions.js'; +export * from './components/Component.js'; +export * from './components/Components.js'; + +export * from './interactions/commands/chatInput/mixins/ApplicationCommandNumericOptionMinMaxValueMixin.js'; +export * from './interactions/commands/chatInput/mixins/ApplicationCommandOptionBase.js'; +export * from './interactions/commands/chatInput/mixins/ApplicationCommandOptionChannelTypesMixin.js'; +export * from './interactions/commands/chatInput/mixins/ApplicationCommandOptionWithAutocompleteMixin.js'; +export * from './interactions/commands/chatInput/mixins/ApplicationCommandOptionWithChoicesMixin.js'; +export * from './interactions/commands/chatInput/mixins/SharedChatInputCommandOptions.js'; +export * from './interactions/commands/chatInput/mixins/SharedSubcommands.js'; + +export * from './interactions/commands/chatInput/options/boolean.js'; +export * from './interactions/commands/chatInput/options/channel.js'; +export * from './interactions/commands/chatInput/options/integer.js'; +export * from './interactions/commands/chatInput/options/mentionable.js'; +export * from './interactions/commands/chatInput/options/number.js'; +export * from './interactions/commands/chatInput/options/role.js'; +export * from './interactions/commands/chatInput/options/attachment.js'; +export * from './interactions/commands/chatInput/options/string.js'; +export * from './interactions/commands/chatInput/options/user.js'; + +export * as ChatInputCommandAssertions from './interactions/commands/chatInput/Assertions.js'; +export * from './interactions/commands/chatInput/ChatInputCommand.js'; +export * from './interactions/commands/chatInput/ChatInputCommandSubcommands.js'; + +export * as ContextMenuCommandAssertions from './interactions/commands/contextMenu/Assertions.js'; +export * from './interactions/commands/contextMenu/ContextMenuCommand.js'; +export * from './interactions/commands/contextMenu/MessageCommand.js'; +export * from './interactions/commands/contextMenu/UserCommand.js'; + +export * from './interactions/commands/Command.js'; +export * from './interactions/commands/SharedName.js'; +export * from './interactions/commands/SharedNameAndDescription.js'; + +export * as ModalAssertions from './interactions/modals/Assertions.js'; +export * from './interactions/modals/Modal.js'; + +export * as EmbedAssertions from './messages/embed/Assertions.js'; +export * from './messages/embed/Embed.js'; +export * from './messages/embed/EmbedAuthor.js'; +export * from './messages/embed/EmbedField.js'; +export * from './messages/embed/EmbedFooter.js'; export * from './util/componentUtil.js'; export * from './util/normalizeArray.js'; export * from './util/validation.js'; +export * as BaseAssertions from './Assertions.js'; + /** * The {@link https://github.com/discordjs/discord.js/blob/main/packages/builders#readme | @discordjs/builders} version * that you are currently using. diff --git a/packages/builders/src/interactions/commands/Command.ts b/packages/builders/src/interactions/commands/Command.ts new file mode 100644 index 000000000000..95fa60e642ea --- /dev/null +++ b/packages/builders/src/interactions/commands/Command.ts @@ -0,0 +1,83 @@ +import type { JSONEncodable } from '@discordjs/util'; +import type { + ApplicationIntegrationType, + InteractionContextType, + Permissions, + RESTPostAPIApplicationCommandsJSONBody, +} from 'discord-api-types/v10'; +import type { RestOrArray } from '../../util/normalizeArray.js'; +import { normalizeArray } from '../../util/normalizeArray.js'; + +export interface CommandData + extends Partial< + Pick< + RESTPostAPIApplicationCommandsJSONBody, + 'contexts' | 'default_member_permissions' | 'integration_types' | 'nsfw' + > + > {} + +export abstract class CommandBuilder + implements JSONEncodable +{ + protected declare readonly data: CommandData; + + /** + * Sets the contexts of this command. + * + * @param contexts - The contexts + */ + public setContexts(...contexts: RestOrArray) { + this.data.contexts = normalizeArray(contexts); + return this; + } + + /** + * Sets the integration types of this command. + * + * @param integrationTypes - The integration types + */ + public setIntegrationTypes(...integrationTypes: RestOrArray) { + this.data.integration_types = normalizeArray(integrationTypes); + return this; + } + + /** + * Sets the default permissions a member should have in order to run the command. + * + * @remarks + * You can set this to `'0'` to disable the command by default. + * @param permissions - The permissions bit field to set + * @see {@link https://discord.com/developers/docs/interactions/application-commands#permissions} + */ + public setDefaultMemberPermissions(permissions: Permissions | bigint | number) { + this.data.default_member_permissions = typeof permissions === 'string' ? permissions : permissions.toString(); + return this; + } + + /** + * Clears the default permissions a member should have in order to run the command. + */ + public clearDefaultMemberPermissions() { + this.data.default_member_permissions = undefined; + return this; + } + + /** + * Sets whether this command is NSFW. + * + * @param nsfw - Whether this command is NSFW + */ + public setNSFW(nsfw = true) { + this.data.nsfw = nsfw; + return this; + } + + /** + * Serializes this builder to API-compatible JSON data. + * + * Note that by disabling validation, there is no guarantee that the resulting object will be valid. + * + * @param validationOverride - Force validation to run/not run regardless of your global preference + */ + public abstract toJSON(validationOverride?: boolean): Command; +} diff --git a/packages/builders/src/interactions/commands/SharedName.ts b/packages/builders/src/interactions/commands/SharedName.ts new file mode 100644 index 000000000000..2a17f79c54bd --- /dev/null +++ b/packages/builders/src/interactions/commands/SharedName.ts @@ -0,0 +1,69 @@ +import type { LocaleString, RESTPostAPIApplicationCommandsJSONBody } from 'discord-api-types/v10'; + +export interface SharedNameData + extends Partial> {} + +/** + * This mixin holds name and description symbols for chat input commands. + */ +export class SharedName { + protected readonly data: SharedNameData = {}; + + /** + * Sets the name of this command. + * + * @param name - The name to use + */ + public setName(name: string): this { + this.data.name = name; + return this; + } + + /** + * Sets a name localization for this command. + * + * @param locale - The locale to set + * @param localizedName - The localized name for the given `locale` + */ + public setNameLocalization(locale: LocaleString, localizedName: string) { + this.data.name_localizations ??= {}; + this.data.name_localizations[locale] = localizedName; + + return this; + } + + /** + * Clears a name localization for this command. + * + * @param locale - The locale to clear + */ + public clearNameLocalization(locale: LocaleString) { + this.data.name_localizations ??= {}; + this.data.name_localizations[locale] = undefined; + + return this; + } + + /** + * Sets the name localizations for this command. + * + * @param localizedNames - The object of localized names to set + */ + public setNameLocalizations(localizedNames: Partial>) { + this.data.name_localizations = {}; + + for (const args of Object.entries(localizedNames)) { + this.setNameLocalization(...(args as [LocaleString, string])); + } + + return this; + } + + /** + * Clears all name localizations for this command. + */ + public clearNameLocalizations() { + this.data.name_localizations = undefined; + return this; + } +} diff --git a/packages/builders/src/interactions/commands/SharedNameAndDescription.ts b/packages/builders/src/interactions/commands/SharedNameAndDescription.ts new file mode 100644 index 000000000000..af021bc45641 --- /dev/null +++ b/packages/builders/src/interactions/commands/SharedNameAndDescription.ts @@ -0,0 +1,72 @@ +import type { APIApplicationCommand, LocaleString } from 'discord-api-types/v10'; +import type { SharedNameData } from './SharedName.js'; +import { SharedName } from './SharedName.js'; + +export interface SharedNameAndDescriptionData + extends SharedNameData, + Partial> {} + +/** + * This mixin holds name and description symbols for chat input commands. + */ +export class SharedNameAndDescription extends SharedName { + protected override readonly data: SharedNameAndDescriptionData = {}; + + /** + * Sets the description of this command. + * + * @param description - The description to use + */ + public setDescription(description: string) { + this.data.description = description; + return this; + } + + /** + * Sets a description localization for this command. + * + * @param locale - The locale to set + * @param localizedDescription - The localized description for the given `locale` + */ + public setDescriptionLocalization(locale: LocaleString, localizedDescription: string) { + this.data.description_localizations ??= {}; + this.data.description_localizations[locale] = localizedDescription; + + return this; + } + + /** + * Clears a description localization for this command. + * + * @param locale - The locale to clear + */ + public clearDescriptionLocalization(locale: LocaleString) { + this.data.description_localizations ??= {}; + this.data.description_localizations[locale] = undefined; + + return this; + } + + /** + * Sets the description localizations for this command. + * + * @param localizedDescriptions - The object of localized descriptions to set + */ + public setDescriptionLocalizations(localizedDescriptions: Partial>) { + this.data.description_localizations = {}; + + for (const args of Object.entries(localizedDescriptions)) { + this.setDescriptionLocalization(...(args as [LocaleString, string])); + } + + return this; + } + + /** + * Clears all description localizations for this command. + */ + public clearDescriptionLocalizations() { + this.data.description_localizations = undefined; + return this; + } +} diff --git a/packages/builders/src/interactions/commands/chatInput/Assertions.ts b/packages/builders/src/interactions/commands/chatInput/Assertions.ts new file mode 100644 index 000000000000..35e7dda482ec --- /dev/null +++ b/packages/builders/src/interactions/commands/chatInput/Assertions.ts @@ -0,0 +1,139 @@ +import { + ApplicationIntegrationType, + InteractionContextType, + ApplicationCommandOptionType, +} from 'discord-api-types/v10'; +import type { ZodTypeAny } from 'zod'; +import { z } from 'zod'; +import { localeMapPredicate, memberPermissionsPredicate } from '../../../Assertions.js'; +import { applicationCommandOptionAllowedChannelTypes } from './mixins/ApplicationCommandOptionChannelTypesMixin.js'; + +export const namePredicate = z + .string() + .min(1) + .max(32) + .regex(/^[\p{Ll}\p{Lm}\p{Lo}\p{N}\p{sc=Devanagari}\p{sc=Thai}_-]+$/u); + +export const descriptionPredicate = z.string().min(1).max(100); + +export const sharedNameAndDescriptionPredicate = z.object({ + name: namePredicate, + name_localizations: localeMapPredicate.optional(), + description: descriptionPredicate, + description_localizations: localeMapPredicate.optional(), +}); + +export const numericMixinNumberOptionPredicate = z.object({ + max_value: z.number().optional(), + min_value: z.number().optional(), +}); + +export const numericMixinIntegerOptionPredicate = z.object({ + max_value: z.number().int().optional(), + min_value: z.number().int().optional(), +}); + +export const channelMixinOptionPredicate = z.object({ + channel_types: z + .union( + applicationCommandOptionAllowedChannelTypes.map((type) => z.literal(type)) as unknown as [ + ZodTypeAny, + ZodTypeAny, + ...ZodTypeAny[], + ], + ) + .array() + .optional(), +}); + +export const autocompleteMixinOptionPredicate = z.object({ + autocomplete: z.literal(true), + choices: z.union([z.never(), z.never().array(), z.undefined()]), +}); + +export const choiceStringPredicate = z.string().min(1).max(100); +export const choiceNumberPredicate = z.number().min(Number.NEGATIVE_INFINITY).max(Number.POSITIVE_INFINITY); +export const choicePredicate = z.object({ + name: choiceStringPredicate, + name_localizations: localeMapPredicate.optional(), + value: z.union([choiceStringPredicate, choiceNumberPredicate]), +}); + +export const choicesOptionMixinPredicate = z.object({ + autocomplete: z.literal(false).optional(), + choices: choicePredicate.array().max(25).optional(), +}); + +export const basicOptionTypes = [ + ApplicationCommandOptionType.Attachment, + ApplicationCommandOptionType.Boolean, + ApplicationCommandOptionType.Channel, + ApplicationCommandOptionType.Integer, + ApplicationCommandOptionType.Mentionable, + ApplicationCommandOptionType.Number, + ApplicationCommandOptionType.Role, + ApplicationCommandOptionType.String, + ApplicationCommandOptionType.User, +] as const; + +export const basicOptionTypesPredicate = z.union( + basicOptionTypes.map((type) => z.literal(type)) as unknown as [ZodTypeAny, ZodTypeAny, ...ZodTypeAny[]], +); + +export const basicOptionPredicate = sharedNameAndDescriptionPredicate.extend({ + required: z.boolean().optional(), + type: basicOptionTypesPredicate, +}); + +export const autocompleteOrChoicesMixinOptionPredicate = z.discriminatedUnion('autocomplete', [ + autocompleteMixinOptionPredicate, + choicesOptionMixinPredicate, +]); + +export const channelOptionPredicate = basicOptionPredicate.merge(channelMixinOptionPredicate); + +export const integerOptionPredicate = basicOptionPredicate + .merge(numericMixinIntegerOptionPredicate) + .and(autocompleteOrChoicesMixinOptionPredicate); + +export const numberOptionPredicate = basicOptionPredicate + .merge(numericMixinNumberOptionPredicate) + .and(autocompleteOrChoicesMixinOptionPredicate); + +export const stringOptionPredicate = basicOptionPredicate + .extend({ + max_length: z.number().optional(), + min_length: z.number().optional(), + }) + .and(autocompleteOrChoicesMixinOptionPredicate); + +export const baseChatInputCommandPredicate = sharedNameAndDescriptionPredicate.extend({ + contexts: z.array(z.nativeEnum(InteractionContextType)).optional(), + default_member_permissions: memberPermissionsPredicate.optional(), + integration_types: z.array(z.nativeEnum(ApplicationIntegrationType)).optional(), + nsfw: z.boolean().optional(), +}); + +// Because you can only add options via builders, there's no need to validate whole objects here otherwise +export const chatInputCommandOptionsPredicate = z.union([ + z.object({ type: basicOptionTypesPredicate }).array(), + z.object({ type: z.literal(ApplicationCommandOptionType.Subcommand) }).array(), + z.object({ type: z.literal(ApplicationCommandOptionType.SubcommandGroup) }).array(), +]); + +export const chatInputCommandPredicate = baseChatInputCommandPredicate.extend({ + options: chatInputCommandOptionsPredicate.optional(), +}); + +export const chatInputCommandSubcommandGroupPredicate = sharedNameAndDescriptionPredicate.extend({ + type: z.literal(ApplicationCommandOptionType.SubcommandGroup), + options: z + .array(z.object({ type: z.literal(ApplicationCommandOptionType.Subcommand) })) + .min(1) + .max(25), +}); + +export const chatInputCommandSubcommandPredicate = sharedNameAndDescriptionPredicate.extend({ + type: z.literal(ApplicationCommandOptionType.Subcommand), + options: z.array(z.object({ type: basicOptionTypesPredicate })).max(25), +}); diff --git a/packages/builders/src/interactions/commands/chatInput/ChatInputCommand.ts b/packages/builders/src/interactions/commands/chatInput/ChatInputCommand.ts new file mode 100644 index 000000000000..422b5d9371ae --- /dev/null +++ b/packages/builders/src/interactions/commands/chatInput/ChatInputCommand.ts @@ -0,0 +1,37 @@ +import { ApplicationCommandType, type RESTPostAPIChatInputApplicationCommandsJSONBody } from 'discord-api-types/v10'; +import { Mixin } from 'ts-mixer'; +import { isValidationEnabled } from '../../../util/validation.js'; +import { CommandBuilder } from '../Command.js'; +import { SharedNameAndDescription } from '../SharedNameAndDescription.js'; +import { chatInputCommandPredicate } from './Assertions.js'; +import { SharedChatInputCommandOptions } from './mixins/SharedChatInputCommandOptions.js'; +import { SharedChatInputCommandSubcommands } from './mixins/SharedSubcommands.js'; + +/** + * A builder that creates API-compatible JSON data for chat input commands. + */ +export class ChatInputCommandBuilder extends Mixin( + CommandBuilder, + SharedChatInputCommandOptions, + SharedNameAndDescription, + SharedChatInputCommandSubcommands, +) { + /** + * {@inheritDoc CommandBuilder.toJSON} + */ + public toJSON(validationOverride?: boolean): RESTPostAPIChatInputApplicationCommandsJSONBody { + const { options, ...rest } = this.data; + + const data: RESTPostAPIChatInputApplicationCommandsJSONBody = { + ...structuredClone(rest as Omit), + type: ApplicationCommandType.ChatInput, + options: options?.map((option) => option.toJSON(validationOverride)), + }; + + if (validationOverride ?? isValidationEnabled()) { + chatInputCommandPredicate.parse(data); + } + + return data; + } +} diff --git a/packages/builders/src/interactions/commands/chatInput/ChatInputCommandSubcommands.ts b/packages/builders/src/interactions/commands/chatInput/ChatInputCommandSubcommands.ts new file mode 100644 index 000000000000..4a1b0e4b41e1 --- /dev/null +++ b/packages/builders/src/interactions/commands/chatInput/ChatInputCommandSubcommands.ts @@ -0,0 +1,107 @@ +import type { JSONEncodable } from '@discordjs/util'; +import type { + APIApplicationCommandSubcommandOption, + APIApplicationCommandSubcommandGroupOption, +} from 'discord-api-types/v10'; +import { ApplicationCommandOptionType } from 'discord-api-types/v10'; +import { Mixin } from 'ts-mixer'; +import { normalizeArray, type RestOrArray } from '../../../util/normalizeArray.js'; +import { resolveBuilder } from '../../../util/resolveBuilder.js'; +import { isValidationEnabled } from '../../../util/validation.js'; +import type { SharedNameAndDescriptionData } from '../SharedNameAndDescription.js'; +import { SharedNameAndDescription } from '../SharedNameAndDescription.js'; +import { chatInputCommandSubcommandGroupPredicate, chatInputCommandSubcommandPredicate } from './Assertions.js'; +import { SharedChatInputCommandOptions } from './mixins/SharedChatInputCommandOptions.js'; + +export interface ChatInputCommandSubcommandGroupData { + options?: ChatInputCommandSubcommandBuilder[]; +} + +/** + * Represents a folder for subcommands. + * + * @see {@link https://discord.com/developers/docs/interactions/application-commands#subcommands-and-subcommand-groups} + */ +export class ChatInputCommandSubcommandGroupBuilder + extends SharedNameAndDescription + implements JSONEncodable +{ + protected declare readonly data: ChatInputCommandSubcommandGroupData & SharedNameAndDescriptionData; + + /** + * Adds a new subcommand to this group. + * + * @param input - A function that returns a subcommand builder or an already built builder + */ + public addSubcommands( + ...input: RestOrArray< + | ChatInputCommandSubcommandBuilder + | ((subcommandGroup: ChatInputCommandSubcommandBuilder) => ChatInputCommandSubcommandBuilder) + > + ) { + const normalized = normalizeArray(input); + // eslint-disable-next-line @typescript-eslint/no-use-before-define + const result = normalized.map((builder) => resolveBuilder(builder, ChatInputCommandSubcommandBuilder)); + + this.data.options ??= []; + this.data.options.push(...result); + + return this; + } + + /** + * Serializes this builder to API-compatible JSON data. + * + * Note that by disabling validation, there is no guarantee that the resulting object will be valid. + * + * @param validationOverride - Force validation to run/not run regardless of your global preference + */ + public toJSON(validationOverride?: boolean): APIApplicationCommandSubcommandGroupOption { + const { options, ...rest } = this.data; + + const data = { + ...(structuredClone(rest) as Omit), + type: ApplicationCommandOptionType.SubcommandGroup as const, + options: options?.map((option) => option.toJSON(validationOverride)) ?? [], + }; + + if (validationOverride ?? isValidationEnabled()) { + chatInputCommandSubcommandGroupPredicate.parse(data); + } + + return data; + } +} + +/** + * A builder that creates API-compatible JSON data for chat input command subcommands. + * + * @see {@link https://discord.com/developers/docs/interactions/application-commands#subcommands-and-subcommand-groups} + */ +export class ChatInputCommandSubcommandBuilder + extends Mixin(SharedNameAndDescription, SharedChatInputCommandOptions) + implements JSONEncodable +{ + /** + * Serializes this builder to API-compatible JSON data. + * + * Note that by disabling validation, there is no guarantee that the resulting object will be valid. + * + * @param validationOverride - Force validation to run/not run regardless of your global preference + */ + public toJSON(validationOverride?: boolean): APIApplicationCommandSubcommandOption { + const { options, ...rest } = this.data; + + const data = { + ...(structuredClone(rest) as Omit), + type: ApplicationCommandOptionType.Subcommand as const, + options: options?.map((option) => option.toJSON(validationOverride)) ?? [], + }; + + if (validationOverride ?? isValidationEnabled()) { + chatInputCommandSubcommandPredicate.parse(data); + } + + return data; + } +} diff --git a/packages/builders/src/interactions/commands/chatInput/mixins/ApplicationCommandNumericOptionMinMaxValueMixin.ts b/packages/builders/src/interactions/commands/chatInput/mixins/ApplicationCommandNumericOptionMinMaxValueMixin.ts new file mode 100644 index 000000000000..409cae2998aa --- /dev/null +++ b/packages/builders/src/interactions/commands/chatInput/mixins/ApplicationCommandNumericOptionMinMaxValueMixin.ts @@ -0,0 +1,47 @@ +import type { APIApplicationCommandIntegerOption } from 'discord-api-types/v10'; + +export interface ApplicationCommandNumericOptionMinMaxValueData + extends Pick {} + +/** + * This mixin holds minimum and maximum symbols used for options. + */ +export abstract class ApplicationCommandNumericOptionMinMaxValueMixin { + protected declare readonly data: ApplicationCommandNumericOptionMinMaxValueData; + + /** + * Sets the maximum number value of this option. + * + * @param max - The maximum value this option can be + */ + public setMaxValue(max: number): this { + this.data.max_value = max; + return this; + } + + /** + * Removes the maximum number value of this option. + */ + public clearMaxValue(): this { + this.data.max_value = undefined; + return this; + } + + /** + * Sets the minimum number value of this option. + * + * @param min - The minimum value this option can be + */ + public setMinValue(min: number): this { + this.data.min_value = min; + return this; + } + + /** + * Removes the minimum number value of this option. + */ + public clearMinValue(): this { + this.data.min_value = undefined; + return this; + } +} diff --git a/packages/builders/src/interactions/commands/chatInput/mixins/ApplicationCommandOptionBase.ts b/packages/builders/src/interactions/commands/chatInput/mixins/ApplicationCommandOptionBase.ts new file mode 100644 index 000000000000..c0112e2b836d --- /dev/null +++ b/packages/builders/src/interactions/commands/chatInput/mixins/ApplicationCommandOptionBase.ts @@ -0,0 +1,59 @@ +import type { JSONEncodable } from '@discordjs/util'; +import type { + APIApplicationCommandBasicOption, + APIApplicationCommandOption, + ApplicationCommandOptionType, +} from 'discord-api-types/v10'; +import type { z } from 'zod'; +import { isValidationEnabled } from '../../../../util/validation.js'; +import type { SharedNameAndDescriptionData } from '../../SharedNameAndDescription.js'; +import { SharedNameAndDescription } from '../../SharedNameAndDescription.js'; +import { basicOptionPredicate } from '../Assertions.js'; + +export interface ApplicationCommandOptionBaseData extends Partial> { + type: ApplicationCommandOptionType; +} + +/** + * The base application command option builder that contains common symbols for application command builders. + */ +export abstract class ApplicationCommandOptionBase + extends SharedNameAndDescription + implements JSONEncodable +{ + protected readonly predicate: z.ZodTypeAny = basicOptionPredicate; + + protected declare readonly data: ApplicationCommandOptionBaseData & SharedNameAndDescriptionData; + + public constructor(type: ApplicationCommandOptionType) { + super(); + this.data.type = type; + } + + /** + * Sets whether this option is required. + * + * @param required - Whether this option should be required + */ + public setRequired(required = true) { + this.data.required = required; + return this; + } + + /** + * Serializes this builder to API-compatible JSON data. + * + * Note that by disabling validation, there is no guarantee that the resulting object will be valid. + * + * @param validationOverride - Force validation to run/not run regardless of your global preference + */ + public toJSON(validationOverride?: boolean): APIApplicationCommandBasicOption { + const clone = structuredClone(this.data); + + if (validationOverride ?? isValidationEnabled()) { + this.predicate.parse(clone); + } + + return clone as APIApplicationCommandBasicOption; + } +} diff --git a/packages/builders/src/interactions/commands/chatInput/mixins/ApplicationCommandOptionChannelTypesMixin.ts b/packages/builders/src/interactions/commands/chatInput/mixins/ApplicationCommandOptionChannelTypesMixin.ts new file mode 100644 index 000000000000..e9a184bb2f90 --- /dev/null +++ b/packages/builders/src/interactions/commands/chatInput/mixins/ApplicationCommandOptionChannelTypesMixin.ts @@ -0,0 +1,42 @@ +import { ChannelType, type APIApplicationCommandChannelOption } from 'discord-api-types/v10'; +import { normalizeArray, type RestOrArray } from '../../../../util/normalizeArray'; + +export const applicationCommandOptionAllowedChannelTypes = [ + ChannelType.GuildText, + ChannelType.GuildVoice, + ChannelType.GuildCategory, + ChannelType.GuildAnnouncement, + ChannelType.AnnouncementThread, + ChannelType.PublicThread, + ChannelType.PrivateThread, + ChannelType.GuildStageVoice, + ChannelType.GuildForum, + ChannelType.GuildMedia, +] as const; + +/** + * Allowed channel types used for a channel option. + */ +export type ApplicationCommandOptionAllowedChannelTypes = (typeof applicationCommandOptionAllowedChannelTypes)[number]; + +export interface ApplicationCommandOptionChannelTypesData + extends Pick {} + +/** + * This mixin holds channel type symbols used for options. + */ +export class ApplicationCommandOptionChannelTypesMixin { + protected declare readonly data: ApplicationCommandOptionChannelTypesData; + + /** + * Adds channel types to this option. + * + * @param channelTypes - The channel types + */ + public addChannelTypes(...channelTypes: RestOrArray) { + this.data.channel_types ??= []; + this.data.channel_types.push(...normalizeArray(channelTypes)); + + return this; + } +} diff --git a/packages/builders/src/interactions/commands/chatInput/mixins/ApplicationCommandOptionWithAutocompleteMixin.ts b/packages/builders/src/interactions/commands/chatInput/mixins/ApplicationCommandOptionWithAutocompleteMixin.ts new file mode 100644 index 000000000000..2e9271e2246f --- /dev/null +++ b/packages/builders/src/interactions/commands/chatInput/mixins/ApplicationCommandOptionWithAutocompleteMixin.ts @@ -0,0 +1,29 @@ +import type { + APIApplicationCommandIntegerOption, + APIApplicationCommandNumberOption, + APIApplicationCommandStringOption, +} from 'discord-api-types/v10'; + +export type AutocompletableOptions = + | APIApplicationCommandIntegerOption + | APIApplicationCommandNumberOption + | APIApplicationCommandStringOption; + +export interface ApplicationCommandOptionWithAutocompleteData extends Pick {} + +/** + * This mixin holds choices and autocomplete symbols used for options. + */ +export class ApplicationCommandOptionWithAutocompleteMixin { + protected declare readonly data: ApplicationCommandOptionWithAutocompleteData; + + /** + * Whether this option uses autocomplete. + * + * @param autocomplete - Whether this option should use autocomplete + */ + public setAutocomplete(autocomplete = true): this { + this.data.autocomplete = autocomplete; + return this; + } +} diff --git a/packages/builders/src/interactions/commands/chatInput/mixins/ApplicationCommandOptionWithChoicesMixin.ts b/packages/builders/src/interactions/commands/chatInput/mixins/ApplicationCommandOptionWithChoicesMixin.ts new file mode 100644 index 000000000000..93223390df48 --- /dev/null +++ b/packages/builders/src/interactions/commands/chatInput/mixins/ApplicationCommandOptionWithChoicesMixin.ts @@ -0,0 +1,38 @@ +import type { APIApplicationCommandOptionChoice } from 'discord-api-types/v10'; +import { normalizeArray, type RestOrArray } from '../../../../util/normalizeArray.js'; + +// Unlike other places, we're not `Pick`ing from discord-api-types. The union includes `[]` and it breaks everything. +export interface ApplicationCommandOptionWithChoicesData { + choices?: APIApplicationCommandOptionChoice[]; +} + +/** + * This mixin holds choices and autocomplete symbols used for options. + */ +export class ApplicationCommandOptionWithChoicesMixin { + protected declare readonly data: ApplicationCommandOptionWithChoicesData; + + /** + * Adds multiple choices to this option. + * + * @param choices - The choices to add + */ + public addChoices(...choices: RestOrArray>): this { + const normalizedChoices = normalizeArray(choices); + + this.data.choices ??= []; + this.data.choices.push(...normalizedChoices); + + return this; + } + + /** + * Sets multiple choices for this option. + * + * @param choices - The choices to set + */ + public setChoices(...choices: RestOrArray>): this { + this.data.choices = normalizeArray(choices); + return this; + } +} diff --git a/packages/builders/src/interactions/commands/chatInput/mixins/SharedChatInputCommandOptions.ts b/packages/builders/src/interactions/commands/chatInput/mixins/SharedChatInputCommandOptions.ts new file mode 100644 index 000000000000..dd6d31291b5f --- /dev/null +++ b/packages/builders/src/interactions/commands/chatInput/mixins/SharedChatInputCommandOptions.ts @@ -0,0 +1,161 @@ +import { normalizeArray, type RestOrArray } from '../../../../util/normalizeArray.js'; +import { resolveBuilder } from '../../../../util/resolveBuilder.js'; +import { ChatInputCommandAttachmentOption } from '../options/attachment.js'; +import { ChatInputCommandBooleanOption } from '../options/boolean.js'; +import { ChatInputCommandChannelOption } from '../options/channel.js'; +import { ChatInputCommandIntegerOption } from '../options/integer.js'; +import { ChatInputCommandMentionableOption } from '../options/mentionable.js'; +import { ChatInputCommandNumberOption } from '../options/number.js'; +import { ChatInputCommandRoleOption } from '../options/role.js'; +import { ChatInputCommandStringOption } from '../options/string.js'; +import { ChatInputCommandUserOption } from '../options/user.js'; +import type { ApplicationCommandOptionBase } from './ApplicationCommandOptionBase.js'; + +export interface SharedChatInputCommandOptions { + options?: ApplicationCommandOptionBase[]; +} +/** + * This mixin holds symbols that can be shared in chat input command options. + * + * @typeParam TypeAfterAddingOptions - The type this class should return after adding an option. + */ +export class SharedChatInputCommandOptions { + protected declare readonly data: SharedChatInputCommandOptions; + + /** + * Adds boolean options. + * + * @param options - Options to add + */ + public addBooleanOptions( + ...options: RestOrArray< + ChatInputCommandBooleanOption | ((builder: ChatInputCommandBooleanOption) => ChatInputCommandBooleanOption) + > + ) { + return this.sharedAddOptions(ChatInputCommandBooleanOption, ...options); + } + + /** + * Adds user options. + * + * @param options - Options to add + */ + public addUserOptions( + ...options: RestOrArray< + ChatInputCommandUserOption | ((builder: ChatInputCommandUserOption) => ChatInputCommandUserOption) + > + ) { + return this.sharedAddOptions(ChatInputCommandUserOption, ...options); + } + + /** + * Adds channel options. + * + * @param options - Options to add + */ + public addChannelOptions( + ...options: RestOrArray< + ChatInputCommandChannelOption | ((builder: ChatInputCommandChannelOption) => ChatInputCommandChannelOption) + > + ) { + return this.sharedAddOptions(ChatInputCommandChannelOption, ...options); + } + + /** + * Adds role options. + * + * @param options - Options to add + */ + public addRoleOptions( + ...options: RestOrArray< + ChatInputCommandRoleOption | ((builder: ChatInputCommandRoleOption) => ChatInputCommandRoleOption) + > + ) { + return this.sharedAddOptions(ChatInputCommandRoleOption, ...options); + } + + /** + * Adds attachment options. + * + * @param options - Options to add + */ + public addAttachmentOptions( + ...options: RestOrArray< + | ChatInputCommandAttachmentOption + | ((builder: ChatInputCommandAttachmentOption) => ChatInputCommandAttachmentOption) + > + ) { + return this.sharedAddOptions(ChatInputCommandAttachmentOption, ...options); + } + + /** + * Adds mentionable options. + * + * @param options - Options to add + */ + public addMentionableOptions( + ...options: RestOrArray< + | ChatInputCommandMentionableOption + | ((builder: ChatInputCommandMentionableOption) => ChatInputCommandMentionableOption) + > + ) { + return this.sharedAddOptions(ChatInputCommandMentionableOption, ...options); + } + + /** + * Adds string options. + * + * @param options - Options to add + */ + public addStringOptions( + ...options: RestOrArray< + ChatInputCommandStringOption | ((builder: ChatInputCommandStringOption) => ChatInputCommandStringOption) + > + ) { + return this.sharedAddOptions(ChatInputCommandStringOption, ...options); + } + + /** + * Adds integer options. + * + * @param options - Options to add + */ + public addIntegerOptions( + ...options: RestOrArray< + ChatInputCommandIntegerOption | ((builder: ChatInputCommandIntegerOption) => ChatInputCommandIntegerOption) + > + ) { + return this.sharedAddOptions(ChatInputCommandIntegerOption, ...options); + } + + /** + * Adds number options. + * + * @param options - Options to add + */ + public addNumberOptions( + ...options: RestOrArray< + ChatInputCommandNumberOption | ((builder: ChatInputCommandNumberOption) => ChatInputCommandNumberOption) + > + ) { + return this.sharedAddOptions(ChatInputCommandNumberOption, ...options); + } + + /** + * Where the actual adding magic happens. ✨ + * + * @internal + */ + private sharedAddOptions( + Instance: new () => OptionBuilder, + ...options: RestOrArray OptionBuilder)> + ): this { + const normalized = normalizeArray(options); + const resolved = normalized.map((option) => resolveBuilder(option, Instance)); + + this.data.options ??= []; + this.data.options.push(...resolved); + + return this; + } +} diff --git a/packages/builders/src/interactions/commands/chatInput/mixins/SharedSubcommands.ts b/packages/builders/src/interactions/commands/chatInput/mixins/SharedSubcommands.ts new file mode 100644 index 000000000000..a3d03edf8fa6 --- /dev/null +++ b/packages/builders/src/interactions/commands/chatInput/mixins/SharedSubcommands.ts @@ -0,0 +1,60 @@ +import type { RestOrArray } from '../../../../util/normalizeArray.js'; +import { normalizeArray } from '../../../../util/normalizeArray.js'; +import { resolveBuilder } from '../../../../util/resolveBuilder.js'; +import { + ChatInputCommandSubcommandGroupBuilder, + ChatInputCommandSubcommandBuilder, +} from '../ChatInputCommandSubcommands.js'; + +export interface SharedChatInputCommandSubcommandsData { + options?: (ChatInputCommandSubcommandBuilder | ChatInputCommandSubcommandGroupBuilder)[]; +} + +/** + * This mixin holds symbols that can be shared in chat input subcommands. + * + * @typeParam TypeAfterAddingSubcommands - The type this class should return after adding a subcommand or subcommand group. + */ +export class SharedChatInputCommandSubcommands { + protected declare readonly data: SharedChatInputCommandSubcommandsData; + + /** + * Adds subcommand groups to this command. + * + * @param input - Subcommand groups to add + */ + public addSubcommandGroups( + ...input: RestOrArray< + | ChatInputCommandSubcommandGroupBuilder + | ((subcommandGroup: ChatInputCommandSubcommandGroupBuilder) => ChatInputCommandSubcommandGroupBuilder) + > + ): this { + const normalized = normalizeArray(input); + const resolved = normalized.map((value) => resolveBuilder(value, ChatInputCommandSubcommandGroupBuilder)); + + this.data.options ??= []; + this.data.options.push(...resolved); + + return this; + } + + /** + * Adds subcommands to this command. + * + * @param input - Subcommands to add + */ + public addSubcommands( + ...input: RestOrArray< + | ChatInputCommandSubcommandBuilder + | ((subcommandGroup: ChatInputCommandSubcommandBuilder) => ChatInputCommandSubcommandBuilder) + > + ): this { + const normalized = normalizeArray(input); + const resolved = normalized.map((value) => resolveBuilder(value, ChatInputCommandSubcommandBuilder)); + + this.data.options ??= []; + this.data.options.push(...resolved); + + return this; + } +} diff --git a/packages/builders/src/interactions/commands/chatInput/options/attachment.ts b/packages/builders/src/interactions/commands/chatInput/options/attachment.ts new file mode 100644 index 000000000000..36d887df2bf4 --- /dev/null +++ b/packages/builders/src/interactions/commands/chatInput/options/attachment.ts @@ -0,0 +1,11 @@ +import { ApplicationCommandOptionType } from 'discord-api-types/v10'; +import { ApplicationCommandOptionBase } from '../mixins/ApplicationCommandOptionBase.js'; + +/** + * A chat input command attachment option. + */ +export class ChatInputCommandAttachmentOption extends ApplicationCommandOptionBase { + public constructor() { + super(ApplicationCommandOptionType.Attachment); + } +} diff --git a/packages/builders/src/interactions/commands/chatInput/options/boolean.ts b/packages/builders/src/interactions/commands/chatInput/options/boolean.ts new file mode 100644 index 000000000000..c284b020b316 --- /dev/null +++ b/packages/builders/src/interactions/commands/chatInput/options/boolean.ts @@ -0,0 +1,11 @@ +import { ApplicationCommandOptionType } from 'discord-api-types/v10'; +import { ApplicationCommandOptionBase } from '../mixins/ApplicationCommandOptionBase.js'; + +/** + * A chat input command boolean option. + */ +export class ChatInputCommandBooleanOption extends ApplicationCommandOptionBase { + public constructor() { + super(ApplicationCommandOptionType.Boolean); + } +} diff --git a/packages/builders/src/interactions/commands/chatInput/options/channel.ts b/packages/builders/src/interactions/commands/chatInput/options/channel.ts new file mode 100644 index 000000000000..62c5b2a053e4 --- /dev/null +++ b/packages/builders/src/interactions/commands/chatInput/options/channel.ts @@ -0,0 +1,19 @@ +import { ApplicationCommandOptionType } from 'discord-api-types/v10'; +import { Mixin } from 'ts-mixer'; +import { channelOptionPredicate } from '../Assertions.js'; +import { ApplicationCommandOptionBase } from '../mixins/ApplicationCommandOptionBase.js'; +import { ApplicationCommandOptionChannelTypesMixin } from '../mixins/ApplicationCommandOptionChannelTypesMixin.js'; + +/** + * A chat input command channel option. + */ +export class ChatInputCommandChannelOption extends Mixin( + ApplicationCommandOptionBase, + ApplicationCommandOptionChannelTypesMixin, +) { + protected override readonly predicate = channelOptionPredicate; + + public constructor() { + super(ApplicationCommandOptionType.Channel); + } +} diff --git a/packages/builders/src/interactions/commands/chatInput/options/integer.ts b/packages/builders/src/interactions/commands/chatInput/options/integer.ts new file mode 100644 index 000000000000..631ece91850f --- /dev/null +++ b/packages/builders/src/interactions/commands/chatInput/options/integer.ts @@ -0,0 +1,23 @@ +import { ApplicationCommandOptionType } from 'discord-api-types/v10'; +import { Mixin } from 'ts-mixer'; +import { integerOptionPredicate } from '../Assertions.js'; +import { ApplicationCommandNumericOptionMinMaxValueMixin } from '../mixins/ApplicationCommandNumericOptionMinMaxValueMixin.js'; +import { ApplicationCommandOptionBase } from '../mixins/ApplicationCommandOptionBase.js'; +import { ApplicationCommandOptionWithAutocompleteMixin } from '../mixins/ApplicationCommandOptionWithAutocompleteMixin.js'; +import { ApplicationCommandOptionWithChoicesMixin } from '../mixins/ApplicationCommandOptionWithChoicesMixin.js'; + +/** + * A chat input command integer option. + */ +export class ChatInputCommandIntegerOption extends Mixin( + ApplicationCommandOptionBase, + ApplicationCommandNumericOptionMinMaxValueMixin, + ApplicationCommandOptionWithAutocompleteMixin, + ApplicationCommandOptionWithChoicesMixin, +) { + protected override readonly predicate = integerOptionPredicate; + + public constructor() { + super(ApplicationCommandOptionType.Integer); + } +} diff --git a/packages/builders/src/interactions/commands/chatInput/options/mentionable.ts b/packages/builders/src/interactions/commands/chatInput/options/mentionable.ts new file mode 100644 index 000000000000..eba6f1a80a61 --- /dev/null +++ b/packages/builders/src/interactions/commands/chatInput/options/mentionable.ts @@ -0,0 +1,11 @@ +import { ApplicationCommandOptionType } from 'discord-api-types/v10'; +import { ApplicationCommandOptionBase } from '../mixins/ApplicationCommandOptionBase.js'; + +/** + * A chat input command mentionable option. + */ +export class ChatInputCommandMentionableOption extends ApplicationCommandOptionBase { + public constructor() { + super(ApplicationCommandOptionType.Mentionable); + } +} diff --git a/packages/builders/src/interactions/commands/chatInput/options/number.ts b/packages/builders/src/interactions/commands/chatInput/options/number.ts new file mode 100644 index 000000000000..48d06cbc0ef5 --- /dev/null +++ b/packages/builders/src/interactions/commands/chatInput/options/number.ts @@ -0,0 +1,23 @@ +import { ApplicationCommandOptionType } from 'discord-api-types/v10'; +import { Mixin } from 'ts-mixer'; +import { numberOptionPredicate } from '../Assertions.js'; +import { ApplicationCommandNumericOptionMinMaxValueMixin } from '../mixins/ApplicationCommandNumericOptionMinMaxValueMixin.js'; +import { ApplicationCommandOptionBase } from '../mixins/ApplicationCommandOptionBase.js'; +import { ApplicationCommandOptionWithAutocompleteMixin } from '../mixins/ApplicationCommandOptionWithAutocompleteMixin.js'; +import { ApplicationCommandOptionWithChoicesMixin } from '../mixins/ApplicationCommandOptionWithChoicesMixin.js'; + +/** + * A chat input command number option. + */ +export class ChatInputCommandNumberOption extends Mixin( + ApplicationCommandOptionBase, + ApplicationCommandNumericOptionMinMaxValueMixin, + ApplicationCommandOptionWithAutocompleteMixin, + ApplicationCommandOptionWithChoicesMixin, +) { + protected override readonly predicate = numberOptionPredicate; + + public constructor() { + super(ApplicationCommandOptionType.Number); + } +} diff --git a/packages/builders/src/interactions/commands/chatInput/options/role.ts b/packages/builders/src/interactions/commands/chatInput/options/role.ts new file mode 100644 index 000000000000..8a1f5ea1dc21 --- /dev/null +++ b/packages/builders/src/interactions/commands/chatInput/options/role.ts @@ -0,0 +1,11 @@ +import { ApplicationCommandOptionType } from 'discord-api-types/v10'; +import { ApplicationCommandOptionBase } from '../mixins/ApplicationCommandOptionBase.js'; + +/** + * A chat input command role option. + */ +export class ChatInputCommandRoleOption extends ApplicationCommandOptionBase { + public constructor() { + super(ApplicationCommandOptionType.Role); + } +} diff --git a/packages/builders/src/interactions/commands/chatInput/options/string.ts b/packages/builders/src/interactions/commands/chatInput/options/string.ts new file mode 100644 index 000000000000..7949eab63b03 --- /dev/null +++ b/packages/builders/src/interactions/commands/chatInput/options/string.ts @@ -0,0 +1,65 @@ +import { ApplicationCommandOptionType, type APIApplicationCommandStringOption } from 'discord-api-types/v10'; +import { Mixin } from 'ts-mixer'; +import { stringOptionPredicate } from '../Assertions.js'; +import type { ApplicationCommandOptionBaseData } from '../mixins/ApplicationCommandOptionBase.js'; +import { ApplicationCommandOptionBase } from '../mixins/ApplicationCommandOptionBase.js'; +import type { ApplicationCommandOptionWithAutocompleteData } from '../mixins/ApplicationCommandOptionWithAutocompleteMixin.js'; +import { ApplicationCommandOptionWithAutocompleteMixin } from '../mixins/ApplicationCommandOptionWithAutocompleteMixin.js'; +import type { ApplicationCommandOptionWithChoicesData } from '../mixins/ApplicationCommandOptionWithChoicesMixin.js'; +import { ApplicationCommandOptionWithChoicesMixin } from '../mixins/ApplicationCommandOptionWithChoicesMixin.js'; + +/** + * A chat input command string option. + */ +export class ChatInputCommandStringOption extends Mixin( + ApplicationCommandOptionBase, + ApplicationCommandOptionWithAutocompleteMixin, + ApplicationCommandOptionWithChoicesMixin, +) { + protected override readonly predicate = stringOptionPredicate; + + protected declare readonly data: ApplicationCommandOptionBaseData & + ApplicationCommandOptionWithAutocompleteData & + ApplicationCommandOptionWithChoicesData & + Partial>; + + public constructor() { + super(ApplicationCommandOptionType.String); + } + + /** + * Sets the maximum length of this string option. + * + * @param max - The maximum length this option can be + */ + public setMaxLength(max: number): this { + this.data.max_length = max; + return this; + } + + /** + * Clears the maximum length of this string option. + */ + public clearMaxLength(): this { + this.data.max_length = undefined; + return this; + } + + /** + * Sets the minimum length of this string option. + * + * @param min - The minimum length this option can be + */ + public setMinLength(min: number): this { + this.data.min_length = min; + return this; + } + + /** + * Clears the minimum length of this string option. + */ + public clearMinLength(): this { + this.data.min_length = undefined; + return this; + } +} diff --git a/packages/builders/src/interactions/commands/chatInput/options/user.ts b/packages/builders/src/interactions/commands/chatInput/options/user.ts new file mode 100644 index 000000000000..3a392a000232 --- /dev/null +++ b/packages/builders/src/interactions/commands/chatInput/options/user.ts @@ -0,0 +1,11 @@ +import { ApplicationCommandOptionType } from 'discord-api-types/v10'; +import { ApplicationCommandOptionBase } from '../mixins/ApplicationCommandOptionBase.js'; + +/** + * A chat input command user option. + */ +export class ChatInputCommandUserOption extends ApplicationCommandOptionBase { + public constructor() { + super(ApplicationCommandOptionType.User); + } +} diff --git a/packages/builders/src/interactions/commands/contextMenu/Assertions.ts b/packages/builders/src/interactions/commands/contextMenu/Assertions.ts new file mode 100644 index 000000000000..6bab39a70399 --- /dev/null +++ b/packages/builders/src/interactions/commands/contextMenu/Assertions.ts @@ -0,0 +1,34 @@ +import { ApplicationCommandType, ApplicationIntegrationType, InteractionContextType } from 'discord-api-types/v10'; +import { z } from 'zod'; +import { localeMapPredicate, memberPermissionsPredicate } from '../../../Assertions.js'; + +export const namePredicate = z + .string() + .min(1) + .max(32) + // eslint-disable-next-line prefer-named-capture-group + .regex(/^( *[\p{P}\p{L}\p{N}\p{sc=Devanagari}\p{sc=Thai}]+ *)+$/u); +export const typePredicate = z.union([ + z.literal(ApplicationCommandType.User), + z.literal(ApplicationCommandType.Message), +]); + +export const contextsPredicate = z.array(z.nativeEnum(InteractionContextType)); +export const integrationTypesPredicate = z.array(z.nativeEnum(ApplicationIntegrationType)); + +export const baseContextMenuCommandPredicate = z.object({ + contexts: contextsPredicate.optional(), + default_member_permissions: memberPermissionsPredicate.optional(), + name: namePredicate, + name_localizations: localeMapPredicate.optional(), + integration_types: integrationTypesPredicate.optional(), + nsfw: z.boolean().optional(), +}); + +export const userCommandPredicate = baseContextMenuCommandPredicate.extend({ + type: z.literal(ApplicationCommandType.User), +}); + +export const messageCommandPredicate = baseContextMenuCommandPredicate.extend({ + type: z.literal(ApplicationCommandType.Message), +}); diff --git a/packages/builders/src/interactions/commands/contextMenu/ContextMenuCommand.ts b/packages/builders/src/interactions/commands/contextMenu/ContextMenuCommand.ts new file mode 100644 index 000000000000..1d12a8134612 --- /dev/null +++ b/packages/builders/src/interactions/commands/contextMenu/ContextMenuCommand.ts @@ -0,0 +1,29 @@ +import type { ApplicationCommandType, RESTPostAPIContextMenuApplicationCommandsJSONBody } from 'discord-api-types/v10'; +import { Mixin } from 'ts-mixer'; +import { CommandBuilder } from '../Command.js'; +import { SharedName } from '../SharedName.js'; + +/** + * The type a context menu command can be. + */ +export type ContextMenuCommandType = ApplicationCommandType.Message | ApplicationCommandType.User; + +/** + * A builder that creates API-compatible JSON data for context menu commands. + */ +export abstract class ContextMenuCommandBuilder extends Mixin( + CommandBuilder, + SharedName, +) { + protected override readonly data: Partial; + + public constructor(data: Partial = {}) { + super(); + this.data = structuredClone(data); + } + + /** + * {@inheritDoc CommandBuilder.toJSON} + */ + public abstract override toJSON(validationOverride?: boolean): RESTPostAPIContextMenuApplicationCommandsJSONBody; +} diff --git a/packages/builders/src/interactions/commands/contextMenu/MessageCommand.ts b/packages/builders/src/interactions/commands/contextMenu/MessageCommand.ts new file mode 100644 index 000000000000..ccaab7bc33eb --- /dev/null +++ b/packages/builders/src/interactions/commands/contextMenu/MessageCommand.ts @@ -0,0 +1,19 @@ +import { ApplicationCommandType, type RESTPostAPIContextMenuApplicationCommandsJSONBody } from 'discord-api-types/v10'; +import { isValidationEnabled } from '../../../util/validation.js'; +import { messageCommandPredicate } from './Assertions.js'; +import { ContextMenuCommandBuilder } from './ContextMenuCommand.js'; + +export class MessageContextCommandBuilder extends ContextMenuCommandBuilder { + /** + * {@inheritDoc CommandBuilder.toJSON} + */ + public override toJSON(validationOverride?: boolean): RESTPostAPIContextMenuApplicationCommandsJSONBody { + const data = { ...structuredClone(this.data), type: ApplicationCommandType.Message }; + + if (validationOverride ?? isValidationEnabled()) { + messageCommandPredicate.parse(data); + } + + return data as RESTPostAPIContextMenuApplicationCommandsJSONBody; + } +} diff --git a/packages/builders/src/interactions/commands/contextMenu/UserCommand.ts b/packages/builders/src/interactions/commands/contextMenu/UserCommand.ts new file mode 100644 index 000000000000..b911fb11f387 --- /dev/null +++ b/packages/builders/src/interactions/commands/contextMenu/UserCommand.ts @@ -0,0 +1,19 @@ +import { ApplicationCommandType, type RESTPostAPIContextMenuApplicationCommandsJSONBody } from 'discord-api-types/v10'; +import { isValidationEnabled } from '../../../util/validation.js'; +import { userCommandPredicate } from './Assertions.js'; +import { ContextMenuCommandBuilder } from './ContextMenuCommand.js'; + +export class UserContextCommandBuilder extends ContextMenuCommandBuilder { + /** + * {@inheritDoc CommandBuilder.toJSON} + */ + public override toJSON(validationOverride?: boolean): RESTPostAPIContextMenuApplicationCommandsJSONBody { + const data = { ...structuredClone(this.data), type: ApplicationCommandType.User }; + + if (validationOverride ?? isValidationEnabled()) { + userCommandPredicate.parse(data); + } + + return data as RESTPostAPIContextMenuApplicationCommandsJSONBody; + } +} diff --git a/packages/builders/src/interactions/contextMenuCommands/Assertions.ts b/packages/builders/src/interactions/contextMenuCommands/Assertions.ts deleted file mode 100644 index 72d6c50f05cf..000000000000 --- a/packages/builders/src/interactions/contextMenuCommands/Assertions.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { s } from '@sapphire/shapeshift'; -import { ApplicationCommandType, ApplicationIntegrationType, InteractionContextType } from 'discord-api-types/v10'; -import { isValidationEnabled } from '../../util/validation.js'; -import type { ContextMenuCommandType } from './ContextMenuCommandBuilder.js'; - -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) - .setValidationEnabled(isValidationEnabled); -const typePredicate = s - .union([s.literal(ApplicationCommandType.User), s.literal(ApplicationCommandType.Message)]) - .setValidationEnabled(isValidationEnabled); -const booleanPredicate = s.boolean(); - -export function validateDefaultPermission(value: unknown): asserts value is boolean { - booleanPredicate.parse(value); -} - -export function validateName(name: unknown): asserts name is string { - namePredicate.parse(name); -} - -export function validateType(type: unknown): asserts type is ContextMenuCommandType { - typePredicate.parse(type); -} - -export function validateRequiredParameters(name: string, type: number) { - // Assert name matches all conditions - validateName(name); - - // Assert type is valid - validateType(type); -} - -const dmPermissionPredicate = s.boolean().nullish(); - -export function validateDMPermission(value: unknown): asserts value is boolean | null | undefined { - dmPermissionPredicate.parse(value); -} - -const memberPermissionPredicate = s - .union([ - s.bigint().transform((value) => value.toString()), - s - .number() - .safeInt() - .transform((value) => value.toString()), - s.string().regex(/^\d+$/), - ]) - .nullish(); - -export function validateDefaultMemberPermissions(permissions: unknown) { - return memberPermissionPredicate.parse(permissions); -} - -export const contextsPredicate = s.array( - s.nativeEnum(InteractionContextType).setValidationEnabled(isValidationEnabled), -); - -export const integrationTypesPredicate = s.array( - s.nativeEnum(ApplicationIntegrationType).setValidationEnabled(isValidationEnabled), -); diff --git a/packages/builders/src/interactions/contextMenuCommands/ContextMenuCommandBuilder.ts b/packages/builders/src/interactions/contextMenuCommands/ContextMenuCommandBuilder.ts deleted file mode 100644 index db0e9712f4f1..000000000000 --- a/packages/builders/src/interactions/contextMenuCommands/ContextMenuCommandBuilder.ts +++ /dev/null @@ -1,239 +0,0 @@ -import type { - ApplicationCommandType, - ApplicationIntegrationType, - InteractionContextType, - LocaleString, - LocalizationMap, - Permissions, - RESTPostAPIContextMenuApplicationCommandsJSONBody, -} from 'discord-api-types/v10'; -import type { RestOrArray } from '../../util/normalizeArray.js'; -import { normalizeArray } from '../../util/normalizeArray.js'; -import { validateLocale, validateLocalizationMap } from '../slashCommands/Assertions.js'; -import { - validateRequiredParameters, - validateName, - validateType, - validateDefaultPermission, - validateDefaultMemberPermissions, - validateDMPermission, - contextsPredicate, - integrationTypesPredicate, -} from './Assertions.js'; - -/** - * The type a context menu command can be. - */ -export type ContextMenuCommandType = ApplicationCommandType.Message | ApplicationCommandType.User; - -/** - * A builder that creates API-compatible JSON data for context menu commands. - */ -export class ContextMenuCommandBuilder { - /** - * The name of this command. - */ - public readonly name: string = undefined!; - - /** - * The name localizations of this command. - */ - public readonly name_localizations?: LocalizationMap; - - /** - * The type of this command. - */ - public readonly type: ContextMenuCommandType = undefined!; - - /** - * The contexts for this command. - */ - public readonly contexts?: InteractionContextType[]; - - /** - * Whether this command is enabled by default when the application is added to a guild. - * - * @deprecated Use {@link ContextMenuCommandBuilder.setDefaultMemberPermissions} or {@link ContextMenuCommandBuilder.setDMPermission} instead. - */ - public readonly default_permission: boolean | undefined = undefined; - - /** - * The set of permissions represented as a bit set for the command. - */ - public readonly default_member_permissions: Permissions | null | undefined = undefined; - - /** - * Indicates whether the command is available in direct messages with the application. - * - * @remarks - * By default, commands are visible. This property is only for global commands. - * @deprecated - * Use {@link ContextMenuCommandBuilder.contexts} instead. - */ - public readonly dm_permission: boolean | undefined = undefined; - - /** - * The integration types for this command. - */ - public readonly integration_types?: ApplicationIntegrationType[]; - - /** - * Sets the contexts of this command. - * - * @param contexts - The contexts - */ - public setContexts(...contexts: RestOrArray) { - Reflect.set(this, 'contexts', contextsPredicate.parse(normalizeArray(contexts))); - - return this; - } - - /** - * Sets integration types of this command. - * - * @param integrationTypes - The integration types - */ - public setIntegrationTypes(...integrationTypes: RestOrArray) { - Reflect.set(this, 'integration_types', integrationTypesPredicate.parse(normalizeArray(integrationTypes))); - - return this; - } - - /** - * Sets the name of this command. - * - * @param name - The name to use - */ - public setName(name: string) { - // Assert the name matches the conditions - validateName(name); - - Reflect.set(this, 'name', name); - - return this; - } - - /** - * Sets the type of this command. - * - * @param type - The type to use - */ - public setType(type: ContextMenuCommandType) { - // Assert the type is valid - validateType(type); - - Reflect.set(this, 'type', type); - - return this; - } - - /** - * Sets whether the command is enabled by default when the application is added to a guild. - * - * @remarks - * If set to `false`, you will have to later `PUT` the permissions for this command. - * @param value - Whether to enable this command by default - * @see {@link https://discord.com/developers/docs/interactions/application-commands#permissions} - * @deprecated Use {@link ContextMenuCommandBuilder.setDefaultMemberPermissions} or {@link ContextMenuCommandBuilder.setDMPermission} instead. - */ - public setDefaultPermission(value: boolean) { - // Assert the value matches the conditions - validateDefaultPermission(value); - - Reflect.set(this, 'default_permission', value); - - return this; - } - - /** - * Sets the default permissions a member should have in order to run this command. - * - * @remarks - * You can set this to `'0'` to disable the command by default. - * @param permissions - The permissions bit field to set - * @see {@link https://discord.com/developers/docs/interactions/application-commands#permissions} - */ - public setDefaultMemberPermissions(permissions: Permissions | bigint | number | null | undefined) { - // Assert the value and parse it - const permissionValue = validateDefaultMemberPermissions(permissions); - - Reflect.set(this, 'default_member_permissions', permissionValue); - - return this; - } - - /** - * Sets if the command is available in direct messages with the application. - * - * @remarks - * By default, commands are visible. This method is only for global commands. - * @param enabled - Whether the command should be enabled in direct messages - * @see {@link https://discord.com/developers/docs/interactions/application-commands#permissions} - * @deprecated Use {@link ContextMenuCommandBuilder.setContexts} instead. - */ - public setDMPermission(enabled: boolean | null | undefined) { - // Assert the value matches the conditions - validateDMPermission(enabled); - - Reflect.set(this, 'dm_permission', enabled); - - return this; - } - - /** - * Sets a name localization for this command. - * - * @param locale - The locale to set - * @param localizedName - The localized name for the given `locale` - */ - public setNameLocalization(locale: LocaleString, localizedName: string | null) { - if (!this.name_localizations) { - Reflect.set(this, 'name_localizations', {}); - } - - const parsedLocale = validateLocale(locale); - - if (localizedName === null) { - this.name_localizations![parsedLocale] = null; - return this; - } - - validateName(localizedName); - - this.name_localizations![parsedLocale] = localizedName; - return this; - } - - /** - * Sets the name localizations for this command. - * - * @param localizedNames - The object of localized names to set - */ - public setNameLocalizations(localizedNames: LocalizationMap | null) { - if (localizedNames === null) { - Reflect.set(this, 'name_localizations', null); - return this; - } - - Reflect.set(this, 'name_localizations', {}); - - for (const args of Object.entries(localizedNames)) - this.setNameLocalization(...(args as [LocaleString, string | null])); - 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(): RESTPostAPIContextMenuApplicationCommandsJSONBody { - validateRequiredParameters(this.name, this.type); - - validateLocalizationMap(this.name_localizations); - - return { ...this }; - } -} diff --git a/packages/builders/src/interactions/modals/Assertions.ts b/packages/builders/src/interactions/modals/Assertions.ts index 79597ff47076..9a63c7bb7f19 100644 --- a/packages/builders/src/interactions/modals/Assertions.ts +++ b/packages/builders/src/interactions/modals/Assertions.ts @@ -1,25 +1,21 @@ -import { s } from '@sapphire/shapeshift'; -import { ActionRowBuilder, type ModalActionRowComponentBuilder } from '../../components/ActionRow.js'; -import { customIdValidator } from '../../components/Assertions.js'; -import { isValidationEnabled } from '../../util/validation.js'; +import { ComponentType } from 'discord-api-types/v10'; +import { z } from 'zod'; +import { customIdPredicate } from '../../Assertions.js'; -export const titleValidator = s - .string() - .lengthGreaterThanOrEqual(1) - .lengthLessThanOrEqual(45) - .setValidationEnabled(isValidationEnabled); -export const componentsValidator = s - .instance(ActionRowBuilder) - .array() - .lengthGreaterThanOrEqual(1) - .setValidationEnabled(isValidationEnabled); +export const titlePredicate = z.string().min(1).max(45); -export function validateRequiredParameters( - customId?: string, - title?: string, - components?: ActionRowBuilder[], -) { - customIdValidator.parse(customId); - titleValidator.parse(title); - componentsValidator.parse(components); -} +export const modalPredicate = z.object({ + title: titlePredicate, + custom_id: customIdPredicate, + components: z + .object({ + type: z.literal(ComponentType.ActionRow), + components: z + .object({ type: z.literal(ComponentType.TextInput) }) + .array() + .length(1), + }) + .array() + .min(1) + .max(5), +}); diff --git a/packages/builders/src/interactions/modals/Modal.ts b/packages/builders/src/interactions/modals/Modal.ts index 948d774df203..ba1595d9be17 100644 --- a/packages/builders/src/interactions/modals/Modal.ts +++ b/packages/builders/src/interactions/modals/Modal.ts @@ -6,11 +6,16 @@ import type { APIModalActionRowComponent, APIModalInteractionResponseCallbackData, } from 'discord-api-types/v10'; -import { ActionRowBuilder, type ModalActionRowComponentBuilder } from '../../components/ActionRow.js'; -import { customIdValidator } from '../../components/Assertions.js'; +import { ActionRowBuilder } from '../../components/ActionRow.js'; import { createComponentBuilder } from '../../components/Components.js'; import { normalizeArray, type RestOrArray } from '../../util/normalizeArray.js'; -import { titleValidator, validateRequiredParameters } from './Assertions.js'; +import { resolveBuilder } from '../../util/resolveBuilder.js'; +import { isValidationEnabled } from '../../util/validation.js'; +import { modalPredicate } from './Assertions.js'; + +export interface ModalBuilderData extends Partial> { + components: ActionRowBuilder[]; +} /** * A builder that creates API-compatible JSON data for modals. @@ -19,22 +24,25 @@ export class ModalBuilder implements JSONEncodable; + private readonly data: ModalBuilderData; /** * The components within this modal. */ - public readonly components: ActionRowBuilder[] = []; + public get components(): readonly ActionRowBuilder[] { + return this.data.components; + } /** * Creates a new modal from API data. * * @param data - The API data to create this modal with */ - public constructor({ components, ...data }: Partial = {}) { - this.data = { ...data }; - this.components = (components?.map((component) => createComponentBuilder(component)) ?? - []) as ActionRowBuilder[]; + public constructor({ components = [], ...data }: Partial = {}) { + this.data = { + ...structuredClone(data), + components: components.map((component) => createComponentBuilder(component)), + }; } /** @@ -43,7 +51,7 @@ export class ModalBuilder implements JSONEncodable | APIActionRowComponent + | ActionRowBuilder + | APIActionRowComponent + | ((builder: ActionRowBuilder) => ActionRowBuilder) > ) { - this.components.push( - ...normalizeArray(components).map((component) => - component instanceof ActionRowBuilder - ? component - : new ActionRowBuilder(component), - ), - ); + const normalized = normalizeArray(components); + const resolved = normalized.map((row) => resolveBuilder(row, ActionRowBuilder)); + + this.data.components.push(...resolved); + return this; } /** - * Sets components for this modal. + * Sets the action rows for this modal. * * @param components - The components to set */ - public setComponents(...components: RestOrArray>) { - this.components.splice(0, this.components.length, ...normalizeArray(components)); + public setActionRows(...components: RestOrArray) { + this.data.components.splice(0, this.data.components.length, ...normalizeArray(components)); return this; } /** - * {@inheritDoc ComponentBuilder.toJSON} + * Removes, replaces, or inserts action rows for this modal. + * + * @remarks + * This method behaves similarly + * to {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/splice | Array.prototype.splice()}. + * The maximum amount of action rows that can be added is 5. + * + * It's useful for modifying and adjusting order of the already-existing action rows of a modal. + * @example + * Remove the first action row: + * ```ts + * embed.spliceActionRows(0, 1); + * ``` + * @example + * Remove the first n action rows: + * ```ts + * const n = 4; + * embed.spliceActionRows(0, n); + * ``` + * @example + * Remove the last action row: + * ```ts + * embed.spliceActionRows(-1, 1); + * ``` + * @param index - The index to start at + * @param deleteCount - The number of action rows to remove + * @param rows - The replacing action row objects */ - public toJSON(): APIModalInteractionResponseCallbackData { - validateRequiredParameters(this.data.custom_id, this.data.title, this.components); + public spliceActionRows( + index: number, + deleteCount: number, + ...rows: ( + | ActionRowBuilder + | APIActionRowComponent + | ((builder: ActionRowBuilder) => ActionRowBuilder) + )[] + ): this { + const resolved = rows.map((row) => resolveBuilder(row, ActionRowBuilder)); + this.data.components.splice(index, deleteCount, ...resolved); + + return this; + } + + /** + * Serializes this builder to API-compatible JSON data. + * + * Note that by disabling validation, there is no guarantee that the resulting object will be valid. + * + * @param validationOverride - Force validation to run/not run regardless of your global preference + */ + public toJSON(validationOverride?: boolean): APIModalInteractionResponseCallbackData { + const { components, ...rest } = this.data; + + const data = { + ...structuredClone(rest), + components: components.map((component) => component.toJSON(validationOverride)), + }; + + if (validationOverride ?? isValidationEnabled()) { + modalPredicate.parse(data); + } - return { - ...this.data, - components: this.components.map((component) => component.toJSON()), - } as APIModalInteractionResponseCallbackData; + return data as APIModalInteractionResponseCallbackData; } } diff --git a/packages/builders/src/interactions/slashCommands/Assertions.ts b/packages/builders/src/interactions/slashCommands/Assertions.ts deleted file mode 100644 index cc12e4dfef9a..000000000000 --- a/packages/builders/src/interactions/slashCommands/Assertions.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { s } from '@sapphire/shapeshift'; -import { - ApplicationIntegrationType, - InteractionContextType, - Locale, - type APIApplicationCommandOptionChoice, - type LocalizationMap, -} from 'discord-api-types/v10'; -import { isValidationEnabled } from '../../util/validation.js'; -import type { ToAPIApplicationCommandOptions } from './SlashCommandBuilder.js'; -import type { SlashCommandSubcommandBuilder, SlashCommandSubcommandGroupBuilder } from './SlashCommandSubcommands.js'; -import type { ApplicationCommandOptionBase } from './mixins/ApplicationCommandOptionBase.js'; - -const namePredicate = s - .string() - .lengthGreaterThanOrEqual(1) - .lengthLessThanOrEqual(32) - .regex(/^[\p{Ll}\p{Lm}\p{Lo}\p{N}\p{sc=Devanagari}\p{sc=Thai}_-]+$/u) - .setValidationEnabled(isValidationEnabled); - -export function validateName(name: unknown): asserts name is string { - namePredicate.parse(name); -} - -const descriptionPredicate = s - .string() - .lengthGreaterThanOrEqual(1) - .lengthLessThanOrEqual(100) - .setValidationEnabled(isValidationEnabled); -const localePredicate = s.nativeEnum(Locale); - -export function validateDescription(description: unknown): asserts description is string { - descriptionPredicate.parse(description); -} - -const maxArrayLengthPredicate = s.unknown().array().lengthLessThanOrEqual(25).setValidationEnabled(isValidationEnabled); -export function validateLocale(locale: unknown) { - return localePredicate.parse(locale); -} - -export function validateMaxOptionsLength(options: unknown): asserts options is ToAPIApplicationCommandOptions[] { - maxArrayLengthPredicate.parse(options); -} - -export function validateRequiredParameters( - name: string, - description: string, - options: ToAPIApplicationCommandOptions[], -) { - // Assert name matches all conditions - validateName(name); - - // Assert description conditions - validateDescription(description); - - // Assert options conditions - validateMaxOptionsLength(options); -} - -const booleanPredicate = s.boolean(); - -export function validateDefaultPermission(value: unknown): asserts value is boolean { - booleanPredicate.parse(value); -} - -export function validateRequired(required: unknown): asserts required is boolean { - booleanPredicate.parse(required); -} - -const choicesLengthPredicate = s.number().lessThanOrEqual(25).setValidationEnabled(isValidationEnabled); - -export function validateChoicesLength(amountAdding: number, choices?: APIApplicationCommandOptionChoice[]): void { - choicesLengthPredicate.parse((choices?.length ?? 0) + amountAdding); -} - -export function assertReturnOfBuilder< - ReturnType extends ApplicationCommandOptionBase | SlashCommandSubcommandBuilder | SlashCommandSubcommandGroupBuilder, ->(input: unknown, ExpectedInstanceOf: new () => ReturnType): asserts input is ReturnType { - s.instance(ExpectedInstanceOf).parse(input); -} - -export const localizationMapPredicate = s - .object(Object.fromEntries(Object.values(Locale).map((locale) => [locale, s.string().nullish()]))) - .strict() - .nullish() - .setValidationEnabled(isValidationEnabled); - -export function validateLocalizationMap(value: unknown): asserts value is LocalizationMap { - localizationMapPredicate.parse(value); -} - -const dmPermissionPredicate = s.boolean().nullish(); - -export function validateDMPermission(value: unknown): asserts value is boolean | null | undefined { - dmPermissionPredicate.parse(value); -} - -const memberPermissionPredicate = s - .union([ - s.bigint().transform((value) => value.toString()), - s - .number() - .safeInt() - .transform((value) => value.toString()), - s.string().regex(/^\d+$/), - ]) - .nullish(); - -export function validateDefaultMemberPermissions(permissions: unknown) { - return memberPermissionPredicate.parse(permissions); -} - -export function validateNSFW(value: unknown): asserts value is boolean { - booleanPredicate.parse(value); -} - -export const contextsPredicate = s.array( - s.nativeEnum(InteractionContextType).setValidationEnabled(isValidationEnabled), -); - -export const integrationTypesPredicate = s.array( - s.nativeEnum(ApplicationIntegrationType).setValidationEnabled(isValidationEnabled), -); diff --git a/packages/builders/src/interactions/slashCommands/SlashCommandBuilder.ts b/packages/builders/src/interactions/slashCommands/SlashCommandBuilder.ts deleted file mode 100644 index ef6ae652eb8f..000000000000 --- a/packages/builders/src/interactions/slashCommands/SlashCommandBuilder.ts +++ /dev/null @@ -1,110 +0,0 @@ -import type { - APIApplicationCommandOption, - ApplicationIntegrationType, - InteractionContextType, - LocalizationMap, - Permissions, -} from 'discord-api-types/v10'; -import { mix } from 'ts-mixer'; -import { SharedNameAndDescription } from './mixins/NameAndDescription.js'; -import { SharedSlashCommand } from './mixins/SharedSlashCommand.js'; -import { SharedSlashCommandOptions } from './mixins/SharedSlashCommandOptions.js'; -import { SharedSlashCommandSubcommands } from './mixins/SharedSubcommands.js'; - -/** - * A builder that creates API-compatible JSON data for slash commands. - */ -@mix(SharedSlashCommandOptions, SharedNameAndDescription, SharedSlashCommandSubcommands, SharedSlashCommand) -export class SlashCommandBuilder { - /** - * The name of this command. - */ - public readonly name: string = undefined!; - - /** - * The name localizations of this command. - */ - public readonly name_localizations?: LocalizationMap; - - /** - * The description of this command. - */ - public readonly description: string = undefined!; - - /** - * The description localizations of this command. - */ - public readonly description_localizations?: LocalizationMap; - - /** - * The options of this command. - */ - public readonly options: ToAPIApplicationCommandOptions[] = []; - - /** - * The contexts for this command. - */ - public readonly contexts?: InteractionContextType[]; - - /** - * Whether this command is enabled by default when the application is added to a guild. - * - * @deprecated Use {@link SharedSlashCommand.setDefaultMemberPermissions} or {@link SharedSlashCommand.setDMPermission} instead. - */ - public readonly default_permission: boolean | undefined = undefined; - - /** - * The set of permissions represented as a bit set for the command. - */ - public readonly default_member_permissions: Permissions | null | undefined = undefined; - - /** - * Indicates whether the command is available in direct messages with the application. - * - * @remarks - * By default, commands are visible. This property is only for global commands. - * @deprecated - * Use {@link SlashCommandBuilder.contexts} instead. - */ - public readonly dm_permission: boolean | undefined = undefined; - - /** - * The integration types for this command. - */ - public readonly integration_types?: ApplicationIntegrationType[]; - - /** - * Whether this command is NSFW. - */ - public readonly nsfw: boolean | undefined = undefined; -} - -export interface SlashCommandBuilder - extends SharedNameAndDescription, - SharedSlashCommandOptions, - SharedSlashCommandSubcommands, - SharedSlashCommand {} - -/** - * An interface specifically for slash command subcommands. - */ -export interface SlashCommandSubcommandsOnlyBuilder - extends SharedNameAndDescription, - SharedSlashCommandSubcommands, - SharedSlashCommand {} - -/** - * An interface specifically for slash command options. - */ -export interface SlashCommandOptionsOnlyBuilder - extends SharedNameAndDescription, - SharedSlashCommandOptions, - SharedSlashCommand {} - -/** - * An interface that ensures the `toJSON()` call will return something - * that can be serialized into API-compatible data. - */ -export interface ToAPIApplicationCommandOptions { - toJSON(): APIApplicationCommandOption; -} diff --git a/packages/builders/src/interactions/slashCommands/SlashCommandSubcommands.ts b/packages/builders/src/interactions/slashCommands/SlashCommandSubcommands.ts deleted file mode 100644 index 58159e4f98a0..000000000000 --- a/packages/builders/src/interactions/slashCommands/SlashCommandSubcommands.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { - ApplicationCommandOptionType, - type APIApplicationCommandSubcommandGroupOption, - type APIApplicationCommandSubcommandOption, -} from 'discord-api-types/v10'; -import { mix } from 'ts-mixer'; -import { assertReturnOfBuilder, validateMaxOptionsLength, validateRequiredParameters } from './Assertions.js'; -import type { ToAPIApplicationCommandOptions } from './SlashCommandBuilder.js'; -import type { ApplicationCommandOptionBase } from './mixins/ApplicationCommandOptionBase.js'; -import { SharedNameAndDescription } from './mixins/NameAndDescription.js'; -import { SharedSlashCommandOptions } from './mixins/SharedSlashCommandOptions.js'; - -/** - * Represents a folder for subcommands. - * - * @see {@link https://discord.com/developers/docs/interactions/application-commands#subcommands-and-subcommand-groups} - */ -@mix(SharedNameAndDescription) -export class SlashCommandSubcommandGroupBuilder implements ToAPIApplicationCommandOptions { - /** - * The name of this subcommand group. - */ - public readonly name: string = undefined!; - - /** - * The description of this subcommand group. - */ - public readonly description: string = undefined!; - - /** - * The subcommands within this subcommand group. - */ - public readonly options: SlashCommandSubcommandBuilder[] = []; - - /** - * Adds a new subcommand to this group. - * - * @param input - A function that returns a subcommand builder or an already built builder - */ - public addSubcommand( - input: - | SlashCommandSubcommandBuilder - | ((subcommandGroup: SlashCommandSubcommandBuilder) => SlashCommandSubcommandBuilder), - ) { - const { options } = this; - - // First, assert options conditions - we cannot have more than 25 options - validateMaxOptionsLength(options); - - // Get the final result - // eslint-disable-next-line @typescript-eslint/no-use-before-define - const result = typeof input === 'function' ? input(new SlashCommandSubcommandBuilder()) : input; - - // eslint-disable-next-line @typescript-eslint/no-use-before-define - assertReturnOfBuilder(result, SlashCommandSubcommandBuilder); - - // Push it - options.push(result); - - 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(): APIApplicationCommandSubcommandGroupOption { - validateRequiredParameters(this.name, this.description, this.options); - - return { - type: ApplicationCommandOptionType.SubcommandGroup, - name: this.name, - name_localizations: this.name_localizations, - description: this.description, - description_localizations: this.description_localizations, - options: this.options.map((option) => option.toJSON()), - }; - } -} - -export interface SlashCommandSubcommandGroupBuilder extends SharedNameAndDescription {} - -/** - * A builder that creates API-compatible JSON data for slash command subcommands. - * - * @see {@link https://discord.com/developers/docs/interactions/application-commands#subcommands-and-subcommand-groups} - */ -@mix(SharedNameAndDescription, SharedSlashCommandOptions) -export class SlashCommandSubcommandBuilder implements ToAPIApplicationCommandOptions { - /** - * The name of this subcommand. - */ - public readonly name: string = undefined!; - - /** - * The description of this subcommand. - */ - public readonly description: string = undefined!; - - /** - * The options within this subcommand. - */ - public readonly options: ApplicationCommandOptionBase[] = []; - - /** - * 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(): APIApplicationCommandSubcommandOption { - validateRequiredParameters(this.name, this.description, this.options); - - return { - type: ApplicationCommandOptionType.Subcommand, - name: this.name, - name_localizations: this.name_localizations, - description: this.description, - description_localizations: this.description_localizations, - options: this.options.map((option) => option.toJSON()), - }; - } -} - -export interface SlashCommandSubcommandBuilder - extends SharedNameAndDescription, - SharedSlashCommandOptions {} diff --git a/packages/builders/src/interactions/slashCommands/mixins/ApplicationCommandNumericOptionMinMaxValueMixin.ts b/packages/builders/src/interactions/slashCommands/mixins/ApplicationCommandNumericOptionMinMaxValueMixin.ts deleted file mode 100644 index 0cdbdbe6266f..000000000000 --- a/packages/builders/src/interactions/slashCommands/mixins/ApplicationCommandNumericOptionMinMaxValueMixin.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * This mixin holds minimum and maximum symbols used for options. - */ -export abstract class ApplicationCommandNumericOptionMinMaxValueMixin { - /** - * The maximum value of this option. - */ - public readonly max_value?: number; - - /** - * The minimum value of this option. - */ - public readonly min_value?: number; - - /** - * Sets the maximum number value of this option. - * - * @param max - The maximum value this option can be - */ - public abstract setMaxValue(max: number): this; - - /** - * Sets the minimum number value of this option. - * - * @param min - The minimum value this option can be - */ - public abstract setMinValue(min: number): this; -} diff --git a/packages/builders/src/interactions/slashCommands/mixins/ApplicationCommandOptionBase.ts b/packages/builders/src/interactions/slashCommands/mixins/ApplicationCommandOptionBase.ts deleted file mode 100644 index 51f450e0f355..000000000000 --- a/packages/builders/src/interactions/slashCommands/mixins/ApplicationCommandOptionBase.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { APIApplicationCommandBasicOption, ApplicationCommandOptionType } from 'discord-api-types/v10'; -import { validateRequiredParameters, validateRequired, validateLocalizationMap } from '../Assertions.js'; -import { SharedNameAndDescription } from './NameAndDescription.js'; - -/** - * The base application command option builder that contains common symbols for application command builders. - */ -export abstract class ApplicationCommandOptionBase extends SharedNameAndDescription { - /** - * The type of this option. - */ - public abstract readonly type: ApplicationCommandOptionType; - - /** - * Whether this option is required. - * - * @defaultValue `false` - */ - public readonly required: boolean = false; - - /** - * Sets whether this option is required. - * - * @param required - Whether this option should be required - */ - public setRequired(required: boolean) { - // Assert that you actually passed a boolean - validateRequired(required); - - Reflect.set(this, 'required', required); - - 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 abstract toJSON(): APIApplicationCommandBasicOption; - - /** - * This method runs required validators on this builder. - */ - protected runRequiredValidations() { - validateRequiredParameters(this.name, this.description, []); - - // Validate localizations - validateLocalizationMap(this.name_localizations); - validateLocalizationMap(this.description_localizations); - - // Assert that you actually passed a boolean - validateRequired(this.required); - } -} diff --git a/packages/builders/src/interactions/slashCommands/mixins/ApplicationCommandOptionChannelTypesMixin.ts b/packages/builders/src/interactions/slashCommands/mixins/ApplicationCommandOptionChannelTypesMixin.ts deleted file mode 100644 index 98f3242bcd49..000000000000 --- a/packages/builders/src/interactions/slashCommands/mixins/ApplicationCommandOptionChannelTypesMixin.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { s } from '@sapphire/shapeshift'; -import { ChannelType } from 'discord-api-types/v10'; -import { normalizeArray, type RestOrArray } from '../../../util/normalizeArray'; - -/** - * The allowed channel types used for a channel option in a slash command builder. - * - * @privateRemarks This can't be dynamic because const enums are erased at runtime. - * @internal - */ -const allowedChannelTypes = [ - ChannelType.GuildText, - ChannelType.GuildVoice, - ChannelType.GuildCategory, - ChannelType.GuildAnnouncement, - ChannelType.AnnouncementThread, - ChannelType.PublicThread, - ChannelType.PrivateThread, - ChannelType.GuildStageVoice, - ChannelType.GuildForum, - ChannelType.GuildMedia, -] as const; - -/** - * The type of allowed channel types used for a channel option. - */ -export type ApplicationCommandOptionAllowedChannelTypes = (typeof allowedChannelTypes)[number]; - -const channelTypesPredicate = s.array(s.union(allowedChannelTypes.map((type) => s.literal(type)))); - -/** - * This mixin holds channel type symbols used for options. - */ -export class ApplicationCommandOptionChannelTypesMixin { - /** - * The channel types of this option. - */ - public readonly channel_types?: ApplicationCommandOptionAllowedChannelTypes[]; - - /** - * Adds channel types to this option. - * - * @param channelTypes - The channel types - */ - public addChannelTypes(...channelTypes: RestOrArray) { - if (this.channel_types === undefined) { - Reflect.set(this, 'channel_types', []); - } - - this.channel_types!.push(...channelTypesPredicate.parse(normalizeArray(channelTypes))); - - return this; - } -} diff --git a/packages/builders/src/interactions/slashCommands/mixins/ApplicationCommandOptionWithAutocompleteMixin.ts b/packages/builders/src/interactions/slashCommands/mixins/ApplicationCommandOptionWithAutocompleteMixin.ts deleted file mode 100644 index 6f2ceee10966..000000000000 --- a/packages/builders/src/interactions/slashCommands/mixins/ApplicationCommandOptionWithAutocompleteMixin.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { s } from '@sapphire/shapeshift'; -import type { ApplicationCommandOptionType } from 'discord-api-types/v10'; - -const booleanPredicate = s.boolean(); - -/** - * This mixin holds choices and autocomplete symbols used for options. - */ -export class ApplicationCommandOptionWithAutocompleteMixin { - /** - * Whether this option utilizes autocomplete. - */ - public readonly autocomplete?: boolean; - - /** - * The type of this option. - * - * @privateRemarks Since this is present and this is a mixin, this is needed. - */ - public readonly type!: ApplicationCommandOptionType; - - /** - * Whether this option uses autocomplete. - * - * @param autocomplete - Whether this option should use autocomplete - */ - public setAutocomplete(autocomplete: boolean): this { - // Assert that you actually passed a boolean - booleanPredicate.parse(autocomplete); - - if (autocomplete && 'choices' in this && Array.isArray(this.choices) && this.choices.length > 0) { - throw new RangeError('Autocomplete and choices are mutually exclusive to each other.'); - } - - Reflect.set(this, 'autocomplete', autocomplete); - - return this; - } -} diff --git a/packages/builders/src/interactions/slashCommands/mixins/ApplicationCommandOptionWithChoicesMixin.ts b/packages/builders/src/interactions/slashCommands/mixins/ApplicationCommandOptionWithChoicesMixin.ts deleted file mode 100644 index 68359b4b2130..000000000000 --- a/packages/builders/src/interactions/slashCommands/mixins/ApplicationCommandOptionWithChoicesMixin.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { s } from '@sapphire/shapeshift'; -import { ApplicationCommandOptionType, type APIApplicationCommandOptionChoice } from 'discord-api-types/v10'; -import { normalizeArray, type RestOrArray } from '../../../util/normalizeArray.js'; -import { localizationMapPredicate, validateChoicesLength } from '../Assertions.js'; - -const stringPredicate = s.string().lengthGreaterThanOrEqual(1).lengthLessThanOrEqual(100); -const numberPredicate = s.number().greaterThan(Number.NEGATIVE_INFINITY).lessThan(Number.POSITIVE_INFINITY); -const choicesPredicate = s - .object({ - name: stringPredicate, - name_localizations: localizationMapPredicate, - value: s.union([stringPredicate, numberPredicate]), - }) - .array(); - -/** - * This mixin holds choices and autocomplete symbols used for options. - */ -export class ApplicationCommandOptionWithChoicesMixin { - /** - * The choices of this option. - */ - public readonly choices?: APIApplicationCommandOptionChoice[]; - - /** - * The type of this option. - * - * @privateRemarks Since this is present and this is a mixin, this is needed. - */ - public readonly type!: ApplicationCommandOptionType; - - /** - * Adds multiple choices to this option. - * - * @param choices - The choices to add - */ - public addChoices(...choices: RestOrArray>): this { - const normalizedChoices = normalizeArray(choices); - if (normalizedChoices.length > 0 && 'autocomplete' in this && this.autocomplete) { - throw new RangeError('Autocomplete and choices are mutually exclusive to each other.'); - } - - choicesPredicate.parse(normalizedChoices); - - if (this.choices === undefined) { - Reflect.set(this, 'choices', []); - } - - validateChoicesLength(normalizedChoices.length, this.choices); - - for (const { name, name_localizations, value } of normalizedChoices) { - // Validate the value - if (this.type === ApplicationCommandOptionType.String) { - stringPredicate.parse(value); - } else { - numberPredicate.parse(value); - } - - this.choices!.push({ name, name_localizations, value }); - } - - return this; - } - - /** - * Sets multiple choices for this option. - * - * @param choices - The choices to set - */ - public setChoices>(...choices: RestOrArray): this { - const normalizedChoices = normalizeArray(choices); - if (normalizedChoices.length > 0 && 'autocomplete' in this && this.autocomplete) { - throw new RangeError('Autocomplete and choices are mutually exclusive to each other.'); - } - - choicesPredicate.parse(normalizedChoices); - - Reflect.set(this, 'choices', []); - this.addChoices(normalizedChoices); - - return this; - } -} diff --git a/packages/builders/src/interactions/slashCommands/mixins/NameAndDescription.ts b/packages/builders/src/interactions/slashCommands/mixins/NameAndDescription.ts deleted file mode 100644 index 644c9bac6fa6..000000000000 --- a/packages/builders/src/interactions/slashCommands/mixins/NameAndDescription.ts +++ /dev/null @@ -1,142 +0,0 @@ -import type { LocaleString, LocalizationMap } from 'discord-api-types/v10'; -import { validateDescription, validateLocale, validateName } from '../Assertions.js'; - -/** - * This mixin holds name and description symbols for slash commands. - */ -export class SharedNameAndDescription { - /** - * The name of this command. - */ - public readonly name!: string; - - /** - * The name localizations of this command. - */ - public readonly name_localizations?: LocalizationMap; - - /** - * The description of this command. - */ - public readonly description!: string; - - /** - * The description localizations of this command. - */ - public readonly description_localizations?: LocalizationMap; - - /** - * Sets the name of this command. - * - * @param name - The name to use - */ - public setName(name: string): this { - // Assert the name matches the conditions - validateName(name); - - Reflect.set(this, 'name', name); - - return this; - } - - /** - * Sets the description of this command. - * - * @param description - The description to use - */ - public setDescription(description: string) { - // Assert the description matches the conditions - validateDescription(description); - - Reflect.set(this, 'description', description); - - return this; - } - - /** - * Sets a name localization for this command. - * - * @param locale - The locale to set - * @param localizedName - The localized name for the given `locale` - */ - public setNameLocalization(locale: LocaleString, localizedName: string | null) { - if (!this.name_localizations) { - Reflect.set(this, 'name_localizations', {}); - } - - const parsedLocale = validateLocale(locale); - - if (localizedName === null) { - this.name_localizations![parsedLocale] = null; - return this; - } - - validateName(localizedName); - - this.name_localizations![parsedLocale] = localizedName; - return this; - } - - /** - * Sets the name localizations for this command. - * - * @param localizedNames - The object of localized names to set - */ - public setNameLocalizations(localizedNames: LocalizationMap | null) { - if (localizedNames === null) { - Reflect.set(this, 'name_localizations', null); - return this; - } - - Reflect.set(this, 'name_localizations', {}); - - for (const args of Object.entries(localizedNames)) { - this.setNameLocalization(...(args as [LocaleString, string | null])); - } - - return this; - } - - /** - * Sets a description localization for this command. - * - * @param locale - The locale to set - * @param localizedDescription - The localized description for the given locale - */ - public setDescriptionLocalization(locale: LocaleString, localizedDescription: string | null) { - if (!this.description_localizations) { - Reflect.set(this, 'description_localizations', {}); - } - - const parsedLocale = validateLocale(locale); - - if (localizedDescription === null) { - this.description_localizations![parsedLocale] = null; - return this; - } - - validateDescription(localizedDescription); - - this.description_localizations![parsedLocale] = localizedDescription; - return this; - } - - /** - * Sets the description localizations for this command. - * - * @param localizedDescriptions - The object of localized descriptions to set - */ - public setDescriptionLocalizations(localizedDescriptions: LocalizationMap | null) { - if (localizedDescriptions === null) { - Reflect.set(this, 'description_localizations', null); - return this; - } - - Reflect.set(this, 'description_localizations', {}); - for (const args of Object.entries(localizedDescriptions)) { - this.setDescriptionLocalization(...(args as [LocaleString, string | null])); - } - - return this; - } -} diff --git a/packages/builders/src/interactions/slashCommands/mixins/SharedSlashCommand.ts b/packages/builders/src/interactions/slashCommands/mixins/SharedSlashCommand.ts deleted file mode 100644 index 32b48edd459d..000000000000 --- a/packages/builders/src/interactions/slashCommands/mixins/SharedSlashCommand.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { - ApplicationCommandType, - type ApplicationIntegrationType, - type InteractionContextType, - type LocalizationMap, - type Permissions, - type RESTPostAPIChatInputApplicationCommandsJSONBody, -} from 'discord-api-types/v10'; -import type { RestOrArray } from '../../../util/normalizeArray.js'; -import { normalizeArray } from '../../../util/normalizeArray.js'; -import { - contextsPredicate, - integrationTypesPredicate, - validateDMPermission, - validateDefaultMemberPermissions, - validateDefaultPermission, - validateLocalizationMap, - validateNSFW, - validateRequiredParameters, -} from '../Assertions.js'; -import type { ToAPIApplicationCommandOptions } from '../SlashCommandBuilder.js'; - -/** - * This mixin holds symbols that can be shared in slashcommands independent of options or subcommands. - */ -export class SharedSlashCommand { - public readonly name: string = undefined!; - - public readonly name_localizations?: LocalizationMap; - - public readonly description: string = undefined!; - - public readonly description_localizations?: LocalizationMap; - - public readonly options: ToAPIApplicationCommandOptions[] = []; - - public readonly contexts?: InteractionContextType[]; - - /** - * @deprecated Use {@link SharedSlashCommand.setDefaultMemberPermissions} or {@link SharedSlashCommand.setDMPermission} instead. - */ - public readonly default_permission: boolean | undefined = undefined; - - public readonly default_member_permissions: Permissions | null | undefined = undefined; - - /** - * @deprecated Use {@link SharedSlashCommand.contexts} instead. - */ - public readonly dm_permission: boolean | undefined = undefined; - - public readonly integration_types?: ApplicationIntegrationType[]; - - public readonly nsfw: boolean | undefined = undefined; - - /** - * Sets the contexts of this command. - * - * @param contexts - The contexts - */ - public setContexts(...contexts: RestOrArray) { - Reflect.set(this, 'contexts', contextsPredicate.parse(normalizeArray(contexts))); - - return this; - } - - /** - * Sets the integration types of this command. - * - * @param integrationTypes - The integration types - */ - public setIntegrationTypes(...integrationTypes: RestOrArray) { - Reflect.set(this, 'integration_types', integrationTypesPredicate.parse(normalizeArray(integrationTypes))); - - return this; - } - - /** - * Sets whether the command is enabled by default when the application is added to a guild. - * - * @remarks - * If set to `false`, you will have to later `PUT` the permissions for this command. - * @param value - Whether or not to enable this command by default - * @see {@link https://discord.com/developers/docs/interactions/application-commands#permissions} - * @deprecated Use {@link SharedSlashCommand.setDefaultMemberPermissions} or {@link SharedSlashCommand.setDMPermission} instead. - */ - public setDefaultPermission(value: boolean) { - // Assert the value matches the conditions - validateDefaultPermission(value); - - Reflect.set(this, 'default_permission', value); - - return this; - } - - /** - * Sets the default permissions a member should have in order to run the command. - * - * @remarks - * You can set this to `'0'` to disable the command by default. - * @param permissions - The permissions bit field to set - * @see {@link https://discord.com/developers/docs/interactions/application-commands#permissions} - */ - public setDefaultMemberPermissions(permissions: Permissions | bigint | number | null | undefined) { - // Assert the value and parse it - const permissionValue = validateDefaultMemberPermissions(permissions); - - Reflect.set(this, 'default_member_permissions', permissionValue); - - return this; - } - - /** - * Sets if the command is available in direct messages with the application. - * - * @remarks - * By default, commands are visible. This method is only for global commands. - * @param enabled - Whether the command should be enabled in direct messages - * @see {@link https://discord.com/developers/docs/interactions/application-commands#permissions} - * @deprecated - * Use {@link SharedSlashCommand.setContexts} instead. - */ - public setDMPermission(enabled: boolean | null | undefined) { - // Assert the value matches the conditions - validateDMPermission(enabled); - - Reflect.set(this, 'dm_permission', enabled); - - return this; - } - - /** - * Sets whether this command is NSFW. - * - * @param nsfw - Whether this command is NSFW - */ - public setNSFW(nsfw = true) { - // Assert the value matches the conditions - validateNSFW(nsfw); - Reflect.set(this, 'nsfw', nsfw); - 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(): RESTPostAPIChatInputApplicationCommandsJSONBody { - validateRequiredParameters(this.name, this.description, this.options); - - validateLocalizationMap(this.name_localizations); - validateLocalizationMap(this.description_localizations); - - return { - ...this, - type: ApplicationCommandType.ChatInput, - options: this.options.map((option) => option.toJSON()), - }; - } -} diff --git a/packages/builders/src/interactions/slashCommands/mixins/SharedSlashCommandOptions.ts b/packages/builders/src/interactions/slashCommands/mixins/SharedSlashCommandOptions.ts deleted file mode 100644 index 6a6d1297ecec..000000000000 --- a/packages/builders/src/interactions/slashCommands/mixins/SharedSlashCommandOptions.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { assertReturnOfBuilder, validateMaxOptionsLength } from '../Assertions.js'; -import type { ToAPIApplicationCommandOptions } from '../SlashCommandBuilder'; -import { SlashCommandAttachmentOption } from '../options/attachment.js'; -import { SlashCommandBooleanOption } from '../options/boolean.js'; -import { SlashCommandChannelOption } from '../options/channel.js'; -import { SlashCommandIntegerOption } from '../options/integer.js'; -import { SlashCommandMentionableOption } from '../options/mentionable.js'; -import { SlashCommandNumberOption } from '../options/number.js'; -import { SlashCommandRoleOption } from '../options/role.js'; -import { SlashCommandStringOption } from '../options/string.js'; -import { SlashCommandUserOption } from '../options/user.js'; -import type { ApplicationCommandOptionBase } from './ApplicationCommandOptionBase.js'; - -/** - * This mixin holds symbols that can be shared in slash command options. - * - * @typeParam TypeAfterAddingOptions - The type this class should return after adding an option. - */ -export class SharedSlashCommandOptions< - TypeAfterAddingOptions extends SharedSlashCommandOptions, -> { - public readonly options!: ToAPIApplicationCommandOptions[]; - - /** - * Adds a boolean option. - * - * @param input - A function that returns an option builder or an already built builder - */ - public addBooleanOption( - input: SlashCommandBooleanOption | ((builder: SlashCommandBooleanOption) => SlashCommandBooleanOption), - ) { - return this._sharedAddOptionMethod(input, SlashCommandBooleanOption); - } - - /** - * Adds a user option. - * - * @param input - A function that returns an option builder or an already built builder - */ - public addUserOption(input: SlashCommandUserOption | ((builder: SlashCommandUserOption) => SlashCommandUserOption)) { - return this._sharedAddOptionMethod(input, SlashCommandUserOption); - } - - /** - * Adds a channel option. - * - * @param input - A function that returns an option builder or an already built builder - */ - public addChannelOption( - input: SlashCommandChannelOption | ((builder: SlashCommandChannelOption) => SlashCommandChannelOption), - ) { - return this._sharedAddOptionMethod(input, SlashCommandChannelOption); - } - - /** - * Adds a role option. - * - * @param input - A function that returns an option builder or an already built builder - */ - public addRoleOption(input: SlashCommandRoleOption | ((builder: SlashCommandRoleOption) => SlashCommandRoleOption)) { - return this._sharedAddOptionMethod(input, SlashCommandRoleOption); - } - - /** - * Adds an attachment option. - * - * @param input - A function that returns an option builder or an already built builder - */ - public addAttachmentOption( - input: SlashCommandAttachmentOption | ((builder: SlashCommandAttachmentOption) => SlashCommandAttachmentOption), - ) { - return this._sharedAddOptionMethod(input, SlashCommandAttachmentOption); - } - - /** - * Adds a mentionable option. - * - * @param input - A function that returns an option builder or an already built builder - */ - public addMentionableOption( - input: SlashCommandMentionableOption | ((builder: SlashCommandMentionableOption) => SlashCommandMentionableOption), - ) { - return this._sharedAddOptionMethod(input, SlashCommandMentionableOption); - } - - /** - * Adds a string option. - * - * @param input - A function that returns an option builder or an already built builder - */ - public addStringOption( - input: SlashCommandStringOption | ((builder: SlashCommandStringOption) => SlashCommandStringOption), - ) { - return this._sharedAddOptionMethod(input, SlashCommandStringOption); - } - - /** - * Adds an integer option. - * - * @param input - A function that returns an option builder or an already built builder - */ - public addIntegerOption( - input: SlashCommandIntegerOption | ((builder: SlashCommandIntegerOption) => SlashCommandIntegerOption), - ) { - return this._sharedAddOptionMethod(input, SlashCommandIntegerOption); - } - - /** - * Adds a number option. - * - * @param input - A function that returns an option builder or an already built builder - */ - public addNumberOption( - input: SlashCommandNumberOption | ((builder: SlashCommandNumberOption) => SlashCommandNumberOption), - ) { - return this._sharedAddOptionMethod(input, SlashCommandNumberOption); - } - - /** - * Where the actual adding magic happens. ✨ - * - * @param input - The input. What else? - * @param Instance - The instance of whatever is being added - * @internal - */ - private _sharedAddOptionMethod( - input: OptionBuilder | ((builder: OptionBuilder) => OptionBuilder), - Instance: new () => OptionBuilder, - ): TypeAfterAddingOptions { - const { options } = this; - - // First, assert options conditions - we cannot have more than 25 options - validateMaxOptionsLength(options); - - // Get the final result - const result = typeof input === 'function' ? input(new Instance()) : input; - - assertReturnOfBuilder(result, Instance); - - // Push it - options.push(result); - - return this as unknown as TypeAfterAddingOptions; - } -} diff --git a/packages/builders/src/interactions/slashCommands/mixins/SharedSubcommands.ts b/packages/builders/src/interactions/slashCommands/mixins/SharedSubcommands.ts deleted file mode 100644 index ce9274f3b6d7..000000000000 --- a/packages/builders/src/interactions/slashCommands/mixins/SharedSubcommands.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { assertReturnOfBuilder, validateMaxOptionsLength } from '../Assertions.js'; -import type { ToAPIApplicationCommandOptions } from '../SlashCommandBuilder.js'; -import { SlashCommandSubcommandBuilder, SlashCommandSubcommandGroupBuilder } from '../SlashCommandSubcommands.js'; - -/** - * This mixin holds symbols that can be shared in slash subcommands. - * - * @typeParam TypeAfterAddingSubcommands - The type this class should return after adding a subcommand or subcommand group. - */ -export class SharedSlashCommandSubcommands< - TypeAfterAddingSubcommands extends SharedSlashCommandSubcommands, -> { - public readonly options: ToAPIApplicationCommandOptions[] = []; - - /** - * Adds a new subcommand group to this command. - * - * @param input - A function that returns a subcommand group builder or an already built builder - */ - public addSubcommandGroup( - input: - | SlashCommandSubcommandGroupBuilder - | ((subcommandGroup: SlashCommandSubcommandGroupBuilder) => SlashCommandSubcommandGroupBuilder), - ): TypeAfterAddingSubcommands { - const { options } = this; - - // First, assert options conditions - we cannot have more than 25 options - validateMaxOptionsLength(options); - - // Get the final result - const result = typeof input === 'function' ? input(new SlashCommandSubcommandGroupBuilder()) : input; - - assertReturnOfBuilder(result, SlashCommandSubcommandGroupBuilder); - - // Push it - options.push(result); - - return this as unknown as TypeAfterAddingSubcommands; - } - - /** - * Adds a new subcommand to this command. - * - * @param input - A function that returns a subcommand builder or an already built builder - */ - public addSubcommand( - input: - | SlashCommandSubcommandBuilder - | ((subcommandGroup: SlashCommandSubcommandBuilder) => SlashCommandSubcommandBuilder), - ): TypeAfterAddingSubcommands { - const { options } = this; - - // First, assert options conditions - we cannot have more than 25 options - validateMaxOptionsLength(options); - - // Get the final result - const result = typeof input === 'function' ? input(new SlashCommandSubcommandBuilder()) : input; - - assertReturnOfBuilder(result, SlashCommandSubcommandBuilder); - - // Push it - options.push(result); - - return this as unknown as TypeAfterAddingSubcommands; - } -} diff --git a/packages/builders/src/interactions/slashCommands/options/attachment.ts b/packages/builders/src/interactions/slashCommands/options/attachment.ts deleted file mode 100644 index cb31812f1c4a..000000000000 --- a/packages/builders/src/interactions/slashCommands/options/attachment.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { ApplicationCommandOptionType, type APIApplicationCommandAttachmentOption } from 'discord-api-types/v10'; -import { ApplicationCommandOptionBase } from '../mixins/ApplicationCommandOptionBase.js'; - -/** - * A slash command attachment option. - */ -export class SlashCommandAttachmentOption extends ApplicationCommandOptionBase { - /** - * The type of this option. - */ - public override readonly type = ApplicationCommandOptionType.Attachment as const; - - /** - * {@inheritDoc ApplicationCommandOptionBase.toJSON} - */ - public toJSON(): APIApplicationCommandAttachmentOption { - this.runRequiredValidations(); - - return { ...this }; - } -} diff --git a/packages/builders/src/interactions/slashCommands/options/boolean.ts b/packages/builders/src/interactions/slashCommands/options/boolean.ts deleted file mode 100644 index 5d82ea77c8ae..000000000000 --- a/packages/builders/src/interactions/slashCommands/options/boolean.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { ApplicationCommandOptionType, type APIApplicationCommandBooleanOption } from 'discord-api-types/v10'; -import { ApplicationCommandOptionBase } from '../mixins/ApplicationCommandOptionBase.js'; - -/** - * A slash command boolean option. - */ -export class SlashCommandBooleanOption extends ApplicationCommandOptionBase { - /** - * The type of this option. - */ - public readonly type = ApplicationCommandOptionType.Boolean as const; - - /** - * {@inheritDoc ApplicationCommandOptionBase.toJSON} - */ - public toJSON(): APIApplicationCommandBooleanOption { - this.runRequiredValidations(); - - return { ...this }; - } -} diff --git a/packages/builders/src/interactions/slashCommands/options/channel.ts b/packages/builders/src/interactions/slashCommands/options/channel.ts deleted file mode 100644 index 89400820c004..000000000000 --- a/packages/builders/src/interactions/slashCommands/options/channel.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { ApplicationCommandOptionType, type APIApplicationCommandChannelOption } from 'discord-api-types/v10'; -import { mix } from 'ts-mixer'; -import { ApplicationCommandOptionBase } from '../mixins/ApplicationCommandOptionBase.js'; -import { ApplicationCommandOptionChannelTypesMixin } from '../mixins/ApplicationCommandOptionChannelTypesMixin.js'; - -/** - * A slash command channel option. - */ -@mix(ApplicationCommandOptionChannelTypesMixin) -export class SlashCommandChannelOption extends ApplicationCommandOptionBase { - /** - * The type of this option. - */ - public override readonly type = ApplicationCommandOptionType.Channel as const; - - /** - * {@inheritDoc ApplicationCommandOptionBase.toJSON} - */ - public toJSON(): APIApplicationCommandChannelOption { - this.runRequiredValidations(); - - return { ...this }; - } -} - -export interface SlashCommandChannelOption extends ApplicationCommandOptionChannelTypesMixin {} diff --git a/packages/builders/src/interactions/slashCommands/options/integer.ts b/packages/builders/src/interactions/slashCommands/options/integer.ts deleted file mode 100644 index 4346595878a9..000000000000 --- a/packages/builders/src/interactions/slashCommands/options/integer.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { s } from '@sapphire/shapeshift'; -import { ApplicationCommandOptionType, type APIApplicationCommandIntegerOption } from 'discord-api-types/v10'; -import { mix } from 'ts-mixer'; -import { ApplicationCommandNumericOptionMinMaxValueMixin } from '../mixins/ApplicationCommandNumericOptionMinMaxValueMixin.js'; -import { ApplicationCommandOptionBase } from '../mixins/ApplicationCommandOptionBase.js'; -import { ApplicationCommandOptionWithAutocompleteMixin } from '../mixins/ApplicationCommandOptionWithAutocompleteMixin.js'; -import { ApplicationCommandOptionWithChoicesMixin } from '../mixins/ApplicationCommandOptionWithChoicesMixin.js'; - -const numberValidator = s.number().int(); - -/** - * A slash command integer option. - */ -@mix( - ApplicationCommandNumericOptionMinMaxValueMixin, - ApplicationCommandOptionWithAutocompleteMixin, - ApplicationCommandOptionWithChoicesMixin, -) -export class SlashCommandIntegerOption - extends ApplicationCommandOptionBase - implements ApplicationCommandNumericOptionMinMaxValueMixin -{ - /** - * The type of this option. - */ - public readonly type = ApplicationCommandOptionType.Integer as const; - - /** - * {@inheritDoc ApplicationCommandNumericOptionMinMaxValueMixin.setMaxValue} - */ - public setMaxValue(max: number): this { - numberValidator.parse(max); - - Reflect.set(this, 'max_value', max); - - return this; - } - - /** - * {@inheritDoc ApplicationCommandNumericOptionMinMaxValueMixin.setMinValue} - */ - public setMinValue(min: number): this { - numberValidator.parse(min); - - Reflect.set(this, 'min_value', min); - - return this; - } - - /** - * {@inheritDoc ApplicationCommandOptionBase.toJSON} - */ - public toJSON(): APIApplicationCommandIntegerOption { - this.runRequiredValidations(); - - if (this.autocomplete && Array.isArray(this.choices) && this.choices.length > 0) { - throw new RangeError('Autocomplete and choices are mutually exclusive to each other.'); - } - - return { ...this } as APIApplicationCommandIntegerOption; - } -} - -export interface SlashCommandIntegerOption - extends ApplicationCommandNumericOptionMinMaxValueMixin, - ApplicationCommandOptionWithChoicesMixin, - ApplicationCommandOptionWithAutocompleteMixin {} diff --git a/packages/builders/src/interactions/slashCommands/options/mentionable.ts b/packages/builders/src/interactions/slashCommands/options/mentionable.ts deleted file mode 100644 index 56292f612675..000000000000 --- a/packages/builders/src/interactions/slashCommands/options/mentionable.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { ApplicationCommandOptionType, type APIApplicationCommandMentionableOption } from 'discord-api-types/v10'; -import { ApplicationCommandOptionBase } from '../mixins/ApplicationCommandOptionBase.js'; - -/** - * A slash command mentionable option. - */ -export class SlashCommandMentionableOption extends ApplicationCommandOptionBase { - /** - * The type of this option. - */ - public readonly type = ApplicationCommandOptionType.Mentionable as const; - - /** - * {@inheritDoc ApplicationCommandOptionBase.toJSON} - */ - public toJSON(): APIApplicationCommandMentionableOption { - this.runRequiredValidations(); - - return { ...this }; - } -} diff --git a/packages/builders/src/interactions/slashCommands/options/number.ts b/packages/builders/src/interactions/slashCommands/options/number.ts deleted file mode 100644 index b53bb21b5973..000000000000 --- a/packages/builders/src/interactions/slashCommands/options/number.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { s } from '@sapphire/shapeshift'; -import { ApplicationCommandOptionType, type APIApplicationCommandNumberOption } from 'discord-api-types/v10'; -import { mix } from 'ts-mixer'; -import { ApplicationCommandNumericOptionMinMaxValueMixin } from '../mixins/ApplicationCommandNumericOptionMinMaxValueMixin.js'; -import { ApplicationCommandOptionBase } from '../mixins/ApplicationCommandOptionBase.js'; -import { ApplicationCommandOptionWithAutocompleteMixin } from '../mixins/ApplicationCommandOptionWithAutocompleteMixin.js'; -import { ApplicationCommandOptionWithChoicesMixin } from '../mixins/ApplicationCommandOptionWithChoicesMixin.js'; - -const numberValidator = s.number(); - -/** - * A slash command number option. - */ -@mix( - ApplicationCommandNumericOptionMinMaxValueMixin, - ApplicationCommandOptionWithAutocompleteMixin, - ApplicationCommandOptionWithChoicesMixin, -) -export class SlashCommandNumberOption - extends ApplicationCommandOptionBase - implements ApplicationCommandNumericOptionMinMaxValueMixin -{ - /** - * The type of this option. - */ - public readonly type = ApplicationCommandOptionType.Number as const; - - /** - * {@inheritDoc ApplicationCommandNumericOptionMinMaxValueMixin.setMaxValue} - */ - public setMaxValue(max: number): this { - numberValidator.parse(max); - - Reflect.set(this, 'max_value', max); - - return this; - } - - /** - * {@inheritDoc ApplicationCommandNumericOptionMinMaxValueMixin.setMinValue} - */ - public setMinValue(min: number): this { - numberValidator.parse(min); - - Reflect.set(this, 'min_value', min); - - return this; - } - - /** - * {@inheritDoc ApplicationCommandOptionBase.toJSON} - */ - public toJSON(): APIApplicationCommandNumberOption { - this.runRequiredValidations(); - - if (this.autocomplete && Array.isArray(this.choices) && this.choices.length > 0) { - throw new RangeError('Autocomplete and choices are mutually exclusive to each other.'); - } - - return { ...this } as APIApplicationCommandNumberOption; - } -} - -export interface SlashCommandNumberOption - extends ApplicationCommandNumericOptionMinMaxValueMixin, - ApplicationCommandOptionWithChoicesMixin, - ApplicationCommandOptionWithAutocompleteMixin {} diff --git a/packages/builders/src/interactions/slashCommands/options/role.ts b/packages/builders/src/interactions/slashCommands/options/role.ts deleted file mode 100644 index 8dca05d0adc6..000000000000 --- a/packages/builders/src/interactions/slashCommands/options/role.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { ApplicationCommandOptionType, type APIApplicationCommandRoleOption } from 'discord-api-types/v10'; -import { ApplicationCommandOptionBase } from '../mixins/ApplicationCommandOptionBase.js'; - -/** - * A slash command role option. - */ -export class SlashCommandRoleOption extends ApplicationCommandOptionBase { - /** - * The type of this option. - */ - public override readonly type = ApplicationCommandOptionType.Role as const; - - /** - * {@inheritDoc ApplicationCommandOptionBase.toJSON} - */ - public toJSON(): APIApplicationCommandRoleOption { - this.runRequiredValidations(); - - return { ...this }; - } -} diff --git a/packages/builders/src/interactions/slashCommands/options/string.ts b/packages/builders/src/interactions/slashCommands/options/string.ts deleted file mode 100644 index ebe2bd8e2c2c..000000000000 --- a/packages/builders/src/interactions/slashCommands/options/string.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { s } from '@sapphire/shapeshift'; -import { ApplicationCommandOptionType, type APIApplicationCommandStringOption } from 'discord-api-types/v10'; -import { mix } from 'ts-mixer'; -import { ApplicationCommandOptionBase } from '../mixins/ApplicationCommandOptionBase.js'; -import { ApplicationCommandOptionWithAutocompleteMixin } from '../mixins/ApplicationCommandOptionWithAutocompleteMixin.js'; -import { ApplicationCommandOptionWithChoicesMixin } from '../mixins/ApplicationCommandOptionWithChoicesMixin.js'; - -const minLengthValidator = s.number().greaterThanOrEqual(0).lessThanOrEqual(6_000); -const maxLengthValidator = s.number().greaterThanOrEqual(1).lessThanOrEqual(6_000); - -/** - * A slash command string option. - */ -@mix(ApplicationCommandOptionWithAutocompleteMixin, ApplicationCommandOptionWithChoicesMixin) -export class SlashCommandStringOption extends ApplicationCommandOptionBase { - /** - * The type of this option. - */ - public readonly type = ApplicationCommandOptionType.String as const; - - /** - * The maximum length of this option. - */ - public readonly max_length?: number; - - /** - * The minimum length of this option. - */ - public readonly min_length?: number; - - /** - * Sets the maximum length of this string option. - * - * @param max - The maximum length this option can be - */ - public setMaxLength(max: number): this { - maxLengthValidator.parse(max); - - Reflect.set(this, 'max_length', max); - - return this; - } - - /** - * Sets the minimum length of this string option. - * - * @param min - The minimum length this option can be - */ - public setMinLength(min: number): this { - minLengthValidator.parse(min); - - Reflect.set(this, 'min_length', min); - - return this; - } - - /** - * {@inheritDoc ApplicationCommandOptionBase.toJSON} - */ - public toJSON(): APIApplicationCommandStringOption { - this.runRequiredValidations(); - - if (this.autocomplete && Array.isArray(this.choices) && this.choices.length > 0) { - throw new RangeError('Autocomplete and choices are mutually exclusive to each other.'); - } - - return { ...this } as APIApplicationCommandStringOption; - } -} - -export interface SlashCommandStringOption - extends ApplicationCommandOptionWithChoicesMixin, - ApplicationCommandOptionWithAutocompleteMixin {} diff --git a/packages/builders/src/interactions/slashCommands/options/user.ts b/packages/builders/src/interactions/slashCommands/options/user.ts deleted file mode 100644 index 471faf96ce44..000000000000 --- a/packages/builders/src/interactions/slashCommands/options/user.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { ApplicationCommandOptionType, type APIApplicationCommandUserOption } from 'discord-api-types/v10'; -import { ApplicationCommandOptionBase } from '../mixins/ApplicationCommandOptionBase.js'; - -/** - * A slash command user option. - */ -export class SlashCommandUserOption extends ApplicationCommandOptionBase { - /** - * The type of this option. - */ - public readonly type = ApplicationCommandOptionType.User as const; - - /** - * {@inheritDoc ApplicationCommandOptionBase.toJSON} - */ - public toJSON(): APIApplicationCommandUserOption { - this.runRequiredValidations(); - - return { ...this }; - } -} diff --git a/packages/builders/src/messages/embed/Assertions.ts b/packages/builders/src/messages/embed/Assertions.ts index 8bf9b3eeff09..41e5ee899062 100644 --- a/packages/builders/src/messages/embed/Assertions.ts +++ b/packages/builders/src/messages/embed/Assertions.ts @@ -1,99 +1,70 @@ -import { s } from '@sapphire/shapeshift'; -import type { APIEmbedField } from 'discord-api-types/v10'; -import { isValidationEnabled } from '../../util/validation.js'; +import { z } from 'zod'; +import { refineURLPredicate } from '../../Assertions.js'; +import { embedLength } from '../../util/componentUtil.js'; -export const fieldNamePredicate = s - .string() - .lengthGreaterThanOrEqual(1) - .lengthLessThanOrEqual(256) - .setValidationEnabled(isValidationEnabled); - -export const fieldValuePredicate = s - .string() - .lengthGreaterThanOrEqual(1) - .lengthLessThanOrEqual(1_024) - .setValidationEnabled(isValidationEnabled); - -export const fieldInlinePredicate = s.boolean().optional(); - -export const embedFieldPredicate = s - .object({ - name: fieldNamePredicate, - value: fieldValuePredicate, - inline: fieldInlinePredicate, - }) - .setValidationEnabled(isValidationEnabled); - -export const embedFieldsArrayPredicate = embedFieldPredicate.array().setValidationEnabled(isValidationEnabled); - -export const fieldLengthPredicate = s.number().lessThanOrEqual(25).setValidationEnabled(isValidationEnabled); - -export function validateFieldLength(amountAdding: number, fields?: APIEmbedField[]): void { - fieldLengthPredicate.parse((fields?.length ?? 0) + amountAdding); -} - -export const authorNamePredicate = fieldNamePredicate.nullable().setValidationEnabled(isValidationEnabled); - -export const imageURLPredicate = s - .string() - .url({ - allowedProtocols: ['http:', 'https:', 'attachment:'], - }) - .nullish() - .setValidationEnabled(isValidationEnabled); - -export const urlPredicate = s - .string() - .url({ - allowedProtocols: ['http:', 'https:'], - }) - .nullish() - .setValidationEnabled(isValidationEnabled); - -export const embedAuthorPredicate = s - .object({ - name: authorNamePredicate, - iconURL: imageURLPredicate, - url: urlPredicate, - }) - .setValidationEnabled(isValidationEnabled); - -export const RGBPredicate = s - .number() - .int() - .greaterThanOrEqual(0) - .lessThanOrEqual(255) - .setValidationEnabled(isValidationEnabled); -export const colorPredicate = s - .number() - .int() - .greaterThanOrEqual(0) - .lessThanOrEqual(0xffffff) - .or(s.tuple([RGBPredicate, RGBPredicate, RGBPredicate])) - .nullable() - .setValidationEnabled(isValidationEnabled); +export const namePredicate = z.string().min(1).max(256); -export const descriptionPredicate = s +export const iconURLPredicate = z .string() - .lengthGreaterThanOrEqual(1) - .lengthLessThanOrEqual(4_096) - .nullable() - .setValidationEnabled(isValidationEnabled); + .url() + .refine(refineURLPredicate(['http:', 'https:', 'attachment:']), { + message: 'Invalid protocol for icon URL. Must be http:, https:, or attachment:', + }); -export const footerTextPredicate = s +export const URLPredicate = z .string() - .lengthGreaterThanOrEqual(1) - .lengthLessThanOrEqual(2_048) - .nullable() - .setValidationEnabled(isValidationEnabled); - -export const embedFooterPredicate = s + .url() + .refine(refineURLPredicate(['http:', 'https:']), { message: 'Invalid protocol for URL. Must be http: or https:' }); + +export const embedFieldPredicate = z.object({ + name: namePredicate, + value: z.string().min(1).max(1_024), + inline: z.boolean().optional(), +}); + +export const embedAuthorPredicate = z.object({ + name: namePredicate, + icon_url: iconURLPredicate.optional(), + url: URLPredicate.optional(), +}); + +export const embedFooterPredicate = z.object({ + text: z.string().min(1).max(2_048), + icon_url: iconURLPredicate.optional(), +}); + +export const embedPredicate = z .object({ - text: footerTextPredicate, - iconURL: imageURLPredicate, + title: namePredicate.optional(), + description: z.string().min(1).max(4_096).optional(), + url: URLPredicate.optional(), + timestamp: z.string().optional(), + color: z.number().int().min(0).max(0xffffff).optional(), + footer: embedFooterPredicate.optional(), + image: z.object({ url: URLPredicate }).optional(), + thumbnail: z.object({ url: URLPredicate }).optional(), + author: embedAuthorPredicate.optional(), + fields: z.array(embedFieldPredicate).max(25).optional(), }) - .setValidationEnabled(isValidationEnabled); - -export const timestampPredicate = s.union([s.number(), s.date()]).nullable().setValidationEnabled(isValidationEnabled); - -export const titlePredicate = fieldNamePredicate.nullable().setValidationEnabled(isValidationEnabled); + .refine( + (embed) => { + return ( + embed.title !== undefined || + embed.description !== undefined || + (embed.fields !== undefined && embed.fields.length > 0) || + embed.footer !== undefined || + embed.author !== undefined || + embed.image !== undefined || + embed.thumbnail !== undefined + ); + }, + { + message: 'Embed must have at least a title, description, a field, a footer, an author, an image, OR a thumbnail.', + }, + ) + .refine( + (embed) => { + return embedLength(embed) <= 6_000; + }, + { message: 'Embeds must not exceed 6000 characters in total.' }, + ); diff --git a/packages/builders/src/messages/embed/Embed.ts b/packages/builders/src/messages/embed/Embed.ts index 683e0598c188..25e408189120 100644 --- a/packages/builders/src/messages/embed/Embed.ts +++ b/packages/builders/src/messages/embed/Embed.ts @@ -1,77 +1,38 @@ -import type { APIEmbed, APIEmbedAuthor, APIEmbedField, APIEmbedFooter, APIEmbedImage } from 'discord-api-types/v10'; -import { normalizeArray, type RestOrArray } from '../../util/normalizeArray.js'; -import { - colorPredicate, - descriptionPredicate, - embedAuthorPredicate, - embedFieldsArrayPredicate, - embedFooterPredicate, - imageURLPredicate, - timestampPredicate, - titlePredicate, - urlPredicate, - validateFieldLength, -} from './Assertions.js'; +import type { JSONEncodable } from '@discordjs/util'; +import type { APIEmbed, APIEmbedAuthor, APIEmbedField, APIEmbedFooter } from 'discord-api-types/v10'; +import type { RestOrArray } from '../../util/normalizeArray.js'; +import { normalizeArray } from '../../util/normalizeArray.js'; +import { resolveBuilder } from '../../util/resolveBuilder.js'; +import { isValidationEnabled } from '../../util/validation.js'; +import { embedPredicate } from './Assertions.js'; +import { EmbedAuthorBuilder } from './EmbedAuthor.js'; +import { EmbedFieldBuilder } from './EmbedField.js'; +import { EmbedFooterBuilder } from './EmbedFooter.js'; /** - * A tuple satisfying the RGB color model. - * - * @see {@link https://developer.mozilla.org/docs/Glossary/RGB} + * Data stored in the process of constructing an embed. */ -export type RGBTuple = [red: number, green: number, blue: number]; - -/** - * The base icon data typically used in payloads. - */ -export interface IconData { - /** - * The URL of the icon. - */ - iconURL?: string; - /** - * The proxy URL of the icon. - */ - proxyIconURL?: string; +export interface EmbedBuilderData extends Omit { + author?: EmbedAuthorBuilder; + fields: EmbedFieldBuilder[]; + footer?: EmbedFooterBuilder; } /** - * Represents the author data of an embed. - */ -export interface EmbedAuthorData extends IconData, Omit {} - -/** - * Represents the author options of an embed. - */ -export interface EmbedAuthorOptions extends Omit {} - -/** - * Represents the footer data of an embed. - */ -export interface EmbedFooterData extends IconData, Omit {} - -/** - * Represents the footer options of an embed. - */ -export interface EmbedFooterOptions extends Omit {} - -/** - * Represents the image data of an embed. + * A builder that creates API-compatible JSON data for embeds. */ -export interface EmbedImageData extends Omit { +export class EmbedBuilder implements JSONEncodable { /** - * The proxy URL for the image. + * The API data associated with this embed. */ - proxyURL?: string; -} + private readonly data: EmbedBuilderData; -/** - * A builder that creates API-compatible JSON data for embeds. - */ -export class EmbedBuilder { /** - * The API data associated with this embed. + * Gets the fields of this embed. */ - public readonly data: APIEmbed; + public get fields(): readonly EmbedFieldBuilder[] { + return this.data.fields; + } /** * Creates a new embed from API data. @@ -79,8 +40,12 @@ export class EmbedBuilder { * @param data - The API data to create this embed with */ public constructor(data: APIEmbed = {}) { - this.data = { ...data }; - if (data.timestamp) this.data.timestamp = new Date(data.timestamp).toISOString(); + this.data = { + ...structuredClone(data), + author: data.author && new EmbedAuthorBuilder(data.author), + fields: data.fields?.map((field) => new EmbedFieldBuilder(field)) ?? [], + footer: data.footer && new EmbedFooterBuilder(data.footer), + }; } /** @@ -107,16 +72,13 @@ export class EmbedBuilder { * ``` * @param fields - The fields to add */ - public addFields(...fields: RestOrArray): this { + public addFields( + ...fields: RestOrArray EmbedFieldBuilder)> + ): this { const normalizedFields = normalizeArray(fields); - // Ensure adding these fields won't exceed the 25 field limit - validateFieldLength(normalizedFields.length, this.data.fields); + const resolved = normalizedFields.map((field) => resolveBuilder(field, EmbedFieldBuilder)); - // Data assertions - embedFieldsArrayPredicate.parse(normalizedFields); - - if (this.data.fields) this.data.fields.push(...normalizedFields); - else this.data.fields = normalizedFields; + this.data.fields.push(...resolved); return this; } @@ -149,14 +111,14 @@ export class EmbedBuilder { * @param deleteCount - The number of fields to remove * @param fields - The replacing field objects */ - public spliceFields(index: number, deleteCount: number, ...fields: APIEmbedField[]): this { - // Ensure adding these fields won't exceed the 25 field limit - validateFieldLength(fields.length - deleteCount, this.data.fields); - - // Data assertions - embedFieldsArrayPredicate.parse(fields); - if (this.data.fields) this.data.fields.splice(index, deleteCount, ...fields); - else this.data.fields = fields; + public spliceFields( + index: number, + deleteCount: number, + ...fields: (APIEmbedField | EmbedFieldBuilder | ((builder: EmbedFieldBuilder) => EmbedFieldBuilder))[] + ): this { + const resolved = fields.map((field) => resolveBuilder(field, EmbedFieldBuilder)); + this.data.fields.splice(index, deleteCount, ...resolved); + return this; } @@ -170,8 +132,10 @@ export class EmbedBuilder { * You can set a maximum of 25 fields. * @param fields - The fields to set */ - public setFields(...fields: RestOrArray): this { - this.spliceFields(0, this.data.fields?.length ?? 0, ...normalizeArray(fields)); + public setFields( + ...fields: RestOrArray EmbedFieldBuilder)> + ): this { + this.spliceFields(0, this.data.fields.length, ...normalizeArray(fields)); return this; } @@ -180,17 +144,28 @@ export class EmbedBuilder { * * @param options - The options to use */ + public setAuthor( + options: APIEmbedAuthor | EmbedAuthorBuilder | ((builder: EmbedAuthorBuilder) => EmbedAuthorBuilder), + ): this { + this.data.author = resolveBuilder(options, EmbedAuthorBuilder); + return this; + } - public setAuthor(options: EmbedAuthorOptions | null): this { - if (options === null) { - this.data.author = undefined; - return this; - } - - // Data assertions - embedAuthorPredicate.parse(options); + /** + * Updates the author of this embed (and creates it if it doesn't exist). + * + * @param updater - The function to update the author with + */ + public updateAuthor(updater: (builder: EmbedAuthorBuilder) => void) { + updater((this.data.author ??= new EmbedAuthorBuilder())); + return this; + } - this.data.author = { name: options.name, url: options.url, icon_url: options.iconURL }; + /** + * Clears the author of this embed. + */ + public clearAuthor(): this { + this.data.author = undefined; return this; } @@ -199,17 +174,16 @@ export class EmbedBuilder { * * @param color - The color to use */ - public setColor(color: RGBTuple | number | null): this { - // Data assertions - colorPredicate.parse(color); - - if (Array.isArray(color)) { - const [red, green, blue] = color; - this.data.color = (red << 16) + (green << 8) + blue; - return this; - } + public setColor(color: number): this { + this.data.color = color; + return this; + } - this.data.color = color ?? undefined; + /** + * Clears the color of this embed. + */ + public clearColor(): this { + this.data.color = undefined; return this; } @@ -218,11 +192,16 @@ export class EmbedBuilder { * * @param description - The description to use */ - public setDescription(description: string | null): this { - // Data assertions - descriptionPredicate.parse(description); + public setDescription(description: string): this { + this.data.description = description; + return this; + } - this.data.description = description ?? undefined; + /** + * Clears the description of this embed. + */ + public clearDescription(): this { + this.data.description = undefined; return this; } @@ -231,16 +210,28 @@ export class EmbedBuilder { * * @param options - The footer to use */ - public setFooter(options: EmbedFooterOptions | null): this { - if (options === null) { - this.data.footer = undefined; - return this; - } + public setFooter( + options: APIEmbedFooter | EmbedFooterBuilder | ((builder: EmbedFooterBuilder) => EmbedFooterBuilder), + ): this { + this.data.footer = resolveBuilder(options, EmbedFooterBuilder); + return this; + } - // Data assertions - embedFooterPredicate.parse(options); + /** + * Updates the footer of this embed (and creates it if it doesn't exist). + * + * @param updater - The function to update the footer with + */ + public updateFooter(updater: (builder: EmbedFooterBuilder) => void) { + updater((this.data.footer ??= new EmbedFooterBuilder())); + return this; + } - this.data.footer = { text: options.text, icon_url: options.iconURL }; + /** + * Clears the footer of this embed. + */ + public clearFooter(): this { + this.data.footer = undefined; return this; } @@ -249,11 +240,16 @@ export class EmbedBuilder { * * @param url - The image URL to use */ - public setImage(url: string | null): this { - // Data assertions - imageURLPredicate.parse(url); + public setImage(url: string): this { + this.data.image = { url }; + return this; + } - this.data.image = url ? { url } : undefined; + /** + * Clears the image of this embed. + */ + public clearImage(): this { + this.data.image = undefined; return this; } @@ -262,11 +258,16 @@ export class EmbedBuilder { * * @param url - The thumbnail URL to use */ - public setThumbnail(url: string | null): this { - // Data assertions - imageURLPredicate.parse(url); + public setThumbnail(url: string): this { + this.data.thumbnail = { url }; + return this; + } - this.data.thumbnail = url ? { url } : undefined; + /** + * Clears the thumbnail of this embed. + */ + public clearThumbnail(): this { + this.data.thumbnail = undefined; return this; } @@ -275,11 +276,16 @@ export class EmbedBuilder { * * @param timestamp - The timestamp or date to use */ - public setTimestamp(timestamp: Date | number | null = Date.now()): this { - // Data assertions - timestampPredicate.parse(timestamp); + public setTimestamp(timestamp: Date | number | string = Date.now()): this { + this.data.timestamp = new Date(timestamp).toISOString(); + return this; + } - this.data.timestamp = timestamp ? new Date(timestamp).toISOString() : undefined; + /** + * Clears the timestamp of this embed. + */ + public clearTimestamp(): this { + this.data.timestamp = undefined; return this; } @@ -288,11 +294,16 @@ export class EmbedBuilder { * * @param title - The title to use */ - public setTitle(title: string | null): this { - // Data assertions - titlePredicate.parse(title); + public setTitle(title: string): this { + this.data.title = title; + return this; + } - this.data.title = title ?? undefined; + /** + * Clears the title of this embed. + */ + public clearTitle(): this { + this.data.title = undefined; return this; } @@ -301,22 +312,41 @@ export class EmbedBuilder { * * @param url - The URL to use */ - public setURL(url: string | null): this { - // Data assertions - urlPredicate.parse(url); + public setURL(url: string): this { + this.data.url = url; + return this; + } - this.data.url = url ?? undefined; + /** + * Clears the URL of this embed. + */ + public clearURL(): this { + this.data.url = undefined; 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. + * Note that by disabling validation, there is no guarantee that the resulting object will be valid. + * + * @param validationOverride - Force validation to run/not run regardless of your global preference */ - public toJSON(): APIEmbed { - return { ...this.data }; + public toJSON(validationOverride?: boolean): APIEmbed { + const { author, fields, footer, ...rest } = this.data; + + const data = { + ...structuredClone(rest), + // Disable validation because the embedPredicate below will validate those as well + author: this.data.author?.toJSON(false), + fields: this.data.fields?.map((field) => field.toJSON(false)), + footer: this.data.footer?.toJSON(false), + }; + + if (validationOverride ?? isValidationEnabled()) { + embedPredicate.parse(data); + } + + return data; } } diff --git a/packages/builders/src/messages/embed/EmbedAuthor.ts b/packages/builders/src/messages/embed/EmbedAuthor.ts new file mode 100644 index 000000000000..0c3d0b6fb776 --- /dev/null +++ b/packages/builders/src/messages/embed/EmbedAuthor.ts @@ -0,0 +1,82 @@ +import type { APIEmbedAuthor } from 'discord-api-types/v10'; +import { isValidationEnabled } from '../../util/validation.js'; +import { embedAuthorPredicate } from './Assertions.js'; + +/** + * A builder that creates API-compatible JSON data for the embed author. + */ +export class EmbedAuthorBuilder { + private readonly data: Partial; + + /** + * Creates a new embed author from API data. + * + * @param data - The API data to use + */ + public constructor(data?: Partial) { + this.data = structuredClone(data) ?? {}; + } + + /** + * Sets the name for this embed author. + * + * @param name - The name to use + */ + public setName(name: string): this { + this.data.name = name; + return this; + } + + /** + * Sets the URL for this embed author. + * + * @param url - The url to use + */ + public setURL(url: string): this { + this.data.url = url; + return this; + } + + /** + * Clears the URL for this embed author. + */ + public clearURL(): this { + this.data.url = undefined; + return this; + } + + /** + * Sets the icon URL for this embed author. + * + * @param iconURL - The icon URL to use + */ + public setIconURL(iconURL: string): this { + this.data.icon_url = iconURL; + return this; + } + + /** + * Clears the icon URL for this embed author. + */ + public clearIconURL(): this { + this.data.icon_url = undefined; + return this; + } + + /** + * Serializes this builder to API-compatible JSON data. + * + * Note that by disabling validation, there is no guarantee that the resulting object will be valid. + * + * @param validationOverride - Force validation to run/not run regardless of your global preference + */ + public toJSON(validationOverride?: boolean): APIEmbedAuthor { + const clone = structuredClone(this.data); + + if (validationOverride ?? isValidationEnabled()) { + embedAuthorPredicate.parse(clone); + } + + return clone as APIEmbedAuthor; + } +} diff --git a/packages/builders/src/messages/embed/EmbedField.ts b/packages/builders/src/messages/embed/EmbedField.ts new file mode 100644 index 000000000000..e385fad3ec14 --- /dev/null +++ b/packages/builders/src/messages/embed/EmbedField.ts @@ -0,0 +1,66 @@ +import type { APIEmbedField } from 'discord-api-types/v10'; +import { isValidationEnabled } from '../../util/validation.js'; +import { embedFieldPredicate } from './Assertions.js'; + +/** + * A builder that creates API-compatible JSON data for embed fields. + */ +export class EmbedFieldBuilder { + private readonly data: Partial; + + /** + * Creates a new embed field from API data. + * + * @param data - The API data to use + */ + public constructor(data?: Partial) { + this.data = structuredClone(data) ?? {}; + } + + /** + * Sets the name for this embed field. + * + * @param name - The name to use + */ + public setName(name: string): this { + this.data.name = name; + return this; + } + + /** + * Sets the value for this embed field. + * + * @param value - The value to use + */ + public setValue(value: string): this { + this.data.value = value; + return this; + } + + /** + * Sets whether this field should display inline. + * + * @param inline - Whether this field should display inline + */ + public setInline(inline = true): this { + this.data.inline = inline; + return this; + } + + /** + * Serializes this builder to API-compatible JSON data. + * + * Note that by disabling validation, there is no guarantee that the resulting object will be valid. + * + * @param validationOverride - Force validation to run/not run regardless of your global preference + */ + public toJSON(validationOverride?: boolean): APIEmbedField { + const clone = structuredClone(this.data); + + if (validationOverride ?? isValidationEnabled()) { + embedFieldPredicate.parse(clone); + } + + return clone as APIEmbedField; + } +} diff --git a/packages/builders/src/messages/embed/EmbedFooter.ts b/packages/builders/src/messages/embed/EmbedFooter.ts new file mode 100644 index 000000000000..5b3e0c0f8543 --- /dev/null +++ b/packages/builders/src/messages/embed/EmbedFooter.ts @@ -0,0 +1,64 @@ +import type { APIEmbedFooter } from 'discord-api-types/v10'; +import { isValidationEnabled } from '../../util/validation.js'; +import { embedFooterPredicate } from './Assertions.js'; + +/** + * A builder that creates API-compatible JSON data for the embed footer. + */ +export class EmbedFooterBuilder { + private readonly data: Partial; + + /** + * Creates a new embed footer from API data. + * + * @param data - The API data to use + */ + public constructor(data?: Partial) { + this.data = structuredClone(data) ?? {}; + } + + /** + * Sets the text for this embed footer. + * + * @param text - The text to use + */ + public setText(text: string): this { + this.data.text = text; + return this; + } + + /** + * Sets the url for this embed footer. + * + * @param url - The url to use + */ + public setIconURL(url: string): this { + this.data.icon_url = url; + return this; + } + + /** + * Clears the icon URL for this embed footer. + */ + public clearIconURL(): this { + this.data.icon_url = undefined; + return this; + } + + /** + * Serializes this builder to API-compatible JSON data. + * + * Note that by disabling validation, there is no guarantee that the resulting object will be valid. + * + * @param validationOverride - Force validation to run/not run regardless of your global preference + */ + public toJSON(validationOverride?: boolean): APIEmbedFooter { + const clone = structuredClone(this.data); + + if (validationOverride ?? isValidationEnabled()) { + embedFooterPredicate.parse(clone); + } + + return clone as APIEmbedFooter; + } +} diff --git a/packages/builders/src/util/resolveBuilder.ts b/packages/builders/src/util/resolveBuilder.ts new file mode 100644 index 000000000000..1c0917e67593 --- /dev/null +++ b/packages/builders/src/util/resolveBuilder.ts @@ -0,0 +1,40 @@ +import type { JSONEncodable } from '@discordjs/util'; + +/** + * @privateRemarks + * This is a type-guard util, because if you were to in-line `builder instanceof Constructor` in the `resolveBuilder` + * function, TS doesn't narrow out the type `Builder`, causing a type error on the last return statement. + * @internal + */ +function isBuilder>( + builder: unknown, + Constructor: new () => Builder, +): builder is Builder { + return builder instanceof Constructor; +} + +/** + * "Resolves" a builder from the 3 ways it can be input: + * 1. A clean instance + * 2. A data object that can be used to construct the builder + * 3. A function that takes a builder and returns a builder e.g. `builder => builder.setFoo('bar')` + * + * @typeParam Builder - The builder type + * @typeParam BuilderData - The data object that can be used to construct the builder + * @param builder - The user input, as described in the function description + * @param Constructor - The constructor of the builder + */ +export function resolveBuilder, BuilderData extends Record>( + builder: Builder | BuilderData | ((builder: Builder) => Builder), + Constructor: new (data?: BuilderData) => Builder, +): Builder { + if (isBuilder(builder, Constructor)) { + return builder; + } + + if (typeof builder === 'function') { + return builder(new Constructor()); + } + + return new Constructor(builder); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d809a09d8581..7187d800af38 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -670,27 +670,21 @@ importers: packages/builders: dependencies: - '@discordjs/formatters': - specifier: workspace:^ - version: link:../formatters '@discordjs/util': specifier: workspace:^ version: link:../util - '@sapphire/shapeshift': - specifier: ^4.0.0 - version: 4.0.0 discord-api-types: specifier: 0.37.97 version: 0.37.97 - fast-deep-equal: - specifier: ^3.1.3 - version: 3.1.3 ts-mixer: specifier: ^6.0.4 version: 6.0.4 tslib: specifier: ^2.6.3 version: 2.6.3 + zod: + specifier: ^3.23.8 + version: 3.23.8 devDependencies: '@discordjs/api-extractor': specifier: workspace:^ @@ -702,11 +696,11 @@ importers: specifier: ^4.1.0 version: 4.1.0 '@types/node': - specifier: ^16.18.105 - version: 16.18.105 + specifier: ^18.19.44 + version: 18.19.45 '@vitest/coverage-v8': specifier: ^2.0.5 - version: 2.0.5(vitest@2.0.5(@edge-runtime/vm@3.2.0)(@types/node@16.18.105)(happy-dom@14.12.3)(terser@5.31.6)) + version: 2.0.5(vitest@2.0.5(@edge-runtime/vm@3.2.0)(@types/node@18.19.45)(happy-dom@14.12.3)(terser@5.31.6)) cross-env: specifier: ^7.0.3 version: 7.0.3 @@ -727,7 +721,7 @@ importers: version: 3.3.3 tsup: specifier: ^8.2.4 - version: 8.2.4(@microsoft/api-extractor@7.43.0(@types/node@16.18.105))(jiti@1.21.6)(postcss@8.4.41)(typescript@5.5.4)(yaml@2.5.0) + version: 8.2.4(@microsoft/api-extractor@7.43.0(@types/node@18.19.45))(jiti@1.21.6)(postcss@8.4.41)(typescript@5.5.4)(yaml@2.5.0) turbo: specifier: ^2.0.14 version: 2.0.14 @@ -736,7 +730,7 @@ importers: version: 5.5.4 vitest: specifier: ^2.0.5 - version: 2.0.5(@edge-runtime/vm@3.2.0)(@types/node@16.18.105)(happy-dom@14.12.3)(terser@5.31.6) + version: 2.0.5(@edge-runtime/vm@3.2.0)(@types/node@18.19.45)(happy-dom@14.12.3)(terser@5.31.6) packages/collection: devDependencies: @@ -1461,25 +1455,25 @@ importers: version: 4.1.0 '@storybook/addon-essentials': specifier: ^8.1.5 - version: 8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4)) + version: 8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4)) '@storybook/addon-interactions': specifier: ^8.1.5 - version: 8.2.9(@jest/globals@29.7.0)(@types/jest@29.5.12)(jest@29.7.0(@types/node@18.19.45))(storybook@8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4))(vitest@2.0.5(@edge-runtime/vm@3.2.0)(@types/node@18.19.45)(happy-dom@14.12.3)(terser@5.31.6)) + version: 8.2.9(@jest/globals@29.7.0)(@types/jest@29.5.12)(jest@29.7.0(@types/node@18.19.45)(ts-node@10.9.2(@types/node@18.19.45)(typescript@5.5.4)))(storybook@8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4))(vitest@2.0.5(@edge-runtime/vm@3.2.0)(@types/node@18.19.45)(happy-dom@14.12.3)(terser@5.31.6)) '@storybook/addon-links': specifier: ^8.1.5 - version: 8.2.9(react@18.3.1)(storybook@8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4)) + version: 8.2.9(react@18.3.1)(storybook@8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4)) '@storybook/addon-styling': specifier: ^1.3.7 version: 1.3.7(@types/react-dom@18.3.0)(@types/react@18.3.4)(encoding@0.1.13)(postcss@8.4.41)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) '@storybook/blocks': specifier: ^8.1.5 - version: 8.2.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4)) + version: 8.2.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4)) '@storybook/react': specifier: ^8.1.5 - version: 8.2.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4))(typescript@5.5.4) + version: 8.2.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4))(typescript@5.5.4) '@storybook/react-vite': specifier: ^8.1.5 - version: 8.2.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.21.0)(storybook@8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4))(typescript@5.5.4)(vite@5.4.2(@types/node@18.19.45)(terser@5.31.6)) + version: 8.2.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.21.0)(storybook@8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4))(typescript@5.5.4)(vite@5.4.2(@types/node@18.19.45)(terser@5.31.6)) '@storybook/testing-library': specifier: ^0.2.2 version: 0.2.2 @@ -1527,7 +1521,7 @@ importers: version: 15.8.1 storybook: specifier: ^8.1.5 - version: 8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4) + version: 8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4) turbo: specifier: ^2.0.14 version: 2.0.14 @@ -18180,76 +18174,76 @@ snapshots: dependencies: '@sinonjs/commons': 3.0.1 - '@storybook/addon-actions@8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4))': + '@storybook/addon-actions@8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4))': dependencies: '@storybook/global': 5.0.0 '@types/uuid': 9.0.8 dequal: 2.0.3 polished: 4.3.1 - storybook: 8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4) + storybook: 8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4) uuid: 9.0.1 - '@storybook/addon-backgrounds@8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4))': + '@storybook/addon-backgrounds@8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4))': dependencies: '@storybook/global': 5.0.0 memoizerific: 1.11.3 - storybook: 8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4) + storybook: 8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4) ts-dedent: 2.2.0 - '@storybook/addon-controls@8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4))': + '@storybook/addon-controls@8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4))': dependencies: dequal: 2.0.3 lodash: 4.17.21 - storybook: 8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4) + storybook: 8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4) ts-dedent: 2.2.0 - '@storybook/addon-docs@8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4))': + '@storybook/addon-docs@8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4))': dependencies: '@babel/core': 7.25.2 '@mdx-js/react': 3.0.1(@types/react@18.3.4)(react@18.3.1) - '@storybook/blocks': 8.2.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4)) - '@storybook/csf-plugin': 8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4)) + '@storybook/blocks': 8.2.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4)) + '@storybook/csf-plugin': 8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4)) '@storybook/global': 5.0.0 - '@storybook/react-dom-shim': 8.2.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4)) + '@storybook/react-dom-shim': 8.2.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4)) '@types/react': 18.3.4 fs-extra: 11.2.0 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) rehype-external-links: 3.0.0 rehype-slug: 6.0.0 - storybook: 8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4) + storybook: 8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4) ts-dedent: 2.2.0 transitivePeerDependencies: - supports-color - '@storybook/addon-essentials@8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4))': - dependencies: - '@storybook/addon-actions': 8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4)) - '@storybook/addon-backgrounds': 8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4)) - '@storybook/addon-controls': 8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4)) - '@storybook/addon-docs': 8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4)) - '@storybook/addon-highlight': 8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4)) - '@storybook/addon-measure': 8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4)) - '@storybook/addon-outline': 8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4)) - '@storybook/addon-toolbars': 8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4)) - '@storybook/addon-viewport': 8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4)) - storybook: 8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4) + '@storybook/addon-essentials@8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4))': + dependencies: + '@storybook/addon-actions': 8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4)) + '@storybook/addon-backgrounds': 8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4)) + '@storybook/addon-controls': 8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4)) + '@storybook/addon-docs': 8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4)) + '@storybook/addon-highlight': 8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4)) + '@storybook/addon-measure': 8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4)) + '@storybook/addon-outline': 8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4)) + '@storybook/addon-toolbars': 8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4)) + '@storybook/addon-viewport': 8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4)) + storybook: 8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4) ts-dedent: 2.2.0 transitivePeerDependencies: - supports-color - '@storybook/addon-highlight@8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4))': + '@storybook/addon-highlight@8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4))': dependencies: '@storybook/global': 5.0.0 - storybook: 8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4) + storybook: 8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4) - '@storybook/addon-interactions@8.2.9(@jest/globals@29.7.0)(@types/jest@29.5.12)(jest@29.7.0(@types/node@18.19.45))(storybook@8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4))(vitest@2.0.5(@edge-runtime/vm@3.2.0)(@types/node@18.19.45)(happy-dom@14.12.3)(terser@5.31.6))': + '@storybook/addon-interactions@8.2.9(@jest/globals@29.7.0)(@types/jest@29.5.12)(jest@29.7.0(@types/node@18.19.45)(ts-node@10.9.2(@types/node@18.19.45)(typescript@5.5.4)))(storybook@8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4))(vitest@2.0.5(@edge-runtime/vm@3.2.0)(@types/node@18.19.45)(happy-dom@14.12.3)(terser@5.31.6))': dependencies: '@storybook/global': 5.0.0 - '@storybook/instrumenter': 8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4)) - '@storybook/test': 8.2.9(@jest/globals@29.7.0)(@types/jest@29.5.12)(jest@29.7.0(@types/node@18.19.45))(storybook@8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4))(vitest@2.0.5(@edge-runtime/vm@3.2.0)(@types/node@18.19.45)(happy-dom@14.12.3)(terser@5.31.6)) + '@storybook/instrumenter': 8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4)) + '@storybook/test': 8.2.9(@jest/globals@29.7.0)(@types/jest@29.5.12)(jest@29.7.0(@types/node@18.19.45)(ts-node@10.9.2(@types/node@18.19.45)(typescript@5.5.4)))(storybook@8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4))(vitest@2.0.5(@edge-runtime/vm@3.2.0)(@types/node@18.19.45)(happy-dom@14.12.3)(terser@5.31.6)) polished: 4.3.1 - storybook: 8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4) + storybook: 8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4) ts-dedent: 2.2.0 transitivePeerDependencies: - '@jest/globals' @@ -18258,25 +18252,25 @@ snapshots: - jest - vitest - '@storybook/addon-links@8.2.9(react@18.3.1)(storybook@8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4))': + '@storybook/addon-links@8.2.9(react@18.3.1)(storybook@8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4))': dependencies: '@storybook/csf': 0.1.11 '@storybook/global': 5.0.0 - storybook: 8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4) + storybook: 8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4) ts-dedent: 2.2.0 optionalDependencies: react: 18.3.1 - '@storybook/addon-measure@8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4))': + '@storybook/addon-measure@8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4))': dependencies: '@storybook/global': 5.0.0 - storybook: 8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4) + storybook: 8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4) tiny-invariant: 1.3.3 - '@storybook/addon-outline@8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4))': + '@storybook/addon-outline@8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4))': dependencies: '@storybook/global': 5.0.0 - storybook: 8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4) + storybook: 8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4) ts-dedent: 2.2.0 '@storybook/addon-styling@1.3.7(@types/react-dom@18.3.0)(@types/react@18.3.4)(encoding@0.1.13)(postcss@8.4.41)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)': @@ -18315,14 +18309,14 @@ snapshots: - supports-color - typescript - '@storybook/addon-toolbars@8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4))': + '@storybook/addon-toolbars@8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4))': dependencies: - storybook: 8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4) + storybook: 8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4) - '@storybook/addon-viewport@8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4))': + '@storybook/addon-viewport@8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4))': dependencies: memoizerific: 1.11.3 - storybook: 8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4) + storybook: 8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4) '@storybook/api@7.6.17(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: @@ -18332,7 +18326,7 @@ snapshots: - react - react-dom - '@storybook/blocks@8.2.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4))': + '@storybook/blocks@8.2.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4))': dependencies: '@storybook/csf': 0.1.11 '@storybook/global': 5.0.0 @@ -18345,7 +18339,7 @@ snapshots: memoizerific: 1.11.3 polished: 4.3.1 react-colorful: 5.6.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - storybook: 8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4) + storybook: 8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4) telejson: 7.2.0 ts-dedent: 2.2.0 util-deprecate: 1.0.2 @@ -18353,9 +18347,9 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@storybook/builder-vite@8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4))(typescript@5.5.4)(vite@5.4.2(@types/node@18.19.45)(terser@5.31.6))': + '@storybook/builder-vite@8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4))(typescript@5.5.4)(vite@5.4.2(@types/node@18.19.45)(terser@5.31.6))': dependencies: - '@storybook/csf-plugin': 8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4)) + '@storybook/csf-plugin': 8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4)) '@types/find-cache-dir': 3.2.1 browser-assert: 1.2.1 es-module-lexer: 1.5.4 @@ -18363,7 +18357,7 @@ snapshots: find-cache-dir: 3.3.2 fs-extra: 11.2.0 magic-string: 0.30.11 - storybook: 8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4) + storybook: 8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4) ts-dedent: 2.2.0 vite: 5.4.2(@types/node@18.19.45)(terser@5.31.6) optionalDependencies: @@ -18407,7 +18401,7 @@ snapshots: '@types/cross-spawn': 6.0.6 cross-spawn: 7.0.3 globby: 14.0.2 - jscodeshift: 0.15.2(@babel/preset-env@7.25.4) + jscodeshift: 0.15.2(@babel/preset-env@7.25.4(@babel/core@7.25.2)) lodash: 4.17.21 prettier: 3.3.3 recast: 0.23.9 @@ -18435,9 +18429,9 @@ snapshots: - '@types/react' - '@types/react-dom' - '@storybook/components@8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4))': + '@storybook/components@8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4))': dependencies: - storybook: 8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4) + storybook: 8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4) '@storybook/core-common@7.6.20(encoding@0.1.13)': dependencies: @@ -18494,9 +18488,9 @@ snapshots: - supports-color - utf-8-validate - '@storybook/csf-plugin@8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4))': + '@storybook/csf-plugin@8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4))': dependencies: - storybook: 8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4) + storybook: 8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4) unplugin: 1.12.2 '@storybook/csf@0.1.11': @@ -18510,11 +18504,11 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@storybook/instrumenter@8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4))': + '@storybook/instrumenter@8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4))': dependencies: '@storybook/global': 5.0.0 '@vitest/utils': 1.6.0 - storybook: 8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4) + storybook: 8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4) util: 0.12.5 '@storybook/manager-api@7.6.17(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': @@ -18557,9 +18551,9 @@ snapshots: - react - react-dom - '@storybook/manager-api@8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4))': + '@storybook/manager-api@8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4))': dependencies: - storybook: 8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4) + storybook: 8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4) '@storybook/node-logger@7.6.20': {} @@ -18580,29 +18574,29 @@ snapshots: ts-dedent: 2.2.0 util-deprecate: 1.0.2 - '@storybook/preview-api@8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4))': + '@storybook/preview-api@8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4))': dependencies: - storybook: 8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4) + storybook: 8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4) - '@storybook/react-dom-shim@8.2.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4))': + '@storybook/react-dom-shim@8.2.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4))': dependencies: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - storybook: 8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4) + storybook: 8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4) - '@storybook/react-vite@8.2.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.21.0)(storybook@8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4))(typescript@5.5.4)(vite@5.4.2(@types/node@18.19.45)(terser@5.31.6))': + '@storybook/react-vite@8.2.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.21.0)(storybook@8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4))(typescript@5.5.4)(vite@5.4.2(@types/node@18.19.45)(terser@5.31.6))': dependencies: '@joshwooding/vite-plugin-react-docgen-typescript': 0.3.1(typescript@5.5.4)(vite@5.4.2(@types/node@18.19.45)(terser@5.31.6)) '@rollup/pluginutils': 5.1.0(rollup@4.21.0) - '@storybook/builder-vite': 8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4))(typescript@5.5.4)(vite@5.4.2(@types/node@18.19.45)(terser@5.31.6)) - '@storybook/react': 8.2.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4))(typescript@5.5.4) + '@storybook/builder-vite': 8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4))(typescript@5.5.4)(vite@5.4.2(@types/node@18.19.45)(terser@5.31.6)) + '@storybook/react': 8.2.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4))(typescript@5.5.4) find-up: 5.0.0 magic-string: 0.30.11 react: 18.3.1 react-docgen: 7.0.3 react-dom: 18.3.1(react@18.3.1) resolve: 1.22.8 - storybook: 8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4) + storybook: 8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4) tsconfig-paths: 4.2.0 vite: 5.4.2(@types/node@18.19.45)(terser@5.31.6) transitivePeerDependencies: @@ -18612,14 +18606,14 @@ snapshots: - typescript - vite-plugin-glimmerx - '@storybook/react@8.2.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4))(typescript@5.5.4)': + '@storybook/react@8.2.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4))(typescript@5.5.4)': dependencies: - '@storybook/components': 8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4)) + '@storybook/components': 8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4)) '@storybook/global': 5.0.0 - '@storybook/manager-api': 8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4)) - '@storybook/preview-api': 8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4)) - '@storybook/react-dom-shim': 8.2.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4)) - '@storybook/theming': 8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4)) + '@storybook/manager-api': 8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4)) + '@storybook/preview-api': 8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4)) + '@storybook/react-dom-shim': 8.2.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4)) + '@storybook/theming': 8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4)) '@types/escodegen': 0.0.6 '@types/estree': 0.0.51 '@types/node': 18.19.45 @@ -18634,7 +18628,7 @@ snapshots: react-dom: 18.3.1(react@18.3.1) react-element-to-jsx-string: 15.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) semver: 7.5.4 - storybook: 8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4) + storybook: 8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4) ts-dedent: 2.2.0 type-fest: 2.19.0 util-deprecate: 1.0.2 @@ -18653,16 +18647,16 @@ snapshots: memoizerific: 1.11.3 qs: 6.13.0 - '@storybook/test@8.2.9(@jest/globals@29.7.0)(@types/jest@29.5.12)(jest@29.7.0(@types/node@18.19.45))(storybook@8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4))(vitest@2.0.5(@edge-runtime/vm@3.2.0)(@types/node@18.19.45)(happy-dom@14.12.3)(terser@5.31.6))': + '@storybook/test@8.2.9(@jest/globals@29.7.0)(@types/jest@29.5.12)(jest@29.7.0(@types/node@18.19.45)(ts-node@10.9.2(@types/node@18.19.45)(typescript@5.5.4)))(storybook@8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4))(vitest@2.0.5(@edge-runtime/vm@3.2.0)(@types/node@18.19.45)(happy-dom@14.12.3)(terser@5.31.6))': dependencies: '@storybook/csf': 0.1.11 - '@storybook/instrumenter': 8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4)) + '@storybook/instrumenter': 8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4)) '@testing-library/dom': 10.1.0 - '@testing-library/jest-dom': 6.4.5(@jest/globals@29.7.0)(@types/jest@29.5.12)(jest@29.7.0(@types/node@18.19.45))(vitest@2.0.5(@edge-runtime/vm@3.2.0)(@types/node@18.19.45)(happy-dom@14.12.3)(terser@5.31.6)) + '@testing-library/jest-dom': 6.4.5(@jest/globals@29.7.0)(@types/jest@29.5.12)(jest@29.7.0(@types/node@18.19.45)(ts-node@10.9.2(@types/node@18.19.45)(typescript@5.5.4)))(vitest@2.0.5(@edge-runtime/vm@3.2.0)(@types/node@18.19.45)(happy-dom@14.12.3)(terser@5.31.6)) '@testing-library/user-event': 14.5.2(@testing-library/dom@10.1.0) '@vitest/expect': 1.6.0 '@vitest/spy': 1.6.0 - storybook: 8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4) + storybook: 8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4) util: 0.12.5 transitivePeerDependencies: - '@jest/globals' @@ -18695,9 +18689,9 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@storybook/theming@8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4))': + '@storybook/theming@8.2.9(storybook@8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4))': dependencies: - storybook: 8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4) + storybook: 8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4) '@storybook/types@7.6.17': dependencies: @@ -18769,7 +18763,7 @@ snapshots: lz-string: 1.5.0 pretty-format: 27.5.1 - '@testing-library/jest-dom@6.4.5(@jest/globals@29.7.0)(@types/jest@29.5.12)(jest@29.7.0(@types/node@18.19.45))(vitest@2.0.5(@edge-runtime/vm@3.2.0)(@types/node@18.19.45)(happy-dom@14.12.3)(terser@5.31.6))': + '@testing-library/jest-dom@6.4.5(@jest/globals@29.7.0)(@types/jest@29.5.12)(jest@29.7.0(@types/node@18.19.45)(ts-node@10.9.2(@types/node@18.19.45)(typescript@5.5.4)))(vitest@2.0.5(@edge-runtime/vm@3.2.0)(@types/node@18.19.45)(happy-dom@14.12.3)(terser@5.31.6))': dependencies: '@adobe/css-tools': 4.4.0 '@babel/runtime': 7.25.4 @@ -19154,7 +19148,7 @@ snapshots: '@types/ws@8.5.12': dependencies: - '@types/node': 16.18.105 + '@types/node': 18.19.45 '@types/yargs-parser@21.0.3': {} @@ -24302,7 +24296,7 @@ snapshots: jsbn@1.1.0: {} - jscodeshift@0.15.2(@babel/preset-env@7.25.4): + jscodeshift@0.15.2(@babel/preset-env@7.25.4(@babel/core@7.25.2)): dependencies: '@babel/core': 7.25.2 '@babel/parser': 7.25.4 @@ -27813,7 +27807,7 @@ snapshots: store2@2.14.3: {} - storybook@8.2.9(@babel/preset-env@7.25.4)(bufferutil@4.0.8)(utf-8-validate@6.0.4): + storybook@8.2.9(@babel/preset-env@7.25.4(@babel/core@7.25.2))(bufferutil@4.0.8)(utf-8-validate@6.0.4): dependencies: '@babel/core': 7.25.2 '@babel/types': 7.25.4 @@ -27833,7 +27827,7 @@ snapshots: fs-extra: 11.2.0 giget: 1.2.3 globby: 14.0.2 - jscodeshift: 0.15.2(@babel/preset-env@7.25.4) + jscodeshift: 0.15.2(@babel/preset-env@7.25.4(@babel/core@7.25.2)) leven: 3.1.0 ora: 5.4.1 prettier: 3.3.3