diff --git a/src/structures/ApplicationCommand.js b/src/structures/ApplicationCommand.js index c38eace..3a293d8 100644 --- a/src/structures/ApplicationCommand.js +++ b/src/structures/ApplicationCommand.js @@ -1,628 +1,628 @@ -'use strict'; - -const Base = require('./Base'); -const ApplicationCommandPermissionsManager = require('../managers/ApplicationCommandPermissionsManager'); -const { - ApplicationCommandOptionTypes, - ApplicationCommandTypes, - ChannelTypes, -} = require('../util/Constants'); -const SnowflakeUtil = require('../util/SnowflakeUtil'); -const { Message } = require('discord.js'); - -/** - * Represents an application command. - * @extends {Base} - */ -class ApplicationCommand extends Base { - constructor(client, data, guild, guildId) { - super(client); - - /** - * The command's id - * @type {Snowflake} - */ - this.id = data.id; - - /** - * The parent application's id - * @type {Snowflake} - */ - this.applicationId = data.application_id; - - /** - * The guild this command is part of - * @type {?Guild} - */ - this.guild = guild ?? null; - - /** - * The guild's id this command is part of, this may be non-null when `guild` is `null` if the command - * was fetched from the `ApplicationCommandManager` - * @type {?Snowflake} - */ - this.guildId = guild?.id ?? guildId ?? null; - - /** - * The manager for permissions of this command on its guild or arbitrary guilds when the command is global - * @type {ApplicationCommandPermissionsManager} - */ - this.permissions = new ApplicationCommandPermissionsManager( - this, - this.applicationId, - ); - - /** - * The type of this application command - * @type {ApplicationCommandType} - */ - this.type = ApplicationCommandTypes[data.type]; - - this.user = client.users.cache.get(this.applicationId); - - this._patch(data); - } - - _patch(data) { - if ('name' in data) { - /** - * The name of this command - * @type {string} - */ - this.name = data.name; - } - - if ('description' in data) { - /** - * The description of this command - * @type {string} - */ - this.description = data.description; - } - - if ('options' in data) { - /** - * The options of this command - * @type {ApplicationCommandOption[]} - */ - this.options = data.options.map((o) => - this.constructor.transformOption(o, true), - ); - } else { - this.options ??= []; - } - - if ('default_permission' in data) { - /** - * Whether the command is enabled by default when the app is added to a guild - * @type {boolean} - */ - this.defaultPermission = data.default_permission; - } - - if ('version' in data) { - /** - * Autoincrementing version identifier updated during substantial record changes - * @type {Snowflake} - */ - this.version = data.version; - } - } - - /** - * The timestamp the command was created at - * @type {number} - * @readonly - */ - get createdTimestamp() { - return SnowflakeUtil.timestampFrom(this.id); - } - - /** - * The time the command was created at - * @type {Date} - * @readonly - */ - get createdAt() { - return new Date(this.createdTimestamp); - } - - /** - * The manager that this command belongs to - * @type {ApplicationCommandManager} - * @readonly - */ - get manager() { - return (this.guild ?? this.client.application).commands; - } - - /** - * Data for creating or editing an application command. - * @typedef {Object} ApplicationCommandData - * @property {string} name The name of the command - * @property {string} description The description of the command - * @property {ApplicationCommandType} [type] The type of the command - * @property {ApplicationCommandOptionData[]} [options] Options for the command - * @property {boolean} [defaultPermission] Whether the command is enabled by default when the app is added to a guild - */ - - /** - * An option for an application command or subcommand. - * In addition to the listed properties, when used as a parameter, - * API style `snake_case` properties can be used for compatibility with generators like `@discordjs/builders`. - * Note that providing a value for the `camelCase` counterpart for any `snake_case` property - * will discard the provided `snake_case` property. - * @typedef {Object} ApplicationCommandOptionData - * @property {ApplicationCommandOptionType|number} type The type of the option - * @property {string} name The name of the option - * @property {string} description The description of the option - * @property {boolean} [autocomplete] Whether the option is an autocomplete option - * @property {boolean} [required] Whether the option is required - * @property {ApplicationCommandOptionChoice[]} [choices] The choices of the option for the user to pick from - * @property {ApplicationCommandOptionData[]} [options] Additional options if this option is a subcommand (group) - * @property {ChannelType[]|number[]} [channelTypes] When the option type is channel, - * the allowed types of channels that can be selected - * @property {number} [minValue] The minimum value for an `INTEGER` or `NUMBER` option - * @property {number} [maxValue] The maximum value for an `INTEGER` or `NUMBER` option - */ - - /** - * Edits this application command. - * @param {ApplicationCommandData} data The data to update the command with - * @returns {Promise} - * @example - * // Edit the description of this command - * command.edit({ - * description: 'New description', - * }) - * .then(console.log) - * .catch(console.error); - */ - edit(data) { - return this.manager.edit(this, data, this.guildId); - } - - /** - * Edits the name of this ApplicationCommand - * @param {string} name The new name of the command - * @returns {Promise} - */ - setName(name) { - return this.edit({ name }); - } - - /** - * Edits the description of this ApplicationCommand - * @param {string} description The new description of the command - * @returns {Promise} - */ - setDescription(description) { - return this.edit({ description }); - } - - /** - * Edits the default permission of this ApplicationCommand - * @param {boolean} [defaultPermission=true] The default permission for this command - * @returns {Promise} - */ - setDefaultPermission(defaultPermission = true) { - return this.edit({ defaultPermission }); - } - - /** - * Edits the options of this ApplicationCommand - * @param {ApplicationCommandOptionData[]} options The options to set for this command - * @returns {Promise} - */ - setOptions(options) { - return this.edit({ options }); - } - - /** - * Deletes this command. - * @returns {Promise} - * @example - * // Delete this command - * command.delete() - * .then(console.log) - * .catch(console.error); - */ - delete() { - return this.manager.delete(this, this.guildId); - } - - /** - * Whether this command equals another command. It compares all properties, so for most operations - * it is advisable to just compare `command.id === command2.id` as it is much faster and is often - * what most users need. - * @param {ApplicationCommand|ApplicationCommandData|APIApplicationCommand} command The command to compare with - * @param {boolean} [enforceOptionOrder=false] Whether to strictly check that options and choices are in the same - * order in the array The client may not always respect this ordering! - * @returns {boolean} - */ - equals(command, enforceOptionOrder = false) { - // If given an id, check if the id matches - if (command.id && this.id !== command.id) return false; - - // Check top level parameters - const commandType = - typeof command.type === 'string' - ? command.type - : ApplicationCommandTypes[command.type]; - if ( - command.name !== this.name || - ('description' in command && command.description !== this.description) || - ('version' in command && command.version !== this.version) || - ('autocomplete' in command && - command.autocomplete !== this.autocomplete) || - (commandType && commandType !== this.type) || - // Future proof for options being nullable - // TODO: remove ?? 0 on each when nullable - (command.options?.length ?? 0) !== (this.options?.length ?? 0) || - (command.defaultPermission ?? command.default_permission ?? true) !== - this.defaultPermission - ) { - return false; - } - - if (command.options) { - return this.constructor.optionsEqual( - this.options, - command.options, - enforceOptionOrder, - ); - } - return true; - } - - /** - * Recursively checks that all options for an {@link ApplicationCommand} are equal to the provided options. - * In most cases it is better to compare using {@link ApplicationCommand#equals} - * @param {ApplicationCommandOptionData[]} existing The options on the existing command, - * should be {@link ApplicationCommand#options} - * @param {ApplicationCommandOptionData[]|APIApplicationCommandOption[]} options The options to compare against - * @param {boolean} [enforceOptionOrder=false] Whether to strictly check that options and choices are in the same - * order in the array The client may not always respect this ordering! - * @returns {boolean} - */ - static optionsEqual(existing, options, enforceOptionOrder = false) { - if (existing.length !== options.length) return false; - if (enforceOptionOrder) { - return existing.every((option, index) => - this._optionEquals(option, options[index], enforceOptionOrder), - ); - } - const newOptions = new Map(options.map((option) => [option.name, option])); - for (const option of existing) { - const foundOption = newOptions.get(option.name); - if (!foundOption || !this._optionEquals(option, foundOption)) - return false; - } - return true; - } - - /** - * Checks that an option for an {@link ApplicationCommand} is equal to the provided option - * In most cases it is better to compare using {@link ApplicationCommand#equals} - * @param {ApplicationCommandOptionData} existing The option on the existing command, - * should be from {@link ApplicationCommand#options} - * @param {ApplicationCommandOptionData|APIApplicationCommandOption} option The option to compare against - * @param {boolean} [enforceOptionOrder=false] Whether to strictly check that options or choices are in the same - * order in their array The client may not always respect this ordering! - * @returns {boolean} - * @private - */ - static _optionEquals(existing, option, enforceOptionOrder = false) { - const optionType = - typeof option.type === 'string' - ? option.type - : ApplicationCommandOptionTypes[option.type]; - if ( - option.name !== existing.name || - optionType !== existing.type || - option.description !== existing.description || - option.autocomplete !== existing.autocomplete || - (option.required ?? - (['SUB_COMMAND', 'SUB_COMMAND_GROUP'].includes(optionType) - ? undefined - : false)) !== existing.required || - option.choices?.length !== existing.choices?.length || - option.options?.length !== existing.options?.length || - (option.channelTypes ?? option.channel_types)?.length !== - existing.channelTypes?.length || - (option.minValue ?? option.min_value) !== existing.minValue || - (option.maxValue ?? option.max_value) !== existing.maxValue - ) { - return false; - } - - if (existing.choices) { - if ( - enforceOptionOrder && - !existing.choices.every( - (choice, index) => - choice.name === option.choices[index].name && - choice.value === option.choices[index].value, - ) - ) { - return false; - } - if (!enforceOptionOrder) { - const newChoices = new Map( - option.choices.map((choice) => [choice.name, choice]), - ); - for (const choice of existing.choices) { - const foundChoice = newChoices.get(choice.name); - if (!foundChoice || foundChoice.value !== choice.value) return false; - } - } - } - - if (existing.channelTypes) { - const newTypes = (option.channelTypes ?? option.channel_types).map( - (type) => (typeof type === 'number' ? ChannelTypes[type] : type), - ); - for (const type of existing.channelTypes) { - if (!newTypes.includes(type)) return false; - } - } - - if (existing.options) { - return this.optionsEqual( - existing.options, - option.options, - enforceOptionOrder, - ); - } - return true; - } - - /** - * An option for an application command or subcommand. - * @typedef {Object} ApplicationCommandOption - * @property {ApplicationCommandOptionType} type The type of the option - * @property {string} name The name of the option - * @property {string} description The description of the option - * @property {boolean} [required] Whether the option is required - * @property {boolean} [autocomplete] Whether the option is an autocomplete option - * @property {ApplicationCommandOptionChoice[]} [choices] The choices of the option for the user to pick from - * @property {ApplicationCommandOption[]} [options] Additional options if this option is a subcommand (group) - * @property {ChannelType[]} [channelTypes] When the option type is channel, - * the allowed types of channels that can be selected - * @property {number} [minValue] The minimum value for an `INTEGER` or `NUMBER` option - * @property {number} [maxValue] The maximum value for an `INTEGER` or `NUMBER` option - */ - - /** - * A choice for an application command option. - * @typedef {Object} ApplicationCommandOptionChoice - * @property {string} name The name of the choice - * @property {string|number} value The value of the choice - */ - - /** - * Transforms an {@link ApplicationCommandOptionData} object into something that can be used with the API. - * @param {ApplicationCommandOptionData} option The option to transform - * @param {boolean} [received] Whether this option has been received from Discord - * @returns {APIApplicationCommandOption} - * @private - */ - static transformOption(option, received) { - const stringType = - typeof option.type === 'string' - ? option.type - : ApplicationCommandOptionTypes[option.type]; - const channelTypesKey = received ? 'channelTypes' : 'channel_types'; - const minValueKey = received ? 'minValue' : 'min_value'; - const maxValueKey = received ? 'maxValue' : 'max_value'; - return { - type: - typeof option.type === 'number' && !received - ? option.type - : ApplicationCommandOptionTypes[option.type], - name: option.name, - description: option.description, - required: - option.required ?? - (stringType === 'SUB_COMMAND' || stringType === 'SUB_COMMAND_GROUP' - ? undefined - : false), - autocomplete: option.autocomplete, - choices: option.choices, - options: option.options?.map((o) => this.transformOption(o, received)), - [channelTypesKey]: received - ? option.channel_types?.map((type) => ChannelTypes[type]) - : option.channelTypes?.map((type) => - typeof type === 'string' ? ChannelTypes[type] : type, - ) ?? - // When transforming to API data, accept API data - option.channel_types, - [minValueKey]: option.minValue ?? option.min_value, - [maxValueKey]: option.maxValue ?? option.max_value, - }; - } - /** - * Send Slash command to channel - * @param {Message} message Discord Message - * @param {Array} options The options to Slash Command - * @returns {Promise} - * @example - * const botID = '12345678987654321' - * const user = await client.users.fetch(botID); - * const application = await user.applications.fetch(); - * const command = application.commands.first(); - * await command.sendSlashCommand(messsage, ['option1', 'option2']); - */ - async sendSlashCommand(message, options = []) { - // Check Options - if (!message instanceof Message) - throw new TypeError('The message must be a Discord.Message'); - if (!Array.isArray(options)) - throw new TypeError('The options must be an array of strings'); - if (this.type !== 'CHAT_INPUT') return false; - const optionFormat = []; - let option_ = []; - let i = 0; - // Check Command type is Sub group ? - const subCommandCheck = this.options.some((option) => - ['SUB_COMMAND', 'SUB_COMMAND_GROUP'].includes(option.type), - ); - let subCommand; - if (subCommandCheck) { - subCommand = this.options.find((option) => option.name == options[0]); - options.shift(); - option_[0] = { - type: ApplicationCommandOptionTypes[subCommand.type], - name: subCommand.name, - options: optionFormat, - }; - } else { - option_ = optionFormat; - } - for (i; i < options.length; i++) { - const value = options[i]; - if (typeof value !== 'string') { - throw new TypeError( - `Expected option to be a String, got ${typeof value}. If you type Number, please convert to String.`, - ); - } - if (!subCommandCheck && !this?.options[i]) continue; - if (subCommandCheck && subCommand?.options && !subCommand?.options[i]) continue; - if (!subCommandCheck) { - // Check value is invalid - let choice; - if (this.options[i].choices && this.options[i].choices.length > 0) { - choice = - this.options[i].choices.find((c) => c.name == value) || - this.options[i].choices.find((c) => c.value == value); - if (!choice) { - throw new Error( - `Invalid option: ${value} is not a valid choice for this option\nList of choices: ${this.options[ - i - ].choices - .map((c, i) => `#${i + 1} Name: ${c.name} Value: ${c.value}\n`) - .join('')}`, - ); - } - } - const data = { - type: ApplicationCommandOptionTypes[this.options[i].type], - name: this.options[i].name, - value: choice?.value || (this.options[i].type == 'INTEGER' ? Number(value) : (this.options[i].type == 'BOOLEAN' ? Boolean(value) : value)), - }; - optionFormat.push(data); - } else { - // First element is sub command and removed - if (!value) continue; - // Check value is invalid - let choice; - if ( - subCommand?.options && - subCommand.options[i].choices && - subCommand.options[i].choices.length > 0 - ) { - choice = - subCommand.options[i].choices.find((c) => c.name == value) || - subCommand.options[i].choices.find((c) => c.value == value); - if (!choice) { - throw new Error( - `Invalid option: ${value} is not a valid choice for this option\nList of choices: \n${subCommand.options[ - i - ].choices.map( - (c, i) => `#${i + 1} Name: ${c.name} Value: ${c.value}\n`, - ).join('')}`, - ); - } - } - const data = { - type: ApplicationCommandOptionTypes[subCommand.options[i].type], - name: subCommand.options[i].name, - value: - choice?.value || - (subCommand.options[i].type == 'INTEGER' - ? Number(value) - : subCommand.options[i].type == 'BOOLEAN' - ? Boolean(value) - : value), - }; - optionFormat.push(data); - } - } - if (!subCommandCheck && this.options[i]?.required) - throw new Error('Value required missing'); - if ( - subCommandCheck && - subCommand?.options && - subCommand?.options[i]?.required - ) - throw new Error('Value required missing'); - await this.client.api.interactions.post({ - body: { - type: 2, // ??? - application_id: this.applicationId, - guild_id: message.guildId, - channel_id: message.channelId, - session_id: this.client.session_id, - data: { - // ApplicationCommandData - version: this.version, - id: this.id, - name: this.name, - type: ApplicationCommandTypes[this.type], - options: option_, - }, - }, - }); - return true; - } - /** - * Message Context Menu - * @param {Message} message Discord Message - * @returns {Promise} - * @example - * const botID = '12345678987654321' - * const user = await client.users.fetch(botID); - * const application = await user.applications.fetch(); - * const command = application.commands.first(); - * await command.sendContextMenu(messsage); - */ - async sendContextMenu(message, sendFromMessage = false) { - if (!message instanceof Message && !sendFromMessage) - throw new TypeError('The message must be a Discord.Message'); - if (this.type == 'CHAT_INPUT') return false; - await this.client.api.interactions.post({ - body: { - type: 2, // ??? - application_id: this.applicationId, - guild_id: message.guildId, - channel_id: message.channelId, - session_id: this.client.session_id, - data: { - // ApplicationCommandData - version: this.version, - id: this.id, - name: this.name, - type: ApplicationCommandTypes[this.type], - target_id: - ApplicationCommandTypes[this.type] == 1 - ? message.author.id - : message.id, - }, - }, - }); - return true; - } -} - -module.exports = ApplicationCommand; - -/* eslint-disable max-len */ -/** - * @external APIApplicationCommand - * @see {@link https://discord.com/developers/docs/interactions/application-commands#application-command-object-application-command-structure} - */ - -/** - * @external APIApplicationCommandOption - * @see {@link https://discord.com/developers/docs/interactions/application-commands#application-command-object-application-command-option-structure} - */ +'use strict'; + +const Base = require('./Base'); +const ApplicationCommandPermissionsManager = require('../managers/ApplicationCommandPermissionsManager'); +const { + ApplicationCommandOptionTypes, + ApplicationCommandTypes, + ChannelTypes, +} = require('../util/Constants'); +const SnowflakeUtil = require('../util/SnowflakeUtil'); +const { Message } = require('discord.js'); + +/** + * Represents an application command. + * @extends {Base} + */ +class ApplicationCommand extends Base { + constructor(client, data, guild, guildId) { + super(client); + + /** + * The command's id + * @type {Snowflake} + */ + this.id = data.id; + + /** + * The parent application's id + * @type {Snowflake} + */ + this.applicationId = data.application_id; + + /** + * The guild this command is part of + * @type {?Guild} + */ + this.guild = guild ?? null; + + /** + * The guild's id this command is part of, this may be non-null when `guild` is `null` if the command + * was fetched from the `ApplicationCommandManager` + * @type {?Snowflake} + */ + this.guildId = guild?.id ?? guildId ?? null; + + /** + * The manager for permissions of this command on its guild or arbitrary guilds when the command is global + * @type {ApplicationCommandPermissionsManager} + */ + this.permissions = new ApplicationCommandPermissionsManager( + this, + this.applicationId, + ); + + /** + * The type of this application command + * @type {ApplicationCommandType} + */ + this.type = ApplicationCommandTypes[data.type]; + + this.user = client.users.cache.get(this.applicationId); + + this._patch(data); + } + + _patch(data) { + if ('name' in data) { + /** + * The name of this command + * @type {string} + */ + this.name = data.name; + } + + if ('description' in data) { + /** + * The description of this command + * @type {string} + */ + this.description = data.description; + } + + if ('options' in data) { + /** + * The options of this command + * @type {ApplicationCommandOption[]} + */ + this.options = data.options.map((o) => + this.constructor.transformOption(o, true), + ); + } else { + this.options ??= []; + } + + if ('default_permission' in data) { + /** + * Whether the command is enabled by default when the app is added to a guild + * @type {boolean} + */ + this.defaultPermission = data.default_permission; + } + + if ('version' in data) { + /** + * Autoincrementing version identifier updated during substantial record changes + * @type {Snowflake} + */ + this.version = data.version; + } + } + + /** + * The timestamp the command was created at + * @type {number} + * @readonly + */ + get createdTimestamp() { + return SnowflakeUtil.timestampFrom(this.id); + } + + /** + * The time the command was created at + * @type {Date} + * @readonly + */ + get createdAt() { + return new Date(this.createdTimestamp); + } + + /** + * The manager that this command belongs to + * @type {ApplicationCommandManager} + * @readonly + */ + get manager() { + return (this.guild ?? this.client.application).commands; + } + + /** + * Data for creating or editing an application command. + * @typedef {Object} ApplicationCommandData + * @property {string} name The name of the command + * @property {string} description The description of the command + * @property {ApplicationCommandType} [type] The type of the command + * @property {ApplicationCommandOptionData[]} [options] Options for the command + * @property {boolean} [defaultPermission] Whether the command is enabled by default when the app is added to a guild + */ + + /** + * An option for an application command or subcommand. + * In addition to the listed properties, when used as a parameter, + * API style `snake_case` properties can be used for compatibility with generators like `@discordjs/builders`. + * Note that providing a value for the `camelCase` counterpart for any `snake_case` property + * will discard the provided `snake_case` property. + * @typedef {Object} ApplicationCommandOptionData + * @property {ApplicationCommandOptionType|number} type The type of the option + * @property {string} name The name of the option + * @property {string} description The description of the option + * @property {boolean} [autocomplete] Whether the option is an autocomplete option + * @property {boolean} [required] Whether the option is required + * @property {ApplicationCommandOptionChoice[]} [choices] The choices of the option for the user to pick from + * @property {ApplicationCommandOptionData[]} [options] Additional options if this option is a subcommand (group) + * @property {ChannelType[]|number[]} [channelTypes] When the option type is channel, + * the allowed types of channels that can be selected + * @property {number} [minValue] The minimum value for an `INTEGER` or `NUMBER` option + * @property {number} [maxValue] The maximum value for an `INTEGER` or `NUMBER` option + */ + + /** + * Edits this application command. + * @param {ApplicationCommandData} data The data to update the command with + * @returns {Promise} + * @example + * // Edit the description of this command + * command.edit({ + * description: 'New description', + * }) + * .then(console.log) + * .catch(console.error); + */ + edit(data) { + return this.manager.edit(this, data, this.guildId); + } + + /** + * Edits the name of this ApplicationCommand + * @param {string} name The new name of the command + * @returns {Promise} + */ + setName(name) { + return this.edit({ name }); + } + + /** + * Edits the description of this ApplicationCommand + * @param {string} description The new description of the command + * @returns {Promise} + */ + setDescription(description) { + return this.edit({ description }); + } + + /** + * Edits the default permission of this ApplicationCommand + * @param {boolean} [defaultPermission=true] The default permission for this command + * @returns {Promise} + */ + setDefaultPermission(defaultPermission = true) { + return this.edit({ defaultPermission }); + } + + /** + * Edits the options of this ApplicationCommand + * @param {ApplicationCommandOptionData[]} options The options to set for this command + * @returns {Promise} + */ + setOptions(options) { + return this.edit({ options }); + } + + /** + * Deletes this command. + * @returns {Promise} + * @example + * // Delete this command + * command.delete() + * .then(console.log) + * .catch(console.error); + */ + delete() { + return this.manager.delete(this, this.guildId); + } + + /** + * Whether this command equals another command. It compares all properties, so for most operations + * it is advisable to just compare `command.id === command2.id` as it is much faster and is often + * what most users need. + * @param {ApplicationCommand|ApplicationCommandData|APIApplicationCommand} command The command to compare with + * @param {boolean} [enforceOptionOrder=false] Whether to strictly check that options and choices are in the same + * order in the array The client may not always respect this ordering! + * @returns {boolean} + */ + equals(command, enforceOptionOrder = false) { + // If given an id, check if the id matches + if (command.id && this.id !== command.id) return false; + + // Check top level parameters + const commandType = + typeof command.type === 'string' + ? command.type + : ApplicationCommandTypes[command.type]; + if ( + command.name !== this.name || + ('description' in command && command.description !== this.description) || + ('version' in command && command.version !== this.version) || + ('autocomplete' in command && + command.autocomplete !== this.autocomplete) || + (commandType && commandType !== this.type) || + // Future proof for options being nullable + // TODO: remove ?? 0 on each when nullable + (command.options?.length ?? 0) !== (this.options?.length ?? 0) || + (command.defaultPermission ?? command.default_permission ?? true) !== + this.defaultPermission + ) { + return false; + } + + if (command.options) { + return this.constructor.optionsEqual( + this.options, + command.options, + enforceOptionOrder, + ); + } + return true; + } + + /** + * Recursively checks that all options for an {@link ApplicationCommand} are equal to the provided options. + * In most cases it is better to compare using {@link ApplicationCommand#equals} + * @param {ApplicationCommandOptionData[]} existing The options on the existing command, + * should be {@link ApplicationCommand#options} + * @param {ApplicationCommandOptionData[]|APIApplicationCommandOption[]} options The options to compare against + * @param {boolean} [enforceOptionOrder=false] Whether to strictly check that options and choices are in the same + * order in the array The client may not always respect this ordering! + * @returns {boolean} + */ + static optionsEqual(existing, options, enforceOptionOrder = false) { + if (existing.length !== options.length) return false; + if (enforceOptionOrder) { + return existing.every((option, index) => + this._optionEquals(option, options[index], enforceOptionOrder), + ); + } + const newOptions = new Map(options.map((option) => [option.name, option])); + for (const option of existing) { + const foundOption = newOptions.get(option.name); + if (!foundOption || !this._optionEquals(option, foundOption)) + return false; + } + return true; + } + + /** + * Checks that an option for an {@link ApplicationCommand} is equal to the provided option + * In most cases it is better to compare using {@link ApplicationCommand#equals} + * @param {ApplicationCommandOptionData} existing The option on the existing command, + * should be from {@link ApplicationCommand#options} + * @param {ApplicationCommandOptionData|APIApplicationCommandOption} option The option to compare against + * @param {boolean} [enforceOptionOrder=false] Whether to strictly check that options or choices are in the same + * order in their array The client may not always respect this ordering! + * @returns {boolean} + * @private + */ + static _optionEquals(existing, option, enforceOptionOrder = false) { + const optionType = + typeof option.type === 'string' + ? option.type + : ApplicationCommandOptionTypes[option.type]; + if ( + option.name !== existing.name || + optionType !== existing.type || + option.description !== existing.description || + option.autocomplete !== existing.autocomplete || + (option.required ?? + (['SUB_COMMAND', 'SUB_COMMAND_GROUP'].includes(optionType) + ? undefined + : false)) !== existing.required || + option.choices?.length !== existing.choices?.length || + option.options?.length !== existing.options?.length || + (option.channelTypes ?? option.channel_types)?.length !== + existing.channelTypes?.length || + (option.minValue ?? option.min_value) !== existing.minValue || + (option.maxValue ?? option.max_value) !== existing.maxValue + ) { + return false; + } + + if (existing.choices) { + if ( + enforceOptionOrder && + !existing.choices.every( + (choice, index) => + choice.name === option.choices[index].name && + choice.value === option.choices[index].value, + ) + ) { + return false; + } + if (!enforceOptionOrder) { + const newChoices = new Map( + option.choices.map((choice) => [choice.name, choice]), + ); + for (const choice of existing.choices) { + const foundChoice = newChoices.get(choice.name); + if (!foundChoice || foundChoice.value !== choice.value) return false; + } + } + } + + if (existing.channelTypes) { + const newTypes = (option.channelTypes ?? option.channel_types).map( + (type) => (typeof type === 'number' ? ChannelTypes[type] : type), + ); + for (const type of existing.channelTypes) { + if (!newTypes.includes(type)) return false; + } + } + + if (existing.options) { + return this.optionsEqual( + existing.options, + option.options, + enforceOptionOrder, + ); + } + return true; + } + + /** + * An option for an application command or subcommand. + * @typedef {Object} ApplicationCommandOption + * @property {ApplicationCommandOptionType} type The type of the option + * @property {string} name The name of the option + * @property {string} description The description of the option + * @property {boolean} [required] Whether the option is required + * @property {boolean} [autocomplete] Whether the option is an autocomplete option + * @property {ApplicationCommandOptionChoice[]} [choices] The choices of the option for the user to pick from + * @property {ApplicationCommandOption[]} [options] Additional options if this option is a subcommand (group) + * @property {ChannelType[]} [channelTypes] When the option type is channel, + * the allowed types of channels that can be selected + * @property {number} [minValue] The minimum value for an `INTEGER` or `NUMBER` option + * @property {number} [maxValue] The maximum value for an `INTEGER` or `NUMBER` option + */ + + /** + * A choice for an application command option. + * @typedef {Object} ApplicationCommandOptionChoice + * @property {string} name The name of the choice + * @property {string|number} value The value of the choice + */ + + /** + * Transforms an {@link ApplicationCommandOptionData} object into something that can be used with the API. + * @param {ApplicationCommandOptionData} option The option to transform + * @param {boolean} [received] Whether this option has been received from Discord + * @returns {APIApplicationCommandOption} + * @private + */ + static transformOption(option, received) { + const stringType = + typeof option.type === 'string' + ? option.type + : ApplicationCommandOptionTypes[option.type]; + const channelTypesKey = received ? 'channelTypes' : 'channel_types'; + const minValueKey = received ? 'minValue' : 'min_value'; + const maxValueKey = received ? 'maxValue' : 'max_value'; + return { + type: + typeof option.type === 'number' && !received + ? option.type + : ApplicationCommandOptionTypes[option.type], + name: option.name, + description: option.description, + required: + option.required ?? + (stringType === 'SUB_COMMAND' || stringType === 'SUB_COMMAND_GROUP' + ? undefined + : false), + autocomplete: option.autocomplete, + choices: option.choices, + options: option.options?.map((o) => this.transformOption(o, received)), + [channelTypesKey]: received + ? option.channel_types?.map((type) => ChannelTypes[type]) + : option.channelTypes?.map((type) => + typeof type === 'string' ? ChannelTypes[type] : type, + ) ?? + // When transforming to API data, accept API data + option.channel_types, + [minValueKey]: option.minValue ?? option.min_value, + [maxValueKey]: option.maxValue ?? option.max_value, + }; + } + /** + * Send Slash command to channel + * @param {Message} message Discord Message + * @param {Array} options The options to Slash Command + * @returns {Promise} + * @example + * const botID = '12345678987654321' + * const user = await client.users.fetch(botID); + * const application = await user.applications.fetch(); + * const command = application.commands.first(); + * await command.sendSlashCommand(messsage, ['option1', 'option2']); + */ + async sendSlashCommand(message, options = []) { + // Check Options + if (!message instanceof Message) + throw new TypeError('The message must be a Discord.Message'); + if (!Array.isArray(options)) + throw new TypeError('The options must be an array of strings'); + if (this.type !== 'CHAT_INPUT') return false; + const optionFormat = []; + let option_ = []; + let i = 0; + // Check Command type is Sub group ? + const subCommandCheck = this.options.some((option) => + ['SUB_COMMAND', 'SUB_COMMAND_GROUP'].includes(option.type), + ); + let subCommand; + if (subCommandCheck) { + subCommand = this.options.find((option) => option.name == options[0]); + options.shift(); + option_[0] = { + type: ApplicationCommandOptionTypes[subCommand.type], + name: subCommand.name, + options: optionFormat, + }; + } else { + option_ = optionFormat; + } + for (i; i < options.length; i++) { + const value = options[i]; + if (typeof value !== 'string') { + throw new TypeError( + `Expected option to be a String, got ${typeof value}. If you type Number, please convert to String.`, + ); + } + if (!subCommandCheck && !this?.options[i]) continue; + if (subCommandCheck && subCommand?.options && !subCommand?.options[i]) continue; + if (!subCommandCheck) { + // Check value is invalid + let choice; + if (this.options[i].choices && this.options[i].choices.length > 0) { + choice = + this.options[i].choices.find((c) => c.name == value) || + this.options[i].choices.find((c) => c.value == value); + if (!choice) { + throw new Error( + `Invalid option: ${value} is not a valid choice for this option\nList of choices: ${this.options[ + i + ].choices + .map((c, i) => `#${i + 1} Name: ${c.name} Value: ${c.value}\n`) + .join('')}`, + ); + } + } + const data = { + type: ApplicationCommandOptionTypes[this.options[i].type], + name: this.options[i].name, + value: choice?.value || (this.options[i].type == 'INTEGER' ? Number(value) : (this.options[i].type == 'BOOLEAN' ? Boolean(value) : value)), + }; + optionFormat.push(data); + } else { + // First element is sub command and removed + if (!value) continue; + // Check value is invalid + let choice; + if ( + subCommand?.options && + subCommand.options[i].choices && + subCommand.options[i].choices.length > 0 + ) { + choice = + subCommand.options[i].choices.find((c) => c.name == value) || + subCommand.options[i].choices.find((c) => c.value == value); + if (!choice) { + throw new Error( + `Invalid option: ${value} is not a valid choice for this option\nList of choices: \n${subCommand.options[ + i + ].choices.map( + (c, i) => `#${i + 1} Name: ${c.name} Value: ${c.value}\n`, + ).join('')}`, + ); + } + } + const data = { + type: ApplicationCommandOptionTypes[subCommand.options[i].type], + name: subCommand.options[i].name, + value: + choice?.value || + (subCommand.options[i].type == 'INTEGER' + ? Number(value) + : subCommand.options[i].type == 'BOOLEAN' + ? Boolean(value) + : value), + }; + optionFormat.push(data); + } + } + if (!subCommandCheck && this.options[i]?.required) + throw new Error('Value required missing'); + if ( + subCommandCheck && + subCommand?.options && + subCommand?.options[i]?.required + ) + throw new Error('Value required missing'); + await this.client.api.interactions.post({ + body: { + type: 2, // ??? + application_id: this.applicationId, + guild_id: message.guildId, + channel_id: message.channelId, + session_id: this.client.session_id, + data: { + // ApplicationCommandData + version: this.version, + id: this.id, + name: this.name, + type: ApplicationCommandTypes[this.type], + options: option_, + }, + }, + }); + return true; + } + /** + * Message Context Menu + * @param {Message} message Discord Message + * @returns {Promise} + * @example + * const botID = '12345678987654321' + * const user = await client.users.fetch(botID); + * const application = await user.applications.fetch(); + * const command = application.commands.first(); + * await command.sendContextMenu(messsage); + */ + async sendContextMenu(message, sendFromMessage = false) { + if (!message instanceof Message && !sendFromMessage) + throw new TypeError('The message must be a Discord.Message'); + if (this.type == 'CHAT_INPUT') return false; + await this.client.api.interactions.post({ + body: { + type: 2, // ??? + application_id: this.applicationId, + guild_id: message.guildId, + channel_id: message.channelId, + session_id: this.client.session_id, + data: { + // ApplicationCommandData + version: this.version, + id: this.id, + name: this.name, + type: ApplicationCommandTypes[this.type], + target_id: + ApplicationCommandTypes[this.type] == 1 + ? message.author.id + : message.id, + }, + }, + }); + return true; + } +} + +module.exports = ApplicationCommand; + +/* eslint-disable max-len */ +/** + * @external APIApplicationCommand + * @see {@link https://discord.com/developers/docs/interactions/application-commands#application-command-object-application-command-structure} + */ + +/** + * @external APIApplicationCommandOption + * @see {@link https://discord.com/developers/docs/interactions/application-commands#application-command-object-application-command-option-structure} + */ diff --git a/src/structures/BaseMessageComponent.js b/src/structures/BaseMessageComponent.js index c2470e0..38c5ead 100644 --- a/src/structures/BaseMessageComponent.js +++ b/src/structures/BaseMessageComponent.js @@ -1,103 +1,103 @@ -'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 - * * MessageSelectMenuOptions - * @typedef {MessageActionRowOptions|MessageButtonOptions|MessageSelectMenuOptions} MessageComponentOptions - */ - - /** - * Components that can be sent in a message. These can be: - * * MessageActionRow - * * MessageButton - * * MessageSelectMenu - * @typedef {MessageActionRow|MessageButton|MessageSelectMenu} MessageComponent - * @see {@link https://discord.com/developers/docs/interactions/message-components#component-object-component-types} - */ - - /** - * Data that can be resolved to a MessageComponentType. This can be: - * * 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 - * @returns {?MessageComponent} - * @private - */ - static create(data, client) { - let component; - let type = data.type; - - if (typeof type === 'string') type = MessageComponentTypes[type]; - - switch (type) { - case MessageComponentTypes.ACTION_ROW: { - const MessageActionRow = require('./MessageActionRow'); - component = data instanceof MessageActionRow ? data : new MessageActionRow(data, client); - break; - } - case MessageComponentTypes.BUTTON: { - const MessageButton = require('./MessageButton'); - component = data instanceof MessageButton ? data : new MessageButton(data); - break; - } - case MessageComponentTypes.SELECT_MENU: { - const MessageSelectMenu = require('./MessageSelectMenu'); - component = data instanceof MessageSelectMenu ? data : new MessageSelectMenu(data); - break; - } - default: - if (client) { - client.emit(Events.DEBUG, `[BaseMessageComponent] Received component with unknown type: ${data.type}`); - } else { - 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; +'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 + * * MessageSelectMenuOptions + * @typedef {MessageActionRowOptions|MessageButtonOptions|MessageSelectMenuOptions} MessageComponentOptions + */ + + /** + * Components that can be sent in a message. These can be: + * * MessageActionRow + * * MessageButton + * * MessageSelectMenu + * @typedef {MessageActionRow|MessageButton|MessageSelectMenu} MessageComponent + * @see {@link https://discord.com/developers/docs/interactions/message-components#component-object-component-types} + */ + + /** + * Data that can be resolved to a MessageComponentType. This can be: + * * 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 + * @returns {?MessageComponent} + * @private + */ + static create(data, client) { + let component; + let type = data.type; + + if (typeof type === 'string') type = MessageComponentTypes[type]; + + switch (type) { + case MessageComponentTypes.ACTION_ROW: { + const MessageActionRow = require('./MessageActionRow'); + component = data instanceof MessageActionRow ? data : new MessageActionRow(data, client); + break; + } + case MessageComponentTypes.BUTTON: { + const MessageButton = require('./MessageButton'); + component = data instanceof MessageButton ? data : new MessageButton(data); + break; + } + case MessageComponentTypes.SELECT_MENU: { + const MessageSelectMenu = require('./MessageSelectMenu'); + component = data instanceof MessageSelectMenu ? data : new MessageSelectMenu(data); + break; + } + default: + if (client) { + client.emit(Events.DEBUG, `[BaseMessageComponent] Received component with unknown type: ${data.type}`); + } else { + 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/Channel.js b/src/structures/Channel.js index 0448c25..f7e2ea1 100644 --- a/src/structures/Channel.js +++ b/src/structures/Channel.js @@ -1,275 +1,275 @@ -'use strict'; - -const process = require('node:process'); -const Base = require('./Base'); -let CategoryChannel; -let DMChannel; -let NewsChannel; -let StageChannel; -let StoreChannel; -let TextChannel; -let ThreadChannel; -let VoiceChannel; -const { ChannelTypes, ThreadChannelTypes, VoiceBasedChannelTypes } = require('../util/Constants'); -const SnowflakeUtil = require('../util/SnowflakeUtil'); -const { Message } = require('discord.js'); -const { ApplicationCommand } = require('discord.js-selfbot-v13'); - -/** - * @type {WeakSet} - * @private - * @internal - */ -const deletedChannels = new WeakSet(); -let deprecationEmittedForDeleted = false; - -/** - * Represents any channel on Discord. - * @extends {Base} - * @abstract - */ -class Channel extends Base { - constructor(client, data, immediatePatch = true) { - super(client); - - const type = ChannelTypes[data?.type]; - /** - * The type of the channel - * @type {ChannelType} - */ - this.type = type ?? 'UNKNOWN'; - - if (data && immediatePatch) this._patch(data); - } - - _patch(data) { - /** - * The channel's id - * @type {Snowflake} - */ - this.id = data.id; - } - - /** - * The timestamp the channel was created at - * @type {number} - * @readonly - */ - get createdTimestamp() { - return SnowflakeUtil.timestampFrom(this.id); - } - - /** - * The time the channel was created at - * @type {Date} - * @readonly - */ - get createdAt() { - return new Date(this.createdTimestamp); - } - - /** - * Whether or not the structure has been deleted - * @type {boolean} - * @deprecated This will be removed in the next major version, see https://github.com/discordjs/discord.js/issues/7091 - */ - get deleted() { - if (!deprecationEmittedForDeleted) { - deprecationEmittedForDeleted = true; - process.emitWarning( - 'Channel#deleted is deprecated, see https://github.com/discordjs/discord.js/issues/7091.', - 'DeprecationWarning', - ); - } - - return deletedChannels.has(this); - } - - set deleted(value) { - if (!deprecationEmittedForDeleted) { - deprecationEmittedForDeleted = true; - process.emitWarning( - 'Channel#deleted is deprecated, see https://github.com/discordjs/discord.js/issues/7091.', - 'DeprecationWarning', - ); - } - - if (value) deletedChannels.add(this); - else deletedChannels.delete(this); - } - - /** - * Whether this Channel is a partial - * This is always false outside of DM channels. - * @type {boolean} - * @readonly - */ - get partial() { - return false; - } - - /** - * When concatenated with a string, this automatically returns the channel's mention instead of the Channel object. - * @returns {string} - * @example - * // Logs: Hello from <#123456789012345678>! - * console.log(`Hello from ${channel}!`); - */ - toString() { - return `<#${this.id}>`; - } - - /** - * Deletes this channel. - * @returns {Promise} - * @example - * // Delete the channel - * channel.delete() - * .then(console.log) - * .catch(console.error); - */ - async delete() { - await this.client.api.channels(this.id).delete(); - return this; - } - - /** - * Fetches this channel. - * @param {boolean} [force=true] Whether to skip the cache check and request the API - * @returns {Promise} - */ - fetch(force = true) { - return this.client.channels.fetch(this.id, { force }); - } - - /** - * Indicates whether this channel is {@link TextBasedChannels text-based}. - * @returns {boolean} - */ - isText() { - return 'messages' in this; - } - - /** - * Indicates whether this channel is {@link BaseGuildVoiceChannel voice-based}. - * @returns {boolean} - */ - isVoice() { - return VoiceBasedChannelTypes.includes(this.type); - } - - /** - * Indicates whether this channel is a {@link ThreadChannel}. - * @returns {boolean} - */ - isThread() { - return ThreadChannelTypes.includes(this.type); - } - - static create(client, data, guild, { allowUnknownGuild, fromInteraction } = {}) { - CategoryChannel ??= require('./CategoryChannel'); - DMChannel ??= require('./DMChannel'); - NewsChannel ??= require('./NewsChannel'); - StageChannel ??= require('./StageChannel'); - StoreChannel ??= require('./StoreChannel'); - TextChannel ??= require('./TextChannel'); - ThreadChannel ??= require('./ThreadChannel'); - VoiceChannel ??= require('./VoiceChannel'); - - let channel; - if (!data.guild_id && !guild) { - if ((data.recipients && data.type !== ChannelTypes.GROUP_DM) || data.type === ChannelTypes.DM) { - channel = new DMChannel(client, data); - } else if (data.type === ChannelTypes.GROUP_DM) { - const PartialGroupDMChannel = require('./PartialGroupDMChannel'); - channel = new PartialGroupDMChannel(client, data); - } - } else { - guild ??= client.guilds.cache.get(data.guild_id); - - if (guild || allowUnknownGuild) { - switch (data.type) { - case ChannelTypes.GUILD_TEXT: { - channel = new TextChannel(guild, data, client); - break; - } - case ChannelTypes.GUILD_VOICE: { - channel = new VoiceChannel(guild, data, client); - break; - } - case ChannelTypes.GUILD_CATEGORY: { - channel = new CategoryChannel(guild, data, client); - break; - } - case ChannelTypes.GUILD_NEWS: { - channel = new NewsChannel(guild, data, client); - break; - } - case ChannelTypes.GUILD_STORE: { - channel = new StoreChannel(guild, data, client); - break; - } - case ChannelTypes.GUILD_STAGE_VOICE: { - channel = new StageChannel(guild, data, client); - break; - } - case ChannelTypes.GUILD_NEWS_THREAD: - case ChannelTypes.GUILD_PUBLIC_THREAD: - case ChannelTypes.GUILD_PRIVATE_THREAD: { - channel = new ThreadChannel(guild, data, client, fromInteraction); - if (!allowUnknownGuild) channel.parent?.threads.cache.set(channel.id, channel); - break; - } - } - if (channel && !allowUnknownGuild) guild.channels?.cache.set(channel.id, channel); - } - } - return channel; - } - - toJSON(...props) { - return super.toJSON({ createdTimestamp: true }, ...props); - } - - // Send Slash - /** - * Send Slash to this channel - * @param {DiscordBot} botID Bot ID - * @param {String} commandName Command name - * @param {Array} args Command arguments - * @returns {Promise} - */ - async sendSlash(botID, commandName, args = []) { - if (!this.isText()) throw new Error('This channel is not text-based.'); - if(!botID) throw new Error('Bot ID is required'); - const user = await this.client.users.fetch(botID).catch(() => {}); - if (!user || !user.bot || !user.applications) throw new Error('BotID is not a bot or does not have an application slash command'); - if (!commandName || typeof commandName !== 'string') throw new Error('Command name is required'); - const listApplication = user.applications.cache.size == 0 ? await user.applications.fetch() : user.applications.cache; - let slashCommand; - await Promise.all(listApplication.map(async application => { - if (commandName == application.name && application.type == 'CHAT_INPUT') slashCommand = application; - })); - if (!slashCommand) throw new Error( - `Command ${commandName} is not found\nList command avalible: ${listApplication.filter(a => a.type == 'CHAT_INPUT').map(a => a.name).join(', ')}`, - ); - return slashCommand.sendSlashCommand( - new Message(this.client, { - channel_id: this.id, - guild_id: this.guild?.id || null, - author: this.client.user, - content: '', - id: this.client.user.id - }), - args - ); - } -} - -exports.Channel = Channel; -exports.deletedChannels = deletedChannels; - -/** - * @external APIChannel - * @see {@link https://discord.com/developers/docs/resources/channel#channel-object} - */ +'use strict'; + +const process = require('node:process'); +const Base = require('./Base'); +let CategoryChannel; +let DMChannel; +let NewsChannel; +let StageChannel; +let StoreChannel; +let TextChannel; +let ThreadChannel; +let VoiceChannel; +const { ChannelTypes, ThreadChannelTypes, VoiceBasedChannelTypes } = require('../util/Constants'); +const SnowflakeUtil = require('../util/SnowflakeUtil'); +const { Message } = require('discord.js'); +//const { ApplicationCommand } = require('discord.js-selfbot-v13'); - Not being used in this file, not necessary. + +/** + * @type {WeakSet} + * @private + * @internal + */ +const deletedChannels = new WeakSet(); +let deprecationEmittedForDeleted = false; + +/** + * Represents any channel on Discord. + * @extends {Base} + * @abstract + */ +class Channel extends Base { + constructor(client, data, immediatePatch = true) { + super(client); + + const type = ChannelTypes[data?.type]; + /** + * The type of the channel + * @type {ChannelType} + */ + this.type = type ?? 'UNKNOWN'; + + if (data && immediatePatch) this._patch(data); + } + + _patch(data) { + /** + * The channel's id + * @type {Snowflake} + */ + this.id = data.id; + } + + /** + * The timestamp the channel was created at + * @type {number} + * @readonly + */ + get createdTimestamp() { + return SnowflakeUtil.timestampFrom(this.id); + } + + /** + * The time the channel was created at + * @type {Date} + * @readonly + */ + get createdAt() { + return new Date(this.createdTimestamp); + } + + /** + * Whether or not the structure has been deleted + * @type {boolean} + * @deprecated This will be removed in the next major version, see https://github.com/discordjs/discord.js/issues/7091 + */ + get deleted() { + if (!deprecationEmittedForDeleted) { + deprecationEmittedForDeleted = true; + process.emitWarning( + 'Channel#deleted is deprecated, see https://github.com/discordjs/discord.js/issues/7091.', + 'DeprecationWarning', + ); + } + + return deletedChannels.has(this); + } + + set deleted(value) { + if (!deprecationEmittedForDeleted) { + deprecationEmittedForDeleted = true; + process.emitWarning( + 'Channel#deleted is deprecated, see https://github.com/discordjs/discord.js/issues/7091.', + 'DeprecationWarning', + ); + } + + if (value) deletedChannels.add(this); + else deletedChannels.delete(this); + } + + /** + * Whether this Channel is a partial + * This is always false outside of DM channels. + * @type {boolean} + * @readonly + */ + get partial() { + return false; + } + + /** + * When concatenated with a string, this automatically returns the channel's mention instead of the Channel object. + * @returns {string} + * @example + * // Logs: Hello from <#123456789012345678>! + * console.log(`Hello from ${channel}!`); + */ + toString() { + return `<#${this.id}>`; + } + + /** + * Deletes this channel. + * @returns {Promise} + * @example + * // Delete the channel + * channel.delete() + * .then(console.log) + * .catch(console.error); + */ + async delete() { + await this.client.api.channels(this.id).delete(); + return this; + } + + /** + * Fetches this channel. + * @param {boolean} [force=true] Whether to skip the cache check and request the API + * @returns {Promise} + */ + fetch(force = true) { + return this.client.channels.fetch(this.id, { force }); + } + + /** + * Indicates whether this channel is {@link TextBasedChannels text-based}. + * @returns {boolean} + */ + isText() { + return 'messages' in this; + } + + /** + * Indicates whether this channel is {@link BaseGuildVoiceChannel voice-based}. + * @returns {boolean} + */ + isVoice() { + return VoiceBasedChannelTypes.includes(this.type); + } + + /** + * Indicates whether this channel is a {@link ThreadChannel}. + * @returns {boolean} + */ + isThread() { + return ThreadChannelTypes.includes(this.type); + } + + static create(client, data, guild, { allowUnknownGuild, fromInteraction } = {}) { + CategoryChannel ??= require('./CategoryChannel'); + DMChannel ??= require('./DMChannel'); + NewsChannel ??= require('./NewsChannel'); + StageChannel ??= require('./StageChannel'); + StoreChannel ??= require('./StoreChannel'); + TextChannel ??= require('./TextChannel'); + ThreadChannel ??= require('./ThreadChannel'); + VoiceChannel ??= require('./VoiceChannel'); + + let channel; + if (!data.guild_id && !guild) { + if ((data.recipients && data.type !== ChannelTypes.GROUP_DM) || data.type === ChannelTypes.DM) { + channel = new DMChannel(client, data); + } else if (data.type === ChannelTypes.GROUP_DM) { + const PartialGroupDMChannel = require('./PartialGroupDMChannel'); + channel = new PartialGroupDMChannel(client, data); + } + } else { + guild ??= client.guilds.cache.get(data.guild_id); + + if (guild || allowUnknownGuild) { + switch (data.type) { + case ChannelTypes.GUILD_TEXT: { + channel = new TextChannel(guild, data, client); + break; + } + case ChannelTypes.GUILD_VOICE: { + channel = new VoiceChannel(guild, data, client); + break; + } + case ChannelTypes.GUILD_CATEGORY: { + channel = new CategoryChannel(guild, data, client); + break; + } + case ChannelTypes.GUILD_NEWS: { + channel = new NewsChannel(guild, data, client); + break; + } + case ChannelTypes.GUILD_STORE: { + channel = new StoreChannel(guild, data, client); + break; + } + case ChannelTypes.GUILD_STAGE_VOICE: { + channel = new StageChannel(guild, data, client); + break; + } + case ChannelTypes.GUILD_NEWS_THREAD: + case ChannelTypes.GUILD_PUBLIC_THREAD: + case ChannelTypes.GUILD_PRIVATE_THREAD: { + channel = new ThreadChannel(guild, data, client, fromInteraction); + if (!allowUnknownGuild) channel.parent?.threads.cache.set(channel.id, channel); + break; + } + } + if (channel && !allowUnknownGuild) guild.channels?.cache.set(channel.id, channel); + } + } + return channel; + } + + toJSON(...props) { + return super.toJSON({ createdTimestamp: true }, ...props); + } + + // Send Slash + /** + * Send Slash to this channel + * @param {DiscordBot} botID Bot ID + * @param {String} commandName Command name + * @param {Array} args Command arguments + * @returns {Promise} + */ + async sendSlash(botID, commandName, args = []) { + if (!this.isText()) throw new Error('This channel is not text-based.'); + if(!botID) throw new Error('Bot ID is required'); + const user = await this.client.users.fetch(botID).catch(() => {}); + if (!user || !user.bot || !user.applications) throw new Error('BotID is not a bot or does not have an application slash command'); + if (!commandName || typeof commandName !== 'string') throw new Error('Command name is required'); + const listApplication = user.applications.cache.size == 0 ? await user.applications.fetch() : user.applications.cache; + let slashCommand; + await Promise.all(listApplication.map(async application => { + if (commandName == application.name && application.type == 'CHAT_INPUT') slashCommand = application; + })); + if (!slashCommand) throw new Error( + `Command ${commandName} is not found\nList command avalible: ${listApplication.filter(a => a.type == 'CHAT_INPUT').map(a => a.name).join(', ')}`, + ); + return slashCommand.sendSlashCommand( + new Message(this.client, { + channel_id: this.id, + guild_id: this.guild?.id || null, + author: this.client.user, + content: '', + id: this.client.user.id + }), + args + ); + } +} + +exports.Channel = Channel; +exports.deletedChannels = deletedChannels; + +/** + * @external APIChannel + * @see {@link https://discord.com/developers/docs/resources/channel#channel-object} + */ diff --git a/src/structures/ClientUser.js b/src/structures/ClientUser.js index 67dab7d..9eb195e 100644 --- a/src/structures/ClientUser.js +++ b/src/structures/ClientUser.js @@ -1,375 +1,357 @@ -'use strict'; - -const User = require('./User'); -const DataResolver = require('../util/DataResolver'); -const { HypeSquadOptions } = require('../util/Constants'); -const { Util } = require('..'); - -/** - * Represents the logged in client's Discord user. - * @extends {User} - */ -class ClientUser extends User { - _patch(data) { - super._patch(data); - - if ('verified' in data) { - /** - * Whether or not this account has been verified - * @type {boolean} - */ - this.verified = data.verified; - } - - if ('mfa_enabled' in data) { - /** - * If the bot's {@link ClientApplication#owner Owner} has MFA enabled on their account - * @type {?boolean} - */ - this.mfaEnabled = - typeof data.mfa_enabled === 'boolean' ? data.mfa_enabled : null; - } else { - this.mfaEnabled ??= null; - } - - if ('token' in data) this.client.token = data.token; - - // Add (Selfbot) - if ('premium' in data) this.nitro = data.premium; - /** - * Nitro Status - * `0`: None - * `1`: Classic - * `2`: Boost - * @external - * https://discord.com/developers/docs/resources/user#user-object-premium-types - * @type {Number} - */ - if ('purchased_flags' in data) this.nitroType = data.purchased_flags; - if ('phone' in data) this.phoneNumber = data.phone; - if ('nsfw_allowed' in data) this.nsfwAllowed = data.nsfw_allowed; - if ('email' in data) this.emailAddress = data.email; - } - - /** - * Represents the client user's presence - * @type {ClientPresence} - * @readonly - */ - get presence() { - return this.client.presence; - } - - /** - * Data used to edit the logged in client - * @typedef {Object} ClientUserEditData - * @property {string} [username] The new username - * @property {?(BufferResolvable|Base64Resolvable)} [avatar] The new avatar - */ - - /** - * Edits the logged in client. - * @param {ClientUserEditData} data The new data - * @returns {Promise} - */ - async edit(data) { - if (typeof data.avatar !== 'undefined') - data.avatar = await DataResolver.resolveImage(data.avatar); - if (typeof data.banner !== 'undefined') - data.banner = await DataResolver.resolveImage(data.banner); - const newData = await this.client.api.users('@me').patch({ data }); - this.client.token = newData.token; - this.client.password = data?.password - ? data?.password - : this.client.password; - const { updated } = this.client.actions.UserUpdate.handle(newData); - return updated ?? this; - } - - /** - * Sets the username of the logged in client. - * Changing usernames in Discord is heavily rate limited, with only 2 requests - * every hour. Use this sparingly! - * @param {string} username The new username - * @param {string} password The password of the account - * @returns {Promise} - * @example - * // Set username - * client.user.setUsername('discordjs') - * .then(user => console.log(`My new username is ${user.username}`)) - * .catch(console.error); - */ - setUsername(username, password) { - if (!password && !this.client.password) - throw new Error('A password is required to change a username.'); - return this.edit({ - username, - password: this.client.password ? this.client.password : password, - }); - } - - /** - * Sets the avatar of the logged in client. - * @param {?(BufferResolvable|Base64Resolvable)} avatar The new avatar - * @returns {Promise} - * @example - * // Set avatar - * client.user.setAvatar('./avatar.png') - * .then(user => console.log(`New avatar set!`)) - * .catch(console.error); - */ - setAvatar(avatar) { - return this.edit({ avatar }); - } - /** - * Sets the banner of the logged in client. - * @param {?(BufferResolvable|Base64Resolvable)} banner The new banner - * @returns {Promise} - * @example - * // Set banner - * client.user.setBanner('./banner.png') - * .then(user => console.log(`New banner set!`)) - * .catch(console.error); - */ - setBanner(banner) { - if (this.nitroType !== 2) - throw new Error( - 'You must be a Nitro Boosted User to change your banner.', - ); - return this.edit({ banner }); - } - - /** - * Set HyperSquad House - * @param {HypeSquadOptions} type - * `LEAVE`: 0 - * `HOUSE_BRAVERY`: 1 - * `HOUSE_BRILLIANCE`: 2 - * `HOUSE_BALANCE`: 3 - * @returns {Promise} - * @example - * // Set HyperSquad HOUSE_BRAVERY - * client.user.setHypeSquad(1); || client.user.setHypeSquad('HOUSE_BRAVERY'); - * // Leave - * client.user.setHypeSquad(0); - */ - async setHypeSquad(type) { - const id = typeof type === 'string' ? HypeSquadOptions[type] : type; - if (!id && id !== 0) throw new Error('Invalid HypeSquad type.'); - if (id !== 0) - return await this.client.api.hypesquad.online.post({ - data: { house_id: id }, - }); - else return await this.client.api.hypesquad.online.delete(); - } - - /** - * Set Accent color - * @param {ColorResolvable} color Color to set - * @returns {Promise} - */ - setAccentColor(color = null) { - return this.edit({ accent_color: color ? Util.resolveColor(color) : null }); - } - - /** - * Set discriminator - * @param {User.discriminator} discriminator It is #1234 - * @param {string} password The password of the account - * @returns {Promise} - */ - setDiscriminator(discriminator, password) { - if (!this.nitro) - throw new Error('You must be a Nitro User to change your discriminator.'); - if (!password && !this.client.password) - throw new Error('A password is required to change a discriminator.'); - return this.edit({ - discriminator, - password: this.client.password ? this.client.password : password, - }); - } - - /** - * Set About me - * @param {String} bio Bio to set - * @returns {Promise} - */ - setAboutMe(bio = null) { - return this.edit({ - bio, - }); - } - - /** - * Change the email - * @param {Email} email Email to change - * @param {string} password Password of the account - * @returns {Promise} - */ - setEmail(email, password) { - if (!password && !this.client.password) - throw new Error('A password is required to change a email.'); - return this.edit({ - email, - password: this.client.password ? this.client.password : password, - }); - } - - /** - * Set new password - * @param {string} oldPassword Old password - * @param {string} newPassword New password to set - * @returns {Promise} - */ - setPassword(oldPassword, newPassword) { - if (!oldPassword && !this.client.password) - throw new Error('A password is required to change a password.'); - if (!newPassword) throw new Error('New password is required.'); - return this.edit({ - password: this.client.password ? this.client.password : oldPassword, - new_password: newPassword, - }); - } - - /** - * Disable account - * @param {string} password Password of the account - * @returns {Promise} - */ - async disableAccount(password) { - if (!password && !this.client.password) - throw new Error('A password is required to disable an account.'); - return await this.client.api.users['@me'].disable.post({ - data: { - password: this.client.password ? this.client.password : password, - }, - }); - } - - /** - * Delete account. Warning: Cannot be changed once used! - * @param {string} password Password of the account - * @returns {Promise} - */ - async deleteAccount(password) { - if (!password && !this.client.password) - throw new Error('A password is required to delete an account.'); - return await this.client.api.users['@me'].delete.post({ - data: { - password: this.client.password ? this.client.password : password, - }, - }); - } - - /** - * Options for setting activities - * @typedef {Object} ActivitiesOptions - * @property {string} [name] Name of the activity - * @property {ActivityType|number} [type] Type of the activity - * @property {string} [url] Twitch / YouTube stream URL - */ - - /** - * Data resembling a raw Discord presence. - * @typedef {Object} PresenceData - * @property {PresenceStatusData} [status] Status of the user - * @property {boolean} [afk] Whether the user is AFK - * @property {ActivitiesOptions[]} [activities] Activity the user is playing - * @property {number|number[]} [shardId] Shard id(s) to have the activity set on - */ - - /** - * Sets the full presence of the client user. - * @param {PresenceData} data Data for the presence - * @returns {ClientPresence} - * @example - * // Set the client user's presence - * client.user.setPresence({ activities: [{ name: 'with discord.js' }], status: 'idle' }); - */ - setPresence(data) { - return this.client.presence.set(data); - } - - /** - * A user's status. Must be one of: - * * `online` - * * `idle` - * * `invisible` - * * `dnd` (do not disturb) - * @typedef {string} PresenceStatusData - */ - - /** - * Sets the status of the client user. - * @param {PresenceStatusData} status Status to change to - * @param {number|number[]} [shardId] Shard id(s) to have the activity set on - * @returns {ClientPresence} - * @example - * // Set the client user's status - * client.user.setStatus('idle'); - */ - setStatus(status, shardId) { - return this.setPresence({ status, shardId }); - } - - /** - * Options for setting an activity. - * @typedef {Object} ActivityOptions - * @property {string} [name] Name of the activity - * @property {string} [url] Twitch / YouTube stream URL - * @property {ActivityType|number} [type] Type of the activity - * @property {number|number[]} [shardId] Shard Id(s) to have the activity set on - */ - - /** - * Sets the activity the client user is playing. - * @param {string|ActivityOptions} [name] Activity being played, or options for setting the activity - * @param {ActivityOptions} [options] Options for setting the activity - * @returns {ClientPresence} - * @example - * // Set the client user's activity - * client.user.setActivity('discord.js', { type: 'WATCHING' }); - */ - setActivity(name, options = {}) { - if (!name) - return this.setPresence({ activities: [], shardId: options.shardId }); - - const activity = Object.assign( - {}, - options, - typeof name === 'object' ? name : { name }, - ); - return this.setPresence({ - activities: [activity], - shardId: activity.shardId, - }); - } - - /** - * Sets/removes the AFK flag for the client user. - * @param {boolean} [afk=true] Whether or not the user is AFK - * @param {number|number[]} [shardId] Shard Id(s) to have the AFK flag set on - * @returns {ClientPresence} - */ - setAFK(afk = true, shardId) { - return this.setPresence({ afk, shardId }); - } - - /** - * Send Friend Request to the user - * @returns {Promise} the user object - */ - async findFriend(username, discriminator) { - return await this.client.api - .users('@me') - .relationships.post({ - data: { - username: username, - discriminator: parseInt(discriminator), - }, - }) - .then((_) => _); - } -} - -module.exports = ClientUser; +'use strict'; + +const User = require('./User'); +const DataResolver = require('../util/DataResolver'); +const { HypeSquadOptions } = require('../util/Constants'); +const { Util } = require('..'); + +/** + * Represents the logged in client's Discord user. + * @extends {User} + */ +class ClientUser extends User { + _patch(data) { + super._patch(data); + + if ('verified' in data) { + /** + * Whether or not this account has been verified + * @type {boolean} + */ + this.verified = data.verified; + } + + if ('mfa_enabled' in data) { + /** + * If the bot's {@link ClientApplication#owner Owner} has MFA enabled on their account + * @type {?boolean} + */ + this.mfaEnabled = + typeof data.mfa_enabled === 'boolean' ? data.mfa_enabled : null; + } else { + this.mfaEnabled ??= null; + } + + if ('token' in data) this.client.token = data.token; + + // Add (Selfbot) + if ('premium' in data) this.nitro = data.premium; + /** + * Nitro Status + * `0`: None + * `1`: Classic + * `2`: Boost + * @external + * https://discord.com/developers/docs/resources/user#user-object-premium-types + * @type {Number} + */ + if ('purchased_flags' in data) this.nitroType = data.purchased_flags; + if ('phone' in data) this.phoneNumber = data.phone; + if ('nsfw_allowed' in data) this.nsfwAllowed = data.nsfw_allowed; + if ('email' in data) this.emailAddress = data.email; + } + + /** + * Represents the client user's presence + * @type {ClientPresence} + * @readonly + */ + get presence() { + return this.client.presence; + } + + /** + * Data used to edit the logged in client + * @typedef {Object} ClientUserEditData + * @property {string} [username] The new username + * @property {?(BufferResolvable|Base64Resolvable)} [avatar] The new avatar + */ + + /** + * Edits the logged in client. + * @param {ClientUserEditData} data The new data + * @returns {Promise} + */ + async edit(data) { + if (typeof data.avatar !== 'undefined') + data.avatar = await DataResolver.resolveImage(data.avatar); + if (typeof data.banner !== 'undefined') + data.banner = await DataResolver.resolveImage(data.banner); + const newData = await this.client.api.users('@me').patch({ data }); + this.client.token = newData.token; + this.client.password = data?.password + ? data?.password + : this.client.password; + const { updated } = this.client.actions.UserUpdate.handle(newData); + return updated ?? this; + } + + /** + * Sets the username of the logged in client. + * Changing usernames in Discord is heavily rate limited, with only 2 requests + * every hour. Use this sparingly! + * @param {string} username The new username + * @param {string} password The password of the account + * @returns {Promise} + * @example + * // Set username + * client.user.setUsername('discordjs') + * .then(user => console.log(`My new username is ${user.username}`)) + * .catch(console.error); + */ + setUsername(username, password) { + if (!password && !this.client.password) + throw new Error('A password is required to change a username.'); + return this.edit({ + username, + password: this.client.password ? this.client.password : password, + }); + } + + /** + * Sets the avatar of the logged in client. + * @param {?(BufferResolvable|Base64Resolvable)} avatar The new avatar + * @returns {Promise} + * @example + * // Set avatar + * client.user.setAvatar('./avatar.png') + * .then(user => console.log(`New avatar set!`)) + * .catch(console.error); + */ + setAvatar(avatar) { + return this.edit({ avatar }); + } + /** + * Sets the banner of the logged in client. + * @param {?(BufferResolvable|Base64Resolvable)} banner The new banner + * @returns {Promise} + * @example + * // Set banner + * client.user.setBanner('./banner.png') + * .then(user => console.log(`New banner set!`)) + * .catch(console.error); + */ + setBanner(banner) { + if (this.nitroType !== 2) + throw new Error( + 'You must be a Nitro Boosted User to change your banner.', + ); + return this.edit({ banner }); + } + + /** + * Set HyperSquad House + * @param {HypeSquadOptions} type + * `LEAVE`: 0 + * `HOUSE_BRAVERY`: 1 + * `HOUSE_BRILLIANCE`: 2 + * `HOUSE_BALANCE`: 3 + * @returns {Promise} + * @example + * // Set HyperSquad HOUSE_BRAVERY + * client.user.setHypeSquad(1); || client.user.setHypeSquad('HOUSE_BRAVERY'); + * // Leave + * client.user.setHypeSquad(0); + */ + async setHypeSquad(type) { + const id = typeof type === 'string' ? HypeSquadOptions[type] : type; + if (!id && id !== 0) throw new Error('Invalid HypeSquad type.'); + if (id !== 0) return await this.client.api.hypesquad.online.post({ + data: { house_id: id }, + }); + else return await this.client.api.hypesquad.online.delete(); + } + + /** + * Set Accent color + * @param {ColorResolvable} color Color to set + * @returns {Promise} + */ + setAccentColor(color = null) { + return this.edit({ accent_color: color ? Util.resolveColor(color) : null }); + } + + /** + * Set discriminator + * @param {User.discriminator} discriminator It is #1234 + * @param {string} password The password of the account + * @returns {Promise} + */ + setDiscriminator(discriminator, password) { + if (!this.nitro) throw new Error('You must be a Nitro User to change your discriminator.'); + if (!password && !this.client.password) + throw new Error('A password is required to change a discriminator.'); + return this.edit({ + discriminator, + password: this.client.password ? this.client.password : password, + }); + } + + /** + * Set About me + * @param {String} bio Bio to set + * @returns {Promise} + */ + setAboutMe(bio = null) { + return this.edit({ + bio, + }); + } + + /** + * Change the email + * @param {Email} email Email to change + * @param {string} password Password of the account + * @returns {Promise} + */ + setEmail(email, password) { + if (!password && !this.client.password) + throw new Error('A password is required to change a email.'); + return this.edit({ + email, + password: this.client.password ? this.client.password : password, + }); + } + + /** + * Set new password + * @param {string} oldPassword Old password + * @param {string} newPassword New password to set + * @returns {Promise} + */ + setPassword(oldPassword, newPassword) { + if (!oldPassword && !this.client.password) + throw new Error('A password is required to change a password.'); + if (!newPassword) throw new Error('New password is required.'); + return this.edit({ + password: this.client.password ? this.client.password : oldPassword, + new_password: newPassword, + }); + } + + /** + * Disable account + * @param {string} password Password of the account + * @returns {Promise} + */ + async disableAccount(password) { + if (!password && !this.client.password) + throw new Error('A password is required to disable an account.'); + return await this.client.api.users['@me'].disable.post({ + data: { + password: this.client.password ? this.client.password : password, + }, + }); + } + + /** + * Delete account. Warning: Cannot be changed once used! + * @param {string} password Password of the account + * @returns {Promise} + */ + async deleteAccount(password) { + if (!password && !this.client.password) + throw new Error('A password is required to delete an account.'); + return await this.client.api.users['@me'].delete.post({ + data: { + password: this.client.password ? this.client.password : password, + }, + }); + } + + /** + * Options for setting activities + * @typedef {Object} ActivitiesOptions + * @property {string} [name] Name of the activity + * @property {ActivityType|number} [type] Type of the activity + * @property {string} [url] Twitch / YouTube stream URL + */ + + /** + * Data resembling a raw Discord presence. + * @typedef {Object} PresenceData + * @property {PresenceStatusData} [status] Status of the user + * @property {boolean} [afk] Whether the user is AFK + * @property {ActivitiesOptions[]} [activities] Activity the user is playing + * @property {number|number[]} [shardId] Shard id(s) to have the activity set on + */ + + /** + * Sets the full presence of the client user. + * @param {PresenceData} data Data for the presence + * @returns {ClientPresence} + * @example + * // Set the client user's presence + * client.user.setPresence({ activities: [{ name: 'with discord.js' }], status: 'idle' }); + */ + setPresence(data) { + return this.client.presence.set(data); + } + + /** + * A user's status. Must be one of: + * * `online` + * * `idle` + * * `invisible` + * * `dnd` (do not disturb) + * @typedef {string} PresenceStatusData + */ + + /** + * Sets the status of the client user. + * @param {PresenceStatusData} status Status to change to + * @param {number|number[]} [shardId] Shard id(s) to have the activity set on + * @returns {ClientPresence} + * @example + * // Set the client user's status + * client.user.setStatus('idle'); + */ + setStatus(status, shardId) { + return this.setPresence({ status, shardId }); + } + + /** + * Options for setting an activity. + * @typedef {Object} ActivityOptions + * @property {string} [name] Name of the activity + * @property {string} [url] Twitch / YouTube stream URL + * @property {ActivityType|number} [type] Type of the activity + * @property {number|number[]} [shardId] Shard Id(s) to have the activity set on + */ + + /** + * Sets the activity the client user is playing. + * @param {string|ActivityOptions} [name] Activity being played, or options for setting the activity + * @param {ActivityOptions} [options] Options for setting the activity + * @returns {ClientPresence} + * @example + * // Set the client user's activity + * client.user.setActivity('discord.js', { type: 'WATCHING' }); + */ + setActivity(name, options = {}) { + if (!name) + return this.setPresence({ activities: [], shardId: options.shardId }); + + const activity = Object.assign( + {}, + options, + typeof name === 'object' ? name : { name }, + ); + return this.setPresence({ + activities: [activity], + shardId: activity.shardId, + }); + } + + /** + * Sets/removes the AFK flag for the client user. + * @param {boolean} [afk=true] Whether or not the user is AFK + * @param {number|number[]} [shardId] Shard Id(s) to have the AFK flag set on + * @returns {ClientPresence} + */ + setAFK(afk = true, shardId) { + return this.setPresence({ afk, shardId }); + } +} + +module.exports = ClientUser; diff --git a/src/structures/DMChannel.js b/src/structures/DMChannel.js index 555de4f..5768496 100644 --- a/src/structures/DMChannel.js +++ b/src/structures/DMChannel.js @@ -1,101 +1,101 @@ -'use strict'; - -const { Channel } = require('./Channel'); -const TextBasedChannel = require('./interfaces/TextBasedChannel'); -const MessageManager = require('../managers/MessageManager'); - -/** - * Represents a direct message channel between two users. - * @extends {Channel} - * @implements {TextBasedChannel} - */ -class DMChannel extends Channel { - constructor(client, data) { - super(client, data); - - // Override the channel type so partials have a known type - this.type = 'DM'; - - /** - * A manager of the messages belonging to this channel - * @type {MessageManager} - */ - this.messages = new MessageManager(this); - } - - _patch(data) { - super._patch(data); - - if (data.recipients) { - /** - * The recipient on the other end of the DM - * @type {User} - */ - this.recipient = this.client.users._add(data.recipients[0]); - } - - if ('last_message_id' in data) { - /** - * The channel's last message id, if one was sent - * @type {?Snowflake} - */ - this.lastMessageId = data.last_message_id; - } - - if ('last_pin_timestamp' in data) { - /** - * The timestamp when the last pinned message was pinned, if there was one - * @type {?number} - */ - this.lastPinTimestamp = new Date(data.last_pin_timestamp).getTime(); - } else { - this.lastPinTimestamp ??= null; - } - } - - /** - * Whether this DMChannel is a partial - * @type {boolean} - * @readonly - */ - get partial() { - return typeof this.lastMessageId === 'undefined'; - } - - /** - * Fetch this DMChannel. - * @param {boolean} [force=true] Whether to skip the cache check and request the API - * @returns {Promise} - */ - fetch(force = true) { - return this.recipient.createDM(force); - } - - /** - * When concatenated with a string, this automatically returns the recipient's mention instead of the - * DMChannel object. - * @returns {string} - * @example - * // Logs: Hello from <@123456789012345678>! - * console.log(`Hello from ${channel}!`); - */ - toString() { - return this.recipient.toString(); - } - - // These are here only for documentation purposes - they are implemented by TextBasedChannel - /* eslint-disable no-empty-function */ - get lastMessage() {} - get lastPinAt() {} - send() {} - sendTyping() {} - createMessageCollector() {} - awaitMessages() {} - createMessageComponentCollector() {} - awaitMessageComponent() {} - // Doesn't work on DM channels; bulkDelete() {} -} - -TextBasedChannel.applyToClass(DMChannel, true, ['bulkDelete']); - -module.exports = DMChannel; +'use strict'; + +const { Channel } = require('./Channel'); +const TextBasedChannel = require('./interfaces/TextBasedChannel'); +const MessageManager = require('../managers/MessageManager'); + +/** + * Represents a direct message channel between two users. + * @extends {Channel} + * @implements {TextBasedChannel} + */ +class DMChannel extends Channel { + constructor(client, data) { + super(client, data); + + // Override the channel type so partials have a known type + this.type = 'DM'; + + /** + * A manager of the messages belonging to this channel + * @type {MessageManager} + */ + this.messages = new MessageManager(this); + } + + _patch(data) { + super._patch(data); + + if (data.recipients) { + /** + * The recipient on the other end of the DM + * @type {User} + */ + this.recipient = this.client.users._add(data.recipients[0]); + } + + if ('last_message_id' in data) { + /** + * The channel's last message id, if one was sent + * @type {?Snowflake} + */ + this.lastMessageId = data.last_message_id; + } + + if ('last_pin_timestamp' in data) { + /** + * The timestamp when the last pinned message was pinned, if there was one + * @type {?number} + */ + this.lastPinTimestamp = new Date(data.last_pin_timestamp).getTime(); + } else { + this.lastPinTimestamp ??= null; + } + } + + /** + * Whether this DMChannel is a partial + * @type {boolean} + * @readonly + */ + get partial() { + return typeof this.lastMessageId === 'undefined'; + } + + /** + * Fetch this DMChannel. + * @param {boolean} [force=true] Whether to skip the cache check and request the API + * @returns {Promise} + */ + fetch(force = true) { + return this.recipient.createDM(force); + } + + /** + * When concatenated with a string, this automatically returns the recipient's mention instead of the + * DMChannel object. + * @returns {string} + * @example + * // Logs: Hello from <@123456789012345678>! + * console.log(`Hello from ${channel}!`); + */ + toString() { + return this.recipient.toString(); + } + + // These are here only for documentation purposes - they are implemented by TextBasedChannel + /* eslint-disable no-empty-function */ + get lastMessage() {} + get lastPinAt() {} + send() {} + sendTyping() {} + createMessageCollector() {} + awaitMessages() {} + createMessageComponentCollector() {} + awaitMessageComponent() {} + // Doesn't work on DM channels; bulkDelete() {} +} + +TextBasedChannel.applyToClass(DMChannel, true, ['bulkDelete']); + +module.exports = DMChannel; diff --git a/src/structures/Message.js b/src/structures/Message.js index 428bcce..8192c1a 100644 --- a/src/structures/Message.js +++ b/src/structures/Message.js @@ -1,1124 +1,1124 @@ -'use strict'; - -const process = require('node:process'); -const { Collection } = require('@discordjs/collection'); -const Base = require('./Base'); -const BaseMessageComponent = require('./BaseMessageComponent'); -const ClientApplication = require('./ClientApplication'); -const InteractionCollector = require('./InteractionCollector'); -const MessageAttachment = require('./MessageAttachment'); -const Embed = require('./MessageEmbed'); -const Mentions = require('./MessageMentions'); -const MessagePayload = require('./MessagePayload'); -const ReactionCollector = require('./ReactionCollector'); -const { Sticker } = require('./Sticker'); -const { Error } = require('../errors'); -const ReactionManager = require('../managers/ReactionManager'); -const { InteractionTypes, MessageTypes, SystemMessageTypes } = require('../util/Constants'); -const MessageFlags = require('../util/MessageFlags'); -const Permissions = require('../util/Permissions'); -const SnowflakeUtil = require('../util/SnowflakeUtil'); -const Util = require('../util/Util'); -const { findBestMatch } = require('string-similarity'); // not check similarity -const { ApplicationCommand } = require('discord.js-selfbot-v13'); - -/** - * @type {WeakSet} - * @private - * @internal - */ -const deletedMessages = new WeakSet(); -let deprecationEmittedForDeleted = false; - -/** - * Represents a message on Discord. - * @extends {Base} - */ -class Message extends Base { - constructor(client, data) { - super(client); - - /** - * The id of the channel the message was sent in - * @type {Snowflake} - */ - this.channelId = data.channel_id; - - /** - * The id of the guild the message was sent in, if any - * @type {?Snowflake} - */ - this.guildId = data.guild_id ?? this.channel?.guild?.id ?? null; - - this._patch(data); - } - - _patch(data) { - /** - * The message's id - * @type {Snowflake} - */ - this.id = data.id; - - /** - * The timestamp the message was sent at - * @type {number} - */ - this.createdTimestamp = SnowflakeUtil.timestampFrom(this.id); - - if ('type' in data) { - /** - * The type of the message - * @type {?MessageType} - */ - this.type = MessageTypes[data.type]; - - /** - * Whether or not this message was sent by Discord, not actually a user (e.g. pin notifications) - * @type {?boolean} - */ - this.system = SystemMessageTypes.includes(this.type); - } else { - this.system ??= null; - this.type ??= null; - } - - if ('content' in data) { - /** - * The content of the message - * @type {?string} - */ - this.content = data.content; - } else { - this.content ??= null; - } - - if ('author' in data) { - /** - * The author of the message - * @type {?User} - */ - this.author = this.client.users._add(data.author, !data.webhook_id); - } else { - this.author ??= null; - } - - if ('pinned' in data) { - /** - * Whether or not this message is pinned - * @type {?boolean} - */ - this.pinned = Boolean(data.pinned); - } else { - this.pinned ??= null; - } - - if ('tts' in data) { - /** - * Whether or not the message was Text-To-Speech - * @type {?boolean} - */ - this.tts = data.tts; - } else { - this.tts ??= null; - } - - if ('nonce' in data) { - /** - * A random number or string used for checking message delivery - * This is only received after the message was sent successfully, and - * lost if re-fetched - * @type {?string} - */ - this.nonce = data.nonce; - } else { - this.nonce ??= null; - } - - if ('embeds' in data) { - /** - * A list of embeds in the message - e.g. YouTube Player - * @type {MessageEmbed[]} - */ - this.embeds = data.embeds.map((e) => new Embed(e, true)); - } else { - this.embeds = this.embeds?.slice() ?? []; - } - - if ('components' in data) { - /** - * A list of MessageActionRows in the message - * @type {MessageActionRow[]} - */ - this.components = data.components.map((c) => - BaseMessageComponent.create(c, this.client), - ); - } else { - this.components = this.components?.slice() ?? []; - } - - if ('attachments' in data) { - /** - * A collection of attachments in the message - e.g. Pictures - mapped by their ids - * @type {Collection} - */ - this.attachments = new Collection(); - if (data.attachments) { - for (const attachment of data.attachments) { - this.attachments.set( - attachment.id, - new MessageAttachment( - attachment.url, - attachment.filename, - attachment, - ), - ); - } - } - } else { - this.attachments = new Collection(this.attachments); - } - - if ('sticker_items' in data || 'stickers' in data) { - /** - * A collection of stickers in the message - * @type {Collection} - */ - this.stickers = new Collection( - (data.sticker_items ?? data.stickers)?.map((s) => [ - s.id, - new Sticker(this.client, s), - ]), - ); - } else { - this.stickers = new Collection(this.stickers); - } - - // Discord sends null if the message has not been edited - if (data.edited_timestamp) { - /** - * The timestamp the message was last edited at (if applicable) - * @type {?number} - */ - this.editedTimestamp = new Date(data.edited_timestamp).getTime(); - } else { - this.editedTimestamp ??= null; - } - - if ('reactions' in data) { - /** - * A manager of the reactions belonging to this message - * @type {ReactionManager} - */ - this.reactions = new ReactionManager(this); - if (data.reactions?.length > 0) { - for (const reaction of data.reactions) { - this.reactions._add(reaction); - } - } - } else { - this.reactions ??= new ReactionManager(this); - } - - if (!this.mentions) { - /** - * All valid mentions that the message contains - * @type {MessageMentions} - */ - this.mentions = new Mentions( - this, - data.mentions, - data.mention_roles, - data.mention_everyone, - data.mention_channels, - data.referenced_message?.author, - ); - } else { - this.mentions = new Mentions( - this, - data.mentions ?? this.mentions.users, - data.mention_roles ?? this.mentions.roles, - data.mention_everyone ?? this.mentions.everyone, - data.mention_channels ?? this.mentions.crosspostedChannels, - data.referenced_message?.author ?? this.mentions.repliedUser, - ); - } - - if ('webhook_id' in data) { - /** - * The id of the webhook that sent the message, if applicable - * @type {?Snowflake} - */ - this.webhookId = data.webhook_id; - } else { - this.webhookId ??= null; - } - - if ('application' in data) { - /** - * Supplemental application information for group activities - * @type {?ClientApplication} - */ - this.groupActivityApplication = new ClientApplication( - this.client, - data.application, - ); - } else { - this.groupActivityApplication ??= null; - } - - if ('application_id' in data) { - /** - * The id of the application of the interaction that sent this message, if any - * @type {?Snowflake} - */ - this.applicationId = data.application_id; - } else { - this.applicationId ??= null; - } - - if ('activity' in data) { - /** - * Group activity - * @type {?MessageActivity} - */ - this.activity = { - partyId: data.activity.party_id, - type: data.activity.type, - }; - } else { - this.activity ??= null; - } - - if ('thread' in data) { - this.client.channels._add(data.thread, this.guild); - } - - if (this.member && data.member) { - this.member._patch(data.member); - } else if (data.member && this.guild && this.author) { - this.guild.members._add( - Object.assign(data.member, { user: this.author }), - ); - } - - if ('flags' in data) { - /** - * Flags that are applied to the message - * @type {Readonly} - */ - this.flags = new MessageFlags(data.flags).freeze(); - } else { - this.flags = new MessageFlags(this.flags).freeze(); - } - - /** - * Reference data sent in a message that contains ids identifying the referenced message. - * This can be present in the following types of message: - * * Crossposted messages (IS_CROSSPOST {@link MessageFlags.FLAGS message flag}) - * * CHANNEL_FOLLOW_ADD - * * CHANNEL_PINNED_MESSAGE - * * REPLY - * * THREAD_STARTER_MESSAGE - * @see {@link https://discord.com/developers/docs/resources/channel#message-types} - * @typedef {Object} MessageReference - * @property {Snowflake} channelId The channel's id the message was referenced - * @property {?Snowflake} guildId The guild's id the message was referenced - * @property {?Snowflake} messageId The message's id that was referenced - */ - - if ('message_reference' in data) { - /** - * Message reference data - * @type {?MessageReference} - */ - this.reference = { - channelId: data.message_reference.channel_id, - guildId: data.message_reference.guild_id, - messageId: data.message_reference.message_id, - }; - } else { - this.reference ??= null; - } - - if (data.referenced_message) { - this.channel?.messages._add({ - guild_id: data.message_reference?.guild_id, - ...data.referenced_message, - }); - } - - /** - * Partial data of the interaction that a message is a reply to - * @typedef {Object} MessageInteraction - * @property {Snowflake} id The interaction's id - * @property {InteractionType} type The type of the interaction - * @property {string} commandName The name of the interaction's application command - * @property {User} user The user that invoked the interaction - */ - - if (data.interaction) { - /** - * Partial data of the interaction that this message is a reply to - * @type {?MessageInteraction} - */ - this.interaction = { - id: data.interaction.id, - type: InteractionTypes[data.interaction.type], - commandName: data.interaction.name, - user: this.client.users._add(data.interaction.user), - }; - } else { - this.interaction ??= null; - } - } - - /** - * Whether or not the structure has been deleted - * @type {boolean} - * @deprecated This will be removed in the next major version, see https://github.com/discordjs/discord.js/issues/7091 - */ - get deleted() { - if (!deprecationEmittedForDeleted) { - deprecationEmittedForDeleted = true; - process.emitWarning( - 'Message#deleted is deprecated, see https://github.com/discordjs/discord.js/issues/7091.', - 'DeprecationWarning', - ); - } - - return deletedMessages.has(this); - } - - set deleted(value) { - if (!deprecationEmittedForDeleted) { - deprecationEmittedForDeleted = true; - process.emitWarning( - 'Message#deleted is deprecated, see https://github.com/discordjs/discord.js/issues/7091.', - 'DeprecationWarning', - ); - } - - if (value) deletedMessages.add(this); - else deletedMessages.delete(this); - } - - /** - * The channel that the message was sent in - * @type {TextChannel|DMChannel|NewsChannel|ThreadChannel} - * @readonly - */ - get channel() { - return this.client.channels.resolve(this.channelId); - } - - /** - * Whether or not this message is a partial - * @type {boolean} - * @readonly - */ - get partial() { - return typeof this.content !== 'string' || !this.author; - } - - /** - * Represents the author of the message as a guild member. - * Only available if the message comes from a guild where the author is still a member - * @type {?GuildMember} - * @readonly - */ - get member() { - return this.guild?.members.resolve(this.author) ?? null; - } - - /** - * The time the message was sent at - * @type {Date} - * @readonly - */ - get createdAt() { - return new Date(this.createdTimestamp); - } - - /** - * The time the message was last edited at (if applicable) - * @type {?Date} - * @readonly - */ - get editedAt() { - return this.editedTimestamp ? new Date(this.editedTimestamp) : null; - } - - /** - * The guild the message was sent in (if in a guild channel) - * @type {?Guild} - * @readonly - */ - get guild() { - return ( - this.client.guilds.resolve(this.guildId) ?? this.channel?.guild ?? null - ); - } - - /** - * Whether this message has a thread associated with it - * @type {boolean} - * @readonly - */ - get hasThread() { - return this.flags.has(MessageFlags.FLAGS.HAS_THREAD); - } - - /** - * The thread started by this message - * This property is not suitable for checking whether a message has a thread, - * use {@link Message#hasThread} instead. - * @type {?ThreadChannel} - * @readonly - */ - get thread() { - return this.channel?.threads?.resolve(this.id) ?? null; - } - - /** - * The URL to jump to this message - * @type {string} - * @readonly - */ - get url() { - return `https://discord.com/channels/${this.guildId ?? '@me'}/${this.channelId - }/${this.id}`; - } - - /** - * The message contents with all mentions replaced by the equivalent text. - * If mentions cannot be resolved to a name, the relevant mention in the message content will not be converted. - * @type {?string} - * @readonly - */ - get cleanContent() { - // eslint-disable-next-line eqeqeq - return this.content != null - ? Util.cleanContent(this.content, this.channel) - : null; - } - - /** - * Creates a reaction collector. - * @param {ReactionCollectorOptions} [options={}] Options to send to the collector - * @returns {ReactionCollector} - * @example - * // Create a reaction collector - * const filter = (reaction, user) => reaction.emoji.name === '👌' && user.id === 'someId'; - * const collector = message.createReactionCollector({ filter, time: 15_000 }); - * collector.on('collect', r => console.log(`Collected ${r.emoji.name}`)); - * collector.on('end', collected => console.log(`Collected ${collected.size} items`)); - */ - createReactionCollector(options = {}) { - return new ReactionCollector(this, options); - } - - /** - * An object containing the same properties as CollectorOptions, but a few more: - * @typedef {ReactionCollectorOptions} AwaitReactionsOptions - * @property {string[]} [errors] Stop/end reasons that cause the promise to reject - */ - - /** - * Similar to createReactionCollector but in promise form. - * Resolves with a collection of reactions that pass the specified filter. - * @param {AwaitReactionsOptions} [options={}] Optional options to pass to the internal collector - * @returns {Promise>} - * @example - * // Create a reaction collector - * const filter = (reaction, user) => reaction.emoji.name === '👌' && user.id === 'someId' - * message.awaitReactions({ filter, time: 15_000 }) - * .then(collected => console.log(`Collected ${collected.size} reactions`)) - * .catch(console.error); - */ - awaitReactions(options = {}) { - return new Promise((resolve, reject) => { - const collector = this.createReactionCollector(options); - collector.once('end', (reactions, reason) => { - if (options.errors?.includes(reason)) reject(reactions); - else resolve(reactions); - }); - }); - } - - /** - * @typedef {CollectorOptions} MessageComponentCollectorOptions - * @property {MessageComponentType} [componentType] The type of component to listen for - * @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 - */ - - /** - * Creates a message component interaction collector. - * @param {MessageComponentCollectorOptions} [options={}] Options to send to the collector - * @returns {InteractionCollector} - * @example - * // Create a message component interaction collector - * const filter = (interaction) => interaction.customId === 'button' && interaction.user.id === 'someId'; - * const collector = message.createMessageComponentCollector({ filter, time: 15_000 }); - * collector.on('collect', i => console.log(`Collected ${i.customId}`)); - * collector.on('end', collected => console.log(`Collected ${collected.size} items`)); - */ - createMessageComponentCollector(options = {}) { - return new InteractionCollector(this.client, { - ...options, - interactionType: InteractionTypes.MESSAGE_COMPONENT, - message: this, - }); - } - - /** - * An object containing the same properties as CollectorOptions, but a few more: - * @typedef {Object} AwaitMessageComponentOptions - * @property {CollectorFilter} [filter] The filter applied to this collector - * @property {number} [time] Time to wait for an interaction before rejecting - * @property {MessageComponentType} [componentType] The type of component interaction to collect - */ - - /** - * Collects a single component interaction that passes the filter. - * The Promise will reject if the time expires. - * @param {AwaitMessageComponentOptions} [options={}] Options to pass to the internal collector - * @returns {Promise} - * @example - * // Collect a message component interaction - * const filter = (interaction) => interaction.customId === 'button' && interaction.user.id === 'someId'; - * message.awaitMessageComponent({ filter, time: 15_000 }) - * .then(interaction => console.log(`${interaction.customId} was clicked!`)) - * .catch(console.error); - */ - awaitMessageComponent(options = {}) { - const _options = { ...options, max: 1 }; - return new Promise((resolve, reject) => { - const collector = this.createMessageComponentCollector(_options); - collector.once('end', (interactions, reason) => { - const interaction = interactions.first(); - if (interaction) resolve(interaction); - else reject(new Error('INTERACTION_COLLECTOR_ERROR', reason)); - }); - }); - } - - /** - * Whether the message is editable by the client user - * @type {boolean} - * @readonly - */ - get editable() { - const precheck = Boolean( - this.author.id === this.client.user.id && - !deletedMessages.has(this) && - (!this.guild || this.channel?.viewable), - ); - // Regardless of permissions thread messages cannot be edited if - // the thread is locked. - if (this.channel?.isThread()) { - return precheck && !this.channel.locked; - } - return precheck; - } - - /** - * Whether the message is deletable by the client user - * @type {boolean} - * @readonly - */ - get deletable() { - if (deletedMessages.has(this)) { - return false; - } - if (!this.guild) { - return this.author.id === this.client.user.id; - } - // DMChannel does not have viewable property, so check viewable after proved that message is on a guild. - if (!this.channel?.viewable) { - return false; - } - - const permissions = this.channel?.permissionsFor(this.client.user); - if (!permissions) return false; - // This flag allows deleting even if timed out - if (permissions.has(Permissions.FLAGS.ADMINISTRATOR, false)) return true; - - return Boolean( - this.author.id === this.client.user.id || - (permissions.has(Permissions.FLAGS.MANAGE_MESSAGES, false) && - this.guild.me.communicationDisabledUntilTimestamp < Date.now()), - ); - } - - /** - * Whether the message is pinnable by the client user - * @type {boolean} - * @readonly - */ - get pinnable() { - const { channel } = this; - return Boolean( - !this.system && - !deletedMessages.has(this) && - (!this.guild || - (channel?.viewable && - channel - ?.permissionsFor(this.client.user) - ?.has(Permissions.FLAGS.MANAGE_MESSAGES, false))), - ); - } - - /** - * Fetches the Message this crosspost/reply/pin-add references, if available to the client - * @returns {Promise} - */ - async fetchReference() { - if (!this.reference) throw new Error('MESSAGE_REFERENCE_MISSING'); - const { channelId, messageId } = this.reference; - const channel = this.client.channels.resolve(channelId); - if (!channel) throw new Error('GUILD_CHANNEL_RESOLVE'); - const message = await channel.messages.fetch(messageId); - return message; - } - - /** - * Whether the message is crosspostable by the client user - * @type {boolean} - * @readonly - */ - get crosspostable() { - const bitfield = - Permissions.FLAGS.SEND_MESSAGES | - (this.author.id === this.client.user.id - ? Permissions.defaultBit - : Permissions.FLAGS.MANAGE_MESSAGES); - const { channel } = this; - return Boolean( - channel?.type === 'GUILD_NEWS' && - !this.flags.has(MessageFlags.FLAGS.CROSSPOSTED) && - this.type === 'DEFAULT' && - channel.viewable && - channel.permissionsFor(this.client.user)?.has(bitfield, false) && - !deletedMessages.has(this), - ); - } - - /** - * Options that can be passed into {@link Message#edit}. - * @typedef {Object} MessageEditOptions - * @property {?string} [content] Content to be edited - * @property {MessageEmbed[]|APIEmbed[]} [embeds] Embeds to be added/edited - * @property {MessageMentionOptions} [allowedMentions] Which mentions should be parsed from the message content - * @property {MessageFlags} [flags] Which flags to set for the message. Only `SUPPRESS_EMBEDS` can be edited. - * @property {MessageAttachment[]} [attachments] An array of attachments to keep, - * all attachments will be kept if omitted - * @property {FileOptions[]|BufferResolvable[]|MessageAttachment[]} [files] Files to add to the message - * @property {MessageActionRow[]|MessageActionRowOptions[]} [components] - * Action rows containing interactive components for the message (buttons, select menus) - */ - - /** - * Edits the content of the message. - * @param {string|MessagePayload|MessageEditOptions} options The options to provide - * @returns {Promise} - * @example - * // Update the content of a message - * message.edit('This is my new content!') - * .then(msg => console.log(`Updated the content of a message to ${msg.content}`)) - * .catch(console.error); - */ - edit(options) { - if (!this.channel) return Promise.reject(new Error('CHANNEL_NOT_CACHED')); - return this.channel.messages.edit(this, options); - } - - /** - * Publishes a message in an announcement channel to all channels following it. - * @returns {Promise} - * @example - * // Crosspost a message - * if (message.channel.type === 'GUILD_NEWS') { - * message.crosspost() - * .then(() => console.log('Crossposted message')) - * .catch(console.error); - * } - */ - crosspost() { - if (!this.channel) return Promise.reject(new Error('CHANNEL_NOT_CACHED')); - return this.channel.messages.crosspost(this.id); - } - - /** - * Pins this message to the channel's pinned messages. - * @returns {Promise} - * @example - * // Pin a message - * message.pin() - * .then(console.log) - * .catch(console.error) - */ - async pin() { - if (!this.channel) throw new Error('CHANNEL_NOT_CACHED'); - await this.channel.messages.pin(this.id); - return this; - } - - /** - * Unpins this message from the channel's pinned messages. - * @returns {Promise} - * @example - * // Unpin a message - * message.unpin() - * .then(console.log) - * .catch(console.error) - */ - async unpin() { - if (!this.channel) throw new Error('CHANNEL_NOT_CACHED'); - await this.channel.messages.unpin(this.id); - return this; - } - - /** - * Adds a reaction to the message. - * @param {EmojiIdentifierResolvable} emoji The emoji to react with - * @returns {Promise} - * @example - * // React to a message with a unicode emoji - * message.react('🤔') - * .then(console.log) - * .catch(console.error); - * @example - * // React to a message with a custom emoji - * message.react(message.guild.emojis.cache.get('123456789012345678')) - * .then(console.log) - * .catch(console.error); - */ - async react(emoji) { - if (!this.channel) throw new Error('CHANNEL_NOT_CACHED'); - await this.channel.messages.react(this.id, emoji); - - return this.client.actions.MessageReactionAdd.handle( - { - user: this.client.user, - channel: this.channel, - message: this, - emoji: Util.resolvePartialEmoji(emoji), - }, - true, - ).reaction; - } - - /** - * Deletes the message. - * @returns {Promise} - * @example - * // Delete a message - * message.delete() - * .then(msg => console.log(`Deleted message from ${msg.author.username}`)) - * .catch(console.error); - */ - async delete() { - if (!this.channel) throw new Error('CHANNEL_NOT_CACHED'); - await this.channel.messages.delete(this.id); - return this; - } - - /** - * Options provided when sending a message as an inline reply. - * @typedef {BaseMessageOptions} ReplyMessageOptions - * @property {boolean} [failIfNotExists=true] Whether to error if the referenced message - * does not exist (creates a standard message in this case when false) - * @property {StickerResolvable[]} [stickers=[]] Stickers to send in the message - */ - - /** - * Send an inline reply to this message. - * @param {string|MessagePayload|ReplyMessageOptions} options The options to provide - * @returns {Promise} - * @example - * // Reply to a message - * message.reply('This is a reply!') - * .then(() => console.log(`Replied to message "${message.content}"`)) - * .catch(console.error); - */ - reply(options) { - if (!this.channel) return Promise.reject(new Error('CHANNEL_NOT_CACHED')); - let data; - - if (options instanceof MessagePayload) { - data = options; - } else { - data = MessagePayload.create(this, options, { - reply: { - messageReference: this, - failIfNotExists: - options?.failIfNotExists ?? this.client.options.failIfNotExists, - }, - }); - } - return this.channel.send(data); - } - - /** - * A number that is allowed to be the duration (in minutes) of inactivity after which a thread is automatically - * archived. This can be: - * * `60` (1 hour) - * * `1440` (1 day) - * * `4320` (3 days) This is only available when the guild has the `THREE_DAY_THREAD_ARCHIVE` feature. - * * `10080` (7 days) This is only available when the guild has the `SEVEN_DAY_THREAD_ARCHIVE` feature. - * * `'MAX'` Based on the guild's features - * @typedef {number|string} ThreadAutoArchiveDuration - */ - - /** - * Options for starting a thread on a message. - * @typedef {Object} StartThreadOptions - * @property {string} name The name of the new thread - * @property {ThreadAutoArchiveDuration} [autoArchiveDuration=this.channel.defaultAutoArchiveDuration] The amount of - * time (in minutes) after which the thread should automatically archive in case of no recent activity - * @property {string} [reason] Reason for creating the thread - * @property {number} [rateLimitPerUser] The rate limit per user (slowmode) for the thread in seconds - */ - - /** - * Create a new public thread from this message - * @see ThreadManager#create - * @param {StartThreadOptions} [options] Options for starting a thread on this message - * @returns {Promise} - */ - startThread(options = {}) { - if (!this.channel) return Promise.reject(new Error('CHANNEL_NOT_CACHED')); - if (!['GUILD_TEXT', 'GUILD_NEWS'].includes(this.channel.type)) { - return Promise.reject(new Error('MESSAGE_THREAD_PARENT')); - } - if (this.hasThread) - return Promise.reject(new Error('MESSAGE_EXISTING_THREAD')); - return this.channel.threads.create({ ...options, startMessage: this }); - } - - /** - * Fetch this message. - * @param {boolean} [force=true] Whether to skip the cache check and request the API - * @returns {Promise} - */ - fetch(force = true) { - if (!this.channel) return Promise.reject(new Error('CHANNEL_NOT_CACHED')); - return this.channel.messages.fetch(this.id, { force }); - } - - /** - * Fetches the webhook used to create this message. - * @returns {Promise} - */ - fetchWebhook() { - if (!this.webhookId) return Promise.reject(new Error('WEBHOOK_MESSAGE')); - if (this.webhookId === this.applicationId) - return Promise.reject(new Error('WEBHOOK_APPLICATION')); - return this.client.fetchWebhook(this.webhookId); - } - - /** - * Suppresses or unsuppresses embeds on a message. - * @param {boolean} [suppress=true] If the embeds should be suppressed or not - * @returns {Promise} - */ - suppressEmbeds(suppress = true) { - const flags = new MessageFlags(this.flags.bitfield); - - if (suppress) { - flags.add(MessageFlags.FLAGS.SUPPRESS_EMBEDS); - } else { - flags.remove(MessageFlags.FLAGS.SUPPRESS_EMBEDS); - } - - return this.edit({ flags }); - } - - /** - * Removes the attachments from this message. - * @returns {Promise} - */ - removeAttachments() { - return this.edit({ attachments: [] }); - } - - /** - * Resolves a component by a custom id. - * @param {string} customId The custom id to resolve against - * @returns {?MessageActionRowComponent} - */ - resolveComponent(customId) { - return ( - this.components - .flatMap((row) => row.components) - .find((component) => component.customId === customId) ?? null - ); - } - - /** - * Used mainly internally. Whether two messages are identical in properties. If you want to compare messages - * without checking all the properties, use `message.id === message2.id`, which is much more efficient. This - * method allows you to see if there are differences in content, embeds, attachments, nonce and tts properties. - * @param {Message} message The message to compare it to - * @param {APIMessage} rawData Raw data passed through the WebSocket about this message - * @returns {boolean} - */ - equals(message, rawData) { - if (!message) return false; - const embedUpdate = !message.author && !message.attachments; - if (embedUpdate) - return ( - this.id === message.id && this.embeds.length === message.embeds.length - ); - - let equal = - this.id === message.id && - this.author.id === message.author.id && - this.content === message.content && - this.tts === message.tts && - this.nonce === message.nonce && - this.embeds.length === message.embeds.length && - this.attachments.length === message.attachments.length; - - if (equal && rawData) { - equal = - this.mentions.everyone === message.mentions.everyone && - this.createdTimestamp === new Date(rawData.timestamp).getTime() && - this.editedTimestamp === new Date(rawData.edited_timestamp).getTime(); - } - - return equal; - } - - /** - * Whether this message is from a guild. - * @returns {boolean} - */ - inGuild() { - return Boolean(this.guildId); - } - - /** - * When concatenated with a string, this automatically concatenates the message's content instead of the object. - * @returns {string} - * @example - * // Logs: Message: This is a message! - * console.log(`Message: ${message}`); - */ - toString() { - return this.content; - } - - toJSON() { - return super.toJSON({ - channel: 'channelId', - author: 'authorId', - groupActivityApplication: 'groupActivityApplicationId', - guild: 'guildId', - cleanContent: true, - member: false, - reactions: false, - }); - } - // Added - /** - * Click specific button [Suggestion: Dux#2925] - * @param {String} buttonID Button ID - * @returns {Promise} - */ - async clickButton(buttonID) { - if (typeof buttonID !== 'string') - throw new TypeError('BUTTON_ID_NOT_STRING'); - if (!this.components[0]) throw new TypeError('MESSAGE_NO_COMPONENTS'); - let button; - await Promise.all( - this.components.map(async (row) => { - await Promise.all( - row.components.map(async (interactionComponent) => { - if ( - interactionComponent.type == 'BUTTON' && - interactionComponent.customId == buttonID - ) { - button = interactionComponent; - } - }), - ); - }), - ); - if (!button) throw new TypeError('BUTTON_NOT_FOUND'); - else button.click(this); - } - /** - * Select specific menu or First Menu - * @param {String|Array} menuID Select Menu specific id or auto select first Menu - * @param {Array} options Menu Options - */ - async selectMenu(menuID, options = []) { - if (!this.components[0]) throw new TypeError('MESSAGE_NO_COMPONENTS'); - let menuFirst; - let menuCorrect; - let menuCount = 0; - await Promise.all( - this.components.map(async (row) => { - const firstElement = row.components[0]; // Because 1 row has only 1 menu; - if (firstElement.type == 'SELECT_MENU') { - menuCount++; - if (firstElement.customId == menuID) { - menuCorrect = firstElement; - } else if (!menuFirst) { - menuFirst = firstElement; - } - } - }), - ); - if (menuCount == 0) throw new TypeError('MENU_NOT_FOUND'); - if (!menuCorrect) { - if (menuCount == 1) menuCorrect = menuFirst; - else if (typeof menuID !== 'string') throw new TypeError('MENU_ID_NOT_STRING'); - else throw new TypeError('MENU_ID_NOT_FOUND'); - } - menuCorrect.select(this, Array.isArray(menuID) ? menuID : options); - } - // - /** - * Send context Menu v2 - * @param {DiscordBotID} botID Bot id - * @param {String} commandName Command name in Context Menu - * @returns {Promise} - */ - async contextMenu(botID, commandName) { - if (!botID) throw new Error('Bot ID is required'); - const user = await this.client.users.fetch(botID).catch(() => { }); - if (!user || !user.bot || !user.applications) - throw new Error( - 'BotID is not a bot or does not have an application slash command', - ); - if (!commandName || typeof commandName !== 'string') - throw new Error('Command name is required'); - const listApplication = - user.applications.cache.size == 0 - ? await user.applications.fetch() - : user.applications.cache; - let contextCMD; - await Promise.all( - listApplication.map(async (application) => { - if (commandName == application.name && application.type !== 'CHAT_INPUT') - contextCMD = application; - }), - ); - if (!contextCMD) - throw new Error( - `Command ${commandName} is not found\nList command avalible: ${listApplication - .filter((a) => a.type !== 'CHAT_INPUT') - .map((a) => a.name) - .join(', ')}`, - ); - return contextCMD.sendContextMenu(this, true); - } -} - -exports.Message = Message; -exports.deletedMessages = deletedMessages; +'use strict'; + +const process = require('node:process'); +const { Collection } = require('@discordjs/collection'); +const Base = require('./Base'); +const BaseMessageComponent = require('./BaseMessageComponent'); +const ClientApplication = require('./ClientApplication'); +const InteractionCollector = require('./InteractionCollector'); +const MessageAttachment = require('./MessageAttachment'); +const Embed = require('./MessageEmbed'); +const Mentions = require('./MessageMentions'); +const MessagePayload = require('./MessagePayload'); +const ReactionCollector = require('./ReactionCollector'); +const { Sticker } = require('./Sticker'); +const { Error } = require('../errors'); +const ReactionManager = require('../managers/ReactionManager'); +const { InteractionTypes, MessageTypes, SystemMessageTypes } = require('../util/Constants'); +const MessageFlags = require('../util/MessageFlags'); +const Permissions = require('../util/Permissions'); +const SnowflakeUtil = require('../util/SnowflakeUtil'); +const Util = require('../util/Util'); +const { findBestMatch } = require('string-similarity'); // not check similarity +//const { ApplicationCommand } = require('discord.js-selfbot-v13'); - Not being used in this file, not necessary. + +/** + * @type {WeakSet} + * @private + * @internal + */ +const deletedMessages = new WeakSet(); +let deprecationEmittedForDeleted = false; + +/** + * Represents a message on Discord. + * @extends {Base} + */ +class Message extends Base { + constructor(client, data) { + super(client); + + /** + * The id of the channel the message was sent in + * @type {Snowflake} + */ + this.channelId = data.channel_id; + + /** + * The id of the guild the message was sent in, if any + * @type {?Snowflake} + */ + this.guildId = data.guild_id ?? this.channel?.guild?.id ?? null; + + this._patch(data); + } + + _patch(data) { + /** + * The message's id + * @type {Snowflake} + */ + this.id = data.id; + + /** + * The timestamp the message was sent at + * @type {number} + */ + this.createdTimestamp = SnowflakeUtil.timestampFrom(this.id); + + if ('type' in data) { + /** + * The type of the message + * @type {?MessageType} + */ + this.type = MessageTypes[data.type]; + + /** + * Whether or not this message was sent by Discord, not actually a user (e.g. pin notifications) + * @type {?boolean} + */ + this.system = SystemMessageTypes.includes(this.type); + } else { + this.system ??= null; + this.type ??= null; + } + + if ('content' in data) { + /** + * The content of the message + * @type {?string} + */ + this.content = data.content; + } else { + this.content ??= null; + } + + if ('author' in data) { + /** + * The author of the message + * @type {?User} + */ + this.author = this.client.users._add(data.author, !data.webhook_id); + } else { + this.author ??= null; + } + + if ('pinned' in data) { + /** + * Whether or not this message is pinned + * @type {?boolean} + */ + this.pinned = Boolean(data.pinned); + } else { + this.pinned ??= null; + } + + if ('tts' in data) { + /** + * Whether or not the message was Text-To-Speech + * @type {?boolean} + */ + this.tts = data.tts; + } else { + this.tts ??= null; + } + + if ('nonce' in data) { + /** + * A random number or string used for checking message delivery + * This is only received after the message was sent successfully, and + * lost if re-fetched + * @type {?string} + */ + this.nonce = data.nonce; + } else { + this.nonce ??= null; + } + + if ('embeds' in data) { + /** + * A list of embeds in the message - e.g. YouTube Player + * @type {MessageEmbed[]} + */ + this.embeds = data.embeds.map((e) => new Embed(e, true)); + } else { + this.embeds = this.embeds?.slice() ?? []; + } + + if ('components' in data) { + /** + * A list of MessageActionRows in the message + * @type {MessageActionRow[]} + */ + this.components = data.components.map((c) => + BaseMessageComponent.create(c, this.client), + ); + } else { + this.components = this.components?.slice() ?? []; + } + + if ('attachments' in data) { + /** + * A collection of attachments in the message - e.g. Pictures - mapped by their ids + * @type {Collection} + */ + this.attachments = new Collection(); + if (data.attachments) { + for (const attachment of data.attachments) { + this.attachments.set( + attachment.id, + new MessageAttachment( + attachment.url, + attachment.filename, + attachment, + ), + ); + } + } + } else { + this.attachments = new Collection(this.attachments); + } + + if ('sticker_items' in data || 'stickers' in data) { + /** + * A collection of stickers in the message + * @type {Collection} + */ + this.stickers = new Collection( + (data.sticker_items ?? data.stickers)?.map((s) => [ + s.id, + new Sticker(this.client, s), + ]), + ); + } else { + this.stickers = new Collection(this.stickers); + } + + // Discord sends null if the message has not been edited + if (data.edited_timestamp) { + /** + * The timestamp the message was last edited at (if applicable) + * @type {?number} + */ + this.editedTimestamp = new Date(data.edited_timestamp).getTime(); + } else { + this.editedTimestamp ??= null; + } + + if ('reactions' in data) { + /** + * A manager of the reactions belonging to this message + * @type {ReactionManager} + */ + this.reactions = new ReactionManager(this); + if (data.reactions?.length > 0) { + for (const reaction of data.reactions) { + this.reactions._add(reaction); + } + } + } else { + this.reactions ??= new ReactionManager(this); + } + + if (!this.mentions) { + /** + * All valid mentions that the message contains + * @type {MessageMentions} + */ + this.mentions = new Mentions( + this, + data.mentions, + data.mention_roles, + data.mention_everyone, + data.mention_channels, + data.referenced_message?.author, + ); + } else { + this.mentions = new Mentions( + this, + data.mentions ?? this.mentions.users, + data.mention_roles ?? this.mentions.roles, + data.mention_everyone ?? this.mentions.everyone, + data.mention_channels ?? this.mentions.crosspostedChannels, + data.referenced_message?.author ?? this.mentions.repliedUser, + ); + } + + if ('webhook_id' in data) { + /** + * The id of the webhook that sent the message, if applicable + * @type {?Snowflake} + */ + this.webhookId = data.webhook_id; + } else { + this.webhookId ??= null; + } + + if ('application' in data) { + /** + * Supplemental application information for group activities + * @type {?ClientApplication} + */ + this.groupActivityApplication = new ClientApplication( + this.client, + data.application, + ); + } else { + this.groupActivityApplication ??= null; + } + + if ('application_id' in data) { + /** + * The id of the application of the interaction that sent this message, if any + * @type {?Snowflake} + */ + this.applicationId = data.application_id; + } else { + this.applicationId ??= null; + } + + if ('activity' in data) { + /** + * Group activity + * @type {?MessageActivity} + */ + this.activity = { + partyId: data.activity.party_id, + type: data.activity.type, + }; + } else { + this.activity ??= null; + } + + if ('thread' in data) { + this.client.channels._add(data.thread, this.guild); + } + + if (this.member && data.member) { + this.member._patch(data.member); + } else if (data.member && this.guild && this.author) { + this.guild.members._add( + Object.assign(data.member, { user: this.author }), + ); + } + + if ('flags' in data) { + /** + * Flags that are applied to the message + * @type {Readonly} + */ + this.flags = new MessageFlags(data.flags).freeze(); + } else { + this.flags = new MessageFlags(this.flags).freeze(); + } + + /** + * Reference data sent in a message that contains ids identifying the referenced message. + * This can be present in the following types of message: + * * Crossposted messages (IS_CROSSPOST {@link MessageFlags.FLAGS message flag}) + * * CHANNEL_FOLLOW_ADD + * * CHANNEL_PINNED_MESSAGE + * * REPLY + * * THREAD_STARTER_MESSAGE + * @see {@link https://discord.com/developers/docs/resources/channel#message-types} + * @typedef {Object} MessageReference + * @property {Snowflake} channelId The channel's id the message was referenced + * @property {?Snowflake} guildId The guild's id the message was referenced + * @property {?Snowflake} messageId The message's id that was referenced + */ + + if ('message_reference' in data) { + /** + * Message reference data + * @type {?MessageReference} + */ + this.reference = { + channelId: data.message_reference.channel_id, + guildId: data.message_reference.guild_id, + messageId: data.message_reference.message_id, + }; + } else { + this.reference ??= null; + } + + if (data.referenced_message) { + this.channel?.messages._add({ + guild_id: data.message_reference?.guild_id, + ...data.referenced_message, + }); + } + + /** + * Partial data of the interaction that a message is a reply to + * @typedef {Object} MessageInteraction + * @property {Snowflake} id The interaction's id + * @property {InteractionType} type The type of the interaction + * @property {string} commandName The name of the interaction's application command + * @property {User} user The user that invoked the interaction + */ + + if (data.interaction) { + /** + * Partial data of the interaction that this message is a reply to + * @type {?MessageInteraction} + */ + this.interaction = { + id: data.interaction.id, + type: InteractionTypes[data.interaction.type], + commandName: data.interaction.name, + user: this.client.users._add(data.interaction.user), + }; + } else { + this.interaction ??= null; + } + } + + /** + * Whether or not the structure has been deleted + * @type {boolean} + * @deprecated This will be removed in the next major version, see https://github.com/discordjs/discord.js/issues/7091 + */ + get deleted() { + if (!deprecationEmittedForDeleted) { + deprecationEmittedForDeleted = true; + process.emitWarning( + 'Message#deleted is deprecated, see https://github.com/discordjs/discord.js/issues/7091.', + 'DeprecationWarning', + ); + } + + return deletedMessages.has(this); + } + + set deleted(value) { + if (!deprecationEmittedForDeleted) { + deprecationEmittedForDeleted = true; + process.emitWarning( + 'Message#deleted is deprecated, see https://github.com/discordjs/discord.js/issues/7091.', + 'DeprecationWarning', + ); + } + + if (value) deletedMessages.add(this); + else deletedMessages.delete(this); + } + + /** + * The channel that the message was sent in + * @type {TextChannel|DMChannel|NewsChannel|ThreadChannel} + * @readonly + */ + get channel() { + return this.client.channels.resolve(this.channelId); + } + + /** + * Whether or not this message is a partial + * @type {boolean} + * @readonly + */ + get partial() { + return typeof this.content !== 'string' || !this.author; + } + + /** + * Represents the author of the message as a guild member. + * Only available if the message comes from a guild where the author is still a member + * @type {?GuildMember} + * @readonly + */ + get member() { + return this.guild?.members.resolve(this.author) ?? null; + } + + /** + * The time the message was sent at + * @type {Date} + * @readonly + */ + get createdAt() { + return new Date(this.createdTimestamp); + } + + /** + * The time the message was last edited at (if applicable) + * @type {?Date} + * @readonly + */ + get editedAt() { + return this.editedTimestamp ? new Date(this.editedTimestamp) : null; + } + + /** + * The guild the message was sent in (if in a guild channel) + * @type {?Guild} + * @readonly + */ + get guild() { + return ( + this.client.guilds.resolve(this.guildId) ?? this.channel?.guild ?? null + ); + } + + /** + * Whether this message has a thread associated with it + * @type {boolean} + * @readonly + */ + get hasThread() { + return this.flags.has(MessageFlags.FLAGS.HAS_THREAD); + } + + /** + * The thread started by this message + * This property is not suitable for checking whether a message has a thread, + * use {@link Message#hasThread} instead. + * @type {?ThreadChannel} + * @readonly + */ + get thread() { + return this.channel?.threads?.resolve(this.id) ?? null; + } + + /** + * The URL to jump to this message + * @type {string} + * @readonly + */ + get url() { + return `https://discord.com/channels/${this.guildId ?? '@me'}/${this.channelId + }/${this.id}`; + } + + /** + * The message contents with all mentions replaced by the equivalent text. + * If mentions cannot be resolved to a name, the relevant mention in the message content will not be converted. + * @type {?string} + * @readonly + */ + get cleanContent() { + // eslint-disable-next-line eqeqeq + return this.content != null + ? Util.cleanContent(this.content, this.channel) + : null; + } + + /** + * Creates a reaction collector. + * @param {ReactionCollectorOptions} [options={}] Options to send to the collector + * @returns {ReactionCollector} + * @example + * // Create a reaction collector + * const filter = (reaction, user) => reaction.emoji.name === '👌' && user.id === 'someId'; + * const collector = message.createReactionCollector({ filter, time: 15_000 }); + * collector.on('collect', r => console.log(`Collected ${r.emoji.name}`)); + * collector.on('end', collected => console.log(`Collected ${collected.size} items`)); + */ + createReactionCollector(options = {}) { + return new ReactionCollector(this, options); + } + + /** + * An object containing the same properties as CollectorOptions, but a few more: + * @typedef {ReactionCollectorOptions} AwaitReactionsOptions + * @property {string[]} [errors] Stop/end reasons that cause the promise to reject + */ + + /** + * Similar to createReactionCollector but in promise form. + * Resolves with a collection of reactions that pass the specified filter. + * @param {AwaitReactionsOptions} [options={}] Optional options to pass to the internal collector + * @returns {Promise>} + * @example + * // Create a reaction collector + * const filter = (reaction, user) => reaction.emoji.name === '👌' && user.id === 'someId' + * message.awaitReactions({ filter, time: 15_000 }) + * .then(collected => console.log(`Collected ${collected.size} reactions`)) + * .catch(console.error); + */ + awaitReactions(options = {}) { + return new Promise((resolve, reject) => { + const collector = this.createReactionCollector(options); + collector.once('end', (reactions, reason) => { + if (options.errors?.includes(reason)) reject(reactions); + else resolve(reactions); + }); + }); + } + + /** + * @typedef {CollectorOptions} MessageComponentCollectorOptions + * @property {MessageComponentType} [componentType] The type of component to listen for + * @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 + */ + + /** + * Creates a message component interaction collector. + * @param {MessageComponentCollectorOptions} [options={}] Options to send to the collector + * @returns {InteractionCollector} + * @example + * // Create a message component interaction collector + * const filter = (interaction) => interaction.customId === 'button' && interaction.user.id === 'someId'; + * const collector = message.createMessageComponentCollector({ filter, time: 15_000 }); + * collector.on('collect', i => console.log(`Collected ${i.customId}`)); + * collector.on('end', collected => console.log(`Collected ${collected.size} items`)); + */ + createMessageComponentCollector(options = {}) { + return new InteractionCollector(this.client, { + ...options, + interactionType: InteractionTypes.MESSAGE_COMPONENT, + message: this, + }); + } + + /** + * An object containing the same properties as CollectorOptions, but a few more: + * @typedef {Object} AwaitMessageComponentOptions + * @property {CollectorFilter} [filter] The filter applied to this collector + * @property {number} [time] Time to wait for an interaction before rejecting + * @property {MessageComponentType} [componentType] The type of component interaction to collect + */ + + /** + * Collects a single component interaction that passes the filter. + * The Promise will reject if the time expires. + * @param {AwaitMessageComponentOptions} [options={}] Options to pass to the internal collector + * @returns {Promise} + * @example + * // Collect a message component interaction + * const filter = (interaction) => interaction.customId === 'button' && interaction.user.id === 'someId'; + * message.awaitMessageComponent({ filter, time: 15_000 }) + * .then(interaction => console.log(`${interaction.customId} was clicked!`)) + * .catch(console.error); + */ + awaitMessageComponent(options = {}) { + const _options = { ...options, max: 1 }; + return new Promise((resolve, reject) => { + const collector = this.createMessageComponentCollector(_options); + collector.once('end', (interactions, reason) => { + const interaction = interactions.first(); + if (interaction) resolve(interaction); + else reject(new Error('INTERACTION_COLLECTOR_ERROR', reason)); + }); + }); + } + + /** + * Whether the message is editable by the client user + * @type {boolean} + * @readonly + */ + get editable() { + const precheck = Boolean( + this.author.id === this.client.user.id && + !deletedMessages.has(this) && + (!this.guild || this.channel?.viewable), + ); + // Regardless of permissions thread messages cannot be edited if + // the thread is locked. + if (this.channel?.isThread()) { + return precheck && !this.channel.locked; + } + return precheck; + } + + /** + * Whether the message is deletable by the client user + * @type {boolean} + * @readonly + */ + get deletable() { + if (deletedMessages.has(this)) { + return false; + } + if (!this.guild) { + return this.author.id === this.client.user.id; + } + // DMChannel does not have viewable property, so check viewable after proved that message is on a guild. + if (!this.channel?.viewable) { + return false; + } + + const permissions = this.channel?.permissionsFor(this.client.user); + if (!permissions) return false; + // This flag allows deleting even if timed out + if (permissions.has(Permissions.FLAGS.ADMINISTRATOR, false)) return true; + + return Boolean( + this.author.id === this.client.user.id || + (permissions.has(Permissions.FLAGS.MANAGE_MESSAGES, false) && + this.guild.me.communicationDisabledUntilTimestamp < Date.now()), + ); + } + + /** + * Whether the message is pinnable by the client user + * @type {boolean} + * @readonly + */ + get pinnable() { + const { channel } = this; + return Boolean( + !this.system && + !deletedMessages.has(this) && + (!this.guild || + (channel?.viewable && + channel + ?.permissionsFor(this.client.user) + ?.has(Permissions.FLAGS.MANAGE_MESSAGES, false))), + ); + } + + /** + * Fetches the Message this crosspost/reply/pin-add references, if available to the client + * @returns {Promise} + */ + async fetchReference() { + if (!this.reference) throw new Error('MESSAGE_REFERENCE_MISSING'); + const { channelId, messageId } = this.reference; + const channel = this.client.channels.resolve(channelId); + if (!channel) throw new Error('GUILD_CHANNEL_RESOLVE'); + const message = await channel.messages.fetch(messageId); + return message; + } + + /** + * Whether the message is crosspostable by the client user + * @type {boolean} + * @readonly + */ + get crosspostable() { + const bitfield = + Permissions.FLAGS.SEND_MESSAGES | + (this.author.id === this.client.user.id + ? Permissions.defaultBit + : Permissions.FLAGS.MANAGE_MESSAGES); + const { channel } = this; + return Boolean( + channel?.type === 'GUILD_NEWS' && + !this.flags.has(MessageFlags.FLAGS.CROSSPOSTED) && + this.type === 'DEFAULT' && + channel.viewable && + channel.permissionsFor(this.client.user)?.has(bitfield, false) && + !deletedMessages.has(this), + ); + } + + /** + * Options that can be passed into {@link Message#edit}. + * @typedef {Object} MessageEditOptions + * @property {?string} [content] Content to be edited + * @property {MessageEmbed[]|APIEmbed[]} [embeds] Embeds to be added/edited + * @property {MessageMentionOptions} [allowedMentions] Which mentions should be parsed from the message content + * @property {MessageFlags} [flags] Which flags to set for the message. Only `SUPPRESS_EMBEDS` can be edited. + * @property {MessageAttachment[]} [attachments] An array of attachments to keep, + * all attachments will be kept if omitted + * @property {FileOptions[]|BufferResolvable[]|MessageAttachment[]} [files] Files to add to the message + * @property {MessageActionRow[]|MessageActionRowOptions[]} [components] + * Action rows containing interactive components for the message (buttons, select menus) + */ + + /** + * Edits the content of the message. + * @param {string|MessagePayload|MessageEditOptions} options The options to provide + * @returns {Promise} + * @example + * // Update the content of a message + * message.edit('This is my new content!') + * .then(msg => console.log(`Updated the content of a message to ${msg.content}`)) + * .catch(console.error); + */ + edit(options) { + if (!this.channel) return Promise.reject(new Error('CHANNEL_NOT_CACHED')); + return this.channel.messages.edit(this, options); + } + + /** + * Publishes a message in an announcement channel to all channels following it. + * @returns {Promise} + * @example + * // Crosspost a message + * if (message.channel.type === 'GUILD_NEWS') { + * message.crosspost() + * .then(() => console.log('Crossposted message')) + * .catch(console.error); + * } + */ + crosspost() { + if (!this.channel) return Promise.reject(new Error('CHANNEL_NOT_CACHED')); + return this.channel.messages.crosspost(this.id); + } + + /** + * Pins this message to the channel's pinned messages. + * @returns {Promise} + * @example + * // Pin a message + * message.pin() + * .then(console.log) + * .catch(console.error) + */ + async pin() { + if (!this.channel) throw new Error('CHANNEL_NOT_CACHED'); + await this.channel.messages.pin(this.id); + return this; + } + + /** + * Unpins this message from the channel's pinned messages. + * @returns {Promise} + * @example + * // Unpin a message + * message.unpin() + * .then(console.log) + * .catch(console.error) + */ + async unpin() { + if (!this.channel) throw new Error('CHANNEL_NOT_CACHED'); + await this.channel.messages.unpin(this.id); + return this; + } + + /** + * Adds a reaction to the message. + * @param {EmojiIdentifierResolvable} emoji The emoji to react with + * @returns {Promise} + * @example + * // React to a message with a unicode emoji + * message.react('🤔') + * .then(console.log) + * .catch(console.error); + * @example + * // React to a message with a custom emoji + * message.react(message.guild.emojis.cache.get('123456789012345678')) + * .then(console.log) + * .catch(console.error); + */ + async react(emoji) { + if (!this.channel) throw new Error('CHANNEL_NOT_CACHED'); + await this.channel.messages.react(this.id, emoji); + + return this.client.actions.MessageReactionAdd.handle( + { + user: this.client.user, + channel: this.channel, + message: this, + emoji: Util.resolvePartialEmoji(emoji), + }, + true, + ).reaction; + } + + /** + * Deletes the message. + * @returns {Promise} + * @example + * // Delete a message + * message.delete() + * .then(msg => console.log(`Deleted message from ${msg.author.username}`)) + * .catch(console.error); + */ + async delete() { + if (!this.channel) throw new Error('CHANNEL_NOT_CACHED'); + await this.channel.messages.delete(this.id); + return this; + } + + /** + * Options provided when sending a message as an inline reply. + * @typedef {BaseMessageOptions} ReplyMessageOptions + * @property {boolean} [failIfNotExists=true] Whether to error if the referenced message + * does not exist (creates a standard message in this case when false) + * @property {StickerResolvable[]} [stickers=[]] Stickers to send in the message + */ + + /** + * Send an inline reply to this message. + * @param {string|MessagePayload|ReplyMessageOptions} options The options to provide + * @returns {Promise} + * @example + * // Reply to a message + * message.reply('This is a reply!') + * .then(() => console.log(`Replied to message "${message.content}"`)) + * .catch(console.error); + */ + reply(options) { + if (!this.channel) return Promise.reject(new Error('CHANNEL_NOT_CACHED')); + let data; + + if (options instanceof MessagePayload) { + data = options; + } else { + data = MessagePayload.create(this, options, { + reply: { + messageReference: this, + failIfNotExists: + options?.failIfNotExists ?? this.client.options.failIfNotExists, + }, + }); + } + return this.channel.send(data); + } + + /** + * A number that is allowed to be the duration (in minutes) of inactivity after which a thread is automatically + * archived. This can be: + * * `60` (1 hour) + * * `1440` (1 day) + * * `4320` (3 days) This is only available when the guild has the `THREE_DAY_THREAD_ARCHIVE` feature. + * * `10080` (7 days) This is only available when the guild has the `SEVEN_DAY_THREAD_ARCHIVE` feature. + * * `'MAX'` Based on the guild's features + * @typedef {number|string} ThreadAutoArchiveDuration + */ + + /** + * Options for starting a thread on a message. + * @typedef {Object} StartThreadOptions + * @property {string} name The name of the new thread + * @property {ThreadAutoArchiveDuration} [autoArchiveDuration=this.channel.defaultAutoArchiveDuration] The amount of + * time (in minutes) after which the thread should automatically archive in case of no recent activity + * @property {string} [reason] Reason for creating the thread + * @property {number} [rateLimitPerUser] The rate limit per user (slowmode) for the thread in seconds + */ + + /** + * Create a new public thread from this message + * @see ThreadManager#create + * @param {StartThreadOptions} [options] Options for starting a thread on this message + * @returns {Promise} + */ + startThread(options = {}) { + if (!this.channel) return Promise.reject(new Error('CHANNEL_NOT_CACHED')); + if (!['GUILD_TEXT', 'GUILD_NEWS'].includes(this.channel.type)) { + return Promise.reject(new Error('MESSAGE_THREAD_PARENT')); + } + if (this.hasThread) + return Promise.reject(new Error('MESSAGE_EXISTING_THREAD')); + return this.channel.threads.create({ ...options, startMessage: this }); + } + + /** + * Fetch this message. + * @param {boolean} [force=true] Whether to skip the cache check and request the API + * @returns {Promise} + */ + fetch(force = true) { + if (!this.channel) return Promise.reject(new Error('CHANNEL_NOT_CACHED')); + return this.channel.messages.fetch(this.id, { force }); + } + + /** + * Fetches the webhook used to create this message. + * @returns {Promise} + */ + fetchWebhook() { + if (!this.webhookId) return Promise.reject(new Error('WEBHOOK_MESSAGE')); + if (this.webhookId === this.applicationId) + return Promise.reject(new Error('WEBHOOK_APPLICATION')); + return this.client.fetchWebhook(this.webhookId); + } + + /** + * Suppresses or unsuppresses embeds on a message. + * @param {boolean} [suppress=true] If the embeds should be suppressed or not + * @returns {Promise} + */ + suppressEmbeds(suppress = true) { + const flags = new MessageFlags(this.flags.bitfield); + + if (suppress) { + flags.add(MessageFlags.FLAGS.SUPPRESS_EMBEDS); + } else { + flags.remove(MessageFlags.FLAGS.SUPPRESS_EMBEDS); + } + + return this.edit({ flags }); + } + + /** + * Removes the attachments from this message. + * @returns {Promise} + */ + removeAttachments() { + return this.edit({ attachments: [] }); + } + + /** + * Resolves a component by a custom id. + * @param {string} customId The custom id to resolve against + * @returns {?MessageActionRowComponent} + */ + resolveComponent(customId) { + return ( + this.components + .flatMap((row) => row.components) + .find((component) => component.customId === customId) ?? null + ); + } + + /** + * Used mainly internally. Whether two messages are identical in properties. If you want to compare messages + * without checking all the properties, use `message.id === message2.id`, which is much more efficient. This + * method allows you to see if there are differences in content, embeds, attachments, nonce and tts properties. + * @param {Message} message The message to compare it to + * @param {APIMessage} rawData Raw data passed through the WebSocket about this message + * @returns {boolean} + */ + equals(message, rawData) { + if (!message) return false; + const embedUpdate = !message.author && !message.attachments; + if (embedUpdate) + return ( + this.id === message.id && this.embeds.length === message.embeds.length + ); + + let equal = + this.id === message.id && + this.author.id === message.author.id && + this.content === message.content && + this.tts === message.tts && + this.nonce === message.nonce && + this.embeds.length === message.embeds.length && + this.attachments.length === message.attachments.length; + + if (equal && rawData) { + equal = + this.mentions.everyone === message.mentions.everyone && + this.createdTimestamp === new Date(rawData.timestamp).getTime() && + this.editedTimestamp === new Date(rawData.edited_timestamp).getTime(); + } + + return equal; + } + + /** + * Whether this message is from a guild. + * @returns {boolean} + */ + inGuild() { + return Boolean(this.guildId); + } + + /** + * When concatenated with a string, this automatically concatenates the message's content instead of the object. + * @returns {string} + * @example + * // Logs: Message: This is a message! + * console.log(`Message: ${message}`); + */ + toString() { + return this.content; + } + + toJSON() { + return super.toJSON({ + channel: 'channelId', + author: 'authorId', + groupActivityApplication: 'groupActivityApplicationId', + guild: 'guildId', + cleanContent: true, + member: false, + reactions: false, + }); + } + // Added + /** + * Click specific button [Suggestion: Dux#2925] + * @param {String} buttonID Button ID + * @returns {Promise} + */ + async clickButton(buttonID) { + if (typeof buttonID !== 'string') + throw new TypeError('BUTTON_ID_NOT_STRING'); + if (!this.components[0]) throw new TypeError('MESSAGE_NO_COMPONENTS'); + let button; + await Promise.all( + this.components.map(async (row) => { + await Promise.all( + row.components.map(async (interactionComponent) => { + if ( + interactionComponent.type == 'BUTTON' && + interactionComponent.customId == buttonID + ) { + button = interactionComponent; + } + }), + ); + }), + ); + if (!button) throw new TypeError('BUTTON_NOT_FOUND'); + else button.click(this); + } + /** + * Select specific menu or First Menu + * @param {String|Array} menuID Select Menu specific id or auto select first Menu + * @param {Array} options Menu Options + */ + async selectMenu(menuID, options = []) { + if (!this.components[0]) throw new TypeError('MESSAGE_NO_COMPONENTS'); + let menuFirst; + let menuCorrect; + let menuCount = 0; + await Promise.all( + this.components.map(async (row) => { + const firstElement = row.components[0]; // Because 1 row has only 1 menu; + if (firstElement.type == 'SELECT_MENU') { + menuCount++; + if (firstElement.customId == menuID) { + menuCorrect = firstElement; + } else if (!menuFirst) { + menuFirst = firstElement; + } + } + }), + ); + if (menuCount == 0) throw new TypeError('MENU_NOT_FOUND'); + if (!menuCorrect) { + if (menuCount == 1) menuCorrect = menuFirst; + else if (typeof menuID !== 'string') throw new TypeError('MENU_ID_NOT_STRING'); + else throw new TypeError('MENU_ID_NOT_FOUND'); + } + menuCorrect.select(this, Array.isArray(menuID) ? menuID : options); + } + // + /** + * Send context Menu v2 + * @param {DiscordBotID} botID Bot id + * @param {String} commandName Command name in Context Menu + * @returns {Promise} + */ + async contextMenu(botID, commandName) { + if (!botID) throw new Error('Bot ID is required'); + const user = await this.client.users.fetch(botID).catch(() => { }); + if (!user || !user.bot || !user.applications) + throw new Error( + 'BotID is not a bot or does not have an application slash command', + ); + if (!commandName || typeof commandName !== 'string') + throw new Error('Command name is required'); + const listApplication = + user.applications.cache.size == 0 + ? await user.applications.fetch() + : user.applications.cache; + let contextCMD; + await Promise.all( + listApplication.map(async (application) => { + if (commandName == application.name && application.type !== 'CHAT_INPUT') + contextCMD = application; + }), + ); + if (!contextCMD) + throw new Error( + `Command ${commandName} is not found\nList command avalible: ${listApplication + .filter((a) => a.type !== 'CHAT_INPUT') + .map((a) => a.name) + .join(', ')}`, + ); + return contextCMD.sendContextMenu(this, true); + } +} + +exports.Message = Message; +exports.deletedMessages = deletedMessages; diff --git a/src/structures/MessageActionRow.js b/src/structures/MessageActionRow.js index c44b32a..6a95357 100644 --- a/src/structures/MessageActionRow.js +++ b/src/structures/MessageActionRow.js @@ -1,101 +1,101 @@ -'use strict'; - -const BaseMessageComponent = require('./BaseMessageComponent'); -const { MessageComponentTypes } = require('../util/Constants'); - -/** - * Represents an action row containing message components. - * @extends {BaseMessageComponent} - */ -class MessageActionRow extends BaseMessageComponent { - /** - * Components that can be placed in an action row - * * MessageButton - * * MessageSelectMenu - * @typedef {MessageButton|MessageSelectMenu} MessageActionRowComponent - */ - - /** - * Options for components that can be placed in an action row - * * MessageButtonOptions - * * MessageSelectMenuOptions - * @typedef {MessageButtonOptions|MessageSelectMenuOptions} MessageActionRowComponentOptions - */ - - /** - * Data that can be resolved into components that can be placed in an action row - * * MessageActionRowComponent - * * MessageActionRowComponentOptions - * @typedef {MessageActionRowComponent|MessageActionRowComponentOptions} MessageActionRowComponentResolvable - */ - - /** - * @typedef {BaseMessageComponentOptions} MessageActionRowOptions - * @property {MessageActionRowComponentResolvable[]} [components] - * The components to place in this action row - */ - - /** - * @param {MessageActionRow|MessageActionRowOptions} [data={}] MessageActionRow to clone or raw data - * @param {Client} [client] The client constructing this MessageActionRow, if provided - */ - constructor(data = {}, client = null) { - super({ type: 'ACTION_ROW' }); - - /** - * The components in this action row - * @type {MessageActionRowComponent[]} - */ - this.components = data.components?.map(c => BaseMessageComponent.create(c, client)) ?? []; - } - - /** - * Adds components to the action row. - * @param {...MessageActionRowComponentResolvable[]} components The components to add - * @returns {MessageActionRow} - */ - addComponents(...components) { - this.components.push(...components.flat(Infinity).map(c => BaseMessageComponent.create(c))); - return this; - } - - /** - * Sets the components of the action row. - * @param {...MessageActionRowComponentResolvable[]} components The components to set - * @returns {MessageActionRow} - */ - setComponents(...components) { - this.spliceComponents(0, this.components.length, components); - 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 {MessageActionRow} - */ - spliceComponents(index, deleteCount, ...components) { - this.components.splice(index, deleteCount, ...components.flat(Infinity).map(c => BaseMessageComponent.create(c))); - return this; - } - - /** - * Transforms the action row to a plain object. - * @returns {APIMessageComponent} The raw data of this action row - */ - toJSON() { - return { - components: this.components.map(c => c.toJSON()), - type: MessageComponentTypes[this.type], - }; - } -} - -module.exports = MessageActionRow; - -/** - * @external APIMessageComponent - * @see {@link https://discord.com/developers/docs/interactions/message-components#component-object} - */ +'use strict'; + +const BaseMessageComponent = require('./BaseMessageComponent'); +const { MessageComponentTypes } = require('../util/Constants'); + +/** + * Represents an action row containing message components. + * @extends {BaseMessageComponent} + */ +class MessageActionRow extends BaseMessageComponent { + /** + * Components that can be placed in an action row + * * MessageButton + * * MessageSelectMenu + * @typedef {MessageButton|MessageSelectMenu} MessageActionRowComponent + */ + + /** + * Options for components that can be placed in an action row + * * MessageButtonOptions + * * MessageSelectMenuOptions + * @typedef {MessageButtonOptions|MessageSelectMenuOptions} MessageActionRowComponentOptions + */ + + /** + * Data that can be resolved into components that can be placed in an action row + * * MessageActionRowComponent + * * MessageActionRowComponentOptions + * @typedef {MessageActionRowComponent|MessageActionRowComponentOptions} MessageActionRowComponentResolvable + */ + + /** + * @typedef {BaseMessageComponentOptions} MessageActionRowOptions + * @property {MessageActionRowComponentResolvable[]} [components] + * The components to place in this action row + */ + + /** + * @param {MessageActionRow|MessageActionRowOptions} [data={}] MessageActionRow to clone or raw data + * @param {Client} [client] The client constructing this MessageActionRow, if provided + */ + constructor(data = {}, client = null) { + super({ type: 'ACTION_ROW' }); + + /** + * The components in this action row + * @type {MessageActionRowComponent[]} + */ + this.components = data.components?.map(c => BaseMessageComponent.create(c, client)) ?? []; + } + + /** + * Adds components to the action row. + * @param {...MessageActionRowComponentResolvable[]} components The components to add + * @returns {MessageActionRow} + */ + addComponents(...components) { + this.components.push(...components.flat(Infinity).map(c => BaseMessageComponent.create(c))); + return this; + } + + /** + * Sets the components of the action row. + * @param {...MessageActionRowComponentResolvable[]} components The components to set + * @returns {MessageActionRow} + */ + setComponents(...components) { + this.spliceComponents(0, this.components.length, components); + 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 {MessageActionRow} + */ + spliceComponents(index, deleteCount, ...components) { + this.components.splice(index, deleteCount, ...components.flat(Infinity).map(c => BaseMessageComponent.create(c))); + return this; + } + + /** + * Transforms the action row to a plain object. + * @returns {APIMessageComponent} The raw data of this action row + */ + toJSON() { + return { + components: this.components.map(c => c.toJSON()), + type: MessageComponentTypes[this.type], + }; + } +} + +module.exports = MessageActionRow; + +/** + * @external APIMessageComponent + * @see {@link https://discord.com/developers/docs/interactions/message-components#component-object} + */ diff --git a/src/structures/MessageButton.js b/src/structures/MessageButton.js index 9adb88a..c21d98f 100644 --- a/src/structures/MessageButton.js +++ b/src/structures/MessageButton.js @@ -1,194 +1,193 @@ -'use strict'; - -const BaseMessageComponent = require('./BaseMessageComponent'); -const { Message } = require('./Message'); -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 {EmojiIdentifierResolvable} [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 {?RawEmoji} - */ - this.emoji = data.emoji ? Util.resolvePartialEmoji(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 for 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=true] Whether this button should be disabled - * @returns {MessageButton} - */ - setDisabled(disabled = true) { - 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) { - this.emoji = Util.resolvePartialEmoji(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 must be LINK when setting a URL - * @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 {APIMessageButton} 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 - * * MessageButtonStyle - * * number - * @typedef {number|MessageButtonStyle} MessageButtonStyleResolvable - */ - - /** - * Resolves the style of a button - * @param {MessageButtonStyleResolvable} style The style to resolve - * @returns {MessageButtonStyle} - * @private - */ - static resolveStyle(style) { - return typeof style === 'string' ? style : MessageButtonStyles[style]; - } - // Patch Click - /** - * Click the button - * @param {Message} message Discord Message - * @returns {boolean} - */ - async click(message) { - if (!message instanceof Message) throw new Error("[UNKNOWN_MESSAGE] Please pass a valid Message"); - if (!this.customId || this.style == 5 || this.disabled) return false; // Button URL, Disabled - await message.client.api.interactions.post( - { - data: { - type: 3, // ? - guild_id: message.guild?.id ?? null, // In DMs - channel_id: message.channel.id, - message_id: message.id, - application_id: message.author.id, - session_id: message.client.session_id, - data: { - component_type: 2, // Button - custom_id: this.customId - }, - message_flags: message.flags.bitfield, - } - } - ) - return true; - } -} - -module.exports = MessageButton; +'use strict'; + +const BaseMessageComponent = require('./BaseMessageComponent'); +const { Message } = require('./Message'); +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 {EmojiIdentifierResolvable} [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 {?RawEmoji} + */ + this.emoji = data.emoji ? Util.resolvePartialEmoji(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 for 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=true] Whether this button should be disabled + * @returns {MessageButton} + */ + setDisabled(disabled = true) { + 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) { + this.emoji = Util.resolvePartialEmoji(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 must be LINK when setting a URL + * @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 {APIMessageButton} 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 + * * MessageButtonStyle + * * number + * @typedef {number|MessageButtonStyle} MessageButtonStyleResolvable + */ + + /** + * Resolves the style of a button + * @param {MessageButtonStyleResolvable} style The style to resolve + * @returns {MessageButtonStyle} + * @private + */ + static resolveStyle(style) { + return typeof style === 'string' ? style : MessageButtonStyles[style]; + } + // Patch Click + /** + * Click the button + * @param {Message} message Discord Message + * @returns {boolean} + */ + async click(message) { + if (!message instanceof Message) throw new Error("[UNKNOWN_MESSAGE] Please pass a valid Message"); + if (!this.customId || this.style == 5 || this.disabled) return false; // Button URL, Disabled + await message.client.api.interactions.post( + { + data: { + type: 3, // ? + guild_id: message.guild?.id ?? null, // In DMs + channel_id: message.channel.id, + message_id: message.id, + application_id: message.author.id, + session_id: message.client.session_id, + data: { + component_type: 2, // Button + custom_id: this.customId + }, + } + } + ) + return true; + } +} + +module.exports = MessageButton; diff --git a/src/structures/MessageMentions.js b/src/structures/MessageMentions.js index 9b935f9..4b75efb 100644 --- a/src/structures/MessageMentions.js +++ b/src/structures/MessageMentions.js @@ -1,235 +1,235 @@ -'use strict'; - -const { Collection } = require('@discordjs/collection'); -const { ChannelTypes } = require('../util/Constants'); -const Util = require('../util/Util'); - -/** - * Keeps track of mentions in a {@link Message}. - */ -class MessageMentions { - constructor(message, users, roles, everyone, crosspostedChannels, repliedUser) { - /** - * The client the message is from - * @type {Client} - * @readonly - */ - Object.defineProperty(this, 'client', { value: message.client }); - - /** - * The guild the message is in - * @type {?Guild} - * @readonly - */ - Object.defineProperty(this, 'guild', { value: message.guild }); - - /** - * The initial message content - * @type {string} - * @readonly - * @private - */ - Object.defineProperty(this, '_content', { value: message.content }); - - /** - * Whether `@everyone` or `@here` were mentioned - * @type {boolean} - */ - this.everyone = Boolean(everyone); - - if (users) { - if (users instanceof Collection) { - /** - * Any users that were mentioned - * Order as received from the API, not as they appear in the message content - * @type {Collection} - */ - this.users = new Collection(users); - } else { - this.users = new Collection(); - for (const mention of users) { - if (mention.member && message.guild) { - message.guild.members._add(Object.assign(mention.member, { user: mention })); - } - const user = message.client.users._add(mention); - this.users.set(user.id, user); - } - } - } else { - this.users = new Collection(); - } - - if (roles instanceof Collection) { - /** - * Any roles that were mentioned - * Order as received from the API, not as they appear in the message content - * @type {Collection} - */ - this.roles = new Collection(roles); - } else if (roles) { - this.roles = new Collection(); - const guild = message.guild; - if (guild) { - for (const mention of roles) { - const role = guild.roles.cache.get(mention); - if (role) this.roles.set(role.id, role); - } - } - } else { - this.roles = new Collection(); - } - - /** - * Cached members for {@link MessageMentions#members} - * @type {?Collection} - * @private - */ - this._members = null; - - /** - * Cached channels for {@link MessageMentions#channels} - * @type {?Collection} - * @private - */ - this._channels = null; - - /** - * Crossposted channel data. - * @typedef {Object} CrosspostedChannel - * @property {Snowflake} channelId The mentioned channel's id - * @property {Snowflake} guildId The id of the guild that has the channel - * @property {ChannelType} type The channel's type - * @property {string} name The channel's name - */ - - if (crosspostedChannels) { - if (crosspostedChannels instanceof Collection) { - /** - * A collection of crossposted channels - * Order as received from the API, not as they appear in the message content - * @type {Collection} - */ - this.crosspostedChannels = new Collection(crosspostedChannels); - } else { - this.crosspostedChannels = new Collection(); - const channelTypes = Object.keys(ChannelTypes); - for (const d of crosspostedChannels) { - const type = channelTypes[d.type]; - this.crosspostedChannels.set(d.id, { - channelId: d.id, - guildId: d.guild_id, - type: type ?? 'UNKNOWN', - name: d.name, - }); - } - } - } else { - this.crosspostedChannels = new Collection(); - } - - /** - * The author of the message that this message is a reply to - * @type {?User} - */ - this.repliedUser = repliedUser ? this.client.users._add(repliedUser) : null; - } - - /** - * Any members that were mentioned (only in {@link Guild}s) - * Order as received from the API, not as they appear in the message content - * @type {?Collection} - * @readonly - */ - get members() { - if (this._members) return this._members; - if (!this.guild) return null; - this._members = new Collection(); - this.users.forEach(user => { - const member = this.guild.members.resolve(user); - if (member) this._members.set(member.user.id, member); - }); - return this._members; - } - - /** - * Any channels that were mentioned - * Order as they appear first in the message content - * @type {Collection} - * @readonly - */ - get channels() { - if (this._channels) return this._channels; - this._channels = new Collection(); - let matches; - while ((matches = this.constructor.CHANNELS_PATTERN.exec(this._content)) !== null) { - const chan = this.client.channels.cache.get(matches[1]); - if (chan) this._channels.set(chan.id, chan); - } - return this._channels; - } - - /** - * Options used to check for a mention. - * @typedef {Object} MessageMentionsHasOptions - * @property {boolean} [ignoreDirect=false] Whether to ignore direct mentions to the item - * @property {boolean} [ignoreRoles=false] Whether to ignore role mentions to a guild member - * @property {boolean} [ignoreEveryone=false] Whether to ignore everyone/here mentions - */ - - /** - * Checks if a user, guild member, role, or channel is mentioned. - * Takes into account user mentions, role mentions, and `@everyone`/`@here` mentions. - * @param {UserResolvable|RoleResolvable|ChannelResolvable} data The User/Role/Channel to check for - * @param {MessageMentionsHasOptions} [options] The options for the check - * @returns {boolean} - */ - has(data, { ignoreDirect = false, ignoreRoles = false, ignoreEveryone = false } = {}) { - if (!ignoreEveryone && this.everyone) return true; - const { GuildMember } = require('./GuildMember'); - if (!ignoreRoles && data instanceof GuildMember) { - for (const role of this.roles.values()) if (data.roles.cache.has(role.id)) return true; - } - - if (!ignoreDirect) { - const id = - this.guild?.roles.resolveId(data) ?? this.client.channels.resolveId(data) ?? this.client.users.resolveId(data); - - return typeof id === 'string' && (this.users.has(id) || this.channels.has(id) || this.roles.has(id)); - } - - return false; - } - - toJSON() { - return Util.flatten(this, { - members: true, - channels: true, - }); - } -} - -/** - * Regular expression that globally matches `@everyone` and `@here` - * @type {RegExp} - */ -MessageMentions.EVERYONE_PATTERN = /@(everyone|here)/g; - -/** - * Regular expression that globally matches user mentions like `<@81440962496172032>` - * @type {RegExp} - */ -MessageMentions.USERS_PATTERN = /<@!?(\d{17,19})>/g; - -/** - * Regular expression that globally matches role mentions like `<@&297577916114403338>` - * @type {RegExp} - */ -MessageMentions.ROLES_PATTERN = /<@&(\d{17,19})>/g; - -/** - * Regular expression that globally matches channel mentions like `<#222079895583457280>` - * @type {RegExp} - */ -MessageMentions.CHANNELS_PATTERN = /<#(\d{17,19})>/g; - -module.exports = MessageMentions; +'use strict'; + +const { Collection } = require('@discordjs/collection'); +const { ChannelTypes } = require('../util/Constants'); +const Util = require('../util/Util'); + +/** + * Keeps track of mentions in a {@link Message}. + */ +class MessageMentions { + constructor(message, users, roles, everyone, crosspostedChannels, repliedUser) { + /** + * The client the message is from + * @type {Client} + * @readonly + */ + Object.defineProperty(this, 'client', { value: message.client }); + + /** + * The guild the message is in + * @type {?Guild} + * @readonly + */ + Object.defineProperty(this, 'guild', { value: message.guild }); + + /** + * The initial message content + * @type {string} + * @readonly + * @private + */ + Object.defineProperty(this, '_content', { value: message.content }); + + /** + * Whether `@everyone` or `@here` were mentioned + * @type {boolean} + */ + this.everyone = Boolean(everyone); + + if (users) { + if (users instanceof Collection) { + /** + * Any users that were mentioned + * Order as received from the API, not as they appear in the message content + * @type {Collection} + */ + this.users = new Collection(users); + } else { + this.users = new Collection(); + for (const mention of users) { + if (mention.member && message.guild) { + message.guild.members._add(Object.assign(mention.member, { user: mention })); + } + const user = message.client.users._add(mention); + this.users.set(user.id, user); + } + } + } else { + this.users = new Collection(); + } + + if (roles instanceof Collection) { + /** + * Any roles that were mentioned + * Order as received from the API, not as they appear in the message content + * @type {Collection} + */ + this.roles = new Collection(roles); + } else if (roles) { + this.roles = new Collection(); + const guild = message.guild; + if (guild) { + for (const mention of roles) { + const role = guild.roles.cache.get(mention); + if (role) this.roles.set(role.id, role); + } + } + } else { + this.roles = new Collection(); + } + + /** + * Cached members for {@link MessageMentions#members} + * @type {?Collection} + * @private + */ + this._members = null; + + /** + * Cached channels for {@link MessageMentions#channels} + * @type {?Collection} + * @private + */ + this._channels = null; + + /** + * Crossposted channel data. + * @typedef {Object} CrosspostedChannel + * @property {Snowflake} channelId The mentioned channel's id + * @property {Snowflake} guildId The id of the guild that has the channel + * @property {ChannelType} type The channel's type + * @property {string} name The channel's name + */ + + if (crosspostedChannels) { + if (crosspostedChannels instanceof Collection) { + /** + * A collection of crossposted channels + * Order as received from the API, not as they appear in the message content + * @type {Collection} + */ + this.crosspostedChannels = new Collection(crosspostedChannels); + } else { + this.crosspostedChannels = new Collection(); + const channelTypes = Object.keys(ChannelTypes); + for (const d of crosspostedChannels) { + const type = channelTypes[d.type]; + this.crosspostedChannels.set(d.id, { + channelId: d.id, + guildId: d.guild_id, + type: type ?? 'UNKNOWN', + name: d.name, + }); + } + } + } else { + this.crosspostedChannels = new Collection(); + } + + /** + * The author of the message that this message is a reply to + * @type {?User} + */ + this.repliedUser = repliedUser ? this.client.users._add(repliedUser) : null; + } + + /** + * Any members that were mentioned (only in {@link Guild}s) + * Order as received from the API, not as they appear in the message content + * @type {?Collection} + * @readonly + */ + get members() { + if (this._members) return this._members; + if (!this.guild) return null; + this._members = new Collection(); + this.users.forEach(user => { + const member = this.guild.members.resolve(user); + if (member) this._members.set(member.user.id, member); + }); + return this._members; + } + + /** + * Any channels that were mentioned + * Order as they appear first in the message content + * @type {Collection} + * @readonly + */ + get channels() { + if (this._channels) return this._channels; + this._channels = new Collection(); + let matches; + while ((matches = this.constructor.CHANNELS_PATTERN.exec(this._content)) !== null) { + const chan = this.client.channels.cache.get(matches[1]); + if (chan) this._channels.set(chan.id, chan); + } + return this._channels; + } + + /** + * Options used to check for a mention. + * @typedef {Object} MessageMentionsHasOptions + * @property {boolean} [ignoreDirect=false] Whether to ignore direct mentions to the item + * @property {boolean} [ignoreRoles=false] Whether to ignore role mentions to a guild member + * @property {boolean} [ignoreEveryone=false] Whether to ignore everyone/here mentions + */ + + /** + * Checks if a user, guild member, role, or channel is mentioned. + * Takes into account user mentions, role mentions, and `@everyone`/`@here` mentions. + * @param {UserResolvable|RoleResolvable|ChannelResolvable} data The User/Role/Channel to check for + * @param {MessageMentionsHasOptions} [options] The options for the check + * @returns {boolean} + */ + has(data, { ignoreDirect = false, ignoreRoles = false, ignoreEveryone = false } = {}) { + if (!ignoreEveryone && this.everyone) return true; + const { GuildMember } = require('./GuildMember'); + if (!ignoreRoles && data instanceof GuildMember) { + for (const role of this.roles.values()) if (data.roles.cache.has(role.id)) return true; + } + + if (!ignoreDirect) { + const id = + this.guild?.roles.resolveId(data) ?? this.client.channels.resolveId(data) ?? this.client.users.resolveId(data); + + return typeof id === 'string' && (this.users.has(id) || this.channels.has(id) || this.roles.has(id)); + } + + return false; + } + + toJSON() { + return Util.flatten(this, { + members: true, + channels: true, + }); + } +} + +/** + * Regular expression that globally matches `@everyone` and `@here` + * @type {RegExp} + */ +MessageMentions.EVERYONE_PATTERN = /@(everyone|here)/g; + +/** + * Regular expression that globally matches user mentions like `<@81440962496172032>` + * @type {RegExp} + */ +MessageMentions.USERS_PATTERN = /<@!?(\d{17,19})>/g; + +/** + * Regular expression that globally matches role mentions like `<@&297577916114403338>` + * @type {RegExp} + */ +MessageMentions.ROLES_PATTERN = /<@&(\d{17,19})>/g; + +/** + * Regular expression that globally matches channel mentions like `<#222079895583457280>` + * @type {RegExp} + */ +MessageMentions.CHANNELS_PATTERN = /<#(\d{17,19})>/g; + +module.exports = MessageMentions; diff --git a/src/structures/MessagePayload.js b/src/structures/MessagePayload.js index c685b9e..2a0596a 100644 --- a/src/structures/MessagePayload.js +++ b/src/structures/MessagePayload.js @@ -1,313 +1,313 @@ -'use strict'; - -const { Buffer } = require('node:buffer'); -const BaseMessageComponent = require('./BaseMessageComponent'); -const MessageEmbed = require('./MessageEmbed'); -const WebEmbed = require('./WebEmbed'); -const { RangeError } = require('../errors'); -const DataResolver = require('../util/DataResolver'); -const MessageFlags = require('../util/MessageFlags'); -const Util = require('../util/Util'); - -/** - * Represents a message to be sent to the API. - */ -class MessagePayload { - /** - * @param {MessageTarget} target The target for this message to be sent to - * @param {MessageOptions|WebhookMessageOptions} options Options passed in from send - */ - constructor(target, options) { - /** - * The target for this message to be sent to - * @type {MessageTarget} - */ - this.target = target; - - /** - * Options passed in from send - * @type {MessageOptions|WebhookMessageOptions} - */ - this.options = options; - - /** - * Data sendable to the API - * @type {?APIMessage} - */ - this.data = null; - - /** - * @typedef {Object} MessageFile - * @property {Buffer|string|Stream} attachment The original attachment that generated this file - * @property {string} name The name of this file - * @property {Buffer|Stream} file The file to be sent to the API - */ - - /** - * Files sendable to the API - * @type {?MessageFile[]} - */ - this.files = null; - } - - /** - * Whether or not the target is a {@link Webhook} or a {@link WebhookClient} - * @type {boolean} - * @readonly - */ - get isWebhook() { - const Webhook = require('./Webhook'); - const WebhookClient = require('../client/WebhookClient'); - return this.target instanceof Webhook || this.target instanceof WebhookClient; - } - - /** - * Whether or not the target is a {@link User} - * @type {boolean} - * @readonly - */ - get isUser() { - const User = require('./User'); - const { GuildMember } = require('./GuildMember'); - return this.target instanceof User || this.target instanceof GuildMember; - } - - /** - * Whether or not the target is a {@link Message} - * @type {boolean} - * @readonly - */ - get isMessage() { - const { Message } = require('./Message'); - return this.target instanceof Message; - } - - /** - * Whether or not the target is a {@link MessageManager} - * @type {boolean} - * @readonly - */ - get isMessageManager() { - const MessageManager = require('../managers/MessageManager'); - return this.target instanceof MessageManager; - } - - /** - * Whether or not the target is an {@link Interaction} or an {@link InteractionWebhook} - * @type {boolean} - * @readonly - */ - get isInteraction() { - const Interaction = require('./Interaction'); - const InteractionWebhook = require('./InteractionWebhook'); - return this.target instanceof Interaction || this.target instanceof InteractionWebhook; - } - - /** - * Makes the content of this message. - * @returns {?string} - */ - makeContent() { - let content; - if (this.options.content === null) { - content = ''; - } else if (typeof this.options.content !== 'undefined') { - content = Util.verifyString(this.options.content, RangeError, 'MESSAGE_CONTENT_TYPE', false); - } - - return content; - } - - /** - * Resolves data. - * @returns {MessagePayload} - */ - async resolveData() { - if (this.data) return this; - const isInteraction = this.isInteraction; - const isWebhook = this.isWebhook; - - let content = this.makeContent(); - const tts = Boolean(this.options.tts); - - let nonce; - if (typeof this.options.nonce !== 'undefined') { - nonce = this.options.nonce; - // eslint-disable-next-line max-len - if (typeof nonce === 'number' ? !Number.isInteger(nonce) : typeof nonce !== 'string') { - throw new RangeError('MESSAGE_NONCE_TYPE'); - } - } - - const components = this.options.components?.map(c => BaseMessageComponent.create(c).toJSON()); - - let username; - let avatarURL; - if (isWebhook) { - username = this.options.username ?? this.target.name; - if (this.options.avatarURL) avatarURL = this.options.avatarURL; - } - - let flags; - if (this.isMessage || this.isMessageManager) { - // eslint-disable-next-line eqeqeq - flags = this.options.flags != null ? new MessageFlags(this.options.flags).bitfield : this.target.flags?.bitfield; - } else if (isInteraction && this.options.ephemeral) { - flags = MessageFlags.FLAGS.EPHEMERAL; - } - - let allowedMentions = - typeof this.options.allowedMentions === 'undefined' - ? this.target.client.options.allowedMentions - : this.options.allowedMentions; - - if (allowedMentions) { - allowedMentions = Util.cloneObject(allowedMentions); - allowedMentions.replied_user = allowedMentions.repliedUser; - delete allowedMentions.repliedUser; - } - - let message_reference; - if (typeof this.options.reply === 'object') { - const reference = this.options.reply.messageReference; - const message_id = this.isMessage ? reference.id ?? reference : this.target.messages.resolveId(reference); - if (message_id) { - message_reference = { - message_id, - fail_if_not_exists: this.options.reply.failIfNotExists ?? this.target.client.options.failIfNotExists, - }; - } - } - - const attachments = this.options.files?.map((file, index) => ({ - id: index.toString(), - description: file.description, - })); - if (Array.isArray(this.options.attachments)) { - this.options.attachments.push(...(attachments ?? [])); - } else { - this.options.attachments = attachments; - } - - if (this.options.embeds) { - if (!Array.isArray(this.options.embeds)) { - this.options.embeds = [this.options.embeds]; - } - - const webembeds = this.options.embeds.filter( - (e) => !(e instanceof MessageEmbed), - ); - this.options.embeds = this.options.embeds.filter(e => e instanceof MessageEmbed); - - if (webembeds.length > 0) { - // add hidden embed link - content += `\n${WebEmbed.hiddenEmbed} \n`; - if (webembeds.length > 1) { - console.warn('Multiple webembeds are not supported, this will be ignored.'); - } - // const embed = webembeds[0]; - for (const webE of webembeds) { - const data = await webE.toMessage(); - content += `\n${data}`; - } - } - // Check content - if (content.length > 2000) { - console.warn(`[WARN] Content is longer than 2000 characters.`); - } - if (content.length > 4000) { // Max length if user has nitro boost - throw new RangeError('MESSAGE_EMBED_LINK_LENGTH'); - } - } - - this.data = { - content, - tts, - nonce, - embeds: this.options.embeds?.map(embed => new MessageEmbed(embed).toJSON()), - components, - username, - avatar_url: avatarURL, - allowed_mentions: - typeof content === 'undefined' && typeof message_reference === 'undefined' ? undefined : allowedMentions, - flags, - message_reference, - attachments: this.options.attachments, - sticker_ids: this.options.stickers?.map(sticker => sticker.id ?? sticker), - }; - return this; - } - - /** - * Resolves files. - * @returns {Promise} - */ - async resolveFiles() { - if (this.files) return this; - - this.files = await Promise.all(this.options.files?.map(file => this.constructor.resolveFile(file)) ?? []); - return this; - } - - /** - * Resolves a single file into an object sendable to the API. - * @param {BufferResolvable|Stream|FileOptions|MessageAttachment} fileLike Something that could be resolved to a file - * @returns {Promise} - */ - static async resolveFile(fileLike) { - let attachment; - let name; - - const findName = thing => { - if (typeof thing === 'string') { - return Util.basename(thing); - } - - if (thing.path) { - return Util.basename(thing.path); - } - - return 'file.jpg'; - }; - - const ownAttachment = - typeof fileLike === 'string' || fileLike instanceof Buffer || typeof fileLike.pipe === 'function'; - if (ownAttachment) { - attachment = fileLike; - name = findName(attachment); - } else { - attachment = fileLike.attachment; - name = fileLike.name ?? findName(attachment); - } - - const resource = await DataResolver.resolveFile(attachment); - return { attachment, name, file: resource }; - } - - /** - * Creates a {@link MessagePayload} from user-level arguments. - * @param {MessageTarget} target Target to send to - * @param {string|MessageOptions|WebhookMessageOptions} options Options or content to use - * @param {MessageOptions|WebhookMessageOptions} [extra={}] Extra options to add onto specified options - * @returns {MessagePayload} - */ - static create(target, options, extra = {}) { - return new this( - target, - typeof options !== 'object' || options === null ? { content: options, ...extra } : { ...options, ...extra }, - ); - } -} - -module.exports = MessagePayload; - -/** - * A target for a message. - * @typedef {TextChannel|DMChannel|User|GuildMember|Webhook|WebhookClient|Interaction|InteractionWebhook| - * Message|MessageManager} MessageTarget - */ - -/** - * @external APIMessage - * @see {@link https://discord.com/developers/docs/resources/channel#message-object} - */ +'use strict'; + +const { Buffer } = require('node:buffer'); +const BaseMessageComponent = require('./BaseMessageComponent'); +const MessageEmbed = require('./MessageEmbed'); +const WebEmbed = require('./WebEmbed'); +const { RangeError } = require('../errors'); +const DataResolver = require('../util/DataResolver'); +const MessageFlags = require('../util/MessageFlags'); +const Util = require('../util/Util'); + +/** + * Represents a message to be sent to the API. + */ +class MessagePayload { + /** + * @param {MessageTarget} target The target for this message to be sent to + * @param {MessageOptions|WebhookMessageOptions} options Options passed in from send + */ + constructor(target, options) { + /** + * The target for this message to be sent to + * @type {MessageTarget} + */ + this.target = target; + + /** + * Options passed in from send + * @type {MessageOptions|WebhookMessageOptions} + */ + this.options = options; + + /** + * Data sendable to the API + * @type {?APIMessage} + */ + this.data = null; + + /** + * @typedef {Object} MessageFile + * @property {Buffer|string|Stream} attachment The original attachment that generated this file + * @property {string} name The name of this file + * @property {Buffer|Stream} file The file to be sent to the API + */ + + /** + * Files sendable to the API + * @type {?MessageFile[]} + */ + this.files = null; + } + + /** + * Whether or not the target is a {@link Webhook} or a {@link WebhookClient} + * @type {boolean} + * @readonly + */ + get isWebhook() { + const Webhook = require('./Webhook'); + const WebhookClient = require('../client/WebhookClient'); + return this.target instanceof Webhook || this.target instanceof WebhookClient; + } + + /** + * Whether or not the target is a {@link User} + * @type {boolean} + * @readonly + */ + get isUser() { + const User = require('./User'); + const { GuildMember } = require('./GuildMember'); + return this.target instanceof User || this.target instanceof GuildMember; + } + + /** + * Whether or not the target is a {@link Message} + * @type {boolean} + * @readonly + */ + get isMessage() { + const { Message } = require('./Message'); + return this.target instanceof Message; + } + + /** + * Whether or not the target is a {@link MessageManager} + * @type {boolean} + * @readonly + */ + get isMessageManager() { + const MessageManager = require('../managers/MessageManager'); + return this.target instanceof MessageManager; + } + + /** + * Whether or not the target is an {@link Interaction} or an {@link InteractionWebhook} + * @type {boolean} + * @readonly + */ + get isInteraction() { + const Interaction = require('./Interaction'); + const InteractionWebhook = require('./InteractionWebhook'); + return this.target instanceof Interaction || this.target instanceof InteractionWebhook; + } + + /** + * Makes the content of this message. + * @returns {?string} + */ + makeContent() { + let content; + if (this.options.content === null) { + content = ''; + } else if (typeof this.options.content !== 'undefined') { + content = Util.verifyString(this.options.content, RangeError, 'MESSAGE_CONTENT_TYPE', false); + } + + return content; + } + + /** + * Resolves data. + * @returns {MessagePayload} + */ + async resolveData() { + if (this.data) return this; + const isInteraction = this.isInteraction; + const isWebhook = this.isWebhook; + + let content = this.makeContent(); + const tts = Boolean(this.options.tts); + + let nonce; + if (typeof this.options.nonce !== 'undefined') { + nonce = this.options.nonce; + // eslint-disable-next-line max-len + if (typeof nonce === 'number' ? !Number.isInteger(nonce) : typeof nonce !== 'string') { + throw new RangeError('MESSAGE_NONCE_TYPE'); + } + } + + const components = this.options.components?.map(c => BaseMessageComponent.create(c).toJSON()); + + let username; + let avatarURL; + if (isWebhook) { + username = this.options.username ?? this.target.name; + if (this.options.avatarURL) avatarURL = this.options.avatarURL; + } + + let flags; + if (this.isMessage || this.isMessageManager) { + // eslint-disable-next-line eqeqeq + flags = this.options.flags != null ? new MessageFlags(this.options.flags).bitfield : this.target.flags?.bitfield; + } else if (isInteraction && this.options.ephemeral) { + flags = MessageFlags.FLAGS.EPHEMERAL; + } + + let allowedMentions = + typeof this.options.allowedMentions === 'undefined' + ? this.target.client.options.allowedMentions + : this.options.allowedMentions; + + if (allowedMentions) { + allowedMentions = Util.cloneObject(allowedMentions); + allowedMentions.replied_user = allowedMentions.repliedUser; + delete allowedMentions.repliedUser; + } + + let message_reference; + if (typeof this.options.reply === 'object') { + const reference = this.options.reply.messageReference; + const message_id = this.isMessage ? reference.id ?? reference : this.target.messages.resolveId(reference); + if (message_id) { + message_reference = { + message_id, + fail_if_not_exists: this.options.reply.failIfNotExists ?? this.target.client.options.failIfNotExists, + }; + } + } + + const attachments = this.options.files?.map((file, index) => ({ + id: index.toString(), + description: file.description, + })); + if (Array.isArray(this.options.attachments)) { + this.options.attachments.push(...(attachments ?? [])); + } else { + this.options.attachments = attachments; + } + + if (this.options.embeds) { + if (!Array.isArray(this.options.embeds)) { + this.options.embeds = [this.options.embeds]; + } + + const webembeds = this.options.embeds.filter( + (e) => !(e instanceof MessageEmbed), + ); + this.options.embeds = this.options.embeds.filter(e => e instanceof MessageEmbed); + + if (webembeds.length > 0) { + // add hidden embed link + content += `\n${WebEmbed.hiddenEmbed} \n`; + if (webembeds.length > 1) { + console.warn('Multiple webembeds are not supported, this will be ignored.'); + } + // const embed = webembeds[0]; + for (const webE of webembeds) { + const data = await webE.toMessage(); + content += `\n${data}`; + } + } + // Check content + if (content.length > 2000) { + console.warn(`[WARN] Content is longer than 2000 characters.`); + } + if (content.length > 4000) { // Max length if user has nitro boost + throw new RangeError('MESSAGE_EMBED_LINK_LENGTH'); + } + } + + this.data = { + content, + tts, + nonce, + embeds: this.options.embeds?.map(embed => new MessageEmbed(embed).toJSON()), + components, + username, + avatar_url: avatarURL, + allowed_mentions: + typeof content === 'undefined' && typeof message_reference === 'undefined' ? undefined : allowedMentions, + flags, + message_reference, + attachments: this.options.attachments, + sticker_ids: this.options.stickers?.map(sticker => sticker.id ?? sticker), + }; + return this; + } + + /** + * Resolves files. + * @returns {Promise} + */ + async resolveFiles() { + if (this.files) return this; + + this.files = await Promise.all(this.options.files?.map(file => this.constructor.resolveFile(file)) ?? []); + return this; + } + + /** + * Resolves a single file into an object sendable to the API. + * @param {BufferResolvable|Stream|FileOptions|MessageAttachment} fileLike Something that could be resolved to a file + * @returns {Promise} + */ + static async resolveFile(fileLike) { + let attachment; + let name; + + const findName = thing => { + if (typeof thing === 'string') { + return Util.basename(thing); + } + + if (thing.path) { + return Util.basename(thing.path); + } + + return 'file.jpg'; + }; + + const ownAttachment = + typeof fileLike === 'string' || fileLike instanceof Buffer || typeof fileLike.pipe === 'function'; + if (ownAttachment) { + attachment = fileLike; + name = findName(attachment); + } else { + attachment = fileLike.attachment; + name = fileLike.name ?? findName(attachment); + } + + const resource = await DataResolver.resolveFile(attachment); + return { attachment, name, file: resource }; + } + + /** + * Creates a {@link MessagePayload} from user-level arguments. + * @param {MessageTarget} target Target to send to + * @param {string|MessageOptions|WebhookMessageOptions} options Options or content to use + * @param {MessageOptions|WebhookMessageOptions} [extra={}] Extra options to add onto specified options + * @returns {MessagePayload} + */ + static create(target, options, extra = {}) { + return new this( + target, + typeof options !== 'object' || options === null ? { content: options, ...extra } : { ...options, ...extra }, + ); + } +} + +module.exports = MessagePayload; + +/** + * A target for a message. + * @typedef {TextChannel|DMChannel|User|GuildMember|Webhook|WebhookClient|Interaction|InteractionWebhook| + * Message|MessageManager} MessageTarget + */ + +/** + * @external APIMessage + * @see {@link https://discord.com/developers/docs/resources/channel#message-object} + */ diff --git a/src/structures/MessageSelectMenu.js b/src/structures/MessageSelectMenu.js index ac75d58..f84e1c4 100644 --- a/src/structures/MessageSelectMenu.js +++ b/src/structures/MessageSelectMenu.js @@ -1,255 +1,256 @@ -'use strict'; - -const BaseMessageComponent = require('./BaseMessageComponent'); -const { MessageComponentTypes } = require('../util/Constants'); -const Util = require('../util/Util'); -const { Message } = require('./Message'); - -/** - * Represents a select menu message component - * @extends {BaseMessageComponent} - */ -class MessageSelectMenu extends BaseMessageComponent { - /** - * @typedef {BaseMessageComponentOptions} MessageSelectMenuOptions - * @property {string} [customId] A unique string to be sent in the interaction when clicked - * @property {string} [placeholder] Custom placeholder text to display when nothing is selected - * @property {number} [minValues] The minimum number of selections required - * @property {number} [maxValues] The maximum number of selections allowed - * @property {MessageSelectOption[]} [options] Options for the select menu - * @property {boolean} [disabled=false] Disables the select menu to prevent interactions - */ - - /** - * @typedef {Object} MessageSelectOption - * @property {string} label The text to be displayed on this option - * @property {string} value The value to be sent for this option - * @property {?string} description Optional description to show for this option - * @property {?RawEmoji} emoji Emoji to display for this option - * @property {boolean} default Render this option as the default selection - */ - - /** - * @typedef {Object} MessageSelectOptionData - * @property {string} label The text to be displayed on this option - * @property {string} value The value to be sent for this option - * @property {string} [description] Optional description to show for this option - * @property {EmojiIdentifierResolvable} [emoji] Emoji to display for this option - * @property {boolean} [default] Render this option as the default selection - */ - - /** - * @param {MessageSelectMenu|MessageSelectMenuOptions} [data={}] MessageSelectMenu to clone or raw data - */ - constructor(data = {}) { - super({ type: 'SELECT_MENU' }); - - this.setup(data); - } - - setup(data) { - /** - * A unique string to be sent in the interaction when clicked - * @type {?string} - */ - this.customId = data.custom_id ?? data.customId ?? null; - - /** - * Custom placeholder text to display when nothing is selected - * @type {?string} - */ - this.placeholder = data.placeholder ?? null; - - /** - * The minimum number of selections required - * @type {?number} - */ - this.minValues = data.min_values ?? data.minValues ?? null; - - /** - * The maximum number of selections allowed - * @type {?number} - */ - this.maxValues = data.max_values ?? data.maxValues ?? null; - - /** - * Options for the select menu - * @type {MessageSelectOption[]} - */ - this.options = this.constructor.normalizeOptions(data.options ?? []); - - /** - * Whether this select menu is currently disabled - * @type {boolean} - */ - this.disabled = data.disabled ?? false; - } - - /** - * Sets the custom id of this select menu - * @param {string} customId A unique string to be sent in the interaction when clicked - * @returns {MessageSelectMenu} - */ - setCustomId(customId) { - this.customId = Util.verifyString(customId, RangeError, 'SELECT_MENU_CUSTOM_ID'); - return this; - } - - /** - * Sets the interactive status of the select menu - * @param {boolean} [disabled=true] Whether this select menu should be disabled - * @returns {MessageSelectMenu} - */ - setDisabled(disabled = true) { - this.disabled = disabled; - return this; - } - - /** - * Sets the maximum number of selections allowed for this select menu - * @param {number} maxValues Number of selections to be allowed - * @returns {MessageSelectMenu} - */ - setMaxValues(maxValues) { - this.maxValues = maxValues; - return this; - } - - /** - * Sets the minimum number of selections required for this select menu - * This will default the maxValues to the number of options, unless manually set - * @param {number} minValues Number of selections to be required - * @returns {MessageSelectMenu} - */ - setMinValues(minValues) { - this.minValues = minValues; - return this; - } - - /** - * Sets the placeholder of this select menu - * @param {string} placeholder Custom placeholder text to display when nothing is selected - * @returns {MessageSelectMenu} - */ - setPlaceholder(placeholder) { - this.placeholder = Util.verifyString(placeholder, RangeError, 'SELECT_MENU_PLACEHOLDER'); - return this; - } - - /** - * Adds options to the select menu. - * @param {...MessageSelectOptionData|MessageSelectOptionData[]} options The options to add - * @returns {MessageSelectMenu} - */ - addOptions(...options) { - this.options.push(...this.constructor.normalizeOptions(options)); - return this; - } - - /** - * Sets the options of the select menu. - * @param {...MessageSelectOptionData|MessageSelectOptionData[]} options The options to set - * @returns {MessageSelectMenu} - */ - setOptions(...options) { - this.spliceOptions(0, this.options.length, options); - return this; - } - - /** - * Removes, replaces, and inserts options in the select menu. - * @param {number} index The index to start at - * @param {number} deleteCount The number of options to remove - * @param {...MessageSelectOptionData|MessageSelectOptionData[]} [options] The replacing option objects - * @returns {MessageSelectMenu} - */ - spliceOptions(index, deleteCount, ...options) { - this.options.splice(index, deleteCount, ...this.constructor.normalizeOptions(...options)); - return this; - } - - /** - * Transforms the select menu into a plain object - * @returns {APIMessageSelectMenu} The raw data of this select menu - */ - toJSON() { - return { - custom_id: this.customId, - disabled: this.disabled, - placeholder: this.placeholder, - min_values: this.minValues, - max_values: this.maxValues ?? (this.minValues ? this.options.length : undefined), - options: this.options, - type: typeof this.type === 'string' ? MessageComponentTypes[this.type] : this.type, - }; - } - - /** - * Normalizes option input and resolves strings and emojis. - * @param {MessageSelectOptionData} option The select menu option to normalize - * @returns {MessageSelectOption} - */ - static normalizeOption(option) { - let { label, value, description, emoji } = option; - - label = Util.verifyString(label, RangeError, 'SELECT_OPTION_LABEL'); - value = Util.verifyString(value, RangeError, 'SELECT_OPTION_VALUE'); - emoji = emoji ? Util.resolvePartialEmoji(emoji) : null; - description = description ? Util.verifyString(description, RangeError, 'SELECT_OPTION_DESCRIPTION', true) : null; - - return { label, value, description, emoji, default: option.default ?? false }; - } - - /** - * Normalizes option input and resolves strings and emojis. - * @param {...MessageSelectOptionData|MessageSelectOptionData[]} options The select menu options to normalize - * @returns {MessageSelectOption[]} - */ - static normalizeOptions(...options) { - return options.flat(Infinity).map(option => this.normalizeOption(option)); - } - // Add - /** - * Select in menu - * @param {Message} message Discord Message - * @param {Array} values Option values - * @returns {Promise For loop - if (values.length < this.minValues) throw new RangeError("[SELECT_MENU_MIN_VALUES] The minimum number of values is " + this.minValues); - if (values.length > this.maxValues) throw new RangeError("[SELECT_MENU_MAX_VALUES] The maximum number of values is " + this.maxValues); - const validValue = this.options.map(obj => obj.value); - const check_ = await values.find(element => { - if (typeof element !== 'string') return true; - if (!validValue.includes(element)) return true; - return false; - }) - if (check_) throw new RangeError("[SELECT_MENU_INVALID_VALUE] The value " + check_ + " is invalid. Please use a valid value " + validValue.join(', ')); - await message.client.api.interactions.post({ - data: { - type: 3, // ? - guild_id: message.guild?.id ?? null, // In DMs - channel_id: message.channel.id, - message_id: message.id, - application_id: message.author.id, - session_id: message.client.session_id, - data: { - component_type: 3, // Select Menu - custom_id: this.customId, - type: 3, // Select Menu - values, - }, - message_flags: message.flags.bitfield, - }, - }); - return true; - } -} - -module.exports = MessageSelectMenu; +'use strict'; + +const BaseMessageComponent = require('./BaseMessageComponent'); +const { MessageComponentTypes } = require('../util/Constants'); +const Util = require('../util/Util'); +const { Message } = require('./Message'); + +/** + * Represents a select menu message component + * @extends {BaseMessageComponent} + */ +class MessageSelectMenu extends BaseMessageComponent { + /** + * @typedef {BaseMessageComponentOptions} MessageSelectMenuOptions + * @property {string} [customId] A unique string to be sent in the interaction when clicked + * @property {string} [placeholder] Custom placeholder text to display when nothing is selected + * @property {number} [minValues] The minimum number of selections required + * @property {number} [maxValues] The maximum number of selections allowed + * @property {MessageSelectOption[]} [options] Options for the select menu + * @property {boolean} [disabled=false] Disables the select menu to prevent interactions + */ + + /** + * @typedef {Object} MessageSelectOption + * @property {string} label The text to be displayed on this option + * @property {string} value The value to be sent for this option + * @property {?string} description Optional description to show for this option + * @property {?RawEmoji} emoji Emoji to display for this option + * @property {boolean} default Render this option as the default selection + */ + + /** + * @typedef {Object} MessageSelectOptionData + * @property {string} label The text to be displayed on this option + * @property {string} value The value to be sent for this option + * @property {string} [description] Optional description to show for this option + * @property {EmojiIdentifierResolvable} [emoji] Emoji to display for this option + * @property {boolean} [default] Render this option as the default selection + */ + + /** + * @param {MessageSelectMenu|MessageSelectMenuOptions} [data={}] MessageSelectMenu to clone or raw data + */ + constructor(data = {}) { + super({ type: 'SELECT_MENU' }); + + this.setup(data); + } + + setup(data) { + /** + * A unique string to be sent in the interaction when clicked + * @type {?string} + */ + this.customId = data.custom_id ?? data.customId ?? null; + + /** + * Custom placeholder text to display when nothing is selected + * @type {?string} + */ + this.placeholder = data.placeholder ?? null; + + /** + * The minimum number of selections required + * @type {?number} + */ + this.minValues = data.min_values ?? data.minValues ?? null; + + /** + * The maximum number of selections allowed + * @type {?number} + */ + this.maxValues = data.max_values ?? data.maxValues ?? null; + + /** + * Options for the select menu + * @type {MessageSelectOption[]} + */ + this.options = this.constructor.normalizeOptions(data.options ?? []); + + /** + * Whether this select menu is currently disabled + * @type {boolean} + */ + this.disabled = data.disabled ?? false; + } + + /** + * Sets the custom id of this select menu + * @param {string} customId A unique string to be sent in the interaction when clicked + * @returns {MessageSelectMenu} + */ + setCustomId(customId) { + this.customId = Util.verifyString(customId, RangeError, 'SELECT_MENU_CUSTOM_ID'); + return this; + } + + /** + * Sets the interactive status of the select menu + * @param {boolean} [disabled=true] Whether this select menu should be disabled + * @returns {MessageSelectMenu} + */ + setDisabled(disabled = true) { + this.disabled = disabled; + return this; + } + + /** + * Sets the maximum number of selections allowed for this select menu + * @param {number} maxValues Number of selections to be allowed + * @returns {MessageSelectMenu} + */ + setMaxValues(maxValues) { + this.maxValues = maxValues; + return this; + } + + /** + * Sets the minimum number of selections required for this select menu + * This will default the maxValues to the number of options, unless manually set + * @param {number} minValues Number of selections to be required + * @returns {MessageSelectMenu} + */ + setMinValues(minValues) { + this.minValues = minValues; + return this; + } + + /** + * Sets the placeholder of this select menu + * @param {string} placeholder Custom placeholder text to display when nothing is selected + * @returns {MessageSelectMenu} + */ + setPlaceholder(placeholder) { + this.placeholder = Util.verifyString(placeholder, RangeError, 'SELECT_MENU_PLACEHOLDER'); + return this; + } + + /** + * Adds options to the select menu. + * @param {...MessageSelectOptionData|MessageSelectOptionData[]} options The options to add + * @returns {MessageSelectMenu} + */ + addOptions(...options) { + this.options.push(...this.constructor.normalizeOptions(options)); + return this; + } + + /** + * Sets the options of the select menu. + * @param {...MessageSelectOptionData|MessageSelectOptionData[]} options The options to set + * @returns {MessageSelectMenu} + */ + setOptions(...options) { + this.spliceOptions(0, this.options.length, options); + return this; + } + + /** + * Removes, replaces, and inserts options in the select menu. + * @param {number} index The index to start at + * @param {number} deleteCount The number of options to remove + * @param {...MessageSelectOptionData|MessageSelectOptionData[]} [options] The replacing option objects + * @returns {MessageSelectMenu} + */ + spliceOptions(index, deleteCount, ...options) { + this.options.splice(index, deleteCount, ...this.constructor.normalizeOptions(...options)); + return this; + } + + /** + * Transforms the select menu into a plain object + * @returns {APIMessageSelectMenu} The raw data of this select menu + */ + toJSON() { + return { + custom_id: this.customId, + disabled: this.disabled, + placeholder: this.placeholder, + min_values: this.minValues, + max_values: this.maxValues ?? (this.minValues ? this.options.length : undefined), + options: this.options, + type: typeof this.type === 'string' ? MessageComponentTypes[this.type] : this.type, + }; + } + + /** + * Normalizes option input and resolves strings and emojis. + * @param {MessageSelectOptionData} option The select menu option to normalize + * @returns {MessageSelectOption} + */ + static normalizeOption(option) { + let { label, value, description, emoji } = option; + + label = Util.verifyString(label, RangeError, 'SELECT_OPTION_LABEL'); + value = Util.verifyString(value, RangeError, 'SELECT_OPTION_VALUE'); + emoji = emoji ? Util.resolvePartialEmoji(emoji) : null; + description = description ? Util.verifyString(description, RangeError, 'SELECT_OPTION_DESCRIPTION', true) : null; + + return { label, value, description, emoji, default: option.default ?? false }; + } + + /** + * Normalizes option input and resolves strings and emojis. + * @param {...MessageSelectOptionData|MessageSelectOptionData[]} options The select menu options to normalize + * @returns {MessageSelectOption[]} + */ + static normalizeOptions(...options) { + return options.flat(Infinity).map(option => this.normalizeOption(option)); + } + // Add + /** + * Select in menu + * @param {Message} message Discord Message + * @param {Array} values Option values + * @returns {Promise For loop + if (values.length < this.minValues) throw new RangeError("[SELECT_MENU_MIN_VALUES] The minimum number of values is " + this.minValues); + if (values.length > this.maxValues) throw new RangeError("[SELECT_MENU_MAX_VALUES] The maximum number of values is " + this.maxValues); + const validValue = this.options.map(obj => obj.value); + const check_ = await values.find(element => { + if (typeof element !== 'string') return true; + if (!validValue.includes(element)) return true; + return false; + }) + if (check_) throw new RangeError("[SELECT_MENU_INVALID_VALUE] The value " + check_ + " is invalid. Please use a valid value " + validValue.join(', ')); + await message.client.api.interactions.post( + { + data: { + type: 3, // ? + guild_id: message.guild?.id ?? null, // In DMs + channel_id: message.channel.id, + message_id: message.id, + application_id: message.author.id, + session_id: message.client.session_id, + data: { + component_type: 3, // Select Menu + custom_id: this.customId, + type: 3, // Select Menu + values, + }, + } + } + ) + return true; + } +} + +module.exports = MessageSelectMenu; diff --git a/src/structures/WebEmbed.js b/src/structures/WebEmbed.js index 07d0627..ec9a9c6 100644 --- a/src/structures/WebEmbed.js +++ b/src/structures/WebEmbed.js @@ -1,328 +1,323 @@ -'use strict'; -const axios = require('axios'); -const baseURL = 'https://embed.benny.fun/?'; // error, not working .-. sad ... -const hiddenCharter = - '||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||'; -const { RangeError } = require('../errors'); -const Util = require('../util/Util'); -const process = require('node:process'); -const warn = true; - -class WebEmbed { - constructor(data = {}) { - if (warn) process.emitWarning( - 'The WebEmbed constructor encountered a problem with the URL.', - ); - this._setup(data); - /** - * Shorten the link - * @type {?boolean} - */ - this.shorten = data.shorten ?? true; - - /** - * Hidden Embed link - * @type {?boolean} - */ - this.hidden = data.hidden ?? false; - } - _setup(data) { - /** - * The title of this embed - * @type {?string} - */ - this.title = data.title ?? null; - - /** - * The description of this embed - * @type {?string} - */ - this.description = data.description ?? null; - - /** - * The URL of this embed - * @type {?string} - */ - this.url = data.url ?? null; - - /** - * The color of this embed - * @type {?number} - */ - this.color = 'color' in data ? Util.resolveColor(data.color) : null; - - /** - * Represents the image of a MessageEmbed - * @typedef {Object} MessageEmbedImage - * @property {string} url URL for this image - * @property {string} proxyURL ProxyURL for this image - * @property {number} height Height of this image - * @property {number} width Width of this image - */ - - /** - * The image of this embed, if there is one - * @type {?MessageEmbedImage} - */ - this.image = data.image - ? { - url: data.image.url, - proxyURL: data.image.proxyURL ?? data.image.proxy_url, - height: data.image.height, - width: data.image.width, - } - : null; - - /** - * Represents the video of a MessageEmbed - * @typedef {Object} MessageEmbedVideo - * @property {string} url URL of this video - * @property {string} proxyURL ProxyURL for this video - * @property {number} height Height of this video - * @property {number} width Width of this video - */ - - /** - * The video of this embed (if there is one) - * @type {?MessageEmbedVideo} - * @readonly - */ - this.video = data.video - ? { - url: data.video.url, - proxyURL: data.video.proxyURL ?? data.video.proxy_url, - height: data.video.height, - width: data.video.width, - } - : null; - - /** - * Represents the author field of a MessageEmbed - * @typedef {Object} MessageEmbedAuthor - * @property {string} name The name of this author - * @property {string} url URL of this author - * @property {string} iconURL URL of the icon for this author - * @property {string} proxyIconURL Proxied URL of the icon for this author - */ - - /** - * The author of this embed (if there is one) - * @type {?MessageEmbedAuthor} - */ - this.author = data.author - ? { - name: data.author.name, - url: data.author.url, - } - : null; - - /** - * Represents the provider of a MessageEmbed - * @typedef {Object} MessageEmbedProvider - * @property {string} name The name of this provider - * @property {string} url URL of this provider - */ - - /** - * The provider of this embed (if there is one) - * @type {?MessageEmbedProvider} - */ - this.provider = data.provider - ? { - name: data.provider.name, - url: data.provider.name, - } - : null; - } - /** - * The options to provide for setting an author for a {@link MessageEmbed}. - * @typedef {Object} EmbedAuthorData - * @property {string} name The name of this author. - */ - - /** - * Sets the author of this embed. - * @param {string|EmbedAuthorData|null} options The options to provide for the author. - * Provide `null` to remove the author data. - * @returns {MessageEmbed} - */ - setAuthor(options) { - if (options === null) { - this.author = {}; - return this; - } - const { name, url } = options; - this.author = { - name: Util.verifyString(name, RangeError, 'EMBED_AUTHOR_NAME'), - url, - }; - return this; - } - - /** - * The options to provide for setting an provider for a {@link MessageEmbed}. - * @typedef {Object} EmbedProviderData - * @property {string} name The name of this provider. - */ - - /** - * Sets the provider of this embed. - * @param {string|EmbedProviderData|null} options The options to provide for the provider. - * Provide `null` to remove the provider data. - * @returns {MessageEmbed} - */ - setProvider(options) { - if (options === null) { - this.provider = {}; - return this; - } - const { name, url } = options; - this.provider = { - name: Util.verifyString(name, RangeError, 'EMBED_PROVIDER_NAME'), - url, - }; - return this; - } - - /** - * Sets the color of this embed. - * @param {ColorResolvable} color The color of the embed - * @returns {MessageEmbed} - */ - setColor(color) { - this.color = Util.resolveColor(color); - return this; - } - - /** - * Sets the description of this embed. - * @param {string} description The description (Limit 350 characters) - * @returns {MessageEmbed} - */ - setDescription(description) { - this.description = Util.verifyString( - description, - RangeError, - 'EMBED_DESCRIPTION', - ); - return this; - } - - /** - * Sets the image of this embed. - * @param {string} url The URL of the image - * @returns {MessageEmbed} - */ - setImage(url) { - this.image = { url }; - return this; - } - - /** - * Sets the video of this embed. - * @param {string} url The URL of the video - * @returns {MessageEmbed} - */ - setVideo(url) { - this.video = { url }; - return this; - } - - /** - * Sets the title of this embed. - * @param {string} title The title - * @returns {MessageEmbed} - */ - setTitle(title) { - this.title = Util.verifyString(title, RangeError, 'EMBED_TITLE'); - return this; - } - - /** - * Sets the URL of this embed. - * @param {string} url The URL - * @returns {MessageEmbed} - */ - setURL(url) { - this.url = url; - return this; - } - - /** - * Return Message Content + Embed (if hidden, pls check content length because it has 1000+ length) - * @returns {string} Message Content - */ - async toMessage() { - const arrayQuery = []; - if (this.title) { - arrayQuery.push(`title=${encodeURIComponent(this.title)}`); - } - if (this.description) { - arrayQuery.push( - `description=${encodeURIComponent(this.description)}`, - ); - } - if (this.url) { - arrayQuery.push(`url=${encodeURIComponent(this.url)}`); - } - if (this.color) { - arrayQuery.push( - `colour=${encodeURIComponent('#' + this.color.toString(16))}`, - ); - } - if (this.image?.url) { - arrayQuery.push(`image=${encodeURIComponent(this.image.url)}`); - } - if (this.video?.url) { - arrayQuery.push(`video=${encodeURIComponent(this.video.url)}`); - } - if (this.author) { - if (this.author.name) arrayQuery.push( - `author_name=${encodeURIComponent(this.author.name)}`, - ); - if (this.author.url) arrayQuery.push( - `author_url=${encodeURIComponent(this.author.url)}`, - ); - } - if (this.provider) { - if (this.provider.name) arrayQuery.push( - `provider_name=${encodeURIComponent(this.provider.name)}`, - ); - if (this.provider.url) arrayQuery.push( - `provider_url=${encodeURIComponent(this.provider.url)}`, - ); - } - const fullURL = `${baseURL}${arrayQuery.join('&')}`; - if (this.shorten) { - const url = await getShorten(fullURL); - if (!url) console.log('Cannot shorten URL in WebEmbed'); - return this.hidden ? `${hiddenCharter} ${url || fullURL}` : (url || fullURL); - } else { - return this.hidden ? `${hiddenCharter} ${fullURL}` : fullURL; - } - } -} - -// Credit: https://www.npmjs.com/package/node-url-shortener + google :)) -const getShorten = async (url) => { - const APIurl = [ - // 'https://is.gd/create.php?format=simple&url=', :( - 'https://tinyurl.com/api-create.php?url=', - 'https://sagiri-fansub.tk/api/v1/short?url=', // my api, pls don't ddos :( - 'https://lazuee.ga/api/v1/shorten?url=' - // 'https://cdpt.in/shorten?url=', Redirects 5s :( - ]; - try { - const res = await axios.get( - `${ - APIurl[Math.floor(Math.random() * APIurl.length)] - }${encodeURIComponent(url)}`, - ); - return `${res.data}`; - } catch { - return void 0; - } -} - -module.exports = WebEmbed; -module.exports.hiddenEmbed = hiddenCharter; +'use strict'; +const axios = require('axios'); +const baseURL = 'https://embed.benny.fun/?'; +const hiddenCharter = + '||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||||​||'; +const { RangeError } = require('../errors'); +const Util = require('../util/Util'); + +class WebEmbed { + constructor(data = {}) { + this._setup(data); + /** + * Shorten the link + * @type {?boolean} + */ + this.shorten = data.shorten ?? true; + + /** + * Hidden Embed link + * @type {?boolean} + */ + this.hidden = data.hidden ?? false; + } + _setup(data) { + /** + * The title of this embed + * @type {?string} + */ + this.title = data.title ?? null; + + /** + * The description of this embed + * @type {?string} + */ + this.description = data.description ?? null; + + /** + * The URL of this embed + * @type {?string} + */ + this.url = data.url ?? null; + + /** + * The color of this embed + * @type {?number} + */ + this.color = 'color' in data ? Util.resolveColor(data.color) : null; + + /** + * Represents the image of a MessageEmbed + * @typedef {Object} MessageEmbedImage + * @property {string} url URL for this image + * @property {string} proxyURL ProxyURL for this image + * @property {number} height Height of this image + * @property {number} width Width of this image + */ + + /** + * The image of this embed, if there is one + * @type {?MessageEmbedImage} + */ + this.image = data.image + ? { + url: data.image.url, + proxyURL: data.image.proxyURL ?? data.image.proxy_url, + height: data.image.height, + width: data.image.width, + } + : null; + + /** + * Represents the video of a MessageEmbed + * @typedef {Object} MessageEmbedVideo + * @property {string} url URL of this video + * @property {string} proxyURL ProxyURL for this video + * @property {number} height Height of this video + * @property {number} width Width of this video + */ + + /** + * The video of this embed (if there is one) + * @type {?MessageEmbedVideo} + * @readonly + */ + this.video = data.video + ? { + url: data.video.url, + proxyURL: data.video.proxyURL ?? data.video.proxy_url, + height: data.video.height, + width: data.video.width, + } + : null; + + /** + * Represents the author field of a MessageEmbed + * @typedef {Object} MessageEmbedAuthor + * @property {string} name The name of this author + * @property {string} url URL of this author + * @property {string} iconURL URL of the icon for this author + * @property {string} proxyIconURL Proxied URL of the icon for this author + */ + + /** + * The author of this embed (if there is one) + * @type {?MessageEmbedAuthor} + */ + this.author = data.author + ? { + name: data.author.name, + url: data.author.url, + } + : null; + + /** + * Represents the provider of a MessageEmbed + * @typedef {Object} MessageEmbedProvider + * @property {string} name The name of this provider + * @property {string} url URL of this provider + */ + + /** + * The provider of this embed (if there is one) + * @type {?MessageEmbedProvider} + */ + this.provider = data.provider + ? { + name: data.provider.name, + url: data.provider.name, + } + : null; + } + /** + * The options to provide for setting an author for a {@link MessageEmbed}. + * @typedef {Object} EmbedAuthorData + * @property {string} name The name of this author. + */ + + /** + * Sets the author of this embed. + * @param {string|EmbedAuthorData|null} options The options to provide for the author. + * Provide `null` to remove the author data. + * @returns {MessageEmbed} + */ + setAuthor(options) { + if (options === null) { + this.author = {}; + return this; + } + const { name, url } = options; + this.author = { + name: Util.verifyString(name, RangeError, 'EMBED_AUTHOR_NAME'), + url, + }; + return this; + } + + /** + * The options to provide for setting an provider for a {@link MessageEmbed}. + * @typedef {Object} EmbedProviderData + * @property {string} name The name of this provider. + */ + + /** + * Sets the provider of this embed. + * @param {string|EmbedProviderData|null} options The options to provide for the provider. + * Provide `null` to remove the provider data. + * @returns {MessageEmbed} + */ + setProvider(options) { + if (options === null) { + this.provider = {}; + return this; + } + const { name, url } = options; + this.provider = { + name: Util.verifyString(name, RangeError, 'EMBED_PROVIDER_NAME'), + url, + }; + return this; + } + + /** + * Sets the color of this embed. + * @param {ColorResolvable} color The color of the embed + * @returns {MessageEmbed} + */ + setColor(color) { + this.color = Util.resolveColor(color); + return this; + } + + /** + * Sets the description of this embed. + * @param {string} description The description (Limit 350 characters) + * @returns {MessageEmbed} + */ + setDescription(description) { + this.description = Util.verifyString( + description, + RangeError, + 'EMBED_DESCRIPTION', + ); + return this; + } + + /** + * Sets the image of this embed. + * @param {string} url The URL of the image + * @returns {MessageEmbed} + */ + setImage(url) { + this.image = { url }; + return this; + } + + /** + * Sets the video of this embed. + * @param {string} url The URL of the video + * @returns {MessageEmbed} + */ + setVideo(url) { + this.video = { url }; + return this; + } + + /** + * Sets the title of this embed. + * @param {string} title The title + * @returns {MessageEmbed} + */ + setTitle(title) { + this.title = Util.verifyString(title, RangeError, 'EMBED_TITLE'); + return this; + } + + /** + * Sets the URL of this embed. + * @param {string} url The URL + * @returns {MessageEmbed} + */ + setURL(url) { + this.url = url; + return this; + } + + /** + * Return Message Content + Embed (if hidden, pls check content length because it has 1000+ length) + * @returns {string} Message Content + */ + async toMessage() { + const arrayQuery = []; + if (this.title) { + arrayQuery.push(`title=${encodeURIComponent(this.title)}`); + } + if (this.description) { + arrayQuery.push( + `description=${encodeURIComponent(this.description)}`, + ); + } + if (this.url) { + arrayQuery.push(`url=${encodeURIComponent(this.url)}`); + } + if (this.color) { + arrayQuery.push( + `colour=${encodeURIComponent('#' + this.color.toString(16))}`, + ); + } + if (this.image?.url) { + arrayQuery.push(`image=${encodeURIComponent(this.image.url)}`); + } + if (this.video?.url) { + arrayQuery.push(`video=${encodeURIComponent(this.video.url)}`); + } + if (this.author) { + if (this.author.name) arrayQuery.push( + `author_name=${encodeURIComponent(this.author.name)}`, + ); + if (this.author.url) arrayQuery.push( + `author_url=${encodeURIComponent(this.author.url)}`, + ); + } + if (this.provider) { + if (this.provider.name) arrayQuery.push( + `provider_name=${encodeURIComponent(this.provider.name)}`, + ); + if (this.provider.url) arrayQuery.push( + `provider_url=${encodeURIComponent(this.provider.url)}`, + ); + } + const fullURL = `${baseURL}${arrayQuery.join('&')}`; + if (this.shorten) { + const url = await getShorten(fullURL); + if (!url) console.log('Cannot shorten URL in WebEmbed'); + return this.hidden ? `${hiddenCharter} ${url || fullURL}` : (url || fullURL); + } else { + return this.hidden ? `${hiddenCharter} ${fullURL}` : fullURL; + } + } +} + +// Credit: https://www.npmjs.com/package/node-url-shortener + google :)) +const getShorten = async (url) => { + const APIurl = [ + // 'https://is.gd/create.php?format=simple&url=', :( + 'https://tinyurl.com/api-create.php?url=', + 'https://sagiri-fansub.tk/api/v1/short?url=', // my api, pls don't ddos :( + 'https://lazuee.ga/api/v1/shorten?url=' + // 'https://cdpt.in/shorten?url=', Redirects 5s :( + ]; + try { + const res = await axios.get( + `${ + APIurl[Math.floor(Math.random() * APIurl.length)] + }${encodeURIComponent(url)}`, + ); + return `${res.data}`; + } catch { + return void 0; + } +} + +module.exports = WebEmbed; +module.exports.hiddenEmbed = hiddenCharter; diff --git a/src/structures/interfaces/Application.js b/src/structures/interfaces/Application.js index f1c02e0..035a016 100644 --- a/src/structures/interfaces/Application.js +++ b/src/structures/interfaces/Application.js @@ -1,136 +1,136 @@ -'use strict'; - -const { ClientApplicationAssetTypes, Endpoints } = require('../../util/Constants'); -const SnowflakeUtil = require('../../util/SnowflakeUtil'); -const Base = require('../Base'); - -const AssetTypes = Object.keys(ClientApplicationAssetTypes); - -/** - * Represents an OAuth2 Application. - * @abstract - */ -class Application extends Base { - constructor(client, data) { - super(client); - - if (data) { - this._patch(data); - } - } - - _patch(data) { - /** - * The application's id - * @type {Snowflake} - */ - this.id = data.id; - - if ('name' in data) { - /** - * The name of the application - * @type {?string} - */ - this.name = data.name; - } else { - this.name ??= null; - } - - if ('description' in data) { - /** - * The application's description - * @type {?string} - */ - this.description = data.description; - } else { - this.description ??= null; - } - - if ('icon' in data) { - /** - * The application's icon hash - * @type {?string} - */ - this.icon = data.icon; - } else { - this.icon ??= null; - } - } - - /** - * The timestamp the application was created at - * @type {number} - * @readonly - */ - get createdTimestamp() { - return SnowflakeUtil.timestampFrom(this.id); - } - - /** - * The time the application was created at - * @type {Date} - * @readonly - */ - get createdAt() { - return new Date(this.createdTimestamp); - } - - /** - * A link to the application's icon. - * @param {StaticImageURLOptions} [options={}] Options for the Image URL - * @returns {?string} - */ - iconURL({ format, size } = {}) { - if (!this.icon) return null; - return this.client.rest.cdn.AppIcon(this.id, this.icon, { format, size }); - } - - /** - * A link to this application's cover image. - * @param {StaticImageURLOptions} [options={}] Options for the Image URL - * @returns {?string} - */ - coverURL({ format, size } = {}) { - if (!this.cover) return null; - return Endpoints.CDN(this.client.options.http.cdn).AppIcon(this.id, this.cover, { format, size }); - } - - /** - * Asset data. - * @typedef {Object} ApplicationAsset - * @property {Snowflake} id The asset's id - * @property {string} name The asset's name - * @property {string} type The asset's type - */ - - /** - * Gets the application's rich presence assets. - * @returns {Promise>} - */ - async fetchAssets() { - const assets = await this.client.api.oauth2.applications(this.id).assets.get(); - return assets.map(a => ({ - id: a.id, - name: a.name, - type: AssetTypes[a.type - 1], - })); - } - - /** - * When concatenated with a string, this automatically returns the application's name instead of the - * Application object. - * @returns {?string} - * @example - * // Logs: Application name: My App - * console.log(`Application name: ${application}`); - */ - toString() { - return this.name; - } - - toJSON() { - return super.toJSON({ createdTimestamp: true }); - } -} - -module.exports = Application; +'use strict'; + +const { ClientApplicationAssetTypes, Endpoints } = require('../../util/Constants'); +const SnowflakeUtil = require('../../util/SnowflakeUtil'); +const Base = require('../Base'); + +const AssetTypes = Object.keys(ClientApplicationAssetTypes); + +/** + * Represents an OAuth2 Application. + * @abstract + */ +class Application extends Base { + constructor(client, data) { + super(client); + + if (data) { + this._patch(data); + } + } + + _patch(data) { + /** + * The application's id + * @type {Snowflake} + */ + this.id = data.id; + + if ('name' in data) { + /** + * The name of the application + * @type {?string} + */ + this.name = data.name; + } else { + this.name ??= null; + } + + if ('description' in data) { + /** + * The application's description + * @type {?string} + */ + this.description = data.description; + } else { + this.description ??= null; + } + + if ('icon' in data) { + /** + * The application's icon hash + * @type {?string} + */ + this.icon = data.icon; + } else { + this.icon ??= null; + } + } + + /** + * The timestamp the application was created at + * @type {number} + * @readonly + */ + get createdTimestamp() { + return SnowflakeUtil.timestampFrom(this.id); + } + + /** + * The time the application was created at + * @type {Date} + * @readonly + */ + get createdAt() { + return new Date(this.createdTimestamp); + } + + /** + * A link to the application's icon. + * @param {StaticImageURLOptions} [options={}] Options for the Image URL + * @returns {?string} + */ + iconURL({ format, size } = {}) { + if (!this.icon) return null; + return this.client.rest.cdn.AppIcon(this.id, this.icon, { format, size }); + } + + /** + * A link to this application's cover image. + * @param {StaticImageURLOptions} [options={}] Options for the Image URL + * @returns {?string} + */ + coverURL({ format, size } = {}) { + if (!this.cover) return null; + return Endpoints.CDN(this.client.options.http.cdn).AppIcon(this.id, this.cover, { format, size }); + } + + /** + * Asset data. + * @typedef {Object} ApplicationAsset + * @property {Snowflake} id The asset's id + * @property {string} name The asset's name + * @property {string} type The asset's type + */ + + /** + * Gets the application's rich presence assets. + * @returns {Promise>} + */ + async fetchAssets() { + const assets = await this.client.api.oauth2.applications(this.id).assets.get(); + return assets.map(a => ({ + id: a.id, + name: a.name, + type: AssetTypes[a.type - 1], + })); + } + + /** + * When concatenated with a string, this automatically returns the application's name instead of the + * Application object. + * @returns {?string} + * @example + * // Logs: Application name: My App + * console.log(`Application name: ${application}`); + */ + toString() { + return this.name; + } + + toJSON() { + return super.toJSON({ createdTimestamp: true }); + } +} + +module.exports = Application; diff --git a/src/structures/interfaces/TextBasedChannel.js b/src/structures/interfaces/TextBasedChannel.js index e160826..f2fec0a 100644 --- a/src/structures/interfaces/TextBasedChannel.js +++ b/src/structures/interfaces/TextBasedChannel.js @@ -1,360 +1,360 @@ -'use strict'; - -/* eslint-disable import/order */ -const MessageCollector = require('../MessageCollector'); -const MessagePayload = require('../MessagePayload'); -const SnowflakeUtil = require('../../util/SnowflakeUtil'); -const { Collection } = require('@discordjs/collection'); -const { InteractionTypes } = require('../../util/Constants'); -const { TypeError, Error } = require('../../errors'); -const InteractionCollector = require('../InteractionCollector'); - -/** - * Interface for classes that have text-channel-like features. - * @interface - */ -class TextBasedChannel { - constructor() { - /** - * A manager of the messages sent to this channel - * @type {MessageManager} - */ - this.messages = new MessageManager(this); - - /** - * The channel's last message id, if one was sent - * @type {?Snowflake} - */ - this.lastMessageId = null; - - /** - * The timestamp when the last pinned message was pinned, if there was one - * @type {?number} - */ - this.lastPinTimestamp = null; - } - - /** - * The Message object of the last message in the channel, if one was sent - * @type {?Message} - * @readonly - */ - get lastMessage() { - return this.messages.resolve(this.lastMessageId); - } - - /** - * The date when the last pinned message was pinned, if there was one - * @type {?Date} - * @readonly - */ - get lastPinAt() { - return this.lastPinTimestamp ? new Date(this.lastPinTimestamp) : null; - } - - /** - * Base options provided when sending. - * @typedef {Object} BaseMessageOptions - * @property {boolean} [tts=false] Whether or not the message should be spoken aloud - * @property {string} [nonce=''] The nonce for the message - * @property {string} [content=''] The content for the message - * @property {WebEmbed[]|MessageEmbed[]|APIEmbed[]} [embeds] The embeds for the message - * (see [here](https://discord.com/developers/docs/resources/channel#embed-object) for more details) - * @property {MessageMentionOptions} [allowedMentions] Which mentions should be parsed from the message content - * (see [here](https://discord.com/developers/docs/resources/channel#allowed-mentions-object) for more details) - * @property {FileOptions[]|BufferResolvable[]|MessageAttachment[]} [files] Files to send with the message - * @property {MessageActionRow[]|MessageActionRowOptions[]} [components] - * Action rows containing interactive components for the message (buttons, select menus) - * @property {MessageAttachment[]} [attachments] Attachments to send in the message - */ - - /** - * Options provided when sending or editing a message. - * @typedef {BaseMessageOptions} MessageOptions - * @property {ReplyOptions} [reply] The options for replying to a message - * @property {StickerResolvable[]} [stickers=[]] Stickers to send in the message - */ - - /** - * Options provided to control parsing of mentions by Discord - * @typedef {Object} MessageMentionOptions - * @property {MessageMentionTypes[]} [parse] Types of mentions to be parsed - * @property {Snowflake[]} [users] Snowflakes of Users to be parsed as mentions - * @property {Snowflake[]} [roles] Snowflakes of Roles to be parsed as mentions - * @property {boolean} [repliedUser=true] Whether the author of the Message being replied to should be pinged - */ - - /** - * Types of mentions to enable in MessageMentionOptions. - * - `roles` - * - `users` - * - `everyone` - * @typedef {string} MessageMentionTypes - */ - - /** - * @typedef {Object} FileOptions - * @property {BufferResolvable} attachment File to attach - * @property {string} [name='file.jpg'] Filename of the attachment - * @property {string} description The description of the file - */ - - /** - * Options for sending a message with a reply. - * @typedef {Object} ReplyOptions - * @property {MessageResolvable} messageReference The message to reply to (must be in the same channel and not system) - * @property {boolean} [failIfNotExists=true] Whether to error if the referenced message - * does not exist (creates a standard message in this case when false) - */ - - /** - * Sends a message to this channel. - * @param {string|MessagePayload|MessageOptions} options The options to provide - * @returns {Promise} - * @example - * // Send a basic message - * channel.send('hello!') - * .then(message => console.log(`Sent message: ${message.content}`)) - * .catch(console.error); - * @example - * // Send a remote file - * channel.send({ - * files: ['https://cdn.discordapp.com/icons/222078108977594368/6e1019b3179d71046e463a75915e7244.png?size=2048'] - * }) - * .then(console.log) - * .catch(console.error); - * @example - * // Send a local file - * channel.send({ - * files: [{ - * attachment: 'entire/path/to/file.jpg', - * name: 'file.jpg' - * description: 'A description of the file' - * }] - * }) - * .then(console.log) - * .catch(console.error); - * @example - * // Send an embed with a local image inside - * channel.send({ - * content: 'This is an embed', - * embeds: [ - * { - * thumbnail: { - * url: 'attachment://file.jpg' - * } - * } - * ], - * files: [{ - * attachment: 'entire/path/to/file.jpg', - * name: 'file.jpg' - * description: 'A description of the file' - * }] - * }) - * .then(console.log) - * .catch(console.error); - */ - async send(options) { - const User = require('../User'); - const { GuildMember } = require('../GuildMember'); - - if (this instanceof User || this instanceof GuildMember) { - const dm = await this.createDM(); - return dm.send(options); - } - - let messagePayload; - - if (options instanceof MessagePayload) { - messagePayload = await options.resolveData(); - } else { - messagePayload = await MessagePayload.create(this, options).resolveData(); - } - - const { data, files } = await messagePayload.resolveFiles(); - const d = await this.client.api.channels[this.id].messages.post({ data, files }); - - return this.messages.cache.get(d.id) ?? this.messages._add(d); - } - - /** - * Sends a typing indicator in the channel. - * @returns {Promise} Resolves upon the typing status being sent - * @example - * // Start typing in a channel - * channel.sendTyping(); - */ - async sendTyping() { - await this.client.api.channels(this.id).typing.post(); - } - - /** - * Creates a Message Collector. - * @param {MessageCollectorOptions} [options={}] The options to pass to the collector - * @returns {MessageCollector} - * @example - * // Create a message collector - * const filter = m => m.content.includes('discord'); - * const collector = channel.createMessageCollector({ filter, time: 15_000 }); - * collector.on('collect', m => console.log(`Collected ${m.content}`)); - * collector.on('end', collected => console.log(`Collected ${collected.size} items`)); - */ - createMessageCollector(options = {}) { - return new MessageCollector(this, options); - } - - /** - * An object containing the same properties as CollectorOptions, but a few more: - * @typedef {MessageCollectorOptions} AwaitMessagesOptions - * @property {string[]} [errors] Stop/end reasons that cause the promise to reject - */ - - /** - * Similar to createMessageCollector but in promise form. - * Resolves with a collection of messages that pass the specified filter. - * @param {AwaitMessagesOptions} [options={}] Optional options to pass to the internal collector - * @returns {Promise>} - * @example - * // Await !vote messages - * const filter = m => m.content.startsWith('!vote'); - * // Errors: ['time'] treats ending because of the time limit as an error - * channel.awaitMessages({ filter, max: 4, time: 60_000, errors: ['time'] }) - * .then(collected => console.log(collected.size)) - * .catch(collected => console.log(`After a minute, only ${collected.size} out of 4 voted.`)); - */ - awaitMessages(options = {}) { - return new Promise((resolve, reject) => { - const collector = this.createMessageCollector(options); - collector.once('end', (collection, reason) => { - if (options.errors?.includes(reason)) { - reject(collection); - } else { - resolve(collection); - } - }); - }); - } - - /** - * Creates a button interaction collector. - * @param {MessageComponentCollectorOptions} [options={}] Options to send to the collector - * @returns {InteractionCollector} - * @example - * // Create a button interaction collector - * const filter = (interaction) => interaction.customId === 'button' && interaction.user.id === 'someId'; - * const collector = channel.createMessageComponentCollector({ filter, time: 15_000 }); - * collector.on('collect', i => console.log(`Collected ${i.customId}`)); - * collector.on('end', collected => console.log(`Collected ${collected.size} items`)); - */ - createMessageComponentCollector(options = {}) { - return new InteractionCollector(this.client, { - ...options, - interactionType: InteractionTypes.MESSAGE_COMPONENT, - channel: this, - }); - } - - /** - * Collects a single component interaction that passes the filter. - * The Promise will reject if the time expires. - * @param {AwaitMessageComponentOptions} [options={}] Options to pass to the internal collector - * @returns {Promise} - * @example - * // Collect a message component interaction - * const filter = (interaction) => interaction.customId === 'button' && interaction.user.id === 'someId'; - * channel.awaitMessageComponent({ filter, time: 15_000 }) - * .then(interaction => console.log(`${interaction.customId} was clicked!`)) - * .catch(console.error); - */ - awaitMessageComponent(options = {}) { - const _options = { ...options, max: 1 }; - return new Promise((resolve, reject) => { - const collector = this.createMessageComponentCollector(_options); - collector.once('end', (interactions, reason) => { - const interaction = interactions.first(); - if (interaction) resolve(interaction); - else reject(new Error('INTERACTION_COLLECTOR_ERROR', reason)); - }); - }); - } - - /** - * Bulk deletes given messages that are newer than two weeks. - * @param {Collection|MessageResolvable[]|number} messages - * Messages or number of messages to delete - * @param {boolean} [filterOld=false] Filter messages to remove those which are older than two weeks automatically - * @returns {Promise>} Returns the deleted messages - * @example - * // Bulk delete messages - * channel.bulkDelete(5) - * .then(messages => console.log(`Bulk deleted ${messages.size} messages`)) - * .catch(console.error); - */ - async bulkDelete(messages, filterOld = false) { - if (Array.isArray(messages) || messages instanceof Collection) { - let messageIds = messages instanceof Collection ? [...messages.keys()] : messages.map(m => m.id ?? m); - if (filterOld) { - messageIds = messageIds.filter(id => Date.now() - SnowflakeUtil.timestampFrom(id) < 1_209_600_000); - } - if (messageIds.length === 0) return new Collection(); - if (messageIds.length === 1) { - await this.client.api.channels(this.id).messages(messageIds[0]).delete(); - const message = this.client.actions.MessageDelete.getMessage( - { - message_id: messageIds[0], - }, - this, - ); - return message ? new Collection([[message.id, message]]) : new Collection(); - } - await this.client.api.channels[this.id].messages['bulk-delete'].post({ data: { messages: messageIds } }); - return messageIds.reduce( - (col, id) => - col.set( - id, - this.client.actions.MessageDeleteBulk.getMessage( - { - message_id: id, - }, - this, - ), - ), - new Collection(), - ); - } - if (!isNaN(messages)) { - const msgs = await this.messages.fetch({ limit: messages }); - return this.bulkDelete(msgs, filterOld); - } - throw new TypeError('MESSAGE_BULK_DELETE_TYPE'); - } - - static applyToClass(structure, full = false, ignore = []) { - const props = ['send']; - if (full) { - props.push( - 'lastMessage', - 'lastPinAt', - 'bulkDelete', - 'sendTyping', - 'createMessageCollector', - 'awaitMessages', - 'createMessageComponentCollector', - 'awaitMessageComponent', - ); - } - for (const prop of props) { - if (ignore.includes(prop)) continue; - Object.defineProperty( - structure.prototype, - prop, - Object.getOwnPropertyDescriptor(TextBasedChannel.prototype, prop), - ); - } - } -} - -module.exports = TextBasedChannel; - -// Fixes Circular -const MessageManager = require('../../managers/MessageManager'); +'use strict'; + +/* eslint-disable import/order */ +const MessageCollector = require('../MessageCollector'); +const MessagePayload = require('../MessagePayload'); +const SnowflakeUtil = require('../../util/SnowflakeUtil'); +const { Collection } = require('@discordjs/collection'); +const { InteractionTypes } = require('../../util/Constants'); +const { TypeError, Error } = require('../../errors'); +const InteractionCollector = require('../InteractionCollector'); + +/** + * Interface for classes that have text-channel-like features. + * @interface + */ +class TextBasedChannel { + constructor() { + /** + * A manager of the messages sent to this channel + * @type {MessageManager} + */ + this.messages = new MessageManager(this); + + /** + * The channel's last message id, if one was sent + * @type {?Snowflake} + */ + this.lastMessageId = null; + + /** + * The timestamp when the last pinned message was pinned, if there was one + * @type {?number} + */ + this.lastPinTimestamp = null; + } + + /** + * The Message object of the last message in the channel, if one was sent + * @type {?Message} + * @readonly + */ + get lastMessage() { + return this.messages.resolve(this.lastMessageId); + } + + /** + * The date when the last pinned message was pinned, if there was one + * @type {?Date} + * @readonly + */ + get lastPinAt() { + return this.lastPinTimestamp ? new Date(this.lastPinTimestamp) : null; + } + + /** + * Base options provided when sending. + * @typedef {Object} BaseMessageOptions + * @property {boolean} [tts=false] Whether or not the message should be spoken aloud + * @property {string} [nonce=''] The nonce for the message + * @property {string} [content=''] The content for the message + * @property {WebEmbed[]|MessageEmbed[]|APIEmbed[]} [embeds] The embeds for the message + * (see [here](https://discord.com/developers/docs/resources/channel#embed-object) for more details) + * @property {MessageMentionOptions} [allowedMentions] Which mentions should be parsed from the message content + * (see [here](https://discord.com/developers/docs/resources/channel#allowed-mentions-object) for more details) + * @property {FileOptions[]|BufferResolvable[]|MessageAttachment[]} [files] Files to send with the message + * @property {MessageActionRow[]|MessageActionRowOptions[]} [components] + * Action rows containing interactive components for the message (buttons, select menus) + * @property {MessageAttachment[]} [attachments] Attachments to send in the message + */ + + /** + * Options provided when sending or editing a message. + * @typedef {BaseMessageOptions} MessageOptions + * @property {ReplyOptions} [reply] The options for replying to a message + * @property {StickerResolvable[]} [stickers=[]] Stickers to send in the message + */ + + /** + * Options provided to control parsing of mentions by Discord + * @typedef {Object} MessageMentionOptions + * @property {MessageMentionTypes[]} [parse] Types of mentions to be parsed + * @property {Snowflake[]} [users] Snowflakes of Users to be parsed as mentions + * @property {Snowflake[]} [roles] Snowflakes of Roles to be parsed as mentions + * @property {boolean} [repliedUser=true] Whether the author of the Message being replied to should be pinged + */ + + /** + * Types of mentions to enable in MessageMentionOptions. + * - `roles` + * - `users` + * - `everyone` + * @typedef {string} MessageMentionTypes + */ + + /** + * @typedef {Object} FileOptions + * @property {BufferResolvable} attachment File to attach + * @property {string} [name='file.jpg'] Filename of the attachment + * @property {string} description The description of the file + */ + + /** + * Options for sending a message with a reply. + * @typedef {Object} ReplyOptions + * @property {MessageResolvable} messageReference The message to reply to (must be in the same channel and not system) + * @property {boolean} [failIfNotExists=true] Whether to error if the referenced message + * does not exist (creates a standard message in this case when false) + */ + + /** + * Sends a message to this channel. + * @param {string|MessagePayload|MessageOptions} options The options to provide + * @returns {Promise} + * @example + * // Send a basic message + * channel.send('hello!') + * .then(message => console.log(`Sent message: ${message.content}`)) + * .catch(console.error); + * @example + * // Send a remote file + * channel.send({ + * files: ['https://cdn.discordapp.com/icons/222078108977594368/6e1019b3179d71046e463a75915e7244.png?size=2048'] + * }) + * .then(console.log) + * .catch(console.error); + * @example + * // Send a local file + * channel.send({ + * files: [{ + * attachment: 'entire/path/to/file.jpg', + * name: 'file.jpg' + * description: 'A description of the file' + * }] + * }) + * .then(console.log) + * .catch(console.error); + * @example + * // Send an embed with a local image inside + * channel.send({ + * content: 'This is an embed', + * embeds: [ + * { + * thumbnail: { + * url: 'attachment://file.jpg' + * } + * } + * ], + * files: [{ + * attachment: 'entire/path/to/file.jpg', + * name: 'file.jpg' + * description: 'A description of the file' + * }] + * }) + * .then(console.log) + * .catch(console.error); + */ + async send(options) { + const User = require('../User'); + const { GuildMember } = require('../GuildMember'); + + if (this instanceof User || this instanceof GuildMember) { + const dm = await this.createDM(); + return dm.send(options); + } + + let messagePayload; + + if (options instanceof MessagePayload) { + messagePayload = await options.resolveData(); + } else { + messagePayload = await MessagePayload.create(this, options).resolveData(); + } + + const { data, files } = await messagePayload.resolveFiles(); + const d = await this.client.api.channels[this.id].messages.post({ data, files }); + + return this.messages.cache.get(d.id) ?? this.messages._add(d); + } + + /** + * Sends a typing indicator in the channel. + * @returns {Promise} Resolves upon the typing status being sent + * @example + * // Start typing in a channel + * channel.sendTyping(); + */ + async sendTyping() { + await this.client.api.channels(this.id).typing.post(); + } + + /** + * Creates a Message Collector. + * @param {MessageCollectorOptions} [options={}] The options to pass to the collector + * @returns {MessageCollector} + * @example + * // Create a message collector + * const filter = m => m.content.includes('discord'); + * const collector = channel.createMessageCollector({ filter, time: 15_000 }); + * collector.on('collect', m => console.log(`Collected ${m.content}`)); + * collector.on('end', collected => console.log(`Collected ${collected.size} items`)); + */ + createMessageCollector(options = {}) { + return new MessageCollector(this, options); + } + + /** + * An object containing the same properties as CollectorOptions, but a few more: + * @typedef {MessageCollectorOptions} AwaitMessagesOptions + * @property {string[]} [errors] Stop/end reasons that cause the promise to reject + */ + + /** + * Similar to createMessageCollector but in promise form. + * Resolves with a collection of messages that pass the specified filter. + * @param {AwaitMessagesOptions} [options={}] Optional options to pass to the internal collector + * @returns {Promise>} + * @example + * // Await !vote messages + * const filter = m => m.content.startsWith('!vote'); + * // Errors: ['time'] treats ending because of the time limit as an error + * channel.awaitMessages({ filter, max: 4, time: 60_000, errors: ['time'] }) + * .then(collected => console.log(collected.size)) + * .catch(collected => console.log(`After a minute, only ${collected.size} out of 4 voted.`)); + */ + awaitMessages(options = {}) { + return new Promise((resolve, reject) => { + const collector = this.createMessageCollector(options); + collector.once('end', (collection, reason) => { + if (options.errors?.includes(reason)) { + reject(collection); + } else { + resolve(collection); + } + }); + }); + } + + /** + * Creates a button interaction collector. + * @param {MessageComponentCollectorOptions} [options={}] Options to send to the collector + * @returns {InteractionCollector} + * @example + * // Create a button interaction collector + * const filter = (interaction) => interaction.customId === 'button' && interaction.user.id === 'someId'; + * const collector = channel.createMessageComponentCollector({ filter, time: 15_000 }); + * collector.on('collect', i => console.log(`Collected ${i.customId}`)); + * collector.on('end', collected => console.log(`Collected ${collected.size} items`)); + */ + createMessageComponentCollector(options = {}) { + return new InteractionCollector(this.client, { + ...options, + interactionType: InteractionTypes.MESSAGE_COMPONENT, + channel: this, + }); + } + + /** + * Collects a single component interaction that passes the filter. + * The Promise will reject if the time expires. + * @param {AwaitMessageComponentOptions} [options={}] Options to pass to the internal collector + * @returns {Promise} + * @example + * // Collect a message component interaction + * const filter = (interaction) => interaction.customId === 'button' && interaction.user.id === 'someId'; + * channel.awaitMessageComponent({ filter, time: 15_000 }) + * .then(interaction => console.log(`${interaction.customId} was clicked!`)) + * .catch(console.error); + */ + awaitMessageComponent(options = {}) { + const _options = { ...options, max: 1 }; + return new Promise((resolve, reject) => { + const collector = this.createMessageComponentCollector(_options); + collector.once('end', (interactions, reason) => { + const interaction = interactions.first(); + if (interaction) resolve(interaction); + else reject(new Error('INTERACTION_COLLECTOR_ERROR', reason)); + }); + }); + } + + /** + * Bulk deletes given messages that are newer than two weeks. + * @param {Collection|MessageResolvable[]|number} messages + * Messages or number of messages to delete + * @param {boolean} [filterOld=false] Filter messages to remove those which are older than two weeks automatically + * @returns {Promise>} Returns the deleted messages + * @example + * // Bulk delete messages + * channel.bulkDelete(5) + * .then(messages => console.log(`Bulk deleted ${messages.size} messages`)) + * .catch(console.error); + */ + async bulkDelete(messages, filterOld = false) { + if (Array.isArray(messages) || messages instanceof Collection) { + let messageIds = messages instanceof Collection ? [...messages.keys()] : messages.map(m => m.id ?? m); + if (filterOld) { + messageIds = messageIds.filter(id => Date.now() - SnowflakeUtil.timestampFrom(id) < 1_209_600_000); + } + if (messageIds.length === 0) return new Collection(); + if (messageIds.length === 1) { + await this.client.api.channels(this.id).messages(messageIds[0]).delete(); + const message = this.client.actions.MessageDelete.getMessage( + { + message_id: messageIds[0], + }, + this, + ); + return message ? new Collection([[message.id, message]]) : new Collection(); + } + await this.client.api.channels[this.id].messages['bulk-delete'].post({ data: { messages: messageIds } }); + return messageIds.reduce( + (col, id) => + col.set( + id, + this.client.actions.MessageDeleteBulk.getMessage( + { + message_id: id, + }, + this, + ), + ), + new Collection(), + ); + } + if (!isNaN(messages)) { + const msgs = await this.messages.fetch({ limit: messages }); + return this.bulkDelete(msgs, filterOld); + } + throw new TypeError('MESSAGE_BULK_DELETE_TYPE'); + } + + static applyToClass(structure, full = false, ignore = []) { + const props = ['send']; + if (full) { + props.push( + 'lastMessage', + 'lastPinAt', + 'bulkDelete', + 'sendTyping', + 'createMessageCollector', + 'awaitMessages', + 'createMessageComponentCollector', + 'awaitMessageComponent', + ); + } + for (const prop of props) { + if (ignore.includes(prop)) continue; + Object.defineProperty( + structure.prototype, + prop, + Object.getOwnPropertyDescriptor(TextBasedChannel.prototype, prop), + ); + } + } +} + +module.exports = TextBasedChannel; + +// Fixes Circular +const MessageManager = require('../../managers/MessageManager');