From 0ce43186c6e4a98f30528d691aac649a136b133c Mon Sep 17 00:00:00 2001 From: Jake Ward Date: Thu, 10 Feb 2022 17:56:34 +0000 Subject: [PATCH] feat(modals): modals, input text components and modal submits, v13 style (discordjs/discord.js#7431) --- src/client/actions/InteractionCreate.js | 3 + src/errors/Messages.js | 12 + src/index.js | 3 + src/structures/BaseMessageComponent.js | 17 +- src/structures/Interaction.js | 8 + src/structures/MessageActionRow.js | 6 +- src/structures/MessageComponentInteraction.js | 1 + src/structures/Modal.js | 100 ++++++++ src/structures/ModalSubmitFieldsResolver.js | 50 ++++ src/structures/ModalSubmitInteraction.js | 91 +++++++ src/structures/TextInputComponent.js | 201 ++++++++++++++++ .../interfaces/InteractionResponses.js | 17 ++ src/util/Constants.js | 14 +- src/util/Structures.js | 1 + typings/enums.d.ts | 13 + typings/index.d.ts | 222 ++++++++++++++++-- typings/rawDataTypes.d.ts | 23 +- 17 files changed, 752 insertions(+), 30 deletions(-) create mode 100644 src/structures/Modal.js create mode 100644 src/structures/ModalSubmitFieldsResolver.js create mode 100644 src/structures/ModalSubmitInteraction.js create mode 100644 src/structures/TextInputComponent.js diff --git a/src/client/actions/InteractionCreate.js b/src/client/actions/InteractionCreate.js index ef1f87b504a2..60364f0af2ab 100644 --- a/src/client/actions/InteractionCreate.js +++ b/src/client/actions/InteractionCreate.js @@ -53,6 +53,9 @@ class InteractionCreateAction extends Action { case InteractionTypes.APPLICATION_COMMAND_AUTOCOMPLETE: InteractionType = Structures.get('AutocompleteInteraction'); break; + case InteractionTypes.MODAL_SUBMIT: + InteractionType = Structures.get('ModalSubmitInteraction'); + break; default: client.emit(Events.DEBUG, `[INTERACTION] Received interaction with unknown type: ${data.type}`); return; diff --git a/src/errors/Messages.js b/src/errors/Messages.js index e20aadda9971..7fbf4bb9c921 100644 --- a/src/errors/Messages.js +++ b/src/errors/Messages.js @@ -56,6 +56,14 @@ const Messages = { SELECT_OPTION_VALUE: 'MessageSelectOption value must be a string', SELECT_OPTION_DESCRIPTION: 'MessageSelectOption description must be a string', + TEXT_INPUT_CUSTOM_ID: 'TextInputComponent customId must be a string', + TEXT_INPUT_LABEL: 'TextInputComponent label must be a string', + TEXT_INPUT_PLACEHOLDER: 'TextInputComponent placeholder must be a string', + TEXT_INPUT_VALUE: 'TextInputComponent value must be a string', + + MODAL_CUSTOM_ID: 'Modal customId must be a string', + MODAL_TITLE: 'Modal title 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}`, @@ -145,6 +153,10 @@ const Messages = { COMMAND_INTERACTION_OPTION_NO_SUB_COMMAND_GROUP: 'No subcommand group specified for interaction.', AUTOCOMPLETE_INTERACTION_OPTION_NO_FOCUSED_OPTION: 'No focused option for autocomplete interaction.', + MODAL_SUBMIT_INTERACTION_FIELD_NOT_FOUND: customId => `Required field with custom id "${customId}" not found.`, + MODAL_SUBMIT_INTERACTION_FIELD_TYPE: (customId, type, expected) => + `Field with custom id "${customId}" is of type: ${type}; expected ${expected}.`, + INVITE_MISSING_SCOPES: 'At least one valid scope must be provided for the invite', NOT_IMPLEMENTED: (what, name) => `Method ${what} not implemented on ${name}.`, diff --git a/src/index.js b/src/index.js index d78bb4f63fbf..547e650fc189 100644 --- a/src/index.js +++ b/src/index.js @@ -119,6 +119,8 @@ exports.MessageMentions = require('./structures/MessageMentions'); exports.MessagePayload = require('./structures/MessagePayload'); exports.MessageReaction = require('./structures/MessageReaction'); exports.MessageSelectMenu = require('./structures/MessageSelectMenu'); +exports.Modal = require('./structures/Modal'); +exports.ModalSubmitInteraction = require('./structures/ModalSubmitInteraction'); exports.NewsChannel = require('./structures/NewsChannel'); exports.OAuth2Guild = require('./structures/OAuth2Guild'); exports.PartialGroupDMChannel = require('./structures/PartialGroupDMChannel'); @@ -138,6 +140,7 @@ exports.Structures = require('./util/Structures'); exports.Team = require('./structures/Team'); exports.TeamMember = require('./structures/TeamMember'); exports.TextChannel = require('./structures/TextChannel'); +exports.TextInputComponent = require('./structures/TextInputComponent'); exports.ThreadChannel = require('./structures/ThreadChannel'); exports.ThreadMember = require('./structures/ThreadMember'); exports.Typing = require('./structures/Typing'); diff --git a/src/structures/BaseMessageComponent.js b/src/structures/BaseMessageComponent.js index e658eda235b4..0c994ded9cd9 100644 --- a/src/structures/BaseMessageComponent.js +++ b/src/structures/BaseMessageComponent.js @@ -4,7 +4,7 @@ const { TypeError } = require('../errors'); const { MessageComponentTypes, Events } = require('../util/Constants'); /** - * Represents an interactive component of a Message. It should not be necessary to construct this directly. + * Represents an interactive component of a Message or Modal. It should not be necessary to construct this directly. * See {@link MessageComponent} */ class BaseMessageComponent { @@ -15,18 +15,20 @@ class BaseMessageComponent { */ /** - * Data that can be resolved into options for a MessageComponent. This can be: + * Data that can be resolved into options for a component. This can be: * * MessageActionRowOptions * * MessageButtonOptions * * MessageSelectMenuOptions + * * TextInputComponentOptions * @typedef {MessageActionRowOptions|MessageButtonOptions|MessageSelectMenuOptions} MessageComponentOptions */ /** - * Components that can be sent in a message. These can be: + * Components that can be sent in a payload. These can be: * * MessageActionRow * * MessageButton * * MessageSelectMenu + * * TextInputComponent * @typedef {MessageActionRow|MessageButton|MessageSelectMenu} MessageComponent * @see {@link https://discord.com/developers/docs/interactions/message-components#component-object-component-types} */ @@ -51,7 +53,7 @@ class BaseMessageComponent { } /** - * Constructs a MessageComponent based on the type of the incoming data + * Constructs a component based on the type of the incoming data * @param {MessageComponentOptions} data Data for a MessageComponent * @param {Client|WebhookClient} [client] Client constructing this component * @returns {?MessageComponent} @@ -79,6 +81,11 @@ class BaseMessageComponent { component = new MessageSelectMenu(data); break; } + case MessageComponentTypes.TEXT_INPUT: { + const TextInputComponent = require('./TextInputComponent'); + component = data instanceof TextInputComponent ? data : new TextInputComponent(data); + break; + } default: if (client) { client.emit(Events.DEBUG, `[BaseMessageComponent] Received component with unknown type: ${data.type}`); @@ -90,7 +97,7 @@ class BaseMessageComponent { } /** - * Resolves the type of a MessageComponent + * Resolves the type of a component * @param {MessageComponentTypeResolvable} type The type to resolve * @returns {MessageComponentType} * @private diff --git a/src/structures/Interaction.js b/src/structures/Interaction.js index d82420c069f6..a96e6ab86071 100644 --- a/src/structures/Interaction.js +++ b/src/structures/Interaction.js @@ -160,6 +160,14 @@ class Interaction extends Base { return InteractionTypes[this.type] === InteractionTypes.APPLICATION_COMMAND && typeof this.targetId !== 'undefined'; } + /** + * Indicates whether this interaction is a {@link ModalSubmitInteraction} + * @returns {boolean} + */ + isModalSubmit() { + return InteractionTypes[this.type] === InteractionTypes.MODAL_SUBMIT; + } + /** * Indicates whether this interaction is a {@link UserContextMenuInteraction} * @returns {boolean} diff --git a/src/structures/MessageActionRow.js b/src/structures/MessageActionRow.js index c44b32a6018a..d2115d6c0594 100644 --- a/src/structures/MessageActionRow.js +++ b/src/structures/MessageActionRow.js @@ -12,14 +12,16 @@ class MessageActionRow extends BaseMessageComponent { * Components that can be placed in an action row * * MessageButton * * MessageSelectMenu - * @typedef {MessageButton|MessageSelectMenu} MessageActionRowComponent + * * TextInputComponent + * @typedef {MessageButton|MessageSelectMenu|TextInputComponent} MessageActionRowComponent */ /** * Options for components that can be placed in an action row * * MessageButtonOptions * * MessageSelectMenuOptions - * @typedef {MessageButtonOptions|MessageSelectMenuOptions} MessageActionRowComponentOptions + * * TextInputComponentOptions + * @typedef {MessageButtonOptions|MessageSelectMenuOptions|TextInputComponentOptions} MessageActionRowComponentOptions */ /** diff --git a/src/structures/MessageComponentInteraction.js b/src/structures/MessageComponentInteraction.js index dae0107e4b72..673bb282aa46 100644 --- a/src/structures/MessageComponentInteraction.js +++ b/src/structures/MessageComponentInteraction.js @@ -101,6 +101,7 @@ class MessageComponentInteraction extends Interaction { followUp() {} deferUpdate() {} update() {} + presentModal() {} } InteractionResponses.applyToClass(MessageComponentInteraction); diff --git a/src/structures/Modal.js b/src/structures/Modal.js new file mode 100644 index 000000000000..0227dc296715 --- /dev/null +++ b/src/structures/Modal.js @@ -0,0 +1,100 @@ +'use strict'; + +const BaseMessageComponent = require('./BaseMessageComponent'); +const Util = require('../util/Util'); + +class Modal { + /** + * @typedef {Object} ModalOptions + * @property {string} [customId] A unique string to be sent in the interaction when clicked + * @property {string} [title] The title to be displayed on this modal + * @property {MessageActionRow[]|MessageActionRowOptions[]} [components] + * Action rows containing interactive components for the modal (text input components) + */ + + /** + * @param {Modal|ModalOptions} data Modal to clone or raw data + * @param {Client} client The client constructing this Modal, if provided + */ + constructor(data = {}, client = null) { + /** + * A list of MessageActionRows in the modal + * @type {MessageActionRow[]} + */ + this.components = data.components?.map(c => BaseMessageComponent.create(c, client)) ?? []; + + /** + * A unique string to be sent in the interaction when submitted + * @type {?string} + */ + this.customId = data.custom_id ?? data.customId ?? null; + + /** + * The title to be displayed on this modal + * @type {?string} + */ + this.title = data.title ?? null; + } + + /** + * Adds components to the modal. + * @param {...MessageActionRowResolvable[]} components The components to add + * @returns {Modal} + */ + addComponents(...components) { + this.components.push(...components.flat(Infinity).map(c => BaseMessageComponent.create(c))); + return this; + } + + /** + * Sets the components of the modal. + * @param {...MessageActionRowResolvable[]} components The components to set + * @returns {Modal} + */ + setComponents(...components) { + this.spliceComponents(0, this.components.length, components); + return this; + } + + /** + * Sets the custom id for this modal + * @param {string} customId A unique string to be sent in the interaction when submitted + * @returns {Modal} + */ + setCustomId(customId) { + this.customId = Util.verifyString(customId, RangeError, 'MODAL_CUSTOM_ID'); + return this; + } + + /** + * Removes, replaces, and inserts components in the modal. + * @param {number} index The index to start at + * @param {number} deleteCount The number of components to remove + * @param {...MessageActionRowResolvable[]} [components] The replacing components + * @returns {Modal} + */ + spliceComponents(index, deleteCount, ...components) { + this.components.splice(index, deleteCount, ...components.flat(Infinity).map(c => BaseMessageComponent.create(c))); + return this; + } + + /** + * Sets the title of this modal + * @param {string} title The title to be displayed on this modal + * @returns {Modal} + */ + setTitle(title) { + this.title = Util.verifyString(title, RangeError, 'MODAL_TITLE'); + return this; + } + + toJSON() { + return { + components: this.components.map(c => c.toJSON()), + custom_id: this.customId, + title: this.title, + }; + } +} + +module.exports = Modal; diff --git a/src/structures/ModalSubmitFieldsResolver.js b/src/structures/ModalSubmitFieldsResolver.js new file mode 100644 index 000000000000..c1c4861950d2 --- /dev/null +++ b/src/structures/ModalSubmitFieldsResolver.js @@ -0,0 +1,50 @@ +'use strict'; + +const { TypeError } = require('../errors'); +const { MessageComponentTypes } = require('../util/Constants'); + +class ModalSubmitFieldsResolver { + constructor(components) { + /** + * The components within the modal + * @type {PartialModalActionRow[]} The components in the modal + */ + this.components = components; + } + + /** + * The extracted fields from the modal + * @type {PartialInputTextData[]} The fields in the modal + * @private + */ + get _fields() { + return this.components.reduce((previous, next) => previous.concat(next.components), []); + } + + /** + * Gets a field given a custom id from a component + * @param {string} customId The custom id of the component + * @returns {?PartialInputTextData} + */ + getField(customId) { + const field = this._fields.find(f => f.customId === customId); + if (!field) throw new TypeError('MODAL_SUBMIT_INTERACTION_FIELD_NOT_FOUND', customId); + return field; + } + + /** + * Gets the value of a text input component given a custom id + * @param {string} customId The custom id of the text input component + * @returns {?string} + */ + getTextInputValue(customId) { + const field = this.getField(customId); + const expectedType = MessageComponentTypes[MessageComponentTypes.TEXT_INPUT]; + if (field.type !== expectedType) { + throw new TypeError('MODAL_SUBMIT_INTERACTION_FIELD_TYPE', customId, field.type, expectedType); + } + return field.value; + } +} + +module.exports = ModalSubmitFieldsResolver; diff --git a/src/structures/ModalSubmitInteraction.js b/src/structures/ModalSubmitInteraction.js new file mode 100644 index 000000000000..0e54b167f154 --- /dev/null +++ b/src/structures/ModalSubmitInteraction.js @@ -0,0 +1,91 @@ +'use strict'; + +const Interaction = require('./Interaction'); +const ModalSubmitFieldsResolver = require('./ModalSubmitFieldsResolver'); +const InteractionResponses = require('./interfaces/InteractionResponses'); +const { MessageComponentTypes } = require('../util/Constants'); + +class ModalSubmitInteraction extends Interaction { + constructor(client, data) { + super(client, data); + + /** + * The custom id of the modal. + * @type {string} + */ + this.customId = data.data.custom_id; + + /** + * @typedef {Object} PartialTextInputData + * @property {string} [customId] A unique string to be sent in the interaction when submitted + * @property {MessageComponentType} [type] The type of this component + * @property {string} [value] Value of this text input component + */ + + /** + * @typedef {Object} PartialModalActionRow + * @property {MessageComponentType} [type] The type of this component + * @property {PartialTextInputData[]} [components] Partial text input components + */ + + /** + * The inputs within the modal + * @type {PartialModalActionRow[]} + */ + this.components = + data.data.components?.map(c => ({ + type: MessageComponentTypes[c.type], + components: ModalSubmitInteraction.transformComponent(c), + })) ?? []; + + /** + * The fields within the modal + * @type {ModalSubmitFieldsResolver} + */ + this.fields = new ModalSubmitFieldsResolver(this.components); + } + + /** + * Get the value submitted in a text input component + * @param {string} customId Custom id of the text input component + * @returns {string} + */ + getTextInputValue(customId) { + for (const row of this.components) { + const field = row.components.find( + c => c.customId === customId && c.type === MessageComponentTypes[MessageComponentTypes.TEXT_INPUT], + ); + + if (field) { + return field.value; + } + } + return null; + } + + /** + * Transforms component data to discord.js-compatible data + * @param {*} rawComponent The data to transform + * @returns {PartialTextInputData[]} + */ + static transformComponent(rawComponent) { + return rawComponent.components.map(c => ({ + value: c.value, + type: MessageComponentTypes[c.type], + customId: c.custom_id, + })); + } + + // These are here only for documentation purposes - they are implemented by InteractionResponses + /* eslint-disable no-empty-function */ + deferReply() {} + reply() {} + fetchReply() {} + editReply() {} + deleteReply() {} + followUp() {} +} + +InteractionResponses.applyToClass(ModalSubmitInteraction, ['deferUpdate', 'update', 'presentModal']); + +module.exports = ModalSubmitInteraction; diff --git a/src/structures/TextInputComponent.js b/src/structures/TextInputComponent.js new file mode 100644 index 000000000000..721804800ddf --- /dev/null +++ b/src/structures/TextInputComponent.js @@ -0,0 +1,201 @@ +'use strict'; + +const BaseMessageComponent = require('./BaseMessageComponent'); +const { RangeError } = require('../errors'); +const { TextInputStyles, MessageComponentTypes } = require('../util/Constants'); +const Util = require('../util/Util'); + +/** + * Represents a text input component in a modal + * @extends {BaseMessageComponent} + */ + +class TextInputComponent extends BaseMessageComponent { + /** + * @typedef {BaseMessageComponentOptions} TextInputComponentOptions + * @property {string} [customId] A unique string to be sent in the interaction when submitted + * @property {string} [label] The text to be displayed above this text input component + * @property {number} [maxLength] Maximum length of text that can be entered + * @property {number} [minLength] Minimum length of text required to be entered + * @property {string} [placeholder] Custom placeholder text to display when no text is entered + * @property {boolean} [required] Whether or not this text input component is required + * @property {TextInputStyleResolvable} [style] The style of this text input component + * @property {string} [value] Value of this text input component + */ + + /** + * @param {TextInputComponent|TextInputComponentOptions} [data={}] TextInputComponent to clone or raw data + */ + constructor(data = {}) { + super({ type: 'TEXT_INPUT' }); + + this.setup(data); + } + + setup(data) { + /** + * A unique string to be sent in the interaction when submitted + * @type {?string} + */ + this.customId = data.custom_id ?? data.customId ?? null; + + /** + * The text to be displayed above this text input component + * @type {?string} + */ + this.label = data.label ?? null; + + /** + * Maximum length of text that can be entered + * @type {?number} + */ + this.maxLength = data.max_length ?? data.maxLength ?? null; + + /** + * Minimum length of text required to be entered + * @type {?string} + */ + this.minLength = data.min_length ?? data.minLength ?? null; + + /** + * Custom placeholder text to display when no text is entered + * @type {?string} + */ + this.placeholder = data.placeholder ?? null; + + /** + * Whether or not this text input component is required + * @type {?boolean} + */ + this.required = data.required ?? false; + + /** + * The style of this text input component + * @type {?TextInputStyle} + */ + this.style = data.style ? TextInputComponent.resolveStyle(data.style) : null; + + /** + * Value of this text input component + * @type {?string} + */ + this.value = data.value ?? null; + } + + /** + * Sets the custom id of this text input component + * @param {string} customId A unique string to be sent in the interaction when submitted + * @returns {TextInputComponent} + */ + setCustomId(customId) { + this.customId = Util.verifyString(customId, RangeError, 'TEXT_INPUT_CUSTOM_ID'); + return this; + } + + /** + * Sets the label of this text input component + * @param {string} label The text to be displayed above this text input component + * @returns {TextInputComponent} + */ + setLabel(label) { + this.label = Util.verifyString(label, RangeError, 'TEXT_INPUT_LABEL'); + return this; + } + + /** + * Sets the text input component to be required for modal submission + * @param {boolean} [required=true] Whether this text input component is required + * @returns {TextInputComponent} + */ + setRequired(required = true) { + this.required = required; + return this; + } + + /** + * Sets the maximum length of text input required in this text input component + * @param {number} maxLength Maximum length of text to be required + * @returns {TextInputComponent} + */ + setMaxLength(maxLength) { + this.maxLength = maxLength; + return this; + } + + /** + * Sets the minimum length of text input required in this text input component + * @param {number} minLength Minimum length of text to be required + * @returns {TextInputComponent} + */ + setMinLength(minLength) { + this.minLength = minLength; + return this; + } + + /** + * Sets the placeholder of this text input component + * @param {string} placeholder Custom placeholder text to display when no text is entered + * @returns {TextInputComponent} + */ + setPlaceholder(placeholder) { + this.placeholder = Util.verifyString(placeholder, RangeError, 'TEXT_INPUT_PLACEHOLDER'); + return this; + } + + /** + * Sets the style of this text input component + * @param {TextInputStyleResolvable} style The style of this text input component + * @returns {TextInputComponent} + */ + setStyle(style) { + this.style = TextInputComponent.resolveStyle(style); + return this; + } + + /** + * Sets the value of this text input component + * @param {string} value Value of this text input component + * @returns {TextInputComponent} + */ + setValue(value) { + this.value = Util.verifyString(value, RangeError, 'TEXT_INPUT_VALUE'); + return this; + } + + /** + * Transforms the text input component into a plain object + * @returns {APITextInput} The raw data of this text input component + */ + toJSON() { + return { + custom_id: this.customId, + label: this.label, + max_length: this.maxLength, + min_length: this.minLength, + placeholder: this.placeholder, + required: this.required, + style: TextInputStyles[this.style], + type: MessageComponentTypes[this.type], + value: this.value, + }; + } + + /** + * Data that can be resolved to a TextInputStyle. This can be + * * TextInputStyle + * * number + * @typedef {number|TextInputStyle} TextInputStyleResolvable + */ + + /** + * Resolves the style of a text input component + * @param {TextInputStyleResolvable} style The style to resolve + * @returns {TextInputStyle} + * @private + */ + static resolveStyle(style) { + return typeof style === 'string' ? style : TextInputStyles[style]; + } +} + +module.exports = TextInputComponent; diff --git a/src/structures/interfaces/InteractionResponses.js b/src/structures/interfaces/InteractionResponses.js index 85026e9beae5..50f242af39de 100644 --- a/src/structures/interfaces/InteractionResponses.js +++ b/src/structures/interfaces/InteractionResponses.js @@ -4,6 +4,7 @@ const { Error } = require('../../errors'); const { InteractionResponseTypes } = require('../../util/Constants'); const MessageFlags = require('../../util/MessageFlags'); const MessagePayload = require('../MessagePayload'); +const Modal = require('../Modal'); /** * Interface for classes that support shared interaction response types. @@ -223,6 +224,21 @@ class InteractionResponses { return options.fetchReply ? this.fetchReply() : undefined; } + /** + * Presents a modal component + * @param {Modal|ModalOptions} modal The modal to present + * @returns {Promise} + */ + async presentModal(modal) { + const _modal = modal instanceof Modal ? modal : new Modal(modal); + await this.client.api.interactions(this.id, this.token).callback.post({ + data: { + type: InteractionResponseTypes.MODAL, + data: _modal.toJSON(), + }, + }); + } + static applyToClass(structure, ignore = []) { const props = [ 'deferReply', @@ -233,6 +249,7 @@ class InteractionResponses { 'followUp', 'deferUpdate', 'update', + 'presentModal', ]; for (const prop of props) { diff --git a/src/util/Constants.js b/src/util/Constants.js index 3cf6d7da482a..e4b94ea213fe 100644 --- a/src/util/Constants.js +++ b/src/util/Constants.js @@ -978,6 +978,7 @@ exports.ApplicationCommandPermissionTypes = createEnum([null, 'ROLE', 'USER']); * * APPLICATION_COMMAND * * MESSAGE_COMPONENT * * APPLICATION_COMMAND_AUTOCOMPLETE + * * MODAL_SUBMIT * @typedef {string} InteractionType * @see {@link https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-object-interaction-type} */ @@ -987,6 +988,7 @@ exports.InteractionTypes = createEnum([ 'APPLICATION_COMMAND', 'MESSAGE_COMPONENT', 'APPLICATION_COMMAND_AUTOCOMPLETE', + 'MODAL_SUBMIT', ]); /** @@ -1010,6 +1012,7 @@ exports.InteractionResponseTypes = createEnum([ 'DEFERRED_MESSAGE_UPDATE', 'UPDATE_MESSAGE', 'APPLICATION_COMMAND_AUTOCOMPLETE_RESULT', + 'MODAL', ]); /* eslint-enable max-len */ @@ -1021,7 +1024,7 @@ exports.InteractionResponseTypes = createEnum([ * @typedef {string} MessageComponentType * @see {@link https://discord.com/developers/docs/interactions/message-components#component-object-component-types} */ -exports.MessageComponentTypes = createEnum([null, 'ACTION_ROW', 'BUTTON', 'SELECT_MENU']); +exports.MessageComponentTypes = createEnum([null, 'ACTION_ROW', 'BUTTON', 'SELECT_MENU', 'TEXT_INPUT']); /** * The style of a message button @@ -1064,6 +1067,15 @@ exports.NSFWLevels = createEnum(['DEFAULT', 'EXPLICIT', 'SAFE', 'AGE_RESTRICTED' */ exports.PrivacyLevels = createEnum([null, 'PUBLIC', 'GUILD_ONLY']); +/** + * The style of a text input component + * * SHORT + * * LONG + * @typedef {string} TextInputStyle + * @see {@link https://discord.com/developers/docs/interactions/message-components#text-inputs-text-input-styles} + */ +exports.TextInputStyles = createEnum([null, 'SHORT', 'PARAGRAPH']); + /** * The premium tier (Server Boost level) of a guild: * * NONE diff --git a/src/util/Structures.js b/src/util/Structures.js index d89b1ee64c9a..e53e577a2daa 100644 --- a/src/util/Structures.js +++ b/src/util/Structures.js @@ -128,6 +128,7 @@ const structures = { UserContextMenuInteraction: require('../structures/UserContextMenuInteraction'), AutocompleteInteraction: require('../structures/AutocompleteInteraction'), MessageComponentInteraction: require('../structures/MessageComponentInteraction'), + ModalSubmitInteraction: require('../structures/ModalSubmitInteraction'), StageInstance: require('../structures/StageInstance'), }; diff --git a/typings/enums.d.ts b/typings/enums.d.ts index 4332983a55ec..6302d618dca7 100644 --- a/typings/enums.d.ts +++ b/typings/enums.d.ts @@ -93,6 +93,7 @@ export const enum InteractionResponseTypes { DEFERRED_MESSAGE_UPDATE = 6, UPDATE_MESSAGE = 7, APPLICATION_COMMAND_AUTOCOMPLETE_RESULT = 8, + MODAL = 9, } export const enum InteractionTypes { @@ -100,6 +101,7 @@ export const enum InteractionTypes { APPLICATION_COMMAND = 2, MESSAGE_COMPONENT = 3, APPLICATION_COMMAND_AUTOCOMPLETE = 4, + MODAL_SUBMIT = 5, } export const enum InviteTargetType { @@ -124,6 +126,12 @@ export const enum MessageComponentTypes { ACTION_ROW = 1, BUTTON = 2, SELECT_MENU = 3, + TEXT_INPUT = 4, +} + +export const enum ModalComponentTypes { + ACTION_ROW = 1, + TEXT_INPUT = 4, } export const enum MFALevels { @@ -166,6 +174,11 @@ export const enum StickerTypes { GUILD = 2, } +export const enum TextInputStyles { + SHORT = 1, + PARAGRAPH = 2, +} + export const enum VerificationLevels { NONE = 0, LOW = 1, diff --git a/typings/index.d.ts b/typings/index.d.ts index ecd2e30f50da..0b747c9e34f8 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -71,6 +71,7 @@ import { MessageButtonStyles, MessageComponentTypes, MessageTypes, + ModalComponentTypes, MFALevels, NSFWLevels, OverwriteTypes, @@ -78,6 +79,7 @@ import { PrivacyLevels, StickerFormatTypes, StickerTypes, + TextInputStyles, VerificationLevels, WebhookTypes, } from './enums'; @@ -113,6 +115,8 @@ import { RawMessagePayloadData, RawMessageReactionData, RawMessageSelectMenuInteractionData, + RawModalActionRowComponentData, + RawModalSubmitInteractionData, RawOAuth2GuildData, RawPartialGroupDMChannelData, RawPartialMessageData, @@ -344,6 +348,7 @@ export abstract class BaseCommandInteraction>; public fetchReply(): Promise>; public followUp(options: string | MessagePayload | InteractionReplyOptions): Promise>; + public presentModal(modal: Modal | ModalOptions): Promise; public reply(options: InteractionReplyOptions & { fetchReply: true }): Promise>; public reply(options: string | MessagePayload | InteractionReplyOptions): Promise; private transformOption( @@ -1239,6 +1244,7 @@ export class Interaction extends Base { public isUserContextMenu(): this is UserContextMenuInteraction; public isMessageContextMenu(): this is MessageContextMenuInteraction; public isMessageComponent(): this is MessageComponentInteraction; + public isModalSubmit(): this is ModalSubmitInteraction; public isSelectMenu(): this is SelectMenuInteraction; } @@ -1352,6 +1358,7 @@ export interface StringMappedInteractionTypes; SELECT_MENU: SelectMenuInteraction; ACTION_ROW: MessageComponentInteraction; + TEXT_INPUT: ModalSubmitInteraction; } export interface EnumMappedInteractionTypes { @@ -1439,21 +1446,16 @@ export class Message extends Base { public inGuild(): this is Message & this; } -export class MessageActionRow extends BaseMessageComponent { - public constructor(data?: MessageActionRow | MessageActionRowOptions | APIActionRowComponent); +export class MessageActionRow< + T extends MessageActionRowComponent | ModalActionRowComponent = MessageActionRowComponent, + U = T extends ModalActionRowComponent ? ModalActionRowComponentResolvable : MessageActionRowComponentResolvable, +> extends BaseMessageComponent { + public constructor(data?: MessageActionRow | MessageActionRowOptions | APIActionRowComponent); public type: 'ACTION_ROW'; - public components: MessageActionRowComponent[]; - public addComponents( - ...components: MessageActionRowComponentResolvable[] | MessageActionRowComponentResolvable[][] - ): this; - public setComponents( - ...components: MessageActionRowComponentResolvable[] | MessageActionRowComponentResolvable[][] - ): this; - public spliceComponents( - index: number, - deleteCount: number, - ...components: MessageActionRowComponentResolvable[] | MessageActionRowComponentResolvable[][] - ): this; + public components: T[]; + public addComponents(...components: U[] | U[][]): this; + public setComponents(...components: U[] | U[][]): this; + public spliceComponents(index: number, deleteCount: number, ...components: U[] | U[][]): this; public toJSON(): APIActionRowComponent; } @@ -1538,6 +1540,7 @@ export class MessageComponentInteraction e public editReply(options: string | MessagePayload | WebhookEditMessageOptions): Promise>; public fetchReply(): Promise>; public followUp(options: string | MessagePayload | InteractionReplyOptions): Promise>; + public presentModal(modal: Modal | ModalOptions): Promise; public reply(options: InteractionReplyOptions & { fetchReply: true }): Promise>; public reply(options: string | MessagePayload | InteractionReplyOptions): Promise; public update(options: InteractionUpdateOptions & { fetchReply: true }): Promise>; @@ -1699,6 +1702,117 @@ export class MessageSelectMenu extends BaseMessageComponent { public toJSON(): APISelectMenuComponent; } +export class Modal { + public constructor(data?: Modal | ModalOptions); + public components: MessageActionRow[]; + public customId: string; + public title: string; + public addComponents( + ...components: ( + | MessageActionRow + | (Required & MessageActionRowOptions) + )[] + ): this; + public setComponents( + ...components: ( + | MessageActionRow + | (Required & MessageActionRowOptions) + )[] + ): this; + public setCustomId(customId: string): this; + public spliceComponents( + index: number, + deleteCount: number, + ...components: ( + | MessageActionRow + | (Required & MessageActionRowOptions) + )[] + ): this; + public setTitle(title: string): this; + public toJSON(): RawModalSubmitInteractionData; +} + +export class ModalSubmitFieldsResolver { + constructor(components: PartialModalActionRow[]); + private readonly _fields: PartialTextInputData[]; + public getField(customId: string): PartialTextInputData; + public getTextInputValue(customId: string): string; +} + +export class ModalSubmitInteraction extends Interaction { + protected constructor(client: Client, data: RawModalSubmitInteractionData); + public customId: string; + public components: PartialModalActionRow[]; + public fields: ModalSubmitFieldsResolver; + public getTextInputValue(customId: string): string; + public reply(options: InteractionReplyOptions & { fetchReply: true }): Promise>; + public reply(options: string | MessagePayload | InteractionReplyOptions): Promise; + public deleteReply(): Promise; + public editReply(options: string | MessagePayload | WebhookEditMessageOptions): Promise>; + public deferReply(options: InteractionDeferReplyOptions & { fetchReply: true }): Promise>; + public deferReply(options?: InteractionDeferReplyOptions): Promise; + public fetchReply(): Promise>; + public followUp(options: string | MessagePayload | InteractionReplyOptions): Promise>; + public inGuild(): this is ModalSubmitInteraction<'present'>; + public inCachedGuild(): this is ModalSubmitInteraction<'cached'>; + public inRawGuild(): this is ModalSubmitInteraction<'raw'>; +} +export class Modal { + public constructor(data?: Modal | ModalOptions); + public components: MessageActionRow[]; + public customId: string; + public title: string; + public addComponents( + ...components: ( + | MessageActionRow + | (Required & MessageActionRowOptions) + )[] + ): this; + public setComponents( + ...components: ( + | MessageActionRow + | (Required & MessageActionRowOptions) + )[] + ): this; + public setCustomId(customId: string): this; + public spliceComponents( + index: number, + deleteCount: number, + ...components: ( + | MessageActionRow + | (Required & MessageActionRowOptions) + )[] + ): this; + public setTitle(title: string): this; + public toJSON(): RawModalSubmitInteractionData; +} + +export class ModalSubmitFieldsResolver { + constructor(components: PartialModalActionRow[]); + private readonly _fields: PartialTextInputData[]; + public getField(customId: string): PartialTextInputData; + public getTextInputValue(customId: string): string; +} + +export class ModalSubmitInteraction extends Interaction { + protected constructor(client: Client, data: RawModalSubmitInteractionData); + public customId: string; + public components: PartialModalActionRow[]; + public fields: ModalSubmitFieldsResolver; + public getTextInputValue(customId: string): string; + public reply(options: InteractionReplyOptions & { fetchReply: true }): Promise>; + public reply(options: string | MessagePayload | InteractionReplyOptions): Promise; + public deleteReply(): Promise; + public editReply(options: string | MessagePayload | WebhookEditMessageOptions): Promise>; + public deferReply(options: InteractionDeferReplyOptions & { fetchReply: true }): Promise>; + public deferReply(options?: InteractionDeferReplyOptions): Promise; + public fetchReply(): Promise>; + public followUp(options: string | MessagePayload | InteractionReplyOptions): Promise>; + public inGuild(): this is ModalSubmitInteraction<'present'>; + public inCachedGuild(): this is ModalSubmitInteraction<'cached'>; + public inRawGuild(): this is ModalSubmitInteraction<'raw'>; +} + export class NewsChannel extends BaseGuildTextChannel { public threads: ThreadManager; public type: 'GUILD_NEWS'; @@ -2108,6 +2222,28 @@ export class TextChannel extends BaseGuildTextChannel { public setRateLimitPerUser(rateLimitPerUser: number, reason?: string): Promise; } +export class TextInputComponent extends BaseMessageComponent { + public constructor(data?: TextInputComponent | TextInputComponentOptions); + public customId: string | null; + public label: string | null; + public required: boolean; + public maxLength: number | null; + public minLength: number | null; + public placeholder: string | null; + public style: TextInputStyle; + public value: string | null; + public setCustomId(customId: string): this; + public setLabel(label: string): this; + public setRequired(required?: boolean): this; + public setMaxLength(maxLength: number): this; + public setMinLength(minLength: number): this; + public setPlaceholder(placeholder: string): this; + public setStyle(style: TextInputStyleResolvable): this; + public setValue(value: string): this; + public toJSON(): unknown; + public static resolveStyle(style: TextInputStyleResolvable): TextInputStyle; +} + export class ThreadChannel extends TextBasedChannel(Channel) { public constructor(guild: Guild, data?: RawThreadChannelData, client?: Client, fromInteraction?: boolean); public archived: boolean | null; @@ -2644,6 +2780,8 @@ export const Constants: { InteractionResponseTypes: EnumHolder; MessageComponentTypes: EnumHolder; MessageButtonStyles: EnumHolder; + ModalComponentTypes: EnumHolder; + TextInputStyles: EnumHolder; MFALevels: EnumHolder; NSFWLevels: EnumHolder; PrivacyLevels: EnumHolder; @@ -4133,6 +4271,7 @@ interface Extendable { ContextMenuInteraction: typeof ContextMenuInteraction; AutocompleteInteraction: typeof AutocompleteInteraction; MessageComponentInteraction: typeof MessageComponentInteraction; + ModalSubmitInteraction: typeof ModalSubmitInteraction; StageInstance: typeof StageInstance; } @@ -4602,8 +4741,15 @@ export type MessageActionRowComponentOptions = export type MessageActionRowComponentResolvable = MessageActionRowComponent | MessageActionRowComponentOptions; -export interface MessageActionRowOptions extends BaseMessageComponentOptions { - components: MessageActionRowComponentResolvable[]; +export type ModalActionRowComponent = TextInputComponent; +export type ModalActionRowComponentOptions = TextInputComponentOptions; +export type ModalActionRowComponentResolvable = ModalActionRowComponent | ModalActionRowComponentOptions; +export interface MessageActionRowOptions< + T extends + | MessageActionRowComponentResolvable + | ModalActionRowComponentResolvable = MessageActionRowComponentResolvable, +> extends BaseMessageComponentOptions { + components: T[]; } export interface MessageActivity { @@ -4640,15 +4786,13 @@ export interface MessageCollectorOptions extends CollectorOptions<[Message]> { export type MessageComponent = BaseMessageComponent | MessageActionRow | MessageButton | MessageSelectMenu; -export type MessageComponentCollectorOptions = Omit< +export type MessageComponentCollectorOptions = Omit< InteractionCollectorOptions, 'channel' | 'message' | 'guild' | 'interactionType' >; -export type MessageChannelComponentCollectorOptions = Omit< - InteractionCollectorOptions, - 'channel' | 'guild' | 'interactionType' ->; +export type MessageChannelComponentCollectorOptions = + Omit, 'channel' | 'guild' | 'interactionType'>; export type MessageComponentOptions = | BaseMessageComponentOptions @@ -4830,6 +4974,14 @@ export type MessageType = keyof typeof MessageTypes; export type MFALevel = keyof typeof MFALevels; +export interface ModalOptions { + components: + | MessageActionRow[] + | MessageActionRowOptions[]; + customId: string; + title: string; +} + export interface MultipleShardRespawnOptions { shardDelay?: number; respawnDelay?: number; @@ -4855,6 +5007,17 @@ export type OverwriteResolvable = PermissionOverwrites | OverwriteData; export type OverwriteType = 'member' | 'role'; +export interface PartialModalActionRow { + type: MessageComponentType; + components: PartialTextInputData[]; +} +export interface PartialTextInputData { + value: string; + // TODO: use dapi types + type: MessageComponentType; + customId: string; +} + export type PermissionFlags = Record; export type PermissionOverwriteOptions = Partial>; @@ -5149,6 +5312,23 @@ export type TextBasedChannels = PartialDMChannel | DMChannel | TextChannel | New export type TextBasedChannelTypes = TextBasedChannels['type']; +export interface TextInputComponentOptions extends BaseMessageComponentOptions { + customId?: string; + label?: string; + minLength?: number; + maxLength?: number; + placeholder?: string; + required?: boolean; + style?: TextInputStyleResolvable; + value?: string; +} +export type TextInputStyle = keyof typeof TextInputStyles; +export type TextInputStyleResolvable = TextInputStyle | TextInputStyles; +export interface IntegrationAccount { + id: string | Snowflake; + name: string; +} + export type TextChannelResolvable = Snowflake | TextChannel; export type ThreadAutoArchiveDuration = 60 | 1440 | 4320 | 10080 | 'MAX'; diff --git a/typings/rawDataTypes.d.ts b/typings/rawDataTypes.d.ts index 27156fe8a994..ecdf4d543909 100644 --- a/typings/rawDataTypes.d.ts +++ b/typings/rawDataTypes.d.ts @@ -76,7 +76,8 @@ import { RESTPostAPIWebhookWithTokenJSONBody, Snowflake, } from 'discord-api-types/v9'; -import { GuildChannel, Guild, PermissionOverwrites } from '.'; +import { Guild, GuildChannel, PermissionOverwrites } from '.'; +import type { InteractionTypes, MessageComponentTypes } from './enums'; export type RawActivityData = GatewayActivity; @@ -138,6 +139,26 @@ export type RawMessageComponentInteractionData = APIMessageComponentInteraction; export type RawMessageButtonInteractionData = APIMessageButtonInteractionData; export type RawMessageSelectMenuInteractionData = APIMessageSelectMenuInteractionData; +// TODO: Replace with discord-api-types definition +export type RawTextInputComponentData = { + type: MessageComponentTypes.TEXT_INPUT; + custom_id: string; + value: string; +}; + +// TODO: Replace with discord-api-types definition +export type RawModalActionRowComponentData = { + custom_id: string; + components: RawTextInputComponentData; +}; + +// TODO: Replace with discord-api-types definition +export type RawModalSubmitInteractionData = { + custom_id: string; + type: InteractionTypes.MODAL_SUBMIT; + components: RawModalActionRowComponentData[]; +}; + export type RawInviteData = | APIExtendedInvite | APIInvite