diff --git a/src/helpers/type_guards/is_button.ts b/src/helpers/type_guards/is_button.ts index 236d28fe9..9b00f106c 100644 --- a/src/helpers/type_guards/is_button.ts +++ b/src/helpers/type_guards/is_button.ts @@ -1,7 +1,8 @@ import type { ButtonComponent } from "../../types/messages/components/button_component.ts"; import type { ActionRoleComponents } from "../../types/messages/components/message_components.ts"; +import { MessageComponentTypes } from "../../types/messages/components/message_component_types.ts"; /** A type guard function to tell if it is a button component */ export function isButton(component: ActionRoleComponents): component is ButtonComponent { - return Reflect.has(component, "type"); + return component.type === MessageComponentTypes.Button; } diff --git a/src/helpers/type_guards/is_select_menu.ts b/src/helpers/type_guards/is_select_menu.ts index 1e77f5319..7643a0d0c 100644 --- a/src/helpers/type_guards/is_select_menu.ts +++ b/src/helpers/type_guards/is_select_menu.ts @@ -1,7 +1,8 @@ import type { ActionRoleComponents } from "../../types/messages/components/message_components.ts"; -import { SelectMenuComponent } from "../../types/messages/components/select_menu.ts"; +import { MessageComponentTypes } from "../../types/messages/components/message_component_types.ts"; +import type { SelectMenuComponent } from "../../types/messages/components/select_menu.ts"; /** A type guard function to tell if it is a button component */ export function isSelectMenu(component: ActionRoleComponents): component is SelectMenuComponent { - return !Reflect.has(component, "type"); + return component.type === MessageComponentTypes.SelectMenu; } diff --git a/src/types/discordeno/errors.ts b/src/types/discordeno/errors.ts index 8bddc36ca..e40798257 100644 --- a/src/types/discordeno/errors.ts +++ b/src/types/discordeno/errors.ts @@ -98,4 +98,14 @@ export enum Errors { COMPONENT_LABEL_TOO_BIG = "COMPONENT_LABEL_TOO_BIG", COMPONENT_CUSTOM_ID_TOO_BIG = "COMPONENT_CUSTOM_ID_TOO_BIG", BUTTON_REQUIRES_CUSTOM_ID = "BUTTON_REQUIRES_CUSTOM_ID", + COMPONENT_SELECT_MUST_BE_ALONE = "COMPONENT_SELECT_MUST_BE_ALONE", + COMPONENT_PLACEHOLDER_TOO_BIG = "COMPONENT_PLACEHOLDER_TOO_BIG", + COMPONENT_SELECT_MINVALUE_TOO_LOW = "COMPONENT_SELECT_MINVALUE_TOO_LOW", + COMPONENT_SELECT_MINVALUE_TOO_MANY = "COMPONENT_SELECT_MINVALUE_TOO_MANY", + COMPONENT_SELECT_MAXVALUE_TOO_LOW = "COMPONENT_SELECT_MAXVALUE_TOO_LOW", + COMPONENT_SELECT_MAXVALUE_TOO_MANY = "COMPONENT_SELECT_MAXVALUE_TOO_MANY", + COMPONENT_SELECT_OPTIONS_TOO_LOW = "COMPONENT_SELECT_OPTIONS_TOO_LOW", + COMPONENT_SELECT_OPTIONS_TOO_MANY = "COMPONENT_SELECT_OPTIONS_TOO_MANY", + SELECT_OPTION_LABEL_TOO_BIG = "SELECT_OPTION_LABEL_TOO_BIG", + SELECT_OPTION_VALUE_TOO_BIG = "SELECT_OPTION_VALUE_TOO_BIG", } diff --git a/src/types/interactions/interaction.ts b/src/types/interactions/interaction.ts index 4d2181981..aa56a91a1 100644 --- a/src/types/interactions/interaction.ts +++ b/src/types/interactions/interaction.ts @@ -7,7 +7,7 @@ import { SelectMenuData } from "../messages/components/select_data.ts"; import { ButtonData } from "../messages/components/button_data.ts"; /** https://discord.com/developers/docs/interactions/slash-commands#interaction */ -export interface Interaction { +export interface Interaction extends BaseInteraction { /** The command data payload */ data?: ApplicationCommandInteractionData | ButtonData | SelectMenuData; } diff --git a/src/types/messages/components/select_menu.ts b/src/types/messages/components/select_menu.ts index 6ddf342ef..0dadbbcd8 100644 --- a/src/types/messages/components/select_menu.ts +++ b/src/types/messages/components/select_menu.ts @@ -1,6 +1,7 @@ import { SelectOption } from "./select_option.ts"; export interface SelectMenuComponent { + type: 3; /** A custom identifier for this component. Maximum 100 characters. */ customId: string; /** A custom placeholder text if nothing is selected. Maximum 100 characters. */ diff --git a/src/util/utils.ts b/src/util/utils.ts index 4e69fc8a5..2f817cb45 100644 --- a/src/util/utils.ts +++ b/src/util/utils.ts @@ -13,6 +13,7 @@ import type { DiscordImageFormat } from "../types/misc/image_format.ts"; import type { DiscordImageSize } from "../types/misc/image_size.ts"; import { SLASH_COMMANDS_NAME_REGEX } from "./constants.ts"; import { validateLength } from "./validate_length.ts"; +import { isSelectMenu } from "../helpers/type_guards/is_select_menu.ts"; export async function urlToBase64(url: string) { const buffer = await fetch(url).then((res) => res.arrayBuffer()); @@ -221,9 +222,18 @@ export function validateComponents(components: MessageComponents) { // Max of 5 Buttons (or any component type) within an ActionRow if (component.components?.length > 5) { throw new Error(Errors.TOO_MANY_COMPONENTS); + } else if ( + component.components?.length > 1 && + component.components.some((subcomponent) => isSelectMenu(subcomponent)) + ) { + throw new Error(Errors.COMPONENT_SELECT_MUST_BE_ALONE); } for (const subcomponent of component.components) { + if (subcomponent.customId && !validateLength(subcomponent.customId, { max: 100 })) { + throw new Error(Errors.COMPONENT_CUSTOM_ID_TOO_BIG); + } + // 5 Link buttons can not have a customId if (isButton(subcomponent)) { if (subcomponent.type === ButtonStyles.Link && subcomponent.customId) { @@ -235,27 +245,87 @@ export function validateComponents(components: MessageComponents) { } if (!validateLength(subcomponent.label, { max: 80 })) { - throw new Error(Errors.subcomponent_LABEL_TOO_BIG); + throw new Error(Errors.COMPONENT_LABEL_TOO_BIG); } - if (subcomponent.customId && !validateLength(subcomponent.customId, { max: 100 })) { - throw new Error(Errors.subcomponent_CUSTOM_ID_TOO_BIG); + subcomponent.emoji = makeEmojiFromString(subcomponent.emoji); + } + + if (isSelectMenu(subcomponent)) { + if (subcomponent.placeholder && !validateLength(subcomponent.placeholder, { max: 100 })) { + throw new Error(Errors.COMPONENT_PLACEHOLDER_TOO_BIG); } - if (typeof subcomponent.emoji === "string") { - // A snowflake id was provided - if (/^[0-9]+$/.test(subcomponent.emoji)) { - subcomponent.emoji = { - id: subcomponent.emoji, - }; - } else { - // A unicode emoji was provided - subcomponent.emoji = { - name: subcomponent.emoji, - }; + if (subcomponent.minValues) { + if (subcomponent.minValues < 1) { + throw new Error(Errors.COMPONENT_SELECT_MINVALUE_TOO_LOW); } + + if (subcomponent.minValues > 25) { + throw new Error(Errors.COMPONENT_SELECT_MINVALUE_TOO_MANY); + } + } + + if (subcomponent.maxValues) { + if (subcomponent.maxValues < 1) { + throw new Error(Errors.COMPONENT_SELECT_MAXVALUE_TOO_LOW); + } + + if (subcomponent.maxValues > 25) { + throw new Error(Errors.COMPONENT_SELECT_MAXVALUE_TOO_MANY); + } + } + + if (subcomponent.options.length < 1) { + throw new Error(Errors.COMPONENT_SELECT_OPTIONS_TOO_LOW); + } + + if (subcomponent.options.length > 25) { + throw new Error(Errors.COMPONENT_SELECT_OPTIONS_TOO_MANY); + } + + for (const option of subcomponent.options) { + if (!validateLength(option.label, { max: 25 })) { + throw new Error(Errors.SELECT_OPTION_LABEL_TOO_BIG); + } + + if (!validateLength(option.value, { max: 100 })) { + throw new Error(Errors.SELECT_OPTION_VALUE_TOO_BIG); + } + + if (option.description && !validateLength(option.description, { max: 50 })) { + throw new Error(Errors.SELECT_OPTION_VALUE_TOO_BIG); + } + + option.emoji = makeEmojiFromString(option.emoji); } } } } } + +function makeEmojiFromString( + emoji?: + | string + | { + id?: string | undefined; + name?: string | undefined; + animated?: boolean | undefined; + } +) { + if (typeof emoji !== "string") return emoji; + + // A snowflake id was provided + if (/^[0-9]+$/.test(emoji)) { + emoji = { + id: emoji, + }; + } else { + // A unicode emoji was provided + emoji = { + name: emoji, + }; + } + + return emoji; +}