Skip to content

Commit

Permalink
feat(MessageComponents): clickybois (MessageButton, MessageActionRow,…
Browse files Browse the repository at this point in the history
… associated Collectors) (#5674)

Co-authored-by: Vicente <[email protected]>
Co-authored-by: Shubham Parihar <[email protected]>
Co-authored-by: SpaceEEC <[email protected]>
Co-authored-by: BannerBomb <[email protected]>
Co-authored-by: Arechi <[email protected]>
Co-authored-by: Vlad Frangu <[email protected]>
Co-authored-by: Sugden <[email protected]>
Co-authored-by: Antonio Román <[email protected]>
  • Loading branch information
9 people authored Jun 4, 2021
1 parent df9b678 commit cbd7f2b
Show file tree
Hide file tree
Showing 19 changed files with 1,190 additions and 158 deletions.
35 changes: 23 additions & 12 deletions src/client/websocket/handlers/INTERACTION_CREATE.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
};
4 changes: 4 additions & 0 deletions src/errors/Messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -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!',
Expand Down
5 changes: 5 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand All @@ -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'),
Expand Down
4 changes: 4 additions & 0 deletions src/structures/APIMessage.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
'use strict';

const BaseMessageComponent = require('./BaseMessageComponent');
const MessageAttachment = require('./MessageAttachment');
const MessageEmbed = require('./MessageEmbed');
const { RangeError } = require('../errors');
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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:
Expand Down
94 changes: 94 additions & 0 deletions src/structures/BaseMessageComponent.js
Original file line number Diff line number Diff line change
@@ -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;
156 changes: 14 additions & 142 deletions src/structures/CommandInteraction.js
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down Expand Up @@ -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<void>}
* @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<void>}
* @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<Message|Object>}
* @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<Message|Object>}
* @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<void>}
* @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
Expand All @@ -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<Message|Object>}
*/
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
Expand Down Expand Up @@ -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;
2 changes: 2 additions & 0 deletions src/structures/DMChannel.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ class DMChannel extends Channel {
get typingCount() {}
createMessageCollector() {}
awaitMessages() {}
createMessageComponentInteractionCollector() {}
awaitMessageComponentInteractions() {}
// Doesn't work on DM channels; bulkDelete() {}
}

Expand Down
Loading

0 comments on commit cbd7f2b

Please sign in to comment.