diff --git a/src/client/websocket/handlers/INTERACTION_CREATE.js b/src/client/websocket/handlers/INTERACTION_CREATE.js index cef671b97d88..57a507d51742 100644 --- a/src/client/websocket/handlers/INTERACTION_CREATE.js +++ b/src/client/websocket/handlers/INTERACTION_CREATE.js @@ -4,20 +4,31 @@ const { Events, InteractionTypes } = require('../../../util/Constants'); let Structures; module.exports = (client, { d: data }) => { - if (data.type === InteractionTypes.APPLICATION_COMMAND) { - if (!Structures) Structures = require('../../../util/Structures'); - const CommandInteraction = Structures.get('CommandInteraction'); + let interaction; + switch (data.type) { + case InteractionTypes.APPLICATION_COMMAND: { + if (!Structures) Structures = require('../../../util/Structures'); + const CommandInteraction = Structures.get('CommandInteraction'); - const interaction = new CommandInteraction(client, data); + interaction = new CommandInteraction(client, data); + break; + } + case InteractionTypes.MESSAGE_COMPONENT: { + if (!Structures) Structures = require('../../../util/Structures'); + const MessageComponentInteraction = Structures.get('MessageComponentInteraction'); - /** - * Emitted when an interaction is created. - * @event Client#interaction - * @param {Interaction} interaction The interaction which was created - */ - client.emit(Events.INTERACTION_CREATE, interaction); - return; + interaction = new MessageComponentInteraction(client, data); + break; + } + default: + client.emit(Events.DEBUG, `[INTERACTION] Received interaction with unknown type: ${data.type}`); + return; } - client.emit(Events.DEBUG, `[INTERACTION] Received interaction with unknown type: ${data.type}`); + /** + * Emitted when an interaction is created. + * @event Client#interaction + * @param {Interaction} interaction The interaction which was created + */ + client.emit(Events.INTERACTION_CREATE, interaction); }; diff --git a/src/errors/Messages.js b/src/errors/Messages.js index 31961ec41506..f864d71eec9c 100644 --- a/src/errors/Messages.js +++ b/src/errors/Messages.js @@ -44,6 +44,10 @@ const Messages = { EMBED_DESCRIPTION: 'MessageEmbed description must be a string.', EMBED_AUTHOR_NAME: 'MessageEmbed author name must be a string.', + BUTTON_LABEL: 'MessageButton label must be a string', + BUTTON_URL: 'MessageButton url must be a string', + BUTTON_CUSTOM_ID: 'MessageButton customID must be a string', + FILE_NOT_FOUND: file => `File could not be found: ${file}`, USER_NO_DMCHANNEL: 'No DM Channel exists!', diff --git a/src/index.js b/src/index.js index 4d224c2e578c..e8ebb000abdc 100644 --- a/src/index.js +++ b/src/index.js @@ -68,6 +68,7 @@ module.exports = { BaseGuild: require('./structures/BaseGuild'), BaseGuildEmoji: require('./structures/BaseGuildEmoji'), BaseGuildVoiceChannel: require('./structures/BaseGuildVoiceChannel'), + BaseMessageComponent: require('./structures/BaseMessageComponent'), CategoryChannel: require('./structures/CategoryChannel'), Channel: require('./structures/Channel'), ClientApplication: require('./structures/ClientApplication'), @@ -92,8 +93,12 @@ module.exports = { Interaction: require('./structures/Interaction'), Invite: require('./structures/Invite'), Message: require('./structures/Message'), + MessageActionRow: require('./structures/MessageActionRow'), MessageAttachment: require('./structures/MessageAttachment'), + MessageButton: require('./structures/MessageButton'), MessageCollector: require('./structures/MessageCollector'), + MessageComponentInteraction: require('./structures/MessageComponentInteraction'), + MessageComponentInteractionCollector: require('./structures/MessageComponentInteractionCollector'), MessageEmbed: require('./structures/MessageEmbed'), MessageMentions: require('./structures/MessageMentions'), MessageReaction: require('./structures/MessageReaction'), diff --git a/src/structures/APIMessage.js b/src/structures/APIMessage.js index 094195353583..b91699560a7b 100644 --- a/src/structures/APIMessage.js +++ b/src/structures/APIMessage.js @@ -1,5 +1,6 @@ 'use strict'; +const BaseMessageComponent = require('./BaseMessageComponent'); const MessageAttachment = require('./MessageAttachment'); const MessageEmbed = require('./MessageEmbed'); const { RangeError } = require('../errors'); @@ -151,6 +152,8 @@ class APIMessage { } const embeds = embedLikes.map(e => new MessageEmbed(e).toJSON()); + const components = this.options.components?.map(c => BaseMessageComponent.create(c).toJSON()); + let username; let avatarURL; if (isWebhook) { @@ -196,6 +199,7 @@ class APIMessage { nonce, embed: !isWebhookLike ? (this.options.embed === null ? null : embeds[0]) : undefined, embeds: isWebhookLike ? embeds : undefined, + components, username, avatar_url: avatarURL, allowed_mentions: diff --git a/src/structures/BaseMessageComponent.js b/src/structures/BaseMessageComponent.js new file mode 100644 index 000000000000..14b1910a45c9 --- /dev/null +++ b/src/structures/BaseMessageComponent.js @@ -0,0 +1,94 @@ +'use strict'; + +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. + * See {@link MessageComponent} + */ +class BaseMessageComponent { + /** + * Options for a BaseMessageComponent + * @typedef {Object} BaseMessageComponentOptions + * @property {MessageComponentTypeResolvable} type The type of this component + */ + + /** + * Data that can be resolved into options for a MessageComponent. This can be: + * * MessageActionRowOptions + * * MessageButtonOptions + * @typedef {MessageActionRowOptions|MessageButtonOptions} MessageComponentOptions + */ + + /** + * Components that can be sent in a message + * @typedef {MessageActionRow|MessageButton} MessageComponent + */ + + /** + * Data that can be resolved to a MessageComponentType. This can be: + * * {@link MessageComponentType} + * * string + * * number + * @typedef {string|number|MessageComponentType} MessageComponentTypeResolvable + */ + + /** + * @param {BaseMessageComponent|BaseMessageComponentOptions} [data={}] The options for this component + */ + constructor(data) { + /** + * The type of this component + * @type {?MessageComponentType} + */ + this.type = 'type' in data ? BaseMessageComponent.resolveType(data.type) : null; + } + + /** + * Constructs a MessageComponent based on the type of the incoming data + * @param {MessageComponentOptions} data Data for a MessageComponent + * @param {Client|WebhookClient} [client] Client constructing this component + * @param {boolean} [skipValidation=false] Whether or not to validate the component type + * @returns {?MessageComponent} + * @private + */ + static create(data, client, skipValidation = false) { + let component; + let type = data.type; + + if (typeof type === 'string') type = MessageComponentTypes[type]; + + switch (type) { + case MessageComponentTypes.ACTION_ROW: { + const MessageActionRow = require('./MessageActionRow'); + component = new MessageActionRow(data); + break; + } + case MessageComponentTypes.BUTTON: { + const MessageButton = require('./MessageButton'); + component = new MessageButton(data); + break; + } + default: + if (client) { + client.emit(Events.DEBUG, `[BaseMessageComponent] Received component with unknown type: ${data.type}`); + } else if (!skipValidation) { + throw new TypeError('INVALID_TYPE', 'data.type', 'valid MessageComponentType'); + } + } + return component; + } + + /** + * Resolves the type of a MessageComponent + * @param {MessageComponentTypeResolvable} type The type to resolve + * @returns {MessageComponentType} + * @private + */ + static resolveType(type) { + return typeof type === 'string' ? type : MessageComponentTypes[type]; + } +} + +module.exports = BaseMessageComponent; diff --git a/src/structures/CommandInteraction.js b/src/structures/CommandInteraction.js index 095ef871b2a8..7888d5c7e550 100644 --- a/src/structures/CommandInteraction.js +++ b/src/structures/CommandInteraction.js @@ -1,16 +1,15 @@ 'use strict'; -const APIMessage = require('./APIMessage'); const Interaction = require('./Interaction'); +const InteractionResponses = require('./interfaces/InteractionResponses'); const WebhookClient = require('../client/WebhookClient'); -const { Error } = require('../errors'); const Collection = require('../util/Collection'); -const { ApplicationCommandOptionTypes, InteractionResponseTypes } = require('../util/Constants'); -const MessageFlags = require('../util/MessageFlags'); +const { ApplicationCommandOptionTypes } = require('../util/Constants'); /** * Represents a command interaction. * @extends {Interaction} + * @implements {InteractionResponses} */ class CommandInteraction extends Interaction { constructor(client, data) { @@ -69,126 +68,6 @@ class CommandInteraction extends Interaction { return this.guild?.commands.cache.get(id) ?? this.client.application.commands.cache.get(id) ?? null; } - /** - * Options for deferring the reply to a {@link CommandInteraction}. - * @typedef {Object} InteractionDeferOptions - * @property {boolean} [ephemeral] Whether the reply should be ephemeral - */ - - /** - * Defers the reply to this interaction. - * @param {InteractionDeferOptions} [options] Options for deferring the reply to this interaction - * @returns {Promise} - * @example - * // Defer the reply to this interaction - * interaction.defer() - * .then(console.log) - * .catch(console.error) - * @example - * // Defer to send an ephemeral reply later - * interaction.defer({ ephemeral: true }) - * .then(console.log) - * .catch(console.error); - */ - async defer({ ephemeral } = {}) { - if (this.deferred || this.replied) throw new Error('INTERACTION_ALREADY_REPLIED'); - await this.client.api.interactions(this.id, this.token).callback.post({ - data: { - type: InteractionResponseTypes.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE, - data: { - flags: ephemeral ? MessageFlags.FLAGS.EPHEMERAL : undefined, - }, - }, - }); - this.deferred = true; - } - - /** - * Options for a reply to an interaction. - * @typedef {BaseMessageOptions} InteractionReplyOptions - * @property {boolean} [ephemeral] Whether the reply should be ephemeral - * @property {MessageEmbed[]|Object[]} [embeds] An array of embeds for the message - */ - - /** - * Creates a reply to this interaction. - * @param {string|APIMessage|MessageAdditions} content The content for the reply - * @param {InteractionReplyOptions} [options] Additional options for the reply - * @returns {Promise} - * @example - * // Reply to the interaction with an embed - * const embed = new MessageEmbed().setDescription('Pong!'); - * - * interaction.reply(embed) - * .then(console.log) - * .catch(console.error); - * @example - * // Create an ephemeral reply - * interaction.reply('Pong!', { ephemeral: true }) - * .then(console.log) - * .catch(console.error); - */ - async reply(content, options) { - if (this.deferred || this.replied) throw new Error('INTERACTION_ALREADY_REPLIED'); - const apiMessage = content instanceof APIMessage ? content : APIMessage.create(this, content, options); - const { data, files } = await apiMessage.resolveData().resolveFiles(); - - await this.client.api.interactions(this.id, this.token).callback.post({ - data: { - type: InteractionResponseTypes.CHANNEL_MESSAGE_WITH_SOURCE, - data, - }, - files, - }); - this.replied = true; - } - - /** - * Fetches the initial reply to this interaction. - * @see Webhook#fetchMessage - * @returns {Promise} - * @example - * // Fetch the reply to this interaction - * interaction.fetchReply() - * .then(reply => console.log(`Replied with ${reply.content}`)) - * .catch(console.error); - */ - async fetchReply() { - const raw = await this.webhook.fetchMessage('@original'); - return this.channel?.messages.add(raw) ?? raw; - } - - /** - * Edits the initial reply to this interaction. - * @see Webhook#editMessage - * @param {string|APIMessage|MessageAdditions} content The new content for the message - * @param {WebhookEditMessageOptions} [options] The options to provide - * @returns {Promise} - * @example - * // Edit the reply to this interaction - * interaction.editReply('New content') - * .then(console.log) - * .catch(console.error); - */ - async editReply(content, options) { - const raw = await this.webhook.editMessage('@original', content, options); - return this.channel?.messages.add(raw) ?? raw; - } - - /** - * Deletes the initial reply to this interaction. - * @see Webhook#deleteMessage - * @returns {Promise} - * @example - * // Delete the reply to this interaction - * interaction.deleteReply() - * .then(console.log) - * .catch(console.error); - */ - async deleteReply() { - await this.webhook.deleteMessage('@original'); - } - /** * Represents an option of a received command interaction. * @typedef {Object} CommandInteractionOption @@ -203,24 +82,6 @@ class CommandInteraction extends Interaction { * @property {Role|Object} [role] The resolved role */ - /** - * Send a follow-up message to this interaction. - * @param {string|APIMessage|MessageAdditions} content The content for the reply - * @param {InteractionReplyOptions} [options] Additional options for the reply - * @returns {Promise} - */ - async followUp(content, options) { - const apiMessage = content instanceof APIMessage ? content : APIMessage.create(this, content, options); - const { data, files } = await apiMessage.resolveData().resolveFiles(); - - const raw = await this.client.api.webhooks(this.applicationID, this.token).post({ - data, - files, - }); - - return this.channel?.messages.add(raw) ?? raw; - } - /** * Transforms an option received from the API. * @param {Object} option The received option @@ -267,6 +128,17 @@ class CommandInteraction extends Interaction { } return optionsCollection; } + + // These are here only for documentation purposes - they are implemented by InteractionResponses + /* eslint-disable no-empty-function */ + defer() {} + reply() {} + fetchReply() {} + editReply() {} + deleteReply() {} + followUp() {} } +InteractionResponses.applyToClass(CommandInteraction, ['deferUpdate', 'update']); + module.exports = CommandInteraction; diff --git a/src/structures/DMChannel.js b/src/structures/DMChannel.js index 48075974ab85..41ea724f2eea 100644 --- a/src/structures/DMChannel.js +++ b/src/structures/DMChannel.js @@ -91,6 +91,8 @@ class DMChannel extends Channel { get typingCount() {} createMessageCollector() {} awaitMessages() {} + createMessageComponentInteractionCollector() {} + awaitMessageComponentInteractions() {} // Doesn't work on DM channels; bulkDelete() {} } diff --git a/src/structures/Interaction.js b/src/structures/Interaction.js index 87a8c3ddfcf5..2d34f6079f3d 100644 --- a/src/structures/Interaction.js +++ b/src/structures/Interaction.js @@ -112,6 +112,14 @@ class Interaction extends Base { isCommand() { return InteractionTypes[this.type] === InteractionTypes.APPLICATION_COMMAND; } + + /** + * Indicates whether this interaction is a component interaction. + * @returns {boolean} + */ + isMessageComponent() { + return InteractionTypes[this.type] === InteractionTypes.MESSAGE_COMPONENT; + } } module.exports = Interaction; diff --git a/src/structures/Message.js b/src/structures/Message.js index a40fb9242975..46e066056d99 100644 --- a/src/structures/Message.js +++ b/src/structures/Message.js @@ -2,8 +2,10 @@ const APIMessage = require('./APIMessage'); const Base = require('./Base'); +const BaseMessageComponent = require('./BaseMessageComponent'); const ClientApplication = require('./ClientApplication'); const MessageAttachment = require('./MessageAttachment'); +const MessageComponentInteractionCollector = require('./MessageComponentInteractionCollector'); const Embed = require('./MessageEmbed'); const Mentions = require('./MessageMentions'); const ReactionCollector = require('./ReactionCollector'); @@ -123,6 +125,12 @@ class Message extends Base { */ this.embeds = (data.embeds || []).map(e => new Embed(e, true)); + /** + * A list of MessageActionRows in the message + * @type {MessageActionRow[]} + */ + this.components = (data.components ?? []).map(c => BaseMessageComponent.create(c, this.client)); + /** * A collection of attachments in the message - e.g. Pictures - mapped by their ID * @type {Collection} @@ -282,6 +290,8 @@ class Message extends Base { if ('tts' in data) this.tts = data.tts; if ('embeds' in data) this.embeds = data.embeds.map(e => new Embed(e, true)); else this.embeds = this.embeds.slice(); + if ('components' in data) this.components = data.components.map(c => BaseMessageComponent.create(c, this.client)); + else this.components = this.components.slice(); if ('attachments' in data) { this.attachments = new Collection(); @@ -407,6 +417,51 @@ class Message extends Base { }); } + /** + * Creates a message component interaction collector. + * @param {CollectorFilter} filter The filter to apply + * @param {MessageComponentInteractionCollectorOptions} [options={}] Options to send to the collector + * @returns {MessageComponentInteractionCollector} + * @example + * // Create a message component interaction collector + * const filter = (interaction) => interaction.customID === 'button' && interaction.user.id === 'someID'; + * const collector = message.createMessageComponentInteractionCollector(filter, { time: 15000 }); + * collector.on('collect', i => console.log(`Collected ${i.customID}`)); + * collector.on('end', collected => console.log(`Collected ${collected.size} items`)); + */ + createMessageComponentInteractionCollector(filter, options = {}) { + return new MessageComponentInteractionCollector(this, filter, options); + } + + /** + * An object containing the same properties as CollectorOptions, but a few more: + * @typedef {MessageComponentInteractionCollectorOptions} AwaitMessageComponentInteractionsOptions + * @property {string[]} [errors] Stop/end reasons that cause the promise to reject + */ + + /** + * Similar to createMessageComponentInteractionCollector but in promise form. + * Resolves with a collection of interactions that pass the specified filter. + * @param {CollectorFilter} filter The filter function to use + * @param {AwaitMessageComponentInteractionsOptions} [options={}] Optional options to pass to the internal collector + * @returns {Promise>} + * @example + * // Create a message component interaction collector + * const filter = (interaction) => interaction.customID === 'button' && interaction.user.id === 'someID'; + * message.awaitMessageComponentInteractions(filter, { time: 15000 }) + * .then(collected => console.log(`Collected ${collected.size} interactions`)) + * .catch(console.error); + */ + awaitMessageComponentInteractions(filter, options = {}) { + return new Promise((resolve, reject) => { + const collector = this.createMessageComponentInteractionCollector(filter, options); + collector.once('end', (interactions, reason) => { + if (options.errors && options.errors.includes(reason)) reject(interactions); + else resolve(interactions); + }); + }); + } + /** * Whether the message is editable by the client user * @type {boolean} diff --git a/src/structures/MessageActionRow.js b/src/structures/MessageActionRow.js new file mode 100644 index 000000000000..c0f25c5fdad4 --- /dev/null +++ b/src/structures/MessageActionRow.js @@ -0,0 +1,87 @@ +'use strict'; + +const BaseMessageComponent = require('./BaseMessageComponent'); +const { MessageComponentTypes } = require('../util/Constants'); + +/** + * Represents an ActionRow containing message components. + * @extends {BaseMessageComponent} + */ +class MessageActionRow extends BaseMessageComponent { + /** + * Components that can be placed in a MessageActionRow + * * MessageButton + * @typedef {MessageButton} MessageActionRowComponent + */ + + /** + * Options for components that can be placed in a MessageActionRow + * * MessageButtonOptions + * @typedef {MessageButtonOptions} MessageActionRowComponentOptions + */ + + /** + * Data that can be resolved into a components that can be placed in a MessageActionRow + * * MessageActionRowComponent + * * MessageActionRowComponentOptions + * @typedef {MessageActionRowComponent|MessageActionRowComponentOptions} MessageActionRowComponentResolvable + */ + + /** + * @typedef {BaseMessageComponentOptions} MessageActionRowOptions + * @property {MessageActionRowComponentResolvable[]} [components] + * The components to place in this ActionRow + */ + + /** + * @param {MessageActionRow|MessageActionRowOptions} [data={}] MessageActionRow to clone or raw data + */ + constructor(data = {}) { + super({ type: 'ACTION_ROW' }); + + /** + * The components in this MessageActionRow + * @type {MessageActionRowComponent[]} + */ + this.components = (data.components ?? []).map(c => BaseMessageComponent.create(c, null, true)); + } + + /** + * Adds components to the row. + * @param {...MessageActionRowComponentResolvable[]} components The components to add + * @returns {MessageActionRow} + */ + addComponents(...components) { + this.components.push(...components.flat(Infinity).map(c => BaseMessageComponent.create(c, null, true))); + return this; + } + + /** + * Removes, replaces, and inserts components in the action row. + * @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} + */ + spliceComponents(index, deleteCount, ...components) { + this.components.splice( + index, + deleteCount, + ...components.flat(Infinity).map(c => BaseMessageComponent.create(c, null, true)), + ); + return this; + } + + /** + * Transforms the action row to a plain object. + * @returns {Object} The raw data of this action row + */ + toJSON() { + return { + components: this.components.map(c => c.toJSON()), + type: MessageComponentTypes[this.type], + }; + } +} + +module.exports = MessageActionRow; diff --git a/src/structures/MessageButton.js b/src/structures/MessageButton.js new file mode 100644 index 000000000000..737e43685a7d --- /dev/null +++ b/src/structures/MessageButton.js @@ -0,0 +1,166 @@ +'use strict'; + +const BaseMessageComponent = require('./BaseMessageComponent'); +const { RangeError } = require('../errors'); +const { MessageButtonStyles, MessageComponentTypes } = require('../util/Constants'); +const Util = require('../util/Util'); + +/** + * Represents a Button message component. + * @extends {BaseMessageComponent} + */ +class MessageButton extends BaseMessageComponent { + /** + * @typedef {BaseMessageComponentOptions} MessageButtonOptions + * @property {string} [label] The text to be displayed on this button + * @property {string} [customID] A unique string to be sent in the interaction when clicked + * @property {MessageButtonStyleResolvable} [style] The style of this button + * @property {Emoji} [emoji] The emoji to be displayed to the left of the text + * @property {string} [url] Optional URL for link-style buttons + * @property {boolean} [disabled=false] Disables the button to prevent interactions + */ + + /** + * @param {MessageButton|MessageButtonOptions} [data={}] MessageButton to clone or raw data + */ + constructor(data = {}) { + super({ type: 'BUTTON' }); + + this.setup(data); + } + + setup(data) { + /** + * The text to be displayed on this button + * @type {?string} + */ + this.label = data.label ?? null; + + /** + * A unique string to be sent in the interaction when clicked + * @type {?string} + */ + this.customID = data.custom_id ?? data.customID ?? null; + + /** + * The style of this button + * @type {?MessageButtonStyle} + */ + this.style = data.style ? MessageButton.resolveStyle(data.style) : null; + + /** + * Emoji for this button + * @type {?Emoji|string} + */ + this.emoji = data.emoji ?? null; + + /** + * The URL this button links to, if it is a Link style button + * @type {?string} + */ + this.url = data.url ?? null; + + /** + * Whether this button is currently disabled + * @type {?boolean} + */ + this.disabled = data.disabled ?? false; + } + + /** + * Sets the custom ID of this button + * @param {string} customID A unique string to be sent in the interaction when clicked + * @returns {MessageButton} + */ + setCustomID(customID) { + this.customID = Util.verifyString(customID, RangeError, 'BUTTON_CUSTOM_ID'); + return this; + } + + /** + * Sets the interactive status of the button + * @param {boolean} disabled Whether this button should be disabled + * @returns {MessageButton} + */ + setDisabled(disabled) { + this.disabled = disabled; + return this; + } + + /** + * Set the emoji of this button + * @param {EmojiIdentifierResolvable} emoji The emoji to be displayed on this button + * @returns {MessageButton} + */ + setEmoji(emoji) { + if (/^\d{17,19}$/.test(emoji)) this.emoji = { id: emoji }; + else this.emoji = Util.parseEmoji(`${emoji}`); + return this; + } + + /** + * Sets the label of this button + * @param {string} label The text to be displayed on this button + * @returns {MessageButton} + */ + setLabel(label) { + this.label = Util.verifyString(label, RangeError, 'BUTTON_LABEL'); + return this; + } + + /** + * Sets the style of this button + * @param {MessageButtonStyleResolvable} style The style of this button + * @returns {MessageButton} + */ + setStyle(style) { + this.style = MessageButton.resolveStyle(style); + return this; + } + + /** + * Sets the URL of this button. MessageButton#style should be LINK + * @param {string} url The URL of this button + * @returns {MessageButton} + */ + setURL(url) { + this.url = Util.verifyString(url, RangeError, 'BUTTON_URL'); + return this; + } + + /** + * Transforms the button to a plain object. + * @returns {Object} The raw data of this button + */ + toJSON() { + return { + custom_id: this.customID, + disabled: this.disabled, + emoji: this.emoji, + label: this.label, + style: MessageButtonStyles[this.style], + type: MessageComponentTypes[this.type], + url: this.url, + }; + } + + /** + * Data that can be resolved to a MessageButtonStyle. This can be + * * {@link MessageButtonStyle} + * * string + * * number + * @typedef {string|number|MessageButtonStyle} MessageButtonStyleResolvable + */ + + /** + * Resolves the style of a MessageButton + * @param {MessageButtonStyleResolvable} style The style to resolve + * @returns {MessageButtonStyle} + * @private + */ + static resolveStyle(style) { + return typeof style === 'string' ? style : MessageButtonStyles[style]; + } +} + +module.exports = MessageButton; diff --git a/src/structures/MessageComponentInteraction.js b/src/structures/MessageComponentInteraction.js new file mode 100644 index 000000000000..711f7c282522 --- /dev/null +++ b/src/structures/MessageComponentInteraction.js @@ -0,0 +1,78 @@ +'use strict'; + +const Interaction = require('./Interaction'); +const InteractionResponses = require('./interfaces/InteractionResponses'); +const WebhookClient = require('../client/WebhookClient'); +const { MessageComponentTypes } = require('../util/Constants'); + +/** + * Represents a message component interaction. + * @extends {Interaction} + * @implements {InteractionResponses} + */ +class MessageComponentInteraction extends Interaction { + constructor(client, data) { + super(client, data); + + /** + * The message to which the component was attached + * @type {?Message|Object} + */ + this.message = data.message ? this.channel?.messages.add(data.message) ?? data.message : null; + + /** + * The custom ID of the component which was clicked + * @type {string} + */ + this.customID = data.data.custom_id; + + /** + * The type of component that was interacted with + * @type {string} + */ + this.componentType = MessageComponentInteraction.resolveType(data.data.component_type); + + /** + * Whether the reply to this interaction has been deferred + * @type {boolean} + */ + this.deferred = false; + + /** + * Whether this interaction has already been replied to + * @type {boolean} + */ + this.replied = false; + + /** + * An associated webhook client, can be used to create deferred replies + * @type {WebhookClient} + */ + this.webhook = new WebhookClient(this.applicationID, this.token, this.client.options); + } + + /** + * Resolves the type of a MessageComponent + * @param {MessageComponentTypeResolvable} type The type to resolve + * @returns {MessageComponentType} + * @private + */ + static resolveType(type) { + return typeof type === 'string' ? type : MessageComponentTypes[type]; + } + + // These are here only for documentation purposes - they are implemented by InteractionResponses + /* eslint-disable no-empty-function */ + defer() {} + reply() {} + fetchReply() {} + editReply() {} + deleteReply() {} + followUp() {} + deferUpdate() {} + update() {} +} + +InteractionResponses.applyToClass(MessageComponentInteraction); + +module.exports = MessageComponentInteraction; diff --git a/src/structures/MessageComponentInteractionCollector.js b/src/structures/MessageComponentInteractionCollector.js new file mode 100644 index 000000000000..87cfae80ecbb --- /dev/null +++ b/src/structures/MessageComponentInteractionCollector.js @@ -0,0 +1,178 @@ +'use strict'; + +const Collector = require('./interfaces/Collector'); +const Collection = require('../util/Collection'); +const { Events } = require('../util/Constants'); + +/** + * @typedef {CollectorOptions} MessageComponentInteractionCollectorOptions + * @property {number} max The maximum total amount of interactions to collect + * @property {number} maxComponents The maximum number of components to collect + * @property {number} maxUsers The maximum number of users to interact + */ + +/** + * Collects interaction on message components. + * Will automatically stop if the message (`'messageDelete'`), + * channel (`'channelDelete'`), or guild (`'guildDelete'`) are deleted. + * @extends {Collector} + */ +class MessageComponentInteractionCollector extends Collector { + /** + * @param {Message|TextChannel|DMChannel|NewsChannel} source + * The source from which to collect message component interactions + * @param {CollectorFilter} filter The filter to apply to this collector + * @param {MessageComponentInteractionCollectorOptions} [options={}] The options to apply to this collector + */ + constructor(source, filter, options = {}) { + super(source.client, filter, options); + + /** + * The message from which to collect message component interactions, if provided + * @type {?Message} + */ + this.message = source instanceof require('./Message') ? source : null; + + /** + * The source channel from which to collect message component interactions + * @type {TextChannel|DMChannel|NewsChannel} + */ + this.channel = this.message ? this.message.channel : source; + + /** + * The users which have interacted to buttons on this collector + * @type {Collection} + */ + this.users = new Collection(); + + /** + * The total number of interactions collected + * @type {number} + */ + this.total = 0; + + this.empty = this.empty.bind(this); + this._handleChannelDeletion = this._handleChannelDeletion.bind(this); + this._handleGuildDeletion = this._handleGuildDeletion.bind(this); + this._handleMessageDeletion = this._handleMessageDeletion.bind(this); + + this.client.incrementMaxListeners(); + this.client.on(Events.INTERACTION_CREATE, this.handleCollect); + + if (this.message) this.client.on(Events.MESSAGE_DELETE, this._handleMessageDeletion); + + this.client.on(Events.CHANNEL_DELETE, this._handleChannelDeletion); + this.client.on(Events.GUILD_DELETE, this._handleGuildDeletion); + + this.once('end', () => { + this.client.removeListener(Events.INTERACTION_CREATE, this.handleCollect); + + if (this.message) this.client.removeListener(Events.MESSAGE_DELETE, this._handleMessageDeletion); + + this.client.removeListener(Events.CHANNEL_DELETE, this._handleChannelDeletion); + this.client.removeListener(Events.GUILD_DELETE, this._handleGuildDeletion); + this.client.decrementMaxListeners(); + }); + + this.on('collect', interaction => { + this.total++; + this.users.set(interaction.user.id, interaction.user); + }); + } + + /** + * Handles an incoming interaction for possible collection. + * @param {Interaction} interaction The interaction to possibly collect + * @returns {?Snowflake|string} + * @private + */ + collect(interaction) { + /** + * Emitted whenever a interaction is collected. + * @event MessageComponentInteractionCollector#collect + * @param {Interaction} interaction The interaction that was collected + */ + if (!interaction.isMessageComponent()) return null; + + if (this.message) { + return interaction.message.id === this.message.id ? interaction.id : null; + } + + return interaction.channel.id === this.channel.id ? interaction.id : null; + } + + /** + * Handles an interaction for possible disposal. + * @param {Interaction} interaction The interaction that could be disposed of + * @returns {?Snowflake} + */ + dispose(interaction) { + /** + * Emitted whenever an interaction is disposed of. + * @event MessageComponentInteractionCollector#dispose + * @param {Interaction} interaction The interaction that was disposed of + */ + if (!interaction.isMessageComponent()) return null; + + if (this.message) { + return interaction.message.id === this.message.id ? interaction.id : null; + } + + return interaction.channel.id === this.channel.id ? interaction.id : null; + } + + /** + * Empties this message component collector. + */ + empty() { + this.total = 0; + this.collected.clear(); + this.users.clear(); + this.checkEnd(); + } + + get endReason() { + if (this.options.max && this.total >= this.options.max) return 'limit'; + if (this.options.maxComponents && this.collected.size >= this.options.maxComponents) return 'componentLimit'; + if (this.options.maxUsers && this.users.size >= this.options.maxUsers) return 'userLimit'; + return null; + } + + /** + * Handles checking if the message has been deleted, and if so, stops the collector with the reason 'messageDelete'. + * @private + * @param {Message} message The message that was deleted + * @returns {void} + */ + _handleMessageDeletion(message) { + if (message.id === this.message?.id) { + this.stop('messageDelete'); + } + } + + /** + * Handles checking if the channel has been deleted, and if so, stops the collector with the reason 'channelDelete'. + * @private + * @param {GuildChannel} channel The channel that was deleted + * @returns {void} + */ + _handleChannelDeletion(channel) { + if (channel.id === this.channel.id) { + this.stop('channelDelete'); + } + } + + /** + * Handles checking if the guild has been deleted, and if so, stops the collector with the reason 'guildDelete'. + * @private + * @param {Guild} guild The guild that was deleted + * @returns {void} + */ + _handleGuildDeletion(guild) { + if (guild.id === this.channel.guild?.id) { + this.stop('guildDelete'); + } + } +} + +module.exports = MessageComponentInteractionCollector; diff --git a/src/structures/TextChannel.js b/src/structures/TextChannel.js index 5c8e984f4144..9eb86b13c1b7 100644 --- a/src/structures/TextChannel.js +++ b/src/structures/TextChannel.js @@ -157,6 +157,8 @@ class TextChannel extends GuildChannel { get typingCount() {} createMessageCollector() {} awaitMessages() {} + createMessageComponentInteractionCollector() {} + awaitMessageComponentInteractions() {} bulkDelete() {} } diff --git a/src/structures/interfaces/InteractionResponses.js b/src/structures/interfaces/InteractionResponses.js new file mode 100644 index 000000000000..99b1f5245ed1 --- /dev/null +++ b/src/structures/interfaces/InteractionResponses.js @@ -0,0 +1,208 @@ +'use strict'; + +const { InteractionResponseTypes } = require('../../util/Constants'); +const MessageFlags = require('../../util/MessageFlags'); +const APIMessage = require('../APIMessage'); + +/** + * Interface for classes that support shared interaction response types. + * @interface + */ +class InteractionResponses { + /** + * Options for deferring the reply to a {@link CommandInteraction}. + * @typedef {InteractionDeferOptions} + * @property {boolean} [ephemeral] Whether the reply should be ephemeral + */ + + /** + * Options for a reply to an interaction. + * @typedef {BaseMessageOptions} InteractionReplyOptions + * @property {boolean} [ephemeral] Whether the reply should be ephemeral + * @property {MessageEmbed[]|Object[]} [embeds] An array of embeds for the message + */ + + /** + * Defers the reply to this interaction. + * @param {InteractionDeferOptions} [options] Options for deferring the reply to this interaction + * @returns {Promise} + * @example + * // Defer the reply to this interaction + * interaction.defer() + * .then(console.log) + * .catch(console.error) + * @example + * // Defer to send an ephemeral reply later + * interaction.defer({ ephemeral: true }) + * .then(console.log) + * .catch(console.error); + */ + async defer({ ephemeral } = {}) { + if (this.deferred || this.replied) throw new Error('INTERACTION_ALREADY_REPLIED'); + await this.client.api.interactions(this.id, this.token).callback.post({ + data: { + type: InteractionResponseTypes.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE, + data: { + flags: ephemeral ? MessageFlags.FLAGS.EPHEMERAL : undefined, + }, + }, + }); + this.deferred = true; + } + + /** + * Creates a reply to this interaction. + * @param {string|APIMessage|MessageAdditions} content The content for the reply + * @param {InteractionReplyOptions} [options] Additional options for the reply + * @returns {Promise} + * @example + * // Reply to the interaction with an embed + * const embed = new MessageEmbed().setDescription('Pong!'); + * + * interaction.reply(embed) + * .then(console.log) + * .catch(console.error); + * @example + * // Create an ephemeral reply + * interaction.reply('Pong!', { ephemeral: true }) + * .then(console.log) + * .catch(console.error); + */ + async reply(content, options) { + if (this.deferred || this.replied) throw new Error('INTERACTION_ALREADY_REPLIED'); + const apiMessage = content instanceof APIMessage ? content : APIMessage.create(this, content, options); + const { data, files } = await apiMessage.resolveData().resolveFiles(); + + await this.client.api.interactions(this.id, this.token).callback.post({ + data: { + type: InteractionResponseTypes.CHANNEL_MESSAGE_WITH_SOURCE, + data, + }, + files, + }); + this.replied = true; + } + + /** + * Fetches the initial reply to this interaction. + * @see Webhook#fetchMessage + * @returns {Promise} + * @example + * // Fetch the reply to this interaction + * interaction.fetchReply() + * .then(reply => console.log(`Replied with ${reply.content}`)) + * .catch(console.error); + */ + async fetchReply() { + const raw = await this.webhook.fetchMessage('@original'); + return this.channel?.messages.add(raw) ?? raw; + } + + /** + * Edits the initial reply to this interaction. + * @see Webhook#editMessage + * @param {string|APIMessage|MessageAdditions} content The new content for the message + * @param {WebhookEditMessageOptions} [options] The options to provide + * @returns {Promise} + * @example + * // Edit the reply to this interaction + * interaction.editReply('New content') + * .then(console.log) + * .catch(console.error); + */ + async editReply(content, options) { + const raw = await this.webhook.editMessage('@original', content, options); + return this.channel?.messages.add(raw) ?? raw; + } + + /** + * Deletes the initial reply to this interaction. + * @see Webhook#deleteMessage + * @returns {Promise} + * @example + * // Delete the reply to this interaction + * interaction.deleteReply() + * .then(console.log) + * .catch(console.error); + */ + async deleteReply() { + await this.webhook.deleteMessage('@original'); + } + + /** + * Send a follow-up message to this interaction. + * @param {string|APIMessage|MessageAdditions} content The content for the reply + * @param {InteractionReplyOptions} [options] Additional options for the reply + * @returns {Promise} + */ + async followUp(content, options) { + const apiMessage = content instanceof APIMessage ? content : APIMessage.create(this, content, options); + const { data, files } = await apiMessage.resolveData().resolveFiles(); + + const raw = await this.client.api.webhooks(this.applicationID, this.token).post({ + data, + files, + }); + + return this.channel?.messages.add(raw) ?? raw; + } + + /** + * Defers an update to the message to which the button was attached + * @returns {Promise} + * @example + * // Defer to update the button to a loading state + * interaction.deferUpdate() + * .then(console.log) + * .catch(console.error); + */ + async deferUpdate() { + if (this.deferred || this.replied) throw new Error('INTERACTION_ALREADY_REPLIED'); + await this.client.api.interactions(this.id, this.token).callback.post({ + data: { + type: InteractionResponseTypes.DEFERRED_MESSAGE_UPDATE, + }, + }); + this.deferred = true; + } + + /** + * Updates the original message whose button was pressed + * @param {string|APIMessage|MessageAdditions} content The content for the reply + * @param {WebhookEditMessageOptions} [options] Additional options for the reply + * @returns {Promise} + * @example + * // Remove the buttons from the message + * interaction.update("A button was clicked", { components: [] }) + * .then(console.log) + * .catch(console.error); + */ + async update(content, options) { + if (this.deferred || this.replied) throw new Error('INTERACTION_ALREADY_REPLIED'); + const apiMessage = content instanceof APIMessage ? content : APIMessage.create(this, content, options); + const { data, files } = await apiMessage.resolveData().resolveFiles(); + + await this.client.api.interactions(this.id, this.token).callback.post({ + data: { + type: InteractionResponseTypes.UPDATE_MESSAGE, + data, + }, + files, + }); + this.replied = true; + } + + static applyToClass(structure, ignore = []) { + const props = ['defer', 'reply', 'fetchReply', 'editReply', 'deleteReply', 'followUp', 'deferUpdate', 'update']; + for (const prop of props) { + if (ignore.includes(prop)) continue; + Object.defineProperty( + structure.prototype, + prop, + Object.getOwnPropertyDescriptor(InteractionResponses.prototype, prop), + ); + } + } +} + +module.exports = InteractionResponses; diff --git a/src/structures/interfaces/TextBasedChannel.js b/src/structures/interfaces/TextBasedChannel.js index 56f35305a4b8..09322b8149d1 100644 --- a/src/structures/interfaces/TextBasedChannel.js +++ b/src/structures/interfaces/TextBasedChannel.js @@ -6,6 +6,7 @@ const APIMessage = require('../APIMessage'); const SnowflakeUtil = require('../../util/SnowflakeUtil'); const Collection = require('../../util/Collection'); const { RangeError, TypeError } = require('../../errors'); +const MessageComponentInteractionCollector = require('../MessageComponentInteractionCollector'); /** * Interface for classes that have text-channel-like features. @@ -315,6 +316,45 @@ class TextBasedChannel { }); } + /** + * Creates a button interaction collector. + * @param {CollectorFilter} filter The filter to apply + * @param {MessageComponentInteractionCollectorOptions} [options={}] Options to send to the collector + * @returns {MessageComponentInteractionCollector} + * @example + * // Create a button interaction collector + * const filter = (interaction) => interaction.customID === 'button' && interaction.user.id === 'someID'; + * const collector = channel.createMessageComponentInteractionCollector(filter, { time: 15000 }); + * collector.on('collect', i => console.log(`Collected ${i.customID}`)); + * collector.on('end', collected => console.log(`Collected ${collected.size} items`)); + */ + createMessageComponentInteractionCollector(filter, options = {}) { + return new MessageComponentInteractionCollector(this, filter, options); + } + + /** + * Similar to createMessageComponentInteractionCollector but in promise form. + * Resolves with a collection of interactions that pass the specified filter. + * @param {CollectorFilter} filter The filter function to use + * @param {AwaitMessageComponentInteractionsOptions} [options={}] Optional options to pass to the internal collector + * @returns {Promise>} + * @example + * // Create a button interaction collector + * const filter = (interaction) => interaction.customID === 'button' && interaction.user.id === 'someID'; + * channel.awaitMessageComponentInteractions(filter, { time: 15000 }) + * .then(collected => console.log(`Collected ${collected.size} interactions`)) + * .catch(console.error); + */ + awaitMessageComponentInteractions(filter, options = {}) { + return new Promise((resolve, reject) => { + const collector = this.createMessageComponentInteractionCollector(filter, options); + collector.once('end', (interactions, reason) => { + if (options.errors && options.errors.includes(reason)) reject(interactions); + else resolve(interactions); + }); + }); + } + /** * Bulk deletes given messages that are newer than two weeks. * @param {Collection|MessageResolvable[]|number} messages @@ -379,6 +419,8 @@ class TextBasedChannel { 'typingCount', 'createMessageCollector', 'awaitMessages', + 'createMessageComponentInteractionCollector', + 'awaitMessageComponentInteractions', ); } for (const prop of props) { diff --git a/src/util/Constants.js b/src/util/Constants.js index 7b1d9401665d..a97dba1391b8 100644 --- a/src/util/Constants.js +++ b/src/util/Constants.js @@ -799,15 +799,18 @@ exports.ApplicationCommandPermissionTypes = createEnum([null, 'ROLE', 'USER']); * The type of an {@link Interaction} object: * * PING * * APPLICATION_COMMAND + * * MESSAGE_COMPONENT * @typedef {string} InteractionType */ -exports.InteractionTypes = createEnum([null, 'PING', 'APPLICATION_COMMAND']); +exports.InteractionTypes = createEnum([null, 'PING', 'APPLICATION_COMMAND', 'MESSAGE_COMPONENT']); /** * The type of an interaction response: * * PONG * * CHANNEL_MESSAGE_WITH_SOURCE * * DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE + * * DEFERRED_MESSAGE_UPDATE + * * UPDATE_MESSAGE * @typedef {string} InteractionResponseType */ exports.InteractionResponseTypes = createEnum([ @@ -817,8 +820,29 @@ exports.InteractionResponseTypes = createEnum([ null, 'CHANNEL_MESSAGE_WITH_SOURCE', 'DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE', + 'DEFERRED_MESSAGE_UPDATE', + 'UPDATE_MESSAGE', ]); +/** + * The type of a message component + * ACTION_ROW + * BUTTON + * @typedef {string} MessageComponentType + */ +exports.MessageComponentTypes = createEnum([null, 'ACTION_ROW', 'BUTTON']); + +/** + * The style of a message button + * PRIMARY + * SECONDARY + * SUCCESS + * DANGER + * LINK + * @typedef {string} MessageButtonStyle + */ +exports.MessageButtonStyles = createEnum([null, 'PRIMARY', 'SECONDARY', 'SUCCESS', 'DANGER', 'LINK']); + /** * NSFW level of a Guild * * DEFAULT diff --git a/src/util/Structures.js b/src/util/Structures.js index 1fcac228313d..bc6d2e06106e 100644 --- a/src/util/Structures.js +++ b/src/util/Structures.js @@ -20,6 +20,7 @@ * * **`Role`** * * **`User`** * * **`CommandInteraction`** + * * **`MessageComponentInteraction`** * @typedef {string} ExtendableStructure */ @@ -111,6 +112,7 @@ const structures = { Role: require('../structures/Role'), User: require('../structures/User'), CommandInteraction: require('../structures/CommandInteraction'), + MessageComponentInteraction: require('../structures/MessageComponentInteraction'), }; module.exports = Structures; diff --git a/typings/index.d.ts b/typings/index.d.ts index 668c446475db..93e1532071f3 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -25,11 +25,14 @@ declare enum InteractionResponseTypes { PONG = 1, CHANNEL_MESSAGE_WITH_SOURCE = 4, DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE = 5, + DEFERRED_MESSAGE_UPDATE = 6, + UPDATE_MESSAGE = 7, } declare enum InteractionTypes { PING = 1, APPLICATION_COMMAND = 2, + MESSAGE_COMPONENT = 3, } declare enum InviteTargetType { @@ -37,6 +40,19 @@ declare enum InviteTargetType { EMBEDDED_APPLICATION = 2, } +declare enum MessageButtonStyles { + PRIMARY = 1, + SECONDARY = 2, + SUCCESS = 3, + DANGER = 4, + LINK = 5, +} + +declare enum MessageComponentTypes { + ACTION_ROW = 1, + BUTTON = 2, +} + declare enum NSFWLevels { DEFAULT = 0, EXPLICIT = 1, @@ -61,15 +77,16 @@ declare module 'discord.js' { import BaseCollection from '@discordjs/collection'; import { ChildProcess } from 'child_process'; import { - ApplicationCommandOptionType as ApplicationCommandOptionTypes, - ApplicationCommandPermissionType as ApplicationCommandPermissionTypes, APIInteractionDataResolvedChannel as RawInteractionDataResolvedChannel, APIInteractionDataResolvedGuildMember as RawInteractionDataResolvedGuildMember, APIInteractionGuildMember as RawInteractionGuildMember, APIMessage as RawMessage, APIOverwrite as RawOverwrite, + APIPartialEmoji as RawEmoji, APIRole as RawRole, Snowflake as APISnowflake, + ApplicationCommandOptionType as ApplicationCommandOptionTypes, + ApplicationCommandPermissionType as ApplicationCommandPermissionTypes, } from 'discord-api-types/v8'; import { EventEmitter } from 'events'; import { PathLike } from 'fs'; @@ -269,6 +286,13 @@ declare module 'discord.js' { public setRTCRegion(region: string | null): Promise; } + export class BaseMessageComponent { + constructor(data?: BaseMessageComponent | BaseMessageComponentOptions); + public type: MessageComponentType | null; + private static create(data: MessageComponentOptions): MessageComponent; + private static resolveType(type: MessageComponentTypeResolvable): MessageComponentType; + } + class BroadcastDispatcher extends VolumeMixin(StreamDispatcher) { public broadcast: VoiceBroadcast; } @@ -689,6 +713,8 @@ declare module 'discord.js' { ApplicationCommandPermissionTypes: typeof ApplicationCommandPermissionTypes; InteractionTypes: typeof InteractionTypes; InteractionResponseTypes: typeof InteractionResponseTypes; + MessageComponentTypes: typeof MessageComponentTypes; + MessageButtonStyles: typeof MessageButtonStyles; NSFWLevels: typeof NSFWLevels; }; @@ -1110,6 +1136,7 @@ declare module 'discord.js' { public user: User; public version: number; public isCommand(): this is CommandInteraction; + public isMessageComponent(): this is MessageComponentInteraction; } export class Invite extends Base { @@ -1149,6 +1176,7 @@ declare module 'discord.js' { public author: User; public channel: TextChannel | DMChannel | NewsChannel; public readonly cleanContent: string; + public components: MessageActionRow[]; public content: string; public readonly createdAt: Date; public createdTimestamp: number; @@ -1177,6 +1205,10 @@ declare module 'discord.js' { public webhookID: Snowflake | null; public flags: Readonly; public reference: MessageReference | null; + public awaitMessageComponentInteractions( + filter: CollectorFilter<[MessageComponentInteraction]>, + options?: AwaitMessageComponentInteractionsOptions, + ): Promise>; public awaitReactions( filter: CollectorFilter<[MessageReaction, User]>, options?: AwaitReactionsOptions, @@ -1185,6 +1217,10 @@ declare module 'discord.js' { filter: CollectorFilter<[MessageReaction, User]>, options?: ReactionCollectorOptions, ): ReactionCollector; + public createMessageComponentInteractionCollector( + filter: CollectorFilter<[MessageComponentInteraction]>, + options?: AwaitMessageComponentInteractionsOptions, + ): MessageComponentInteractionCollector; public delete(): Promise; public edit( content: string | null | MessageEditOptions | MessageEmbed | APIMessage | MessageAttachment | MessageAttachment[], @@ -1221,6 +1257,21 @@ declare module 'discord.js' { public unpin(): Promise; } + export class MessageActionRow extends BaseMessageComponent { + constructor(data?: MessageActionRow | MessageActionRowOptions); + public type: 'ACTION_ROW'; + public components: MessageActionRowComponent[]; + public addComponents( + ...components: MessageActionRowComponentResolvable[] | MessageActionRowComponentResolvable[][] + ): this; + public spliceComponents( + index: number, + deleteCount: number, + ...components: MessageActionRowComponentResolvable[] | MessageActionRowComponentResolvable[][] + ): this; + public toJSON(): unknown; + } + export class MessageAttachment { constructor(attachment: BufferResolvable | Stream, name?: string, data?: unknown); @@ -1239,6 +1290,25 @@ declare module 'discord.js' { public toJSON(): unknown; } + export class MessageButton extends BaseMessageComponent { + constructor(data?: MessageButton | MessageButtonOptions); + public customID: string | null; + public disabled: boolean; + public emoji: string | RawEmoji | null; + public label: string | null; + public style: MessageButtonStyle | null; + public type: 'BUTTON'; + public url: string | null; + public setCustomID(customID: string): this; + public setDisabled(disabled: boolean): this; + public setEmoji(emoji: EmojiIdentifierResolvable): this; + public setLabel(label: string): this; + public setStyle(style: MessageButtonStyleResolvable): this; + public setURL(url: string): this; + public toJSON(): unknown; + private static resolveStyle(style: MessageButtonStyleResolvable): MessageButtonStyle; + } + export class MessageCollector extends Collector { constructor( channel: TextChannel | DMChannel, @@ -1257,6 +1327,68 @@ declare module 'discord.js' { public dispose(message: Message): Snowflake; } + export class MessageComponentInteraction extends Interaction { + public customID: string; + public deferred: boolean; + public message: Message | RawMessage; + public replied: boolean; + public webhook: WebhookClient; + public defer(ephemeral?: boolean): Promise; + public deferUpdate(): Promise; + public deleteReply(): Promise; + public editReply( + content: string | APIMessage | WebhookEditMessageOptions | MessageEmbed | MessageEmbed[], + ): Promise; + public editReply(content: string, options?: WebhookEditMessageOptions): Promise; + public fetchReply(): Promise; + public followUp( + content: string | APIMessage | InteractionReplyOptions | MessageAdditions, + ): Promise; + public followUp(content: string, options?: InteractionReplyOptions): Promise; + public reply(content: string | APIMessage | InteractionReplyOptions | MessageAdditions): Promise; + public reply(content: string, options?: InteractionReplyOptions): Promise; + public update( + content: string | APIMessage | WebhookEditMessageOptions | MessageEmbed | MessageEmbed[], + ): Promise; + public update(content: string, options?: WebhookEditMessageOptions): Promise; + public static resolveType(type: MessageComponentTypeResolvable): MessageComponentType; + } + + export class MessageComponentInteractionCollector extends Collector { + constructor( + source: Message | TextChannel | NewsChannel | DMChannel, + filter: CollectorFilter<[MessageComponentInteraction]>, + options?: MessageComponentInteractionCollectorOptions, + ); + private _handleMessageDeletion(message: Message): void; + private _handleChannelDeletion(channel: GuildChannel): void; + private _handleGuildDeletion(guild: Guild): void; + + public channel: TextChannel | NewsChannel | DMChannel; + public empty(): void; + public readonly endReason: string | null; + public message: Message | null; + public options: MessageComponentInteractionCollectorOptions; + public total: number; + public users: Collection; + + public collect(interaction: Interaction): Snowflake; + public dispose(interaction: Interaction): Snowflake; + public on(event: 'collect' | 'dispose', listener: (interaction: Interaction) => Awaited): this; + public on( + event: 'end', + listener: (collected: Collection, reason: string) => Awaited, + ): this; + public on(event: string, listener: (...args: any[]) => Awaited): this; + + public once(event: 'collect' | 'dispose', listener: (interaction: Interaction) => Awaited): this; + public once( + event: 'end', + listener: (collected: Collection, reason: string) => Awaited, + ): this; + public once(event: string, listener: (...args: any[]) => Awaited): this; + } + export class MessageEmbed { constructor(data?: MessageEmbed | MessageEmbedOptions); public author: MessageEmbedAuthor | null; @@ -2338,6 +2470,10 @@ declare module 'discord.js' { readonly lastPinAt: Date | null; typing: boolean; typingCount: number; + awaitMessageComponentInteractions( + filter: CollectorFilter<[MessageComponentInteraction]>, + options?: AwaitMessageComponentInteractionsOptions, + ): Promise>; awaitMessages( filter: CollectorFilter<[Message]>, options?: AwaitMessagesOptions, @@ -2346,6 +2482,10 @@ declare module 'discord.js' { messages: Collection | readonly MessageResolvable[] | number, filterOld?: boolean, ): Promise>; + createMessageComponentInteractionCollector( + filter: CollectorFilter<[MessageComponentInteraction]>, + options?: MessageComponentInteractionCollectorOptions, + ): MessageComponentInteractionCollector; createMessageCollector(filter: CollectorFilter<[Message]>, options?: MessageCollectorOptions): MessageCollector; startTyping(count?: number): Promise; stopTyping(force?: boolean): void; @@ -2552,6 +2692,10 @@ declare module 'discord.js' { new?: any; } + interface AwaitMessageComponentInteractionsOptions extends MessageComponentInteractionCollectorOptions { + errors?: string[]; + } + interface AwaitMessagesOptions extends MessageCollectorOptions { errors?: string[]; } @@ -2569,6 +2713,10 @@ declare module 'discord.js' { type Base64String = string; + interface BaseMessageComponentOptions { + type?: MessageComponentType | MessageComponentTypes; + } + type BitFieldResolvable = | RecursiveReadonlyArray>> | T @@ -3201,16 +3349,53 @@ declare module 'discord.js' { type MessageAdditions = MessageEmbed | MessageAttachment | (MessageEmbed | MessageAttachment)[]; + type MessageActionRowComponent = MessageButton; + + type MessageActionRowComponentOptions = MessageButtonOptions; + + type MessageActionRowComponentResolvable = MessageActionRowComponent | MessageActionRowComponentOptions; + + interface MessageActionRowOptions extends BaseMessageComponentOptions { + components?: MessageActionRowComponentResolvable[]; + } + interface MessageActivity { partyID: string; type: number; } + interface MessageButtonOptions extends BaseMessageComponentOptions { + customID?: string; + disabled?: boolean; + emoji?: RawEmoji; + label?: string; + style: MessageButtonStyleResolvable; + url?: string; + } + + type MessageButtonStyle = keyof typeof MessageButtonStyles; + + type MessageButtonStyleResolvable = MessageButtonStyle | MessageButtonStyles; + interface MessageCollectorOptions extends CollectorOptions { max?: number; maxProcessed?: number; } + type MessageComponent = BaseMessageComponent | MessageActionRow | MessageButton; + + interface MessageComponentInteractionCollectorOptions extends CollectorOptions { + max?: number; + maxComponents?: number; + maxUsers?: number; + } + + type MessageComponentOptions = BaseMessageComponentOptions | MessageActionRowOptions | MessageButtonOptions; + + type MessageComponentType = keyof typeof MessageComponentTypes; + + type MessageComponentTypeResolvable = MessageComponentType | MessageComponentTypes; + interface MessageEditOptions { attachments?: MessageAttachment[]; content?: string | null; @@ -3219,6 +3404,7 @@ declare module 'discord.js' { files?: (FileOptions | BufferResolvable | Stream | MessageAttachment)[]; flags?: BitFieldResolvable; allowedMentions?: MessageMentionOptions; + components?: MessageActionRow[] | MessageActionRowOptions[]; } interface MessageEmbedAuthor { @@ -3311,6 +3497,7 @@ declare module 'discord.js' { nonce?: string | number; content?: string; embed?: MessageEmbed | MessageEmbedOptions; + components?: MessageActionRow[] | MessageActionRowOptions[]; allowedMentions?: MessageMentionOptions; files?: (FileOptions | BufferResolvable | Stream | MessageAttachment)[]; code?: string | boolean; @@ -3715,7 +3902,10 @@ declare module 'discord.js' { reason?: string; } - type WebhookEditMessageOptions = Pick; + type WebhookEditMessageOptions = Pick< + WebhookMessageOptions, + 'content' | 'embeds' | 'files' | 'allowedMentions' | 'components' + >; interface WebhookMessageOptions extends Omit { username?: string;