diff --git a/src/client/BaseClient.js b/src/client/BaseClient.js index 73b4039..c91992f 100644 --- a/src/client/BaseClient.js +++ b/src/client/BaseClient.js @@ -13,12 +13,15 @@ const Util = require('../util/Util'); class BaseClient extends EventEmitter { constructor(options = {}) { super(); + if (options.intents) { process.emitWarning('Intents is not available.', 'DeprecationWarning'); } + if (typeof options.captchaSolver === 'function') { options.captchaService = 'custom'; } + /** * The options the client was instantiated with * @type {ClientOptions} diff --git a/src/client/websocket/WebSocketManager.js b/src/client/websocket/WebSocketManager.js index b8f55ee..85c7fbb 100644 --- a/src/client/websocket/WebSocketManager.js +++ b/src/client/websocket/WebSocketManager.js @@ -126,36 +126,15 @@ class WebSocketManager extends EventEmitter { * @private */ async connect() { - // eslint-disable-next-line no-unused-vars - const invalidToken = new Error(WSCodes[4004]); - /* - BOT - const { - url: gatewayURL, - shards: recommendedShards, - session_start_limit: sessionStartLimit, - } = await this.client.api.gateway.bot.get().catch(error => { - throw error.httpStatus === 401 ? invalidToken : error; - }); - */ - let gatewayURL = 'wss://gateway.discord.gg'; - const { url } = await this.client.api.gateway.get({ auth: false }).catch(() => ({ url: gatewayURL })); - // eslint-disable-next-line no-unused-vars - /* - .catch(error => { - // Never throw error :v - // throw error.httpStatus === 401 ? invalidToken : error; - }); - */ - if (url) gatewayURL = url; - const recommendedShards = 1; - const sessionStartLimit = { - total: Infinity, - remaining: Infinity, - }; + await this.client.api.gateway + .get({ auth: false }) + .then(r => (gatewayURL = r.url)) + .catch(() => {}); - const { total, remaining } = sessionStartLimit; + const total = Infinity; + const remaining = Infinity; + const recommendedShards = 1; this.debug(`Fetched Gateway Information URL: ${gatewayURL} @@ -294,7 +273,7 @@ class WebSocketManager extends EventEmitter { } catch (error) { this.debug(`Couldn't reconnect or fetch information about the gateway. ${error}`); if (error.httpStatus !== 401) { - this.debug('Possible network error occurred. Retrying in 5s...'); + this.debug(`Possible network error occurred. Retrying in 5s...`); await sleep(5_000); this.reconnecting = false; return this.reconnect(); @@ -368,11 +347,12 @@ class WebSocketManager extends EventEmitter { /** * Emitted whenever a packet isn't handled. * @event Client#unhandledPacket - * @param {Object} packet The packet (t: Event name, d: Data) - * @param {Number} shard The shard that received the packet (Auto = 0) + * @param {Object} packet The packet (t: EVENT_NAME, d: any) + * @param {Number} shard The shard that received the packet (Shard 0) */ this.client.emit(Events.UNHANDLED_PACKET, packet, shard); } + return true; } diff --git a/src/managers/ApplicationCommandManager.js b/src/managers/ApplicationCommandManager.js index e6223e5..57f6f4b 100644 --- a/src/managers/ApplicationCommandManager.js +++ b/src/managers/ApplicationCommandManager.js @@ -1,5 +1,6 @@ 'use strict'; +const { isJSONEncodable } = require('@discordjs/builders'); const { Collection } = require('@discordjs/collection'); const ApplicationCommandPermissionsManager = require('./ApplicationCommandPermissionsManager'); const CachedManager = require('./CachedManager'); @@ -13,15 +14,14 @@ const Permissions = require('../util/Permissions'); * @extends {CachedManager} */ class ApplicationCommandManager extends CachedManager { - constructor(client, iterable, user) { + constructor(client, iterable) { super(client, ApplicationCommand, iterable); /** * The manager for permissions of arbitrary commands on arbitrary guilds * @type {ApplicationCommandPermissionsManager} */ - this.permissions = new ApplicationCommandPermissionsManager(this, user); - this.user = user; + this.permissions = new ApplicationCommandPermissionsManager(this); } /** @@ -43,7 +43,7 @@ class ApplicationCommandManager extends CachedManager { * @private */ commandPath({ id, guildId } = {}) { - let path = this.client.api.applications(this.user.id); + let path = this.client.api.applications(this.client.application.id); if (this.guild ?? guildId) path = path.guilds(this.guild?.id ?? guildId); return id ? path.commands(id) : path.commands; } @@ -58,7 +58,7 @@ class ApplicationCommandManager extends CachedManager { /* eslint-disable max-len */ /** * Data that resolves to the data of an ApplicationCommand - * @typedef {ApplicationCommandDataResolvable|SlashCommandBuilder|ContextMenuCommandBuilder} ApplicationCommandDataResolvable + * @typedef {ApplicationCommandData|APIApplicationCommand|SlashCommandBuilder|ContextMenuCommandBuilder} ApplicationCommandDataResolvable */ /* eslint-enable max-len */ @@ -94,7 +94,6 @@ class ApplicationCommandManager extends CachedManager { * .catch(console.error); */ async fetch(id, { guildId, cache = true, force = false, locale, withLocalizations } = {}) { - // Change from user.createDM to opcode (risky action) if (typeof id === 'object') { ({ guildId, cache = true, locale, withLocalizations } = id); } else if (id) { @@ -102,11 +101,10 @@ class ApplicationCommandManager extends CachedManager { const existing = this.cache.get(id); if (existing) return existing; } - await this.user.createDM().catch(() => {}); const command = await this.commandPath({ id, guildId }).get(); return this._add(command, cache); } - await this.user.createDM().catch(() => {}); + const data = await this.commandPath({ guildId }).get({ headers: { 'X-Discord-Locale': locale, @@ -132,7 +130,6 @@ class ApplicationCommandManager extends CachedManager { * .catch(console.error); */ async create(command, guildId) { - if (!this.client.user.bot) throw new Error('INVALID_USER_METHOD'); const data = await this.commandPath({ guildId }).post({ data: this.constructor.transformCommand(command), }); @@ -162,7 +159,6 @@ class ApplicationCommandManager extends CachedManager { * .catch(console.error); */ async set(commands, guildId) { - if (!this.client.user.bot) throw new Error('INVALID_USER_METHOD'); const data = await this.commandPath({ guildId }).put({ data: commands.map(c => this.constructor.transformCommand(c)), }); @@ -185,7 +181,6 @@ class ApplicationCommandManager extends CachedManager { * .catch(console.error); */ async edit(command, data, guildId) { - if (!this.client.user.bot) throw new Error('INVALID_USER_METHOD'); const id = this.resolveId(command); if (!id) throw new TypeError('INVALID_TYPE', 'command', 'ApplicationCommandResolvable'); @@ -208,7 +203,6 @@ class ApplicationCommandManager extends CachedManager { * .catch(console.error); */ async delete(command, guildId) { - if (!this.client.user.bot) throw new Error('INVALID_USER_METHOD'); const id = this.resolveId(command); if (!id) throw new TypeError('INVALID_TYPE', 'command', 'ApplicationCommandResolvable'); @@ -226,6 +220,8 @@ class ApplicationCommandManager extends CachedManager { * @private */ static transformCommand(command) { + if (isJSONEncodable(command)) return command.toJSON(); + let default_member_permissions; if ('default_member_permissions' in command) { @@ -240,6 +236,7 @@ class ApplicationCommandManager extends CachedManager { ? new Permissions(command.defaultMemberPermissions).bitfield.toString() : command.defaultMemberPermissions; } + return { name: command.name, name_localizations: command.nameLocalizations ?? command.name_localizations, diff --git a/src/managers/ApplicationCommandPermissionsManager.js b/src/managers/ApplicationCommandPermissionsManager.js index 0f04aa6..a93a8f2 100644 --- a/src/managers/ApplicationCommandPermissionsManager.js +++ b/src/managers/ApplicationCommandPermissionsManager.js @@ -10,7 +10,7 @@ const { ApplicationCommandPermissionTypes, APIErrors } = require('../util/Consta * @extends {BaseManager} */ class ApplicationCommandPermissionsManager extends BaseManager { - constructor(manager, user) { + constructor(manager) { super(manager.client); /** @@ -37,8 +37,6 @@ class ApplicationCommandPermissionsManager extends BaseManager { * @type {?Snowflake} */ this.commandId = manager.id ?? null; - - this.user = user; } /** @@ -49,10 +47,7 @@ class ApplicationCommandPermissionsManager extends BaseManager { * @private */ permissionsPath(guildId, commandId) { - return this.client.api - .applications(typeof this.user === 'string' ? this.user : this.user.id) - .guilds(guildId) - .commands(commandId).permissions; + return this.client.api.applications(this.client.application.id).guilds(guildId).commands(commandId).permissions; } /** @@ -164,7 +159,6 @@ class ApplicationCommandPermissionsManager extends BaseManager { * .catch(console.error); */ async set({ guild, command, permissions, fullPermissions } = {}) { - if (!this.manager.client.user.bot) throw new Error('INVALID_USER_METHOD'); const { guildId, commandId } = this._validateOptions(guild, command); if (commandId) { @@ -226,7 +220,6 @@ class ApplicationCommandPermissionsManager extends BaseManager { * .catch(console.error); */ async add({ guild, command, permissions }) { - if (!this.manager.client.user.bot) throw new Error('INVALID_USER_METHOD'); const { guildId, commandId } = this._validateOptions(guild, command); if (!commandId) throw new TypeError('INVALID_TYPE', 'command', 'ApplicationCommandResolvable'); if (!Array.isArray(permissions)) { @@ -278,13 +271,12 @@ class ApplicationCommandPermissionsManager extends BaseManager { * .catch(console.error); */ async remove({ guild, command, users, roles }) { - if (!this.manager.client.user.bot) throw new Error('INVALID_USER_METHOD'); const { guildId, commandId } = this._validateOptions(guild, command); if (!commandId) throw new TypeError('INVALID_TYPE', 'command', 'ApplicationCommandResolvable'); if (!users && !roles) throw new TypeError('INVALID_TYPE', 'users OR roles', 'Array or Resolvable', true); - const resolvedIds = []; + let resolvedIds = []; if (Array.isArray(users)) { users.forEach(user => { const userId = this.client.users.resolveId(user); diff --git a/src/managers/ReactionUserManager.js b/src/managers/ReactionUserManager.js index 308edd2..cc86187 100644 --- a/src/managers/ReactionUserManager.js +++ b/src/managers/ReactionUserManager.js @@ -3,15 +3,15 @@ const { Collection } = require('@discordjs/collection'); const CachedManager = require('./CachedManager'); const { Error } = require('../errors'); -const { lazy } = require('../util/Util'); -const User = lazy(() => require('../structures/User')); +const User = require('../structures/User'); + /** * Manages API methods for users who reacted to a reaction and stores their cache. * @extends {CachedManager} */ class ReactionUserManager extends CachedManager { constructor(reaction, iterable) { - super(reaction.client, User(), iterable); + super(reaction.client, User, iterable); /** * The reaction that this manager belongs to @@ -22,7 +22,7 @@ class ReactionUserManager extends CachedManager { /** * The cache of this manager - * @type {Collection} + * @type {Collection} * @name ReactionUserManager#cache */ @@ -36,7 +36,7 @@ class ReactionUserManager extends CachedManager { /** * Fetches all the users that gave this reaction. Resolves with a collection of users, mapped by their ids. * @param {FetchReactionUsersOptions} [options] Options for fetching the users - * @returns {Promise>} + * @returns {Promise>} */ async fetch({ limit = 100, after } = {}) { const message = this.reaction.message; diff --git a/src/structures/ApplicationCommand.js b/src/structures/ApplicationCommand.js index 7c643bb..3e7eda1 100644 --- a/src/structures/ApplicationCommand.js +++ b/src/structures/ApplicationCommand.js @@ -1,28 +1,17 @@ 'use strict'; -const { setTimeout } = require('node:timers'); -const { findBestMatch } = require('string-similarity'); const Base = require('./Base'); -const MessagePayload = require('./MessagePayload'); const ApplicationCommandPermissionsManager = require('../managers/ApplicationCommandPermissionsManager'); -const { - ApplicationCommandOptionTypes, - ApplicationCommandTypes, - ChannelTypes, - Events, - InteractionTypes, -} = require('../util/Constants'); +const { ApplicationCommandOptionTypes, ApplicationCommandTypes, ChannelTypes } = require('../util/Constants'); const Permissions = require('../util/Permissions'); const SnowflakeUtil = require('../util/SnowflakeUtil'); -const { lazy, getAttachments, uploadFile } = require('../util/Util'); -const Message = lazy(() => require('../structures/Message').Message); /** * Represents an application command. * @extends {Base} */ class ApplicationCommand extends Base { - constructor(client, data) { + constructor(client, data, guild, guildId) { super(client); /** @@ -37,11 +26,24 @@ class ApplicationCommand extends Base { */ 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); + this.permissions = new ApplicationCommandPermissionsManager(this); /** * The type of this application command @@ -49,30 +51,10 @@ class ApplicationCommand extends Base { */ this.type = ApplicationCommandTypes[data.type]; - this.user = client.users.cache.get(this.applicationId); - this._patch(data); } - /** - * The guild this command is part of - * @type {?Guild} - * @readonly - */ - get guild() { - return this.client.guilds.resolve(this.guildId); - } - _patch(data) { - if ('guild_id' in data) { - /** - * 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 = data.guild_id ?? null; - } - if ('name' in data) { /** * The name of this command @@ -148,7 +130,6 @@ class ApplicationCommand extends Base { */ this.defaultPermission = data.default_permission; } - /* eslint-disable max-len */ if ('default_member_permissions' in data) { @@ -336,7 +317,6 @@ class ApplicationCommand extends Base { setDefaultPermission(defaultPermission = true) { return this.edit({ defaultPermission }); } - /* eslint-enable max-len */ /** @@ -391,6 +371,7 @@ class ApplicationCommand extends Base { equals(command, enforceOptionOrder = false) { // If given an id, check if the id matches if (command.id && this.id !== command.id) return false; + let defaultMemberPermissions = null; let dmPermission = command.dmPermission ?? command.dm_permission; @@ -404,6 +385,7 @@ class ApplicationCommand extends Base { defaultMemberPermissions = command.defaultMemberPermissions !== null ? new Permissions(command.defaultMemberPermissions).bitfield : null; } + // Check top level parameters const commandType = typeof command.type === 'string' ? command.type : ApplicationCommandTypes[command.type]; if ( @@ -446,9 +428,7 @@ class ApplicationCommand extends Base { 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; - } + if (!foundOption || !this._optionEquals(option, foundOption)) return false; } return true; } @@ -597,423 +577,6 @@ class ApplicationCommand extends Base { [maxLengthKey]: option.maxLength ?? option.max_length, }; } - /** - * Send Slash command to channel - * @param {Message} message Discord Message - * @param {Array} subCommandArray SubCommand Array - * @param {Array} options The options to Slash Command - * @returns {Promise} - */ - // eslint-disable-next-line consistent-return - async sendSlashCommand(message, subCommandArray = [], options = []) { - // Todo: Refactor [Done] - const buildError = (type, value, array, msg) => - new Error(`Invalid ${type}: ${value} ${msg}\nList of ${type}:\n${array}`); - // 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') throw new Error('This command is not a chat input [/]'); - const optionFormat = []; - const attachments = []; - const attachmentsBuffer = []; - const parseChoices = (list_choices, value) => { - if (value !== undefined) { - if (Array.isArray(list_choices) && list_choices.length) { - const choice = list_choices.find(c => c.name === value) || list_choices.find(c => c.value === value); - if (choice) { - return choice.value; - } - throw buildError( - 'choice', - value, - list_choices.map((c, i) => ` #${i + 1} Name: ${c.name} Value: ${c.value}`).join('\n'), - 'is not a valid choice for this option', - ); - } else { - return value; - } - } else { - return undefined; - } - }; - const parseOption = async (optionCommand, value) => { - const data = { - type: ApplicationCommandOptionTypes[optionCommand.type], - name: optionCommand.name, - }; - if (value !== undefined) { - value = parseChoices(optionCommand.choices, value); - switch (optionCommand.type) { - case 'BOOLEAN': { - data.value = Boolean(value); - break; - } - case 'INTEGER': { - data.value = Number(value); - break; - } - case 'ATTACHMENT': { - data.value = await addDataFromAttachment(value, this.client); - break; - } - case 'SUB_COMMAND_GROUP': { - break; - } - default: { - if (optionCommand.autocomplete) { - let optionsBuild; - switch (subCommandArray.length) { - case 0: { - optionsBuild = [ - ...optionFormat, - { - type: ApplicationCommandOptionTypes[optionCommand.type], - name: optionCommand.name, - value, - focused: true, - }, - ]; - break; - } - case 1: { - const subCommand = this.options.find(o => o.name == subCommandArray[0] && o.type == 'SUB_COMMAND'); - optionsBuild = [ - { - type: ApplicationCommandOptionTypes[subCommand.type], - name: subCommand.name, - options: [ - ...optionFormat, - { - type: ApplicationCommandOptionTypes[optionCommand.type], - name: optionCommand.name, - value, - focused: true, - }, - ], - }, - ]; - break; - } - case 2: { - const subGroup = this.options.find( - o => o.name == subCommandArray[0] && o.type == 'SUB_COMMAND_GROUP', - ); - const subCommand = subGroup.options.find( - o => o.name == subCommandArray[1] && o.type == 'SUB_COMMAND', - ); - optionsBuild = [ - { - type: ApplicationCommandOptionTypes[subGroup.type], - name: subGroup.name, - options: [ - { - type: ApplicationCommandOptionTypes[subCommand.type], - name: subCommand.name, - options: [ - ...optionFormat, - { - type: ApplicationCommandOptionTypes[optionCommand.type], - name: optionCommand.name, - value, - focused: true, - }, - ], - }, - ], - }, - ]; - break; - } - } - const autoValue = await getAutoResponse(optionsBuild, value); - data.value = autoValue; - } else { - data.value = value; - } - } - } - optionFormat.push(data); - } - return optionFormat; - }; - const parseSubCommand = async (subCommandName, options, subGroup) => { - const options_sub = subGroup ? subGroup.options : this.options; - const subCommand = options_sub.find( - o => (o.name == subCommandName || o.nameLocalized == subCommandName) && o.type == 'SUB_COMMAND', - ); - if (!subCommand) { - throw buildError( - 'SubCommand', - subCommandName, - options_sub.map((o, i) => ` #${i + 1} Name: ${o.name}`).join('\n'), - 'is not a valid sub command', - ); - } - const valueRequired = subCommand.options?.filter(o => o.required).length || 0; - for (let i = 0; i < options.length; i++) { - const optionInput = subCommand.options[i]; - const value = options[i]; - await parseOption(optionInput, value); - } - if (valueRequired > options.length) { - throw new Error(`Value required missing\nDebug: - Required: ${valueRequired} - Options: ${optionFormat.length}`); - } - return { - type: ApplicationCommandOptionTypes[subCommand.type], - name: subCommand.name, - options: optionFormat, - }; - }; - const parseSubGroupCommand = async (subGroupName, subName) => { - const subGroup = this.options.find( - o => (o.name == subGroupName || o.nameLocalized == subGroupName) && o.type == 'SUB_COMMAND_GROUP', - ); - if (!subGroup) { - throw buildError( - 'SubGroupCommand', - subGroupName, - this.options.map((o, i) => ` #${i + 1} Name: ${o.name}`).join('\n'), - 'is not a valid sub group command', - ); - } - const data = await parseSubCommand(subName, options, subGroup); - return { - type: ApplicationCommandOptionTypes[subGroup.type], - name: subGroup.name, - options: [data], - }; - }; - async function addDataFromAttachment(data, client) { - const data_ = await MessagePayload.resolveFile(data); - if (!data_.file) { - throw new TypeError( - 'The attachment data must be a BufferResolvable or Stream or FileOptions of MessageAttachment', - ); - } - if (client.options.usingNewAttachmentAPI === true) { - const attachments_ = await getAttachments(client, message.channelId, data_); - await uploadFile(data_.file, attachments_[0].upload_url); - const id = attachments.length; - attachments.push({ - id: id, - filename: data_.name, - uploaded_filename: attachments_[0].upload_filename, - }); - return id; - } else { - const id = attachments.length; - attachments.push({ - id: id, - filename: data_.name, - }); - attachmentsBuffer.push(data_); - return id; - } - } - const getDataPost = (dataAdd = [], nonce, autocomplete = false) => { - if (!Array.isArray(dataAdd) && typeof dataAdd == 'object') { - dataAdd = [dataAdd]; - } - const data = { - type: autocomplete ? InteractionTypes.APPLICATION_COMMAND_AUTOCOMPLETE : InteractionTypes.APPLICATION_COMMAND, - application_id: this.applicationId, - guild_id: message.guildId, - channel_id: message.channelId, - session_id: this.client.sessionId, - data: { - version: this.version, - id: this.id, - name: this.name, - type: ApplicationCommandTypes[this.type], - options: dataAdd, - attachments: attachments, - }, - nonce, - }; - if (this.guildId) { - data.data.guild_id = message.guildId; - } - return data; - }; - const getAutoResponse = async (sendData, value) => { - let nonce = SnowflakeUtil.generate(); - const data = getDataPost(sendData, nonce, true); - await this.client.api.interactions.post({ - data, - files: attachmentsBuffer, - }); - return new Promise(resolve => { - const handler = data => { - timeout.refresh(); - if (data.nonce !== nonce) return; - clearTimeout(timeout); - this.client.removeListener(Events.APPLICATION_COMMAND_AUTOCOMPLETE_RESPONSE, handler); - this.client.decrementMaxListeners(); - if (data.choices.length > 1) { - // Find best match name - const bestMatch = findBestMatch( - value, - data.choices.map(c => c.name), - ); - const result = data.choices.find(c => c.name == bestMatch.bestMatch.target); - resolve(result.value); - } else { - resolve(value); - } - }; - const timeout = setTimeout(() => { - this.client.removeListener(Events.APPLICATION_COMMAND_AUTOCOMPLETE_RESPONSE, handler); - this.client.decrementMaxListeners(); - resolve(value); - }, this.client.options.interactionTimeout).unref(); - this.client.incrementMaxListeners(); - this.client.on(Events.APPLICATION_COMMAND_AUTOCOMPLETE_RESPONSE, handler); - }); - }; - const sendData = async (optionsData = []) => { - let nonce = SnowflakeUtil.generate(); - const data = getDataPost(optionsData, nonce); - await this.client.api.interactions.post({ - data, - useFormDataPayloadJSON: true, - files: attachmentsBuffer, - }); - this.client._interactionCache.set(nonce, { - channelId: message.channelId, - guildId: message.guildId, - metadata: data, - }); - return new Promise((resolve, reject) => { - const handler = data => { - timeout.refresh(); - if (data.metadata?.nonce !== nonce) return; - clearTimeout(timeout); - this.client.removeListener('interactionResponse', handler); - this.client.decrementMaxListeners(); - if (data.status) { - resolve(data.metadata); - } else { - reject( - new Error('INTERACTION_ERROR', { - cause: data, - }), - ); - } - }; - const timeout = setTimeout(() => { - this.client.removeListener('interactionResponse', handler); - this.client.decrementMaxListeners(); - reject( - new Error('INTERACTION_TIMEOUT', { - cause: data, - }), - ); - }, this.client.options.interactionTimeout).unref(); - this.client.incrementMaxListeners(); - this.client.on('interactionResponse', handler); - }); - }; - // SubCommandArray length max 2 - // length = 0 => no sub command - // length = 1 => sub command - // length = 2 => sub command group + sub command - switch (subCommandArray.length) { - case 0: { - const valueRequired = this.options?.filter(o => o.required).length || 0; - for (let i = 0; i < options.length; i++) { - const optionInput = this.options[i]; - const value = options[i]; - await parseOption(optionInput, value); - } - if (valueRequired > options.length) { - throw new Error(`Value required missing\nDebug: - Required: ${valueRequired} - Options: ${optionFormat.length}`); - } - return sendData(optionFormat); - } - case 1: { - const optionsData = await parseSubCommand(subCommandArray[0], options); - return sendData(optionsData); - } - case 2: { - const optionsData = await parseSubGroupCommand(subCommandArray[0], subCommandArray[1], options); - return sendData(optionsData); - } - } - } - /** - * Message Context Menu - * @param {Message} message Discord Message - * @returns {Promise} - */ - async sendContextMenu(message) { - if (!(message instanceof Message())) { - throw new TypeError('The message must be a Discord.Message'); - } - if (this.type == 'CHAT_INPUT') return false; - const nonce = SnowflakeUtil.generate(); - const data = { - type: InteractionTypes.APPLICATION_COMMAND, - application_id: this.applicationId, - guild_id: message.guildId, - channel_id: message.channelId, - session_id: this.client.sessionId, - data: { - version: this.version, - id: this.id, - name: this.name, - type: ApplicationCommandTypes[this.type], - target_id: ApplicationCommandTypes[this.type] == 1 ? message.author.id : message.id, - }, - nonce, - }; - if (this.guildId) { - data.data.guild_id = message.guildId; - } - await this.client.api.interactions.post({ - data, - useFormDataPayloadJSON: true, - }); - this.client._interactionCache.set(nonce, { - channelId: message.channelId, - guildId: message.guildId, - metadata: data, - }); - return new Promise((resolve, reject) => { - const handler = data => { - timeout.refresh(); - if (data.metadata?.nonce !== nonce) return; - clearTimeout(timeout); - this.client.removeListener('interactionResponse', handler); - this.client.decrementMaxListeners(); - if (data.status) { - resolve(data.metadata); - } else { - reject( - new Error('INTERACTION_ERROR', { - cause: data, - }), - ); - } - }; - const timeout = setTimeout(() => { - this.client.removeListener('interactionResponse', handler); - this.client.decrementMaxListeners(); - reject( - new Error('INTERACTION_TIMEOUT', { - cause: data, - }), - ); - }, this.client.options.interactionTimeout).unref(); - this.client.incrementMaxListeners(); - this.client.on('interactionResponse', handler); - }); - } } module.exports = ApplicationCommand; diff --git a/src/structures/BaseGuildTextChannel.js b/src/structures/BaseGuildTextChannel.js index 0b475a7..7bb4cf0 100644 --- a/src/structures/BaseGuildTextChannel.js +++ b/src/structures/BaseGuildTextChannel.js @@ -3,7 +3,6 @@ const GuildChannel = require('./GuildChannel'); const TextBasedChannel = require('./interfaces/TextBasedChannel'); const GuildTextThreadManager = require('../managers/GuildTextThreadManager'); -const InteractionManager = require('../managers/InteractionManager'); const MessageManager = require('../managers/MessageManager'); /** @@ -21,12 +20,6 @@ class BaseGuildTextChannel extends GuildChannel { */ this.messages = new MessageManager(this); - /** - * A manager of the interactions sent to this channel - * @type {InteractionManager} - */ - this.interactions = new InteractionManager(this); - /** * A manager of the threads belonging to this channel * @type {GuildTextThreadManager} @@ -187,15 +180,10 @@ class BaseGuildTextChannel extends GuildChannel { sendTyping() {} createMessageCollector() {} awaitMessages() {} - createMessageComponentCollector() {} - awaitMessageComponent() {} - bulkDelete() {} fetchWebhooks() {} createWebhook() {} setRateLimitPerUser() {} setNSFW() {} - sendSlash() {} - searchInteraction() {} } TextBasedChannel.applyToClass(BaseGuildTextChannel, true); diff --git a/src/structures/BaseGuildVoiceChannel.js b/src/structures/BaseGuildVoiceChannel.js index e29e9f8..d653e7a 100644 --- a/src/structures/BaseGuildVoiceChannel.js +++ b/src/structures/BaseGuildVoiceChannel.js @@ -3,7 +3,6 @@ const { Collection } = require('@discordjs/collection'); const GuildChannel = require('./GuildChannel'); const TextBasedChannel = require('./interfaces/TextBasedChannel'); -const InteractionManager = require('../managers/InteractionManager'); const MessageManager = require('../managers/MessageManager'); const { VideoQualityModes } = require('../util/Constants'); const Permissions = require('../util/Permissions'); @@ -28,12 +27,6 @@ class BaseGuildVoiceChannel extends GuildChannel { */ this.nsfw = Boolean(data.nsfw); - /** - * A manager of the interactions sent to this channel - * @type {InteractionManager} - */ - this.interactions = new InteractionManager(this); - this._patch(data); } @@ -95,7 +88,7 @@ class BaseGuildVoiceChannel extends GuildChannel { } if ('nsfw' in data) { - this.nsfw = Boolean(data.nsfw); + this.nsfw = data.nsfw; } } @@ -170,11 +163,11 @@ class BaseGuildVoiceChannel extends GuildChannel { * Sets the bitrate of the channel. * @param {number} bitrate The new bitrate * @param {string} [reason] Reason for changing the channel's bitrate - * @returns {Promise} + * @returns {Promise} * @example * // Set the bitrate of a voice channel - * voiceChannel.setBitrate(48_000) - * .then(vc => console.log(`Set bitrate to ${vc.bitrate}bps for ${vc.name}`)) + * channel.setBitrate(48_000) + * .then(channel => console.log(`Set bitrate to ${channel.bitrate}bps for ${channel.name}`)) * .catch(console.error); */ setBitrate(bitrate, reason) { @@ -201,11 +194,11 @@ class BaseGuildVoiceChannel extends GuildChannel { * Sets the user limit of the channel. * @param {number} userLimit The new user limit * @param {string} [reason] Reason for changing the user limit - * @returns {Promise} + * @returns {Promise} * @example * // Set the user limit of a voice channel - * voiceChannel.setUserLimit(42) - * .then(vc => console.log(`Set user limit to ${vc.userLimit} for ${vc.name}`)) + * channel.setUserLimit(42) + * .then(channel => console.log(`Set user limit to ${channel.userLimit} for ${channel.name}`)) * .catch(console.error); */ setUserLimit(userLimit, reason) { @@ -216,7 +209,7 @@ class BaseGuildVoiceChannel extends GuildChannel { * Sets the camera video quality mode of the channel. * @param {VideoQualityMode|number} videoQualityMode The new camera video quality mode. * @param {string} [reason] Reason for changing the camera video quality mode. - * @returns {Promise} + * @returns {Promise} */ setVideoQualityMode(videoQualityMode, reason) { return this.edit({ videoQualityMode }, reason); @@ -229,9 +222,6 @@ class BaseGuildVoiceChannel extends GuildChannel { sendTyping() {} createMessageCollector() {} awaitMessages() {} - createMessageComponentCollector() {} - awaitMessageComponent() {} - bulkDelete() {} fetchWebhooks() {} createWebhook() {} setRateLimitPerUser() {} diff --git a/src/structures/Call.js b/src/structures/CallState.js similarity index 72% rename from src/structures/Call.js rename to src/structures/CallState.js index f9b44e9..877bdef 100644 --- a/src/structures/Call.js +++ b/src/structures/CallState.js @@ -7,7 +7,7 @@ const Base = require('./Base'); * Represents a call * @extends {Base} */ -class Call extends Base { +class CallState extends Base { constructor(client, data) { super(client); /** @@ -16,14 +16,11 @@ class Call extends Base { */ this.channelId = data.channel_id; - /** - * The list of user ID who is ringing - * @type {Collection} - */ - this.ringing = new Collection(); + this._ringing = []; this._patch(data); } + _patch(data) { if ('region' in data) { /** @@ -33,11 +30,10 @@ class Call extends Base { this.region = data.region; } if ('ringing' in data) { - for (const userId of data.ringing) { - this.ringing.set(userId, this.client.users.cache.get(userId)); - } + this._ringing = data.ringing; } } + /** * The channel of the call * @type {?DMChannel|PartialGroupDMChannel} @@ -45,14 +41,23 @@ class Call extends Base { get channel() { return this.client.channels.cache.get(this.channelId); } + /** * Sets the voice region of the call * @param {string} region Region of the call * @returns {Promise} */ - setVoiceRegion(region) { + setRTCRegion(region) { return this.client.api.channels(this.channelId).call.patch({ data: { region } }); } + + /** + * The list of user ID who is ringing + * @type {Collection} + */ + get ringing() { + return new Collection(this._ringing.map(id => [id, this.client.users.cache.get(id)])); + } } -module.exports = Call; +module.exports = CallState; diff --git a/src/structures/GuildAuditLogs.js b/src/structures/GuildAuditLogs.js index 16114c8..bd19fea 100644 --- a/src/structures/GuildAuditLogs.js +++ b/src/structures/GuildAuditLogs.js @@ -224,7 +224,6 @@ class GuildAuditLogs { this.applicationCommands.set(command.id, new ApplicationCommand(guild.client, command, guild)); } } - /** * Cached auto moderation rules. * @type {Collection} @@ -487,7 +486,6 @@ class GuildAuditLogsEntry { count: Number(data.options.count), }; break; - case Actions.MESSAGE_PIN: case Actions.MESSAGE_UNPIN: this.extra = { @@ -533,13 +531,11 @@ class GuildAuditLogsEntry { channel: guild.client.channels.cache.get(data.options?.channel_id) ?? { id: data.options?.channel_id }, }; break; - case Actions.APPLICATION_COMMAND_PERMISSION_UPDATE: this.extra = { applicationId: data.options.application_id, }; break; - case Actions.AUTO_MODERATION_BLOCK_MESSAGE: case Actions.AUTO_MODERATION_FLAG_TO_CHANNEL: case Actions.AUTO_MODERATION_USER_COMMUNICATION_DISABLED: @@ -549,7 +545,6 @@ class GuildAuditLogsEntry { channel: guild.client.channels.cache.get(data.options?.channel_id) ?? { id: data.options?.channel_id }, }; break; - default: break; } diff --git a/src/structures/MessagePayload.js b/src/structures/MessagePayload.js index 3a42a35..37d09c5 100644 --- a/src/structures/MessagePayload.js +++ b/src/structures/MessagePayload.js @@ -3,7 +3,6 @@ const { Buffer } = require('node:buffer'); const BaseMessageComponent = require('./BaseMessageComponent'); const MessageEmbed = require('./MessageEmbed'); -const WebEmbed = require('./WebEmbed'); const { RangeError } = require('../errors'); const ActivityFlags = require('../util/ActivityFlags'); const DataResolver = require('../util/DataResolver'); @@ -42,7 +41,6 @@ class MessagePayload { * @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 - * @extends {APIAttachment} */ /** @@ -52,15 +50,6 @@ class MessagePayload { this.files = null; } - /** - * Whether or not using new API to upload files - * @type {boolean} - * @readonly - */ - get usingNewAttachmentAPI() { - return Boolean(this.options?.usingNewAttachmentAPI); - } - /** * Whether or not the target is a {@link Webhook} or a {@link WebhookClient} * @type {boolean} @@ -133,12 +122,12 @@ class MessagePayload { * Resolves data. * @returns {MessagePayload} */ - async resolveData() { + resolveData() { if (this.data) return this; const isInteraction = this.isInteraction; const isWebhook = this.isWebhook; - let content = this.makeContent(); + const content = this.makeContent(); const tts = Boolean(this.options.tts); let nonce; @@ -208,37 +197,6 @@ class MessagePayload { 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 WebEmbed); - this.options.embeds = this.options.embeds.filter(e => !(e instanceof WebEmbed)); - - if (webembeds.length > 0) { - if (!content) content = ''; - // Add hidden embed link - content += `\n${WebEmbed.hiddenEmbed} \n`; - if (webembeds.length > 1) { - console.warn('[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 (typeof content == 'string' && content.length > 2000) { - console.warn('[WARN] Content is longer than 2000 characters.'); - } - if (typeof content == 'string' && content.length > 4000) { - // Max length if user has nitro boost - throw new RangeError('MESSAGE_EMBED_LINK_LENGTH'); - } - } - // Activity let activity; if ( @@ -247,7 +205,7 @@ class MessagePayload { this.options.activity.type ) { const type = ActivityFlags.resolve(this.options.activity.type); - const sessionId = this.target.client.sessionId; + const sessionId = this.target.client.ws.shards.first()?.sessionId; const partyId = this.options.activity.partyId; activity = { type, @@ -348,7 +306,7 @@ module.exports = MessagePayload; /** * A target for a message. - * @typedef {TextBasedChannels|DMChannel|User|GuildMember|Webhook|WebhookClient|Interaction|InteractionWebhook| + * @typedef {TextBasedChannels|User|GuildMember|Webhook|WebhookClient|Interaction|InteractionWebhook| * Message|MessageManager} MessageTarget */ diff --git a/src/structures/Team.js b/src/structures/Team.js index 85ed2c9..ccb8175 100644 --- a/src/structures/Team.js +++ b/src/structures/Team.js @@ -3,8 +3,6 @@ const { Collection } = require('@discordjs/collection'); const Base = require('./Base'); const TeamMember = require('./TeamMember'); -const User = require('./User'); -const { Error } = require('../errors'); const SnowflakeUtil = require('../util/SnowflakeUtil'); /** @@ -100,53 +98,6 @@ class Team extends Base { return this.client.rest.cdn.TeamIcon(this.id, this.icon, { format, size }); } - /** - * Invite a team member to the team - * @param {User} user The user to invite to the team - * @param {number} MFACode The mfa code - * @returns {Promise} - */ - async inviteMember(user, MFACode) { - if (!(user instanceof User)) return new Error('TEAM_MEMBER_FORMAT'); - const regex = /([0-9]{6})/g; - - const payload = { - username: user.username, - discriminator: user.discriminator, - }; - if (MFACode && !regex.test(MFACode)) return new Error('MFA_INVALID'); - if (MFACode) payload.code = MFACode; - - const member = await this.client.api.teams(this.id).members.post({ - data: payload, - }); - - this.members.set(member.user.id, new TeamMember(this, member)); - return this.members.get(member.user.id); - } - - /** - * Remove a member from the team - * @param {Snowflake} userID The ID of the user you want to remove - * @returns {boolean} - */ - async removeMember(userID) { - await this.client.api.teams[this.id].members[userID].delete(); - return this.members.delete(userID); - } - - /** - * Delete this team - * @param {number} MFACode The 2fa code - * @returns {Promise} - */ - async delete(MFACode) { - const regex = /([0-9]{6})/g; - if (!regex.test(MFACode)) return new Error('MFA_INVALID'); - await this.client.api.teams[this.id].delete({ data: { code: MFACode } }); - return this.client.developerPortal.teams.delete(this.id); - } - /** * When concatenated with a string, this automatically returns the Team's name instead of the * Team object. diff --git a/src/structures/interfaces/Application.js b/src/structures/interfaces/Application.js index 05d5ab2..f2ba953 100644 --- a/src/structures/interfaces/Application.js +++ b/src/structures/interfaces/Application.js @@ -1,10 +1,13 @@ 'use strict'; const process = require('node:process'); +const { ApplicationFlags } = require('../../util/ApplicationFlags'); const { ClientApplicationAssetTypes, Endpoints } = require('../../util/Constants'); const Permissions = require('../../util/Permissions'); const SnowflakeUtil = require('../../util/SnowflakeUtil'); +const { ApplicationRoleConnectionMetadata } = require('../ApplicationRoleConnectionMetadata'); const Base = require('../Base'); +const Team = require('../Team'); const AssetTypes = Object.keys(ClientApplicationAssetTypes); @@ -67,6 +70,132 @@ class Application extends Base { } else { this.roleConnectionsVerificationURL ??= null; } + + // ClientApplication + /** + * The tags this application has (max of 5) + * @type {string[]} + */ + this.tags = data.tags ?? []; + + if ('install_params' in data) { + /** + * Settings for this application's default in-app authorization + * @type {?ClientApplicationInstallParams} + */ + this.installParams = { + scopes: data.install_params.scopes, + permissions: new Permissions(data.install_params.permissions).freeze(), + }; + } else { + this.installParams ??= null; + } + + if ('custom_install_url' in data) { + /** + * This application's custom installation URL + * @type {?string} + */ + this.customInstallURL = data.custom_install_url; + } else { + this.customInstallURL = null; + } + + if ('flags' in data) { + /** + * The flags this application has + * @type {ApplicationFlags} + */ + this.flags = new ApplicationFlags(data.flags).freeze(); + } + + if ('approximate_guild_count' in data) { + /** + * An approximate amount of guilds this application is in. + * @type {?number} + */ + this.approximateGuildCount = data.approximate_guild_count; + } else { + this.approximateGuildCount ??= null; + } + + if ('guild_id' in data) { + /** + * The id of the guild associated with this application. + * @type {?Snowflake} + */ + this.guildId = data.guild_id; + } else { + this.guildId ??= null; + } + + if ('cover_image' in data) { + /** + * The hash of the application's cover image + * @type {?string} + */ + this.cover = data.cover_image; + } else { + this.cover ??= null; + } + + if ('rpc_origins' in data) { + /** + * The application's RPC origins, if enabled + * @type {string[]} + */ + this.rpcOrigins = data.rpc_origins; + } else { + this.rpcOrigins ??= []; + } + + if ('bot_require_code_grant' in data) { + /** + * If this application's bot requires a code grant when using the OAuth2 flow + * @type {?boolean} + */ + this.botRequireCodeGrant = data.bot_require_code_grant; + } else { + this.botRequireCodeGrant ??= null; + } + + if ('bot_public' in data) { + /** + * If this application's bot is public + * @type {?boolean} + */ + this.botPublic = data.bot_public; + } else { + this.botPublic ??= null; + } + + /** + * The owner of this OAuth application + * @type {?(User|Team)} + */ + this.owner = data.team + ? new Team(this.client, data.team) + : data.owner + ? this.client.users._add(data.owner) + : this.owner ?? null; + } + + /** + * The guild associated with this application. + * @type {?Guild} + * @readonly + */ + get guild() { + return this.client.guilds.cache.get(this.guildId) ?? null; + } + + /** + * Whether this application is partial + * @type {boolean} + * @readonly + */ + get partial() { + return !this.name; } /** @@ -88,35 +217,29 @@ class Application extends Base { } /** - * Invites this application to a guild / server - * @param {Snowflake} guild_id The id of the guild that you want to invite the bot to - * @param {PermissionResolvable} [permissions] The permissions for the bot in number form (the default is 8 / Administrator) - * @param {string} [captcha] The captcha key to add - * @returns {Promise} nothing :) + * Obtains this application from Discord. + * @returns {Promise} */ - async invite(guild_id, permissions, captcha = null) { - permissions = Permissions.resolve(permissions || 0n); - const postData = { - authorize: true, - guild_id, - permissions: '0', - }; - if (permissions) { - postData.permissions = permissions; - } - if (captcha && typeof captcha === 'string' && captcha.length > 0) { - postData.captcha = captcha; - } - await this.client.api.oauth2.authorize.post({ + async fetch() { + const app = await this.client.api.oauth2.authorize.get({ query: { client_id: this.id, scope: 'bot applications.commands', }, - data: postData, - headers: { - referer: `https://discord.com/oauth2/authorize?client_id=${this.id}&permissions=${permissions}&scope=bot`, - }, }); + const user = this.client.users._add(app.bot); + user._partial = false; + this._patch(app.application); + return this; + } + + /** + * Gets this application's role connection metadata records + * @returns {Promise} + */ + async fetchRoleConnectionMetadataRecords() { + const metadata = await this.client.api.applications(this.id)('role-connections').metadata.get(); + return metadata.map(data => new ApplicationRoleConnectionMetadata(data)); } /** diff --git a/src/structures/interfaces/TextBasedChannel.js b/src/structures/interfaces/TextBasedChannel.js index 11da672..e88c6aa 100644 --- a/src/structures/interfaces/TextBasedChannel.js +++ b/src/structures/interfaces/TextBasedChannel.js @@ -1,17 +1,14 @@ 'use strict'; /* eslint-disable import/order */ -const InteractionManager = require('../../managers/InteractionManager'); const MessageCollector = require('../MessageCollector'); const MessagePayload = require('../MessagePayload'); +const { InteractionTypes, ApplicationCommandOptionTypes, Events } = require('../../util/Constants'); +const { Error } = require('../../errors'); const SnowflakeUtil = require('../../util/SnowflakeUtil'); -const { Collection } = require('@discordjs/collection'); -const { InteractionTypes, MaxBulkDeletableMessageAge } = require('../../util/Constants'); -const { TypeError, Error } = require('../../errors'); -const InteractionCollector = require('../InteractionCollector'); -const { lazy, getAttachments, uploadFile } = require('../../util/Util'); -const Message = lazy(() => require('../Message').Message); +const { setTimeout } = require('node:timers'); const { s } = require('@sapphire/shapeshift'); +const Util = require('../../util/Util'); const validateName = stringName => s.string .lengthGreaterThanOrEqual(1) @@ -32,12 +29,6 @@ class TextBasedChannel { */ this.messages = new MessageManager(this); - /** - * A manager of the interactions sent to this channel - * @type {InteractionManager} - */ - this.interactions = new InteractionManager(this); - /** * The channel's last message id, if one was sent * @type {?Snowflake} @@ -76,7 +67,7 @@ class TextBasedChannel { * @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 {Array<(MessageEmbed|APIEmbed|WebEmbed)>} [embeds] The embeds for the message + * @property {Array<(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) @@ -84,7 +75,6 @@ class TextBasedChannel { * @property {Array<(MessageActionRow|MessageActionRowOptions)>} [components] * Action rows containing interactive components for the message (buttons, select menus) * @property {MessageAttachment[]} [attachments] Attachments to send in the message - * @property {boolean} [usingNewAttachmentAPI] Whether to use the new attachment API (`channels/:id/attachments`) */ /** @@ -168,41 +158,197 @@ class TextBasedChannel { let messagePayload; if (options instanceof MessagePayload) { - messagePayload = await options.resolveData(); + messagePayload = options.resolveData(); } else { - messagePayload = await MessagePayload.create(this, options).resolveData(); + messagePayload = MessagePayload.create(this, options).resolveData(); } - let { data, files } = await messagePayload.resolveFiles(); - - if (typeof options == 'object' && typeof options.usingNewAttachmentAPI !== 'boolean') { - options.usingNewAttachmentAPI = this.client.options.usingNewAttachmentAPI; - } - - if (options?.usingNewAttachmentAPI === true) { - const attachments = await getAttachments(this.client, this.id, ...files); - const requestPromises = attachments.map(async attachment => { - await uploadFile(files[attachment.id].file, attachment.upload_url); - return { - id: attachment.id, - filename: files[attachment.id].name, - uploaded_filename: attachment.upload_filename, - description: files[attachment.id].description, - duration_secs: files[attachment.id].duration_secs, - waveform: files[attachment.id].waveform, - }; - }); - const attachmentsData = await Promise.all(requestPromises); - attachmentsData.sort((a, b) => parseInt(a.id) - parseInt(b.id)); - data.attachments = attachmentsData; - files = []; - } - - const d = await this.client.api.channels[this.id].messages.post({ data, files }); + const { data, files } = await messagePayload.resolveFiles(); + // New API + const attachments = await Util.getUploadURL(this.client, this.id, files); + const requestPromises = attachments.map(async attachment => { + await Util.uploadFile(files[attachment.id].file, attachment.upload_url); + return { + id: attachment.id, + filename: files[attachment.id].name, + uploaded_filename: attachment.upload_filename, + description: files[attachment.id].description, + duration_secs: files[attachment.id].duration_secs, + waveform: files[attachment.id].waveform, + }; + }); + const attachmentsData = await Promise.all(requestPromises); + attachmentsData.sort((a, b) => parseInt(a.id) - parseInt(b.id)); + data.attachments = attachmentsData; + // Empty Files + const d = await this.client.api.channels[this.id].messages.post({ data }); return this.messages.cache.get(d.id) ?? this.messages._add(d); } + searchInteraction() { + // Support Slash / ContextMenu + // API https://canary.discord.com/api/v9/guilds/:id/application-command-index // Guild + // https://canary.discord.com/api/v9/channels/:id/application-command-index // DM Channel + // Updated: 07/01/2023 + return this.client.api[this.guild ? 'guilds' : 'channels'][this.guild?.id || this.id][ + 'application-command-index' + ].get(); + } + + async sendSlash(botOrApplicationId, commandNameString, ...args) { + // Parse commandName /role add user + const cmd = commandNameString.trim().split(' '); + // Ex: role add user => [role, add, user] + // Parse: name, subGr, sub + const commandName = validateName(cmd[0]); + // Parse: role + const sub = cmd.slice(1); + // Parse: [add, user] + for (let i = 0; i < sub.length; i++) { + if (sub.length > 2) { + throw new Error('INVALID_COMMAND_NAME', cmd); + } + validateName(sub[i]); + } + // Search all + const data = await this.searchInteraction(); + // Find command... + const filterCommand = data.application_commands.filter(obj => + // Filter: name | name_default + [obj.name, obj.name_default].includes(commandName), + ); + // Filter Bot + botOrApplicationId = this.client.users.resolveId(botOrApplicationId); + const application = data.applications.find( + obj => obj.id == botOrApplicationId || obj.bot?.id == botOrApplicationId, + ); + // Find Command with application + const command = filterCommand.find(command => command.application_id == application.id); + + args = args.flat(2); + let optionFormat = []; + let attachments = []; + let optionsMaxdepth, subGroup, subCommand; + if (sub.length == 2) { + // Subcommand Group > Subcommand + // Find Sub group + subGroup = command.options.find( + obj => + obj.type == ApplicationCommandOptionTypes.SUB_COMMAND_GROUP && [obj.name, obj.name_default].includes(sub[0]), + ); + if (!subGroup) throw new Error('SLASH_COMMAND_SUB_COMMAND_GROUP_INVALID', sub[0]); + // Find Sub + subCommand = subGroup.options.find( + obj => obj.type == ApplicationCommandOptionTypes.SUB_COMMAND && [obj.name, obj.name_default].includes(sub[1]), + ); + if (!subCommand) throw new Error('SLASH_COMMAND_SUB_COMMAND_INVALID', sub[1]); + // Options + optionsMaxdepth = subCommand.options; + } else if (sub.length == 1) { + // Subcommand + subCommand = command.options.find( + obj => obj.type == ApplicationCommandOptionTypes.SUB_COMMAND && [obj.name, obj.name_default].includes(sub[0]), + ); + if (!subCommand) throw new Error('SLASH_COMMAND_SUB_COMMAND_INVALID', sub[0]); + // Options + optionsMaxdepth = subCommand.options; + } else { + optionsMaxdepth = command.options; + } + const valueRequired = optionsMaxdepth?.filter(o => o.required).length || 0; + for (let i = 0; i < Math.min(args.length, optionsMaxdepth?.length || 0); i++) { + const optionInput = optionsMaxdepth[i]; + const value = args[i]; + const parseData = await parseOption( + this.client, + optionInput, + value, + optionFormat, + attachments, + command, + application.id, + this.guild?.id, + this.id, + subGroup, + subCommand, + ); + optionFormat = parseData.optionFormat; + attachments = parseData.attachments; + } + if (valueRequired > args.length) { + throw new Error('SLASH_COMMAND_REQUIRED_OPTIONS_MISSING', valueRequired, optionFormat.length); + } + // Post + let postData; + if (subGroup) { + postData = [ + { + type: ApplicationCommandOptionTypes.SUB_COMMAND_GROUP, + name: subGroup.name, + options: [ + { + type: ApplicationCommandOptionTypes.SUB_COMMAND, + name: subCommand.name, + options: optionFormat, + }, + ], + }, + ]; + } else if (subCommand) { + postData = [ + { + type: ApplicationCommandOptionTypes.SUB_COMMAND, + name: subCommand.name, + options: optionFormat, + }, + ]; + } else { + postData = optionFormat; + } + const nonce = SnowflakeUtil.generate(); + const body = createPostData( + this.client, + false, + application.id, + nonce, + this.guild?.id, + Boolean(command.guild_id), + this.id, + command.version, + command.id, + command.name_default || command.name, + command.type, + postData, + attachments, + ); + this.client.api.interactions.post({ + data: body, + usePayloadJSON: true, + }); + return new Promise((resolve, reject) => { + const timeoutMs = 5_000; + // Waiting for MsgCreate / ModalCreate + const handler = data => { + if (data.nonce !== nonce) return; + clearTimeout(timeout); + this.client.removeListener(Events.MESSAGE_CREATE, handler); + this.client.removeListener(Events.INTERACTION_MODAL_CREATE, handler); + this.client.decrementMaxListeners(); + resolve(data); + }; + const timeout = setTimeout(() => { + this.client.removeListener(Events.MESSAGE_CREATE, handler); + this.client.removeListener(Events.INTERACTION_MODAL_CREATE, handler); + this.client.decrementMaxListeners(); + reject(new Error('INTERACTION_FAILED')); + }, timeoutMs).unref(); + this.client.incrementMaxListeners(); + this.client.on(Events.MESSAGE_CREATE, handler); + this.client.on(Events.INTERACTION_MODAL_CREATE, handler); + }); + } + /** * Sends a typing indicator in the channel. * @returns {Promise} Resolves upon the typing status being sent @@ -261,101 +407,6 @@ class TextBasedChannel { }); } - /** - * Creates a component 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 (!this.client.user.bot) throw new Error('INVALID_USER_METHOD'); - 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) < MaxBulkDeletableMessageAge); - } - 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'); - } - /** * Fetches all webhooks for the channel. * @returns {Promise>} @@ -414,139 +465,21 @@ class TextBasedChannel { return this.edit({ nsfw }, reason); } - /** - * Search Slash Command (return raw data) - * @param {Snowflake} applicationId Application ID - * @param {?ApplicationCommandType} type Command Type - * @returns {Object} - */ - searchInteraction(applicationId, type = 'CHAT_INPUT') { - switch (type) { - case 'USER': - case 2: - type = 2; - break; - case 'MESSAGE': - case 3: - type = 3; - break; - default: - type = 1; - break; - } - return this.client.api.channels[this.id]['application-commands'].search.get({ - query: { - type, - application_id: applicationId, - }, - }); - } - - /** - * Send Slash to this channel - * @param {UserResolvable} bot Bot user (BotID, not applicationID) - * @param {string} commandString Command name (and sub / group formats) - * @param {...?any|any[]} args Command arguments - * @returns {Promise} - * @example - * // Send a basic slash - * channel.sendSlash('botid', 'ping') - * .then(console.log) - * .catch(console.error); - * @example - * // Send a remote file - * channel.sendSlash('botid', 'emoji upload', 'https://cdn.discordapp.com/icons/222078108977594368/6e1019b3179d71046e463a75915e7244.png?size=2048', 'test') - * .then(console.log) - * .catch(console.error); - * @see {@link https://github.com/aiko-chan-ai/discord.js-selfbot-v13/blob/main/Document/SlashCommand.md} - */ - async sendSlash(bot, commandString, ...args) { - const perms = - this.type != 'DM' - ? this.permissionsFor(this.client.user).toArray() - : ['USE_APPLICATION_COMMANDS', `${this.recipient.relationships == 'BLOCKED' ? '' : 'SEND_MESSAGES'}`]; - if (!perms.includes('SEND_MESSAGES')) { - throw new Error( - 'INTERACTION_SEND_FAILURE', - `Cannot send Slash to ${this.toString()} ${ - this.recipient ? 'because bot has been blocked' : 'due to missing SEND_MESSAGES permission' - }`, - ); - } - if (!perms.includes('USE_APPLICATION_COMMANDS')) { - throw new Error( - 'INTERACTION_SEND_FAILURE', - `Cannot send Slash to ${this.toString()} due to missing USE_APPLICATION_COMMANDS permission`, - ); - } - args = args.flat(2); - const cmd = commandString.trim().split(' '); - // Validate CommandName - const commandName = validateName(cmd[0]); - const sub = cmd.slice(1); - for (let i = 0; i < sub.length; i++) { - if (sub.length > 2) { - throw new Error('INVALID_COMMAND_NAME', cmd); - } - validateName(sub[i]); - } - if (!bot) throw new Error('MUST_SPECIFY_BOT'); - const botId = this.client.users.resolveId(bot); - const user = await this.client.users.fetch(botId).catch(() => {}); - if (!user || !user.bot || !user.application) { - throw new Error('botId is not a bot or does not have an application slash command'); - } - if (user._partial) await user.getProfile().catch(() => {}); - if (!commandName || typeof commandName !== 'string') throw new Error('Command name is required'); - const data = await this.searchInteraction(user.application?.id ?? user.id, 'CHAT_INPUT'); - for (const command of data.application_commands) { - if (user.id == command.application_id || user.application.id == command.application_id) { - user.application?.commands?._add(command, true); - } - } - // Remove - const commandTarget = user.application?.commands?.cache.find( - c => c.name === commandName && c.type === 'CHAT_INPUT', - ); - if (!commandTarget) { - throw new Error( - 'INTERACTION_SEND_FAILURE', - `SlashCommand ${commandName} is not found (With search)\nDebug:\n+ botId: ${botId} (ApplicationId: ${ - user.application?.id - })\n+ args: ${args.join(' | ') || null}`, - ); - } - return commandTarget.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, - }), - sub && sub.length > 0 ? sub : [], - args && args.length ? args : [], - ); - } - static applyToClass(structure, full = false, ignore = []) { const props = ['send']; if (full) { props.push( + 'sendSlash', + 'searchInteraction', 'lastMessage', 'lastPinAt', - 'bulkDelete', 'sendTyping', 'createMessageCollector', 'awaitMessages', - 'createMessageComponentCollector', - 'awaitMessageComponent', 'fetchWebhooks', 'createWebhook', 'setRateLimitPerUser', 'setNSFW', - 'sendSlash', - 'searchInteraction', ); } for (const prop of props) { @@ -564,3 +497,225 @@ module.exports = TextBasedChannel; // Fixes Circular const MessageManager = require('../../managers/MessageManager'); + +// Utils +function parseChoices(parent, list_choices, value) { + if (value !== undefined) { + if (Array.isArray(list_choices) && list_choices.length) { + const choice = list_choices.find(c => [c.name, c.value].includes(value)); + if (choice) { + return choice.value; + } else { + throw new Error('INVALID_SLASH_COMMAND_CHOICES', parent, value); + } + } else { + return value; + } + } else { + return undefined; + } +} + +async function addDataFromAttachment(value, client, channelId, attachments) { + value = await MessagePayload.resolveFile(value); + if (!value?.file) { + throw new TypeError('The attachment data must be a BufferResolvable or Stream or FileOptions of MessageAttachment'); + } + const data = await Util.getUploadURL(client, channelId, [value]); + await Util.uploadFile(value.file, data[0].upload_url); + const id = attachments.length; + attachments.push({ + id, + filename: value.name, + uploaded_filename: data[0].upload_filename, + }); + return { + id, + attachments, + }; +} + +async function parseOption( + client, + optionCommand, + value, + optionFormat, + attachments, + command, + applicationId, + guildId, + channelId, + subGroup, + subCommand, +) { + const data = { + type: optionCommand.type, + name: optionCommand.name, + }; + if (value !== undefined) { + switch (optionCommand.type) { + case ApplicationCommandOptionTypes.BOOLEAN: + case 'BOOLEAN': { + data.value = Boolean(value); + break; + } + case ApplicationCommandOptionTypes.INTEGER: + case 'INTEGER': { + data.value = Number(value); + break; + } + case ApplicationCommandOptionTypes.ATTACHMENT: + case 'ATTACHMENT': { + const parseData = await addDataFromAttachment(value, client, channelId, attachments); + data.value = parseData.id; + attachments = parseData.attachments; + break; + } + case ApplicationCommandOptionTypes.SUB_COMMAND_GROUP: + case 'SUB_COMMAND_GROUP': { + break; + } + default: { + value = parseChoices(optionCommand.name, optionCommand.choices, value); + if (optionCommand.autocomplete) { + const nonce = SnowflakeUtil.generate(); + // Post + let postData; + if (subGroup) { + postData = [ + { + type: ApplicationCommandOptionTypes.SUB_COMMAND_GROUP, + name: subGroup.name, + options: [ + { + type: ApplicationCommandOptionTypes.SUB_COMMAND, + name: subCommand.name, + options: [ + { + type: optionCommand.type, + name: optionCommand.name, + value, + focused: true, + }, + ], + }, + ], + }, + ]; + } else if (subCommand) { + postData = [ + { + type: ApplicationCommandOptionTypes.SUB_COMMAND, + name: subCommand.name, + options: [ + { + type: optionCommand.type, + name: optionCommand.name, + value, + focused: true, + }, + ], + }, + ]; + } else { + postData = [ + { + type: optionCommand.type, + name: optionCommand.name, + value, + focused: true, + }, + ]; + } + const body = createPostData( + client, + true, + applicationId, + nonce, + guildId, + Boolean(command.guild_id), + channelId, + command.version, + command.id, + command.name_default || command.name, + command.type, + postData, + [], + ); + await client.api.interactions.post({ + data: body, + }); + data.value = await awaitAutocomplete(client, nonce, value); + } else { + data.value = value; + } + } + } + optionFormat.push(data); + } + return { + optionFormat, + attachments, + }; +} + +function awaitAutocomplete(client, nonce, defaultValue) { + return new Promise(resolve => { + const handler = data => { + if (data.t !== 'APPLICATION_COMMAND_AUTOCOMPLETE_RESPONSE') return; + if (data.d?.nonce !== nonce) return; + clearTimeout(timeout); + client.removeListener(Events.UNHANDLED_PACKET, handler); + client.decrementMaxListeners(); + if (data.d.choices.length >= 1) { + resolve(data.d.choices[0].value); + } else { + resolve(defaultValue); + } + }; + const timeout = setTimeout(() => { + client.removeListener(Events.UNHANDLED_PACKET, handler); + client.decrementMaxListeners(); + resolve(defaultValue); + }, 5_000).unref(); + client.incrementMaxListeners(); + client.on(Events.UNHANDLED_PACKET, handler); + }); +} + +function createPostData( + client, + isAutocomplete = false, + applicationId, + nonce, + guildId, + isGuildCommand, + channelId, + commandVersion, + commandId, + commandName, + commandType, + postData, + attachments = [], +) { + const data = { + type: isAutocomplete ? InteractionTypes.APPLICATION_COMMAND_AUTOCOMPLETE : InteractionTypes.APPLICATION_COMMAND, + application_id: applicationId, + guild_id: guildId, + channel_id: channelId, + session_id: client.ws.shards.first()?.sessionId, + data: { + version: commandVersion, + id: commandId, + name: commandName, + type: commandType, + options: postData, + attachments: attachments, + }, + nonce, + }; + if (isGuildCommand) { + data.data.guild_id = guildId; + } + return data; +}