From e5fcf0bee53a15d7a87d4a5cf4e206823d6e7d87 Mon Sep 17 00:00:00 2001 From: monbrey Date: Fri, 25 Jun 2021 07:25:16 +1000 Subject: [PATCH] feat(MessageSelectMenu): droppybois (#5692) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Antonio Román --- src/client/actions/InteractionCreate.js | 3 + src/errors/Messages.js | 6 + src/index.js | 2 + src/structures/BaseMessageComponent.js | 11 +- src/structures/Interaction.js | 20 ++- src/structures/MessageActionRow.js | 10 +- src/structures/MessageSelectMenu.js | 202 ++++++++++++++++++++++++ src/structures/SelectMenuInteraction.js | 21 +++ src/util/Constants.js | 3 +- src/util/Structures.js | 2 + typings/index.d.ts | 70 +++++++- 11 files changed, 336 insertions(+), 14 deletions(-) create mode 100644 src/structures/MessageSelectMenu.js create mode 100644 src/structures/SelectMenuInteraction.js diff --git a/src/client/actions/InteractionCreate.js b/src/client/actions/InteractionCreate.js index 3559963efe2c..e30c3312e1f1 100644 --- a/src/client/actions/InteractionCreate.js +++ b/src/client/actions/InteractionCreate.js @@ -21,6 +21,9 @@ class InteractionCreateAction extends Action { case MessageComponentTypes.BUTTON: InteractionType = Structures.get('ButtonInteraction'); break; + case MessageComponentTypes.SELECT_MENU: + InteractionType = Structures.get('SelectMenuInteraction'); + break; default: client.emit( Events.DEBUG, diff --git a/src/errors/Messages.js b/src/errors/Messages.js index 36b3a123ff11..2e04d168094d 100644 --- a/src/errors/Messages.js +++ b/src/errors/Messages.js @@ -50,6 +50,12 @@ const Messages = { BUTTON_URL: 'MessageButton url must be a string', BUTTON_CUSTOM_ID: 'MessageButton customID must be a string', + SELECT_MENU_CUSTOM_ID: 'MessageSelectMenu customID must be a string', + SELECT_MENU_PLACEHOLDER: 'MessageSelectMenu placeholder must be a string', + SELECT_OPTION_LABEL: 'MessageSelectOption label must be a string', + SELECT_OPTION_VALUE: 'MessageSelectOption value must be a string', + SELECT_OPTION_DESCRIPTION: 'MessageSelectOption description must be a string', + INTERACTION_COLLECTOR_ERROR: reason => `Collector received no interactions before ending with reason: ${reason}`, FILE_NOT_FOUND: file => `File could not be found: ${file}`, diff --git a/src/index.js b/src/index.js index 7fd16b9b6398..ca84b6ea4587 100644 --- a/src/index.js +++ b/src/index.js @@ -97,6 +97,7 @@ module.exports = { MessageEmbed: require('./structures/MessageEmbed'), MessageMentions: require('./structures/MessageMentions'), MessageReaction: require('./structures/MessageReaction'), + MessageSelectMenu: require('./structures/MessageSelectMenu'), NewsChannel: require('./structures/NewsChannel'), OAuth2Guild: require('./structures/OAuth2Guild'), PermissionOverwrites: require('./structures/PermissionOverwrites'), @@ -106,6 +107,7 @@ module.exports = { ReactionEmoji: require('./structures/ReactionEmoji'), RichPresenceAssets: require('./structures/Presence').RichPresenceAssets, Role: require('./structures/Role'), + SelectMenuInteraction: require('./structures/SelectMenuInteraction'), Sticker: require('./structures/Sticker'), StoreChannel: require('./structures/StoreChannel'), StageChannel: require('./structures/StageChannel'), diff --git a/src/structures/BaseMessageComponent.js b/src/structures/BaseMessageComponent.js index 0abb9568baed..3124e34b4bcf 100644 --- a/src/structures/BaseMessageComponent.js +++ b/src/structures/BaseMessageComponent.js @@ -18,14 +18,16 @@ class BaseMessageComponent { * Data that can be resolved into options for a MessageComponent. This can be: * * MessageActionRowOptions * * MessageButtonOptions - * @typedef {MessageActionRowOptions|MessageButtonOptions} MessageComponentOptions + * * MessageSelectMenuOptions + * @typedef {MessageActionRowOptions|MessageButtonOptions|MessageSelectMenuOptions} MessageComponentOptions */ /** * Components that can be sent in a message. This can be: * * MessageActionRow * * MessageButton - * @typedef {MessageActionRow|MessageButton} MessageComponent + * * MessageSelectMenu + * @typedef {MessageActionRow|MessageButton|MessageSelectMenu} MessageComponent */ /** @@ -72,6 +74,11 @@ class BaseMessageComponent { component = new MessageButton(data); break; } + case MessageComponentTypes.SELECT_MENU: { + const MessageSelectMenu = require('./MessageSelectMenu'); + component = new MessageSelectMenu(data); + break; + } default: if (client) { client.emit(Events.DEBUG, `[BaseMessageComponent] Received component with unknown type: ${data.type}`); diff --git a/src/structures/Interaction.js b/src/structures/Interaction.js index 8fb11830ad78..189b3de03ec3 100644 --- a/src/structures/Interaction.js +++ b/src/structures/Interaction.js @@ -1,7 +1,7 @@ 'use strict'; const Base = require('./Base'); -const { InteractionTypes } = require('../util/Constants'); +const { InteractionTypes, MessageComponentTypes } = require('../util/Constants'); const SnowflakeUtil = require('../util/SnowflakeUtil'); /** @@ -114,7 +114,7 @@ class Interaction extends Base { } /** - * Indicates whether this interaction is a component interaction. + * Indicates whether this interaction is a message component interaction. * @returns {boolean} */ isMessageComponent() { @@ -126,7 +126,21 @@ class Interaction extends Base { * @returns {boolean} */ isButton() { - return InteractionTypes[this.type] === InteractionTypes.MESSAGE_COMPONENT && this.componentType === 'BUTTON'; + return ( + InteractionTypes[this.type] === InteractionTypes.MESSAGE_COMPONENT && + MessageComponentTypes[this.componentType] === MessageComponentTypes.BUTTON + ); + } + + /** + * Indicates whether this interaction is a select menu interaction. + * @returns {boolean} + */ + isSelectMenu() { + return ( + InteractionTypes[this.type] === InteractionTypes.MESSAGE_COMPONENT && + MessageComponentTypes[this.componentType] === MessageComponentTypes.SELECT_MENU + ); } } diff --git a/src/structures/MessageActionRow.js b/src/structures/MessageActionRow.js index 450b387783c6..219e27e0825b 100644 --- a/src/structures/MessageActionRow.js +++ b/src/structures/MessageActionRow.js @@ -11,17 +11,19 @@ class MessageActionRow extends BaseMessageComponent { /** * Components that can be placed in an action row * * MessageButton - * @typedef {MessageButton} MessageActionRowComponent + * * MessageSelectMenu + * @typedef {MessageButton|MessageSelectMenu} MessageActionRowComponent */ /** * Options for components that can be placed in an action row * * MessageButtonOptions - * @typedef {MessageButtonOptions} MessageActionRowComponentOptions + * * MessageSelectMenuOptions + * @typedef {MessageButtonOptions|MessageSelectMenuOptions} MessageActionRowComponentOptions */ /** - * Data that can be resolved into a components that can be placed in an action row + * Data that can be resolved into components that can be placed in an action row * * MessageActionRowComponent * * MessageActionRowComponentOptions * @typedef {MessageActionRowComponent|MessageActionRowComponentOptions} MessageActionRowComponentResolvable @@ -61,7 +63,7 @@ class MessageActionRow extends BaseMessageComponent { * @param {number} index The index to start at * @param {number} deleteCount The number of components to remove * @param {...MessageActionRowComponentResolvable[]} [components] The replacing components - * @returns {MessageSelectMenu} + * @returns {MessageActionRow} */ spliceComponents(index, deleteCount, ...components) { this.components.splice( diff --git a/src/structures/MessageSelectMenu.js b/src/structures/MessageSelectMenu.js new file mode 100644 index 000000000000..a79522cd3738 --- /dev/null +++ b/src/structures/MessageSelectMenu.js @@ -0,0 +1,202 @@ +'use strict'; + +const BaseMessageComponent = require('./BaseMessageComponent'); +const { MessageComponentTypes } = require('../util/Constants'); +const Util = require('../util/Util'); + +/** + * Represents a select menu message component + * @extends {BaseMessageComponent} + */ +class MessageSelectMenu extends BaseMessageComponent { + /** + * @typedef {BaseMessageComponentOptions} MessageSelectMenuOptions + * @property {string} [customID] A unique string to be sent in the interaction when clicked + * @property {string} [placeholder] Custom placeholder text to display when nothing is selected + * @property {number} [minValues] The minimum number of selections required + * @property {number} [maxValues] The maximum number of selections allowed + * @property {MessageSelectOption[]} [options] Options for the select menu + * @property {boolean} [disabled=false] Disables the select menu to prevent interactions + */ + + /** + * @typedef {Object} MessageSelectOption + * @property {string} label The text to be displayed on this option + * @property {string} value The value to be sent for this option + * @property {?string} description Optional description to show for this option + * @property {?RawEmoji} emoji Emoji to display for this option + * @property {boolean} default Render this option as the default selection + */ + + /** + * @typedef {Object} MessageSelectOptionData + * @property {string} label The text to be displayed on this option + * @property {string} value The value to be sent for this option + * @property {string} [description] Optional description to show for this option + * @property {EmojiIdentifierResolvable} [emoji] Emoji to display for this option + * @property {boolean} [default] Render this option as the default selection + */ + + /** + * @param {MessageSelectMenu|MessageSelectMenuOptions} [data={}] MessageSelectMenu to clone or raw data + */ + constructor(data = {}) { + super({ type: 'SELECT_MENU' }); + + this.setup(data); + } + + setup(data) { + /** + * A unique string to be sent in the interaction when clicked + * @type {?string} + */ + this.customID = data.custom_id ?? data.customID ?? null; + + /** + * Custom placeholder text to display when nothing is selected + * @type {?string} + */ + this.placeholder = data.placeholder ?? null; + + /** + * The minimum number of selections required + * @type {?number} + */ + this.minValues = data.min_values ?? data.minValues ?? null; + + /** + * The maximum number of selections allowed + * @type {?number} + */ + this.maxValues = data.max_values ?? data.maxValues ?? null; + + /** + * Options for the select menu + * @type {MessageSelectOption[]} + */ + this.options = this.constructor.normalizeOptions(data.options ?? []); + + /** + * Whether this select menu is currently disabled + * @type {?boolean} + */ + this.disabled = data.disabled ?? false; + } + + /** + * Sets the custom ID of this select menu + * @param {string} customID A unique string to be sent in the interaction when clicked + * @returns {MessageSelectMenu} + */ + setCustomID(customID) { + this.customID = Util.verifyString(customID, RangeError, 'SELECT_MENU_CUSTOM_ID'); + return this; + } + + /** + * Sets the interactive status of the select menu + * @param {boolean} disabled Whether this select menu should be disabled + * @returns {MessageSelectMenu} + */ + setDisabled(disabled) { + this.disabled = disabled; + return this; + } + + /** + * Sets the maximum number of selections allowed for this select menu + * @param {number} maxValues Number of selections to be allowed + * @returns {MessageSelectMenu} + */ + setMaxValues(maxValues) { + this.maxValues = maxValues; + return this; + } + + /** + * Sets the minimum number of selections required for this select menu + * This will default the maxValues to the number of options, unless manually set + * @param {number} minValues Number of selections to be required + * @returns {MessageSelectMenu} + */ + setMinValues(minValues) { + this.minValues = minValues; + return this; + } + + /** + * Sets the placeholder of this select menu + * @param {string} placeholder Custom placeholder text to display when nothing is selected + * @returns {MessageSelectMenu} + */ + setPlaceholder(placeholder) { + this.placeholder = Util.verifyString(placeholder, RangeError, 'SELECT_MENU_PLACEHOLDER'); + return this; + } + + /** + * Adds options to the select menu. + * @param {...(MessageSelectOption[]|MessageSelectOption[])} options The options to add + * @returns {MessageSelectMenu} + */ + addOptions(...options) { + this.options.push(...this.constructor.normalizeOptions(options)); + return this; + } + + /** + * Removes, replaces, and inserts options in the select menu. + * @param {number} index The index to start at + * @param {number} deleteCount The number of options to remove + * @param {...MessageSelectOption|MessageSelectOption[]} [options] The replacing option objects + * @returns {MessageSelectMenu} + */ + spliceOptions(index, deleteCount, ...options) { + this.options.splice(index, deleteCount, ...this.constructor.normalizeOptions(...options)); + return this; + } + + /** + * Transforms this select menu to a plain object + * @returns {Object} The raw data of this select menu + */ + toJSON() { + return { + custom_id: this.customID, + disabled: this.disabled, + placeholder: this.placeholder, + min_values: this.minValues, + max_values: this.maxValues ?? (this.minValues ? this.options.length : undefined), + options: this.options, + type: typeof this.type === 'string' ? MessageComponentTypes[this.type] : this.type, + }; + } + + /** + * Normalizes option input and resolves strings and emojis. + * @param {MessageSelectOptionData} option The select menu option to normalize + * @returns {MessageSelectOption} + */ + static normalizeOption(option) { + let { label, value, description, emoji } = option; + + label = Util.verifyString(label, RangeError, 'SELECT_OPTION_LABEL'); + value = Util.verifyString(value, RangeError, 'SELECT_OPTION_VALUE'); + emoji = emoji ? Util.resolvePartialEmoji(emoji) : null; + description = description ? Util.verifyString(description, RangeError, 'SELECT_OPTION_DESCRIPTION', true) : null; + + return { label, value, description, emoji, default: option.default ?? false }; + } + + /** + * Normalizes option input and resolves strings and emojis. + * @param {...MessageSelectOptionData|MessageSelectOption[]} options The select menu options to normalize + * @returns {MessageSelectOption[]} + */ + static normalizeOptions(...options) { + return options.flat(Infinity).map(option => this.normalizeOption(option)); + } +} + +module.exports = MessageSelectMenu; diff --git a/src/structures/SelectMenuInteraction.js b/src/structures/SelectMenuInteraction.js new file mode 100644 index 000000000000..be4f3a0ed649 --- /dev/null +++ b/src/structures/SelectMenuInteraction.js @@ -0,0 +1,21 @@ +'use strict'; + +const MessageComponentInteraction = require('./MessageComponentInteraction'); + +/** + * Represents a select menu interaction. + * @extends {MessageComponentInteraction} + */ +class SelectMenuInteraction extends MessageComponentInteraction { + constructor(client, data) { + super(client, data); + + /** + * The values selected, if the component which was interacted with was a select menu + * @type {string[]} + */ + this.values = this.componentType === 'SELECT_MENU' ? data.data.values : null; + } +} + +module.exports = SelectMenuInteraction; diff --git a/src/util/Constants.js b/src/util/Constants.js index 27b9770502f7..e4e158bc0ea7 100644 --- a/src/util/Constants.js +++ b/src/util/Constants.js @@ -944,9 +944,10 @@ exports.InteractionResponseTypes = createEnum([ * The type of a message component * * ACTION_ROW * * BUTTON + * * SELECT_MENU * @typedef {string} MessageComponentType */ -exports.MessageComponentTypes = createEnum([null, 'ACTION_ROW', 'BUTTON']); +exports.MessageComponentTypes = createEnum([null, 'ACTION_ROW', 'BUTTON', 'SELECT_MENU']); /** * The style of a message button diff --git a/src/util/Structures.js b/src/util/Structures.js index 039a9ca51ee4..325b9e4e6b0d 100644 --- a/src/util/Structures.js +++ b/src/util/Structures.js @@ -24,6 +24,7 @@ * * **`CommandInteraction`** * * **`ButtonInteraction`** * * **`StageInstance`** + * * **`SelectMenuInteraction`** * @typedef {string} ExtendableStructure */ @@ -118,6 +119,7 @@ const structures = { User: require('../structures/User'), CommandInteraction: require('../structures/CommandInteraction'), ButtonInteraction: require('../structures/ButtonInteraction'), + SelectMenuInteraction: require('../structures/SelectMenuInteraction'), StageInstance: require('../structures/StageInstance'), }; diff --git a/typings/index.d.ts b/typings/index.d.ts index 10e20f6825a4..6b63fa2fdc14 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -82,6 +82,7 @@ declare enum MessageButtonStyles { declare enum MessageComponentTypes { ACTION_ROW = 1, BUTTON = 2, + SELECT_MENU = 3, } declare enum MFALevels { @@ -1176,6 +1177,7 @@ declare module 'discord.js' { public isButton(): this is ButtonInteraction; public isCommand(): this is CommandInteraction; public isMessageComponent(): this is MessageComponentInteraction; + public isSelectMenu(): this is SelectMenuInteraction; } export class InteractionWebhook extends PartialWebhookMixin() { @@ -1531,6 +1533,29 @@ declare module 'discord.js' { public toJSON(): unknown; } + class MessageSelectMenu extends BaseMessageComponent { + constructor(data?: MessageSelectMenu | MessageSelectMenuOptions); + public customID: string | null; + public disabled: boolean; + public maxValues: number | null; + public minValues: number | null; + public options: MessageSelectOption[]; + public placeholder: string | null; + public type: 'SELECT_MENU'; + public addOptions(options: MessageSelectOptionData[] | MessageSelectOptionData[][]): this; + public setCustomID(customID: string): this; + public setDisabled(disabled: boolean): this; + public setMaxValues(maxValues: number): this; + public setMinValues(minValues: number): this; + public setPlaceholder(placeholder: string): this; + public spliceOptions( + index: number, + deleteCount: number, + ...options: MessageSelectOptionData[] | MessageSelectOptionData[][] + ): this; + public toJSON(): unknown; + } + export class NewsChannel extends TextBasedChannel(GuildChannel) { constructor(guild: Guild, data?: unknown); public defaultAutoArchiveDuration?: ThreadAutoArchiveDuration; @@ -1693,6 +1718,11 @@ declare module 'discord.js' { public static comparePositions(role1: Role, role2: Role): number; } + export class SelectMenuInteraction extends MessageComponentInteraction { + public componentType: 'SELECT_MENU'; + public values: string[] | null; + } + export class Shard extends EventEmitter { constructor(manager: ShardingManager, id: number); private _evals: Map>; @@ -3182,6 +3212,7 @@ declare module 'discord.js' { User: typeof User; CommandInteraction: typeof CommandInteraction; ButtonInteraction: typeof ButtonInteraction; + SelectMenuInteraction: typeof SelectMenuInteraction; } interface FetchApplicationCommandOptions extends BaseFetchOptions { @@ -3572,9 +3603,9 @@ declare module 'discord.js' { type MembershipState = keyof typeof MembershipStates; - type MessageActionRowComponent = MessageButton; + type MessageActionRowComponent = MessageButton | MessageSelectMenu; - type MessageActionRowComponentOptions = MessageButtonOptions; + type MessageActionRowComponentOptions = MessageButtonOptions | MessageSelectMenuOptions; type MessageActionRowComponentResolvable = MessageActionRowComponent | MessageActionRowComponentOptions; @@ -3587,6 +3618,8 @@ declare module 'discord.js' { type: number; } + type MessageAdditions = MessageEmbed | MessageAttachment | (MessageEmbed | MessageAttachment)[]; + interface MessageButtonOptions extends BaseMessageComponentOptions { customID?: string; disabled?: boolean; @@ -3605,7 +3638,7 @@ declare module 'discord.js' { maxProcessed?: number; } - type MessageComponent = BaseMessageComponent | MessageActionRow | MessageButton; + type MessageComponent = BaseMessageComponent | MessageActionRow | MessageButton | MessageSelectMenu; interface MessageComponentInteractionCollectorOptions extends CollectorOptions { max?: number; @@ -3613,7 +3646,11 @@ declare module 'discord.js' { maxUsers?: number; } - type MessageComponentOptions = BaseMessageComponentOptions | MessageActionRowOptions | MessageButtonOptions; + type MessageComponentOptions = + | BaseMessageComponentOptions + | MessageActionRowOptions + | MessageButtonOptions + | MessageSelectMenuOptions; type MessageComponentType = keyof typeof MessageComponentTypes; @@ -3750,6 +3787,31 @@ declare module 'discord.js' { type MessageResolvable = Message | Snowflake; + interface MessageSelectMenuOptions extends BaseMessageComponentOptions { + customID?: string; + disabled?: boolean; + maxValues?: number; + minValues?: number; + options?: MessageSelectOptionData[]; + placeholder?: string; + } + + interface MessageSelectOption { + default: boolean; + description: string | null; + emoji: RawEmoji | null; + label: string; + value: string; + } + + interface MessageSelectOptionData { + default?: boolean; + description?: string; + emoji?: EmojiIdentifierResolvable; + label: string; + value: string; + } + type MessageTarget = | Interaction | InteractionWebhook