'use strict'; /* eslint-disable import/order */ const MessageCollector = require('../MessageCollector'); const MessagePayload = require('../MessagePayload'); const SnowflakeUtil = require('../../util/SnowflakeUtil'); const { Collection } = require('@discordjs/collection'); const { InteractionTypes } = require('../../util/Constants'); const { TypeError, Error } = require('../../errors'); const InteractionCollector = require('../InteractionCollector'); const { lazy } = require('../../util/Util'); const Message = lazy(() => require('../Message').Message); /** * Interface for classes that have text-channel-like features. * @interface */ class TextBasedChannel { constructor() { /** * A manager of the messages sent to this channel * @type {MessageManager} */ this.messages = new MessageManager(this); /** * The channel's last message id, if one was sent * @type {?Snowflake} */ this.lastMessageId = null; /** * The timestamp when the last pinned message was pinned, if there was one * @type {?number} */ this.lastPinTimestamp = null; } /** * The Message object of the last message in the channel, if one was sent * @type {?Message} * @readonly */ get lastMessage() { return this.messages.resolve(this.lastMessageId); } /** * The date when the last pinned message was pinned, if there was one * @type {?Date} * @readonly */ get lastPinAt() { return this.lastPinTimestamp ? new Date(this.lastPinTimestamp) : null; } /** * Base options provided when sending. * @typedef {Object} BaseMessageOptions * @property {MessageActivity} [activity] Group activity * @property {boolean} [tts=false] Whether or not the message should be spoken aloud * @property {string} [nonce=''] The nonce for the message * @property {string} [content=''] The content for the message * @property {WebEmbed[]|MessageEmbed[]|APIEmbed[]} [embeds] The embeds for the message * (see [here](https://discord.com/developers/docs/resources/channel#embed-object) for more details) * @property {MessageMentionOptions} [allowedMentions] Which mentions should be parsed from the message content * (see [here](https://discord.com/developers/docs/resources/channel#allowed-mentions-object) for more details) * @property {FileOptions[]|BufferResolvable[]|MessageAttachment[]} [files] Files to send with the message * @property {MessageActionRow[]|MessageActionRowOptions[]} [components] * Action rows containing interactive components for the message (buttons, select menus) * @property {MessageAttachment[]} [attachments] Attachments to send in the message */ /** * Options provided when sending or editing a message. * @typedef {BaseMessageOptions} MessageOptions * @property {ReplyOptions} [reply] The options for replying to a message * @property {StickerResolvable[]} [stickers=[]] Stickers to send in the message * @property {MessageFlags} [flags] Which flags to set for the message. Only `SUPPRESS_EMBEDS` can be set. */ /** * Options provided to control parsing of mentions by Discord * @typedef {Object} MessageMentionOptions * @property {MessageMentionTypes[]} [parse] Types of mentions to be parsed * @property {Snowflake[]} [users] Snowflakes of Users to be parsed as mentions * @property {Snowflake[]} [roles] Snowflakes of Roles to be parsed as mentions * @property {boolean} [repliedUser=true] Whether the author of the Message being replied to should be pinged */ /** * Types of mentions to enable in MessageMentionOptions. * - `roles` * - `users` * - `everyone` * @typedef {string} MessageMentionTypes */ /** * @typedef {Object} FileOptions * @property {BufferResolvable} attachment File to attach * @property {string} [name='file.jpg'] Filename of the attachment * @property {string} description The description of the file */ /** * Options for sending a message with a reply. * @typedef {Object} ReplyOptions * @property {MessageResolvable} messageReference The message to reply to (must be in the same channel and not system) * @property {boolean} [failIfNotExists=true] Whether to error if the referenced message * does not exist (creates a standard message in this case when false) */ /** * Sends a message to this channel. * @param {string|MessagePayload|MessageOptions} options The options to provide * @returns {Promise} * @example * // Send a basic message * channel.send('hello!') * .then(message => console.log(`Sent message: ${message.content}`)) * .catch(console.error); * @example * // Send a remote file * channel.send({ * files: ['https://cdn.discordapp.com/icons/222078108977594368/6e1019b3179d71046e463a75915e7244.png?size=2048'] * }) * .then(console.log) * .catch(console.error); * @example * // Send a local file * channel.send({ * files: [{ * attachment: 'entire/path/to/file.jpg', * name: 'file.jpg', * description: 'A description of the file' * }] * }) * .then(console.log) * .catch(console.error); * @example * // Send an embed with a local image inside * channel.send({ * content: 'This is an embed', * embeds: [ * { * thumbnail: { * url: 'attachment://file.jpg' * } * } * ], * files: [{ * attachment: 'entire/path/to/file.jpg', * name: 'file.jpg' * description: 'A description of the file' * }] * }) * .then(console.log) * .catch(console.error); */ async send(options) { const User = require('../User'); const { GuildMember } = require('../GuildMember'); if (this instanceof User || this instanceof GuildMember) { const dm = await this.createDM(); return dm.send(options); } let messagePayload; if (options instanceof MessagePayload) { messagePayload = await options.resolveData(); } else { messagePayload = await MessagePayload.create(this, options).resolveData(); } const { data, files } = await messagePayload.resolveFiles(); const d = await this.client.api.channels[this.id].messages.post({ data, files }); return this.messages.cache.get(d.id) ?? this.messages._add(d); } /** * Sends a typing indicator in the channel. * @returns {Promise} Resolves upon the typing status being sent * @example * // Start typing in a channel * channel.sendTyping(); */ async sendTyping() { await this.client.api.channels(this.id).typing.post(); } /** * Creates a Message Collector. * @param {MessageCollectorOptions} [options={}] The options to pass to the collector * @returns {MessageCollector} * @example * // Create a message collector * const filter = m => m.content.includes('discord'); * const collector = channel.createMessageCollector({ filter, time: 15_000 }); * collector.on('collect', m => console.log(`Collected ${m.content}`)); * collector.on('end', collected => console.log(`Collected ${collected.size} items`)); */ createMessageCollector(options = {}) { return new MessageCollector(this, options); } /** * An object containing the same properties as CollectorOptions, but a few more: * @typedef {MessageCollectorOptions} AwaitMessagesOptions * @property {string[]} [errors] Stop/end reasons that cause the promise to reject */ /** * Similar to createMessageCollector but in promise form. * Resolves with a collection of messages that pass the specified filter. * @param {AwaitMessagesOptions} [options={}] Optional options to pass to the internal collector * @returns {Promise>} * @example * // Await !vote messages * const filter = m => m.content.startsWith('!vote'); * // Errors: ['time'] treats ending because of the time limit as an error * channel.awaitMessages({ filter, max: 4, time: 60_000, errors: ['time'] }) * .then(collected => console.log(collected.size)) * .catch(collected => console.log(`After a minute, only ${collected.size} out of 4 voted.`)); */ awaitMessages(options = {}) { return new Promise((resolve, reject) => { const collector = this.createMessageCollector(options); collector.once('end', (collection, reason) => { if (options.errors?.includes(reason)) { reject(collection); } else { resolve(collection); } }); }); } /** * Creates a 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) < 1_209_600_000); } if (messageIds.length === 0) return new Collection(); if (messageIds.length === 1) { await this.client.api.channels(this.id).messages(messageIds[0]).delete(); const message = this.client.actions.MessageDelete.getMessage( { message_id: messageIds[0], }, this, ); return message ? new Collection([[message.id, message]]) : new Collection(); } await this.client.api.channels[this.id].messages['bulk-delete'].post({ data: { messages: messageIds } }); return messageIds.reduce( (col, id) => col.set( id, this.client.actions.MessageDeleteBulk.getMessage( { message_id: id, }, this, ), ), new Collection(), ); } if (!isNaN(messages)) { const msgs = await this.messages.fetch({ limit: messages }); return this.bulkDelete(msgs, filterOld); } throw new TypeError('MESSAGE_BULK_DELETE_TYPE'); } /** * Fetches all webhooks for the channel. * @returns {Promise>} * @example * // Fetch webhooks * channel.fetchWebhooks() * .then(hooks => console.log(`This channel has ${hooks.size} hooks`)) * .catch(console.error); */ fetchWebhooks() { return this.guild.channels.fetchWebhooks(this.id); } /** * Options used to create a {@link Webhook} in a guild text-based channel. * @typedef {Object} ChannelWebhookCreateOptions * @property {?(BufferResolvable|Base64Resolvable)} [avatar] Avatar for the webhook * @property {string} [reason] Reason for creating the webhook */ /** * Creates a webhook for the channel. * @param {string} name The name of the webhook * @param {ChannelWebhookCreateOptions} [options] Options for creating the webhook * @returns {Promise} Returns the created Webhook * @example * // Create a webhook for the current channel * channel.createWebhook('Snek', { * avatar: 'https://i.imgur.com/mI8XcpG.jpg', * reason: 'Needed a cool new Webhook' * }) * .then(console.log) * .catch(console.error) */ createWebhook(name, options = {}) { return this.guild.channels.createWebhook(this.id, name, options); } /** * Sets the rate limit per user (slowmode) for this channel. * @param {number} rateLimitPerUser The new rate limit in seconds * @param {string} [reason] Reason for changing the channel's rate limit * @returns {Promise} */ setRateLimitPerUser(rateLimitPerUser, reason) { return this.edit({ rateLimitPerUser }, reason); } /** * Sets whether this channel is flagged as NSFW. * @param {boolean} [nsfw=true] Whether the channel should be considered NSFW * @param {string} [reason] Reason for changing the channel's NSFW flag * @returns {Promise} */ setNSFW(nsfw = true, reason) { return this.edit({ nsfw }, reason); } /** * Send Slash to this channel * @param {Snowflake} botId Bot Id (Supports application ID - not bot) * @param {string} commandName Command name * @param {...?string|string[]} args Command arguments * @returns {Promise} */ async sendSlash(botId, commandName, ...args) { args = args.flat(2); if (!botId) throw new Error('Bot ID is required'); // ? maybe ... 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'); // Using API to search (without opcode ~ehehe) let commandTarget; // https://discord.com/api/v9/channels/id/application-commands/search?type=1&query=aiko&limit=7&include_applications=false&application_id=id const data = await this.client.api.channels[this.id]['application-commands'].search.get({ query: { type: 1, // CHAT_INPUT, include_applications: false, query: commandName, limit: 25, // Max // application_id: botId, }, }); for (const command of data.application_commands) { if (user.id == command.application_id) { const c = user.application?.commands?._add(command, true); if (command.name == commandName) commandTarget = c; } else { const tempUser = this.client.users.cache.get(command.application_id); if (tempUser && tempUser.bot && tempUser.application) { tempUser.application?.commands?._add(command, true); } } } // Remove commandTarget = 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}\n+ args: ${args.join( ' | ', )}`, ); } 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, }), args && args.length ? args : [], ); } static applyToClass(structure, full = false, ignore = []) { const props = ['send']; if (full) { props.push( 'lastMessage', 'lastPinAt', 'bulkDelete', 'sendTyping', 'createMessageCollector', 'awaitMessages', 'createMessageComponentCollector', 'awaitMessageComponent', 'fetchWebhooks', 'createWebhook', 'setRateLimitPerUser', 'setNSFW', 'sendSlash', ); } for (const prop of props) { if (ignore.includes(prop)) continue; Object.defineProperty( structure.prototype, prop, Object.getOwnPropertyDescriptor(TextBasedChannel.prototype, prop), ); } } } module.exports = TextBasedChannel; // Fixes Circular const MessageManager = require('../../managers/MessageManager');