diff --git a/src/managers/ChannelManager.js b/src/managers/ChannelManager.js index 58174ad..8a89d54 100644 --- a/src/managers/ChannelManager.js +++ b/src/managers/ChannelManager.js @@ -113,14 +113,13 @@ class ChannelManager extends CachedManager { } const data = await this.client.api.channels(id).get(); - // Delete in cache - this._remove(id); return this._add(data, null, { cache, allowUnknownGuild }); } + /** * Create Group DM * @param {UserResolvable[]} recipients Array of recipients - * @returns {Promise} Channel + * @returns {Promise} Channel */ async createGroupDM(recipients) { // Check diff --git a/src/managers/GuildForumThreadManager.js b/src/managers/GuildForumThreadManager.js index 66c5312..06e04f1 100644 --- a/src/managers/GuildForumThreadManager.js +++ b/src/managers/GuildForumThreadManager.js @@ -3,7 +3,7 @@ const ThreadManager = require('./ThreadManager'); const { TypeError } = require('../errors'); const MessagePayload = require('../structures/MessagePayload'); -const { resolveAutoArchiveMaxLimit, getAttachments, uploadFile } = require('../util/Util'); +const { resolveAutoArchiveMaxLimit, getUploadURL, uploadFile } = require('../util/Util'); /** * Manages API methods for threads in forum channels and stores their cache. @@ -20,7 +20,7 @@ class GuildForumThreadManager extends ThreadManager { * @typedef {BaseMessageOptions} GuildForumThreadMessageCreateOptions * @property {StickerResolvable} [stickers] The stickers to send with the message * @property {BitFieldResolvable} [flags] The flags to send with the message. - * Only `SUPPRESS_EMBEDS`, `SUPPRESS_NOTIFICATIONS` and `IS_VOICE_MESSAGE` can be set. + * Only `SUPPRESS_EMBEDS` and `SUPPRESS_NOTIFICATIONS` can be set. */ /** @@ -63,35 +63,29 @@ class GuildForumThreadManager extends ThreadManager { let messagePayload; if (message instanceof MessagePayload) { - messagePayload = await message.resolveData(); + messagePayload = message.resolveData(); } else { - messagePayload = await MessagePayload.create(this, message).resolveData(); + messagePayload = MessagePayload.create(this, message).resolveData(); } - let { data: body, files } = await messagePayload.resolveFiles(); + const { data: body, files } = await messagePayload.resolveFiles(); - if (typeof message == 'object' && typeof message.usingNewAttachmentAPI !== 'boolean') { - message.usingNewAttachmentAPI = this.client.options.usingNewAttachmentAPI; - } - - if (message?.usingNewAttachmentAPI === true) { - const attachments = await getAttachments(this.client, this.channel.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)); - body.attachments = attachmentsData; - files = []; - } + // New API + const attachments = await getUploadURL(this.client, this.channel.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; if (autoArchiveDuration === 'MAX') autoArchiveDuration = resolveAutoArchiveMaxLimit(this.channel.guild); @@ -103,7 +97,7 @@ class GuildForumThreadManager extends ThreadManager { applied_tags: appliedTags, message: body, }, - files, + files: [], reason, }); diff --git a/src/managers/MessageManager.js b/src/managers/MessageManager.js index 975102b..8a0466f 100644 --- a/src/managers/MessageManager.js +++ b/src/managers/MessageManager.js @@ -2,7 +2,7 @@ const { Collection } = require('@discordjs/collection'); const CachedManager = require('./CachedManager'); -const { TypeError, Error } = require('../errors'); +const { TypeError } = require('../errors'); const { Message } = require('../structures/Message'); const MessagePayload = require('../structures/MessagePayload'); const Util = require('../util/Util'); @@ -123,38 +123,32 @@ class MessageManager extends CachedManager { const messageId = this.resolveId(message); if (!messageId) throw new TypeError('INVALID_TYPE', 'message', 'MessageResolvable'); - let messagePayload; - if (options instanceof MessagePayload) { - messagePayload = await options.resolveData(); - } else { - messagePayload = await MessagePayload.create(message instanceof Message ? message : this, options).resolveData(); - } - let { data, files } = await messagePayload.resolveFiles(); + const { data, files } = await (options instanceof MessagePayload + ? options + : MessagePayload.create(message instanceof Message ? message : this, options) + ) + .resolveData() + .resolveFiles(); - if (typeof options == 'object' && typeof options.usingNewAttachmentAPI !== 'boolean') { - options.usingNewAttachmentAPI = this.client.options.usingNewAttachmentAPI; - } + // New API + const attachments = await Util.getUploadURL(this.client, this.channel.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 - if (options?.usingNewAttachmentAPI === true) { - const attachments = await Util.getAttachments(this.client, this.channel.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; - files = []; - } - - const d = await this.client.api.channels[this.channel.id].messages[messageId].patch({ data, files }); + const d = await this.client.api.channels[this.channel.id].messages[messageId].patch({ data }); const existing = this.cache.get(messageId); if (existing) { @@ -251,12 +245,16 @@ class MessageManager extends CachedManager { const existing = this.cache.get(messageId); if (existing && !existing.partial) return existing; } + // https://discord.com/api/v9/channels/:id/messages?limit=50&around=:msgid return new Promise((resolve, reject) => { - this._fetchMany({ - around: messageId, - limit: 50, - }) + this._fetchMany( + { + around: messageId, + limit: 50, + }, + cache, + ) .then(data_ => data_.has(messageId) ? resolve(data_.get(messageId)) : reject(new Error('MESSAGE_ID_NOT_FOUND')), ) @@ -264,13 +262,6 @@ class MessageManager extends CachedManager { }); } - async _fetchMany(options = {}, cache) { - const data = await this.client.api.channels[this.channel.id].messages.get({ query: options }); - const messages = new Collection(); - for (const message of data) messages.set(message.id, this._add(message, cache)); - return messages; - } - /** * @typedef {object} MessageSearchOptions * @property {Array} [authors] An array of author to filter by @@ -388,6 +379,13 @@ class MessageManager extends CachedManager { total: data.total_results, }; } + + async _fetchMany(options = {}, cache) { + const data = await this.client.api.channels[this.channel.id].messages.get({ query: options }); + const messages = new Collection(); + for (const message of data) messages.set(message.id, this._add(message, cache)); + return messages; + } } module.exports = MessageManager; diff --git a/src/structures/Message.js b/src/structures/Message.js index 08b03b1..d0d72e1 100644 --- a/src/structures/Message.js +++ b/src/structures/Message.js @@ -1,22 +1,26 @@ 'use strict'; const process = require('node:process'); +const { setTimeout } = require('node:timers'); 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 MessageButton = require('./MessageButton'); const Embed = require('./MessageEmbed'); const Mentions = require('./MessageMentions'); const MessagePayload = require('./MessagePayload'); -const MessageSelectMenu = require('./MessageSelectMenu'); const ReactionCollector = require('./ReactionCollector'); const { Sticker } = require('./Sticker'); +const Application = require('./interfaces/Application'); const { Error } = require('../errors'); const ReactionManager = require('../managers/ReactionManager'); -const { InteractionTypes, MessageTypes, SystemMessageTypes, MaxBulkDeletableMessageAge } = require('../util/Constants'); +const { + InteractionTypes, + MessageTypes, + SystemMessageTypes, + MessageComponentTypes, + Events, +} = require('../util/Constants'); const MessageFlags = require('../util/MessageFlags'); const Permissions = require('../util/Permissions'); const SnowflakeUtil = require('../util/SnowflakeUtil'); @@ -256,9 +260,9 @@ class Message extends Base { if ('application' in data) { /** * Supplemental application information for group activities - * @type {?ClientApplication} + * @type {?Application} */ - this.groupActivityApplication = new ClientApplication(this.client, data.application); + this.groupActivityApplication = new Application(this.client, data.application); } else { this.groupActivityApplication ??= null; } @@ -533,65 +537,6 @@ class Message extends Base { }); } - /** - * @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} @@ -653,14 +598,7 @@ class Message extends Base { * channel.bulkDelete(messages.filter(message => message.bulkDeletable)); */ get bulkDeletable() { - return ( - (this.inGuild() && - this.client.user.bot && - Date.now() - this.createdTimestamp < MaxBulkDeletableMessageAge && - this.deletable && - this.channel?.permissionsFor(this.client.user).has(Permissions.FLAGS.MANAGE_MESSAGES, false)) ?? - false - ); + return false; } /** @@ -722,7 +660,7 @@ class Message extends Base { * @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 {Array<(MessageActionRow|MessageActionRowOptions)>} [components] + * @property {MessageActionRow[]|MessageActionRowOptions[]} [components] * Action rows containing interactive components for the message (buttons, select menus) */ @@ -792,7 +730,7 @@ class Message extends Base { /** * Adds a reaction to the message. * @param {EmojiIdentifierResolvable} emoji The emoji to react with - * @param {boolean} [burst=false] Super Reactions (Discord Nitro only) + * @param {boolean} [burst=false] Super Reactions * @returns {Promise} * @example * // React to a message with a unicode emoji @@ -1024,13 +962,221 @@ class Message extends Base { reactions: false, }); } - // Added + + // TypeScript + /** + * Check data + * @type {boolean} + * @readonly + */ + get isMessage() { + return true; + } + + /** + * Click specific button with X and Y + * @typedef {Object} MessageButtonLocation + * @property {number} X Index of the row + * @property {number} Y Index of the column + */ + + /** + * Click specific button or automatically click first button if no button is specified. + * @param {MessageButtonLocation|string|undefined} button button + * @returns {Promise} + * @example + * // Demo msg + * Some content + * ――――――――――――――――――――――――――――――――> X from 0 + * │ [button1] [button2] [button3] + * │ [button4] [button5] [button6] + * ↓ + * Y from 0 + * // Click button6 with X and Y + * [0,0] [1,0] [2,0] + * [0,1] [1,1] [2,1] + * // Code + * message.clickButton({ + * X: 2, Y: 1, + * }); + * // Click button with customId (Ex button 5) + * message.clickButton('button5'); + * // Click button 1 + * message.clickButton(); + */ + clickButton(button) { + if (typeof button == 'undefined') { + button = this.components + .flatMap(row => row.components) + .find(b => b.type === 'BUTTON' && b.customId && !b.disabled); + } else if (typeof button == 'string') { + button = this.components.flatMap(row => row.components).find(b => b.type === 'BUTTON' && b.customId == button); + } else { + button = this.components[button.Y]?.components[button.X]; + } + button = button.toJSON(); + if (!button) throw new TypeError('BUTTON_NOT_FOUND'); + if (!button.custom_id || button.disabled) throw new TypeError('BUTTON_CANNOT_CLICK'); + const nonce = SnowflakeUtil.generate(); + const data = { + type: InteractionTypes.MESSAGE_COMPONENT, + nonce, + guild_id: this.guildId, + channel_id: this.channelId, + message_id: this.id, + application_id: this.applicationId ?? this.author.id, + session_id: this.client.ws.shards.first()?.sessionId, + message_flags: this.flags.bitfield, + data: { + component_type: MessageComponentTypes.BUTTON, + custom_id: button.custom_id, + }, + }; + this.client.api.interactions.post({ + data, + }); + return new Promise((resolve, reject) => { + const timeoutMs = 5_000; + // Waiting for MsgCreate / ModalCreate + const handler = data => { + // UnhandledPacket + if (data.d?.nonce == nonce && data.t == 'INTERACTION_SUCCESS') { + // Interaction#deferUpdate + this.client.removeListener(Events.MESSAGE_CREATE, handler); + this.client.removeListener(Events.UNHANDLED_PACKET, handler); + this.client.removeListener(Events.INTERACTION_MODAL_CREATE, handler); + resolve(this); + } + if (data.nonce !== nonce) return; + clearTimeout(timeout); + this.client.removeListener(Events.MESSAGE_CREATE, handler); + this.client.removeListener(Events.UNHANDLED_PACKET, 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.UNHANDLED_PACKET, 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.UNHANDLED_PACKET, handler); + this.client.on(Events.INTERACTION_MODAL_CREATE, handler); + }); + } + + /** + * Select specific menu + * @param {number|string} menu Target + * @param {Array} values Any value + * @returns {Promise} + */ + selectMenu(menu, values = []) { + let selectMenu; + if (/[0-4]/.test(menu)) { + selectMenu = this.components[menu]?.components[0]; + } else { + selectMenu = this.components + .flatMap(row => row.components) + .find( + b => + ['STRING_SELECT', 'USER_SELECT', 'ROLE_SELECT', 'MENTIONABLE_SELECT', 'CHANNEL_SELECT'].includes(b.type) && + b.customId == menu && + !b.disabled, + ); + } + if (values.length < selectMenu.minValues) { + throw new RangeError(`[SELECT_MENU_MIN_VALUES] The minimum number of values is ${selectMenu.minValues}`); + } + if (values.length > selectMenu.maxValues) { + throw new RangeError(`[SELECT_MENU_MAX_VALUES] The maximum number of values is ${selectMenu.maxValues}`); + } + values = values.map(value => { + switch (selectMenu.type) { + case 'STRING_SELECT': { + return selectMenu.options.find(obj => obj.value === value || obj.label === value).value; + } + case 'USER_SELECT': { + return this.client.users.resolveId(value); + } + case 'ROLE_SELECT': { + return this.guild.roles.resolveId(value); + } + case 'MENTIONABLE_SELECT': { + return this.client.users.resolveId(value) || this.guild.roles.resolveId(value); + } + case 'CHANNEL_SELECT': { + return this.client.channels.resolveId(value); + } + default: { + return value; + } + } + }); + const nonce = SnowflakeUtil.generate(); + const data = { + type: InteractionTypes.MESSAGE_COMPONENT, + guild_id: this.guildId, + channel_id: this.channelId, + message_id: this.id, + application_id: this.applicationId ?? this.author.id, + session_id: this.client.ws.shards.first()?.sessionId, + message_flags: this.flags.bitfield, + data: { + component_type: MessageComponentTypes[selectMenu.type], + custom_id: selectMenu.customId, + type: MessageComponentTypes[selectMenu.type], + values, + }, + nonce, + }; + this.client.api.interactions.post({ + data, + }); + return new Promise((resolve, reject) => { + const timeoutMs = 5_000; + // Waiting for MsgCreate / ModalCreate + const handler = data => { + // UnhandledPacket + if (data.d?.nonce == nonce && data.t == 'INTERACTION_SUCCESS') { + // Interaction#deferUpdate + this.client.removeListener(Events.MESSAGE_CREATE, handler); + this.client.removeListener(Events.UNHANDLED_PACKET, handler); + this.client.removeListener(Events.INTERACTION_MODAL_CREATE, handler); + resolve(this); + } + if (data.nonce !== nonce) return; + clearTimeout(timeout); + this.client.removeListener(Events.MESSAGE_CREATE, handler); + this.client.removeListener(Events.UNHANDLED_PACKET, 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.UNHANDLED_PACKET, 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.UNHANDLED_PACKET, handler); + this.client.on(Events.INTERACTION_MODAL_CREATE, handler); + }); + } + /** * Marks the message as unread. - * @returns {Promise} + * @returns {Promise} */ - async markUnread() { - await this.client.api.channels[this.channelId].messages[this.id].ack.post({ + markUnread() { + return this.client.api.channels[this.channelId].messages[this.id].ack.post({ data: { manual: true, mention_count: @@ -1042,145 +1188,18 @@ class Message extends Base { : 0, }, }); - return true; } /** * Marks the message as read. - * @returns {Promise} + * @returns {Promise} */ - async markRead() { - await this.client.api.channels[this.channelId].messages[this.id].ack.post({ + markRead() { + return this.client.api.channels[this.channelId].messages[this.id].ack.post({ data: { token: null, }, }); - return true; - } - - /** - * @typedef {Object} MessageButtonLocation - * @property {number} row Index of the row - * @property {number} col Index of the column - */ - - /** - * Click specific button or automatically click first button if no button is specified. - * @param {MessageButton|MessageButtonLocation|string} button Button ID - * @returns {Promise} - * @example - * client.on('messageCreate', async message => { - * if (message.components.length) { - * // Find first button and click it - * await message.clickButton(); - * // Click with button ID - * await message.clickButton('button-id'); - * // Click with button location - * await message.clickButton({ row: 0, col: 0 }); - * // Click with class MessageButton - * const button = message.components[0].components[0]; - * await message.clickButton(button); - * // Click with class MessageButton (2) - * button.click(message); - * } - * }); - */ - clickButton(button) { - if (!button) { - button = this.components.flatMap(row => row.components).find(b => b.type === 'BUTTON')?.customId; - } else if (button instanceof MessageButton) { - button = button.customId; - } else if (typeof button === 'object') { - if (!('row' in button) || !('col' in button)) throw new TypeError('INVALID_BUTTON_LOCATION'); - button = this.components[button.row]?.components[button.col]?.customId; - } - if (!button) throw new TypeError('BUTTON_NOT_FOUND'); - button = this.components.flatMap(row => row.components).find(b => b.customId === button && b.type === 'BUTTON'); - return button ? button.click(this) : Promise.reject(new TypeError('BUTTON_NOT_FOUND')); - } - /** - * Select specific menu or First Menu - * @param {MessageSelectMenu|string|number|Array} menuID MenuId / MessageSelectMenu / Row of Menu / Array of Values (first menu) - * @param {Array} options Array of Values - * @returns {Promise} - * @example - * client.on('messageCreate', async message => { - * if (message.components.length) { - * // Row - * await message.selectMenu(1, [message.channel]); // row 1, type: Channel, multi: false - * // Id - * await message.selectMenu('menu-id', ['uid1', client.user, message.member]); // MenuId, type: User, multi: true - * // First Menu - * await message.selectMenu(['role-id']); // First Menu, type: role, multi: false - * // class MessageSelectMenu - * const menu = message.components[0].components[0]; - * await message.selectMenu(menu, ['option1', 'option2']); - * // MessageSelectMenu (2) - * menu.select(message, ['option1', 'option2']); - * } - * }); - */ - selectMenu(menuID, options = []) { - if (!this.components[0]) throw new TypeError('MESSAGE_NO_COMPONENTS'); - if (menuID instanceof MessageSelectMenu) { - // - } else if (/[0-4]/.test(menuID)) { - menuID = this.components[menuID]?.components[0]; - } else { - const menuAll = this.components - .flatMap(row => row.components) - .filter(b => - [ - 'STRING_SELECT', - 'USER_SELECT', - 'ROLE_SELECT', - 'MENTIONABLE_SELECT', - 'CHANNEL_SELECT', - 'SELECT_MENU', - ].includes(b.type), - ); - if (menuAll.length == 0) throw new TypeError('MENU_NOT_FOUND'); - if (menuID) { - menuID = menuAll.find(b => b.customId === menuID); - } else { - menuID = menuAll[0]; - } - } - if (!menuID.type.includes('SELECT')) throw new TypeError('MENU_NOT_FOUND'); - return menuID.select(this, Array.isArray(menuID) ? menuID : options); - } - // - /** - * Send context Menu v2 - * @param {Snowflake} 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.application) { - 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'); - } - let contextCMD; - const data = await this.channel.searchInteraction(botId, 'MESSAGE'); - for (const command of data.application_commands) { - user.application?.commands?._add(command, true); - } - contextCMD = user.application?.commands?.cache.find(c => c.name == commandName && c.type === 'MESSAGE'); - if (!contextCMD) { - throw new Error( - 'INTERACTION_SEND_FAILURE', - `Command ${commandName} is not found (with search)\nList command avalible: ${user.application?.commands?.cache - .filter(a => a.type == 'MESSAGE') - .map(a => a.name) - .join(', ')}`, - ); - } - return contextCMD.sendContextMenu(this, true); } } diff --git a/src/structures/MessageReaction.js b/src/structures/MessageReaction.js index a202800..6983247 100644 --- a/src/structures/MessageReaction.js +++ b/src/structures/MessageReaction.js @@ -31,7 +31,7 @@ class MessageReaction { this.me = data.me || data.me_burst; /** - * Super reaction + * Is super reaction * @type {boolean} */ this.isBurst = Boolean(data.me_burst || data.burst); diff --git a/src/structures/VoiceState.js b/src/structures/VoiceState.js index 90fbdb5..ab7df6e 100644 --- a/src/structures/VoiceState.js +++ b/src/structures/VoiceState.js @@ -86,11 +86,15 @@ class VoiceState extends Base { // The self_stream is property is omitted if false, check for another property // here to avoid incorrectly clearing this when partial data is specified - /** - * Whether this member is streaming using "Screen Share" - * @type {boolean} - */ - this.streaming = data.self_stream ?? false; + if ('self_stream' in data) { + /** + * Whether this member is streaming using "Screen Share" + * @type {boolean} + */ + this.streaming = data.self_stream ?? false; + } else { + this.streaming ??= null; + } if ('channel_id' in data) { /** @@ -144,12 +148,11 @@ class VoiceState extends Base { /** * The channel that the member is connected to - * @type {?(VoiceChannel|StageChannel)} + * @type {?(VoiceChannel|StageChannel|DMChannel|GroupDMChannel)} * @readonly */ get channel() { - if (!this.guild?.id) return this.guild.client.channels.cache.get(this.channelId) ?? null; - return this.guild.channels.cache.get(this.channelId) ?? null; + return (this.guild || this.client).channels.cache.get(this.channelId) ?? null; } /** @@ -177,7 +180,6 @@ class VoiceState extends Base { * @returns {Promise} */ setMute(mute = true, reason) { - if (!this.guild?.id) return null; return this.guild.members.edit(this.id, { mute }, reason); } @@ -188,7 +190,6 @@ class VoiceState extends Base { * @returns {Promise} */ setDeaf(deaf = true, reason) { - if (!this.guild?.id) return null; return this.guild.members.edit(this.id, { deaf }, reason); } @@ -198,7 +199,6 @@ class VoiceState extends Base { * @returns {Promise} */ disconnect(reason) { - if (!this.guild?.id) return this.callVoice?.disconnect(); return this.setChannel(null, reason); } @@ -210,36 +210,9 @@ class VoiceState extends Base { * @returns {Promise} */ setChannel(channel, reason) { - if (!this.guild?.id) return null; return this.guild.members.edit(this.id, { channel }, reason); } - /** - * Sets the status of the voice channel - * @param {string} status The message to set the channel status to - * @example - * // Setting the status to something - * guild.members.me.voice.setStatus("something") - * @example - * // Removing the status - * guild.members.me.voice.setStatus("") - * @returns {Promise} - */ - async setStatus(status) { - // PUT https://discord.com/api/v9/channels/channelID/voice-status - if (this.channel?.id) { - // You can set the staus in normal voice channels and in stages so any type starting with GUILD should work - if (!this.channel?.type.startsWith('GUILD')) throw new Error('VOICE_NOT_IN_GUILD'); - - await this.client.api.channels(this.channel.id, 'voice-status').put({ - data: { - status, - }, - versioned: true, - }); - } - } - /** * Toggles the request to speak in the channel. * Only applicable for stage channels and for the client's own voice state. @@ -253,18 +226,16 @@ class VoiceState extends Base { * @returns {Promise} */ async setRequestToSpeak(request = true) { - if (this.guild?.id) { - if (this.channel?.type !== 'GUILD_STAGE_VOICE') throw new Error('VOICE_NOT_STAGE_CHANNEL'); + if (this.channel?.type !== 'GUILD_STAGE_VOICE') throw new Error('VOICE_NOT_STAGE_CHANNEL'); - if (this.client.user.id !== this.id) throw new Error('VOICE_STATE_NOT_OWN'); + if (this.client.user.id !== this.id) throw new Error('VOICE_STATE_NOT_OWN'); - await this.client.api.guilds(this.guild.id, 'voice-states', '@me').patch({ - data: { - channel_id: this.channelId, - request_to_speak_timestamp: request ? new Date().toISOString() : null, - }, - }); - } + await this.client.api.guilds(this.guild.id, 'voice-states', '@me').patch({ + data: { + channel_id: this.channelId, + request_to_speak_timestamp: request ? new Date().toISOString() : null, + }, + }); } /** @@ -285,21 +256,39 @@ class VoiceState extends Base { * @returns {Promise} */ async setSuppressed(suppressed = true) { - if (this.guild?.id) { - if (typeof suppressed !== 'boolean') throw new TypeError('VOICE_STATE_INVALID_TYPE', 'suppressed'); + if (typeof suppressed !== 'boolean') throw new TypeError('VOICE_STATE_INVALID_TYPE', 'suppressed'); - if (this.channel?.type !== 'GUILD_STAGE_VOICE') throw new Error('VOICE_NOT_STAGE_CHANNEL'); + if (this.channel?.type !== 'GUILD_STAGE_VOICE') throw new Error('VOICE_NOT_STAGE_CHANNEL'); - const target = this.client.user.id === this.id ? '@me' : this.id; + const target = this.client.user.id === this.id ? '@me' : this.id; - await this.client.api.guilds(this.guild.id, 'voice-states', target).patch({ - data: { - channel_id: this.channelId, - suppress: suppressed, - request_to_speak_timestamp: null, - }, - }); - } + await this.client.api.guilds(this.guild.id, 'voice-states', target).patch({ + data: { + channel_id: this.channelId, + suppress: suppressed, + request_to_speak_timestamp: null, + }, + }); + } + + /** + * Sets the status of the voice channel + * @param {string} [status=""] The message to set the channel status to + * @example + * // Setting the status to something + * guild.members.me.voice.setStatus("something") + * @example + * // Removing the status + * guild.members.me.voice.setStatus() + * @returns {Promise} + */ + setStatus(status = '') { + // PUT https://discord.com/api/v9/channels/:id/voice-status + return this.client.api.channels(this.channel.id, 'voice-status').put({ + data: { + status, + }, + }); } /** @@ -322,19 +311,18 @@ class VoiceState extends Base { * @param {string} base64Image Base64 URI (data:image/jpeg;base64,data) * @returns {Promise} */ - async postPreview(base64Image) { + postPreview(base64Image) { if (!this.client.user.id === this.id || !this.streaming) throw new Error('USER_NOT_STREAMING'); // URL: https://discord.com/api/v9/streams/guild:guildid:voicechannelid:userid/preview // URL: https://discord.com/api/v9/streams/call:channelId:userId/preview const streamKey = this.guild.id ? `guild:${this.guild.id}:${this.channelId}:${this.id}` : `call:${this.channelId}:${this.id}`; - await this.client.api.streams[encodeURIComponent(streamKey)].preview.post({ + return this.client.api.streams[encodeURIComponent(streamKey)].preview.post({ data: { thumbnail: base64Image, }, }); - return true; } toJSON() {