buddy

node MVC discord bot
Log | Files | Refs | README

TextBasedChannel.js (13718B)


      1 'use strict';
      2 
      3 /* eslint-disable import/order */
      4 const MessageCollector = require('../MessageCollector');
      5 const APIMessage = require('../APIMessage');
      6 const Snowflake = require('../../util/Snowflake');
      7 const Collection = require('../../util/Collection');
      8 const { RangeError, TypeError } = require('../../errors');
      9 
     10 /**
     11  * Interface for classes that have text-channel-like features.
     12  * @interface
     13  */
     14 class TextBasedChannel {
     15   constructor() {
     16     /**
     17      * A manager of the messages sent to this channel
     18      * @type {MessageManager}
     19      */
     20     this.messages = new MessageManager(this);
     21 
     22     /**
     23      * The ID of the last message in the channel, if one was sent
     24      * @type {?Snowflake}
     25      */
     26     this.lastMessageID = null;
     27 
     28     /**
     29      * The timestamp when the last pinned message was pinned, if there was one
     30      * @type {?number}
     31      */
     32     this.lastPinTimestamp = null;
     33   }
     34 
     35   /**
     36    * The Message object of the last message in the channel, if one was sent
     37    * @type {?Message}
     38    * @readonly
     39    */
     40   get lastMessage() {
     41     return this.messages.cache.get(this.lastMessageID) || null;
     42   }
     43 
     44   /**
     45    * The date when the last pinned message was pinned, if there was one
     46    * @type {?Date}
     47    * @readonly
     48    */
     49   get lastPinAt() {
     50     return this.lastPinTimestamp ? new Date(this.lastPinTimestamp) : null;
     51   }
     52 
     53   /**
     54    * Options provided when sending or editing a message.
     55    * @typedef {Object} MessageOptions
     56    * @property {boolean} [tts=false] Whether or not the message should be spoken aloud
     57    * @property {string} [nonce=''] The nonce for the message
     58    * @property {string} [content=''] The content for the message
     59    * @property {MessageEmbed|Object} [embed] An embed for the message
     60    * (see [here](https://discordapp.com/developers/docs/resources/channel#embed-object) for more details)
     61    * @property {MessageMentionOptions} [allowedMentions] Which mentions should be parsed from the message content
     62    * @property {DisableMentionType} [disableMentions=this.client.options.disableMentions] Whether or not all mentions or
     63    * everyone/here mentions should be sanitized to prevent unexpected mentions
     64    * @property {FileOptions[]|BufferResolvable[]} [files] Files to send with the message
     65    * @property {string|boolean} [code] Language for optional codeblock formatting to apply
     66    * @property {boolean|SplitOptions} [split=false] Whether or not the message should be split into multiple messages if
     67    * it exceeds the character limit. If an object is provided, these are the options for splitting the message
     68    * @property {UserResolvable} [reply] User to reply to (prefixes the message with a mention, except in DMs)
     69    */
     70 
     71   /**
     72    * Options provided to control parsing of mentions by Discord
     73    * @typedef {Object} MessageMentionOptions
     74    * @property {MessageMentionTypes[]} [parse] Types of mentions to be parsed
     75    * @property {Snowflake[]} [users] Snowflakes of Users to be parsed as mentions
     76    * @property {Snowflake[]} [roles] Snowflakes of Roles to be parsed as mentions
     77    */
     78 
     79   /**
     80    * Types of mentions to enable in MessageMentionOptions.
     81    * - `roles`
     82    * - `users`
     83    * - `everyone`
     84    * @typedef {string} MessageMentionTypes
     85    */
     86 
     87   /**
     88    * The type of mentions to disable.
     89    * - `none`
     90    * - `all`
     91    * - `everyone`
     92    * @typedef {string} DisableMentionType
     93    */
     94 
     95   /**
     96    * @typedef {Object} FileOptions
     97    * @property {BufferResolvable} attachment File to attach
     98    * @property {string} [name='file.jpg'] Filename of the attachment
     99    */
    100 
    101   /**
    102    * Options for splitting a message.
    103    * @typedef {Object} SplitOptions
    104    * @property {number} [maxLength=2000] Maximum character length per message piece
    105    * @property {string} [char='\n'] Character to split the message with
    106    * @property {string} [prepend=''] Text to prepend to every piece except the first
    107    * @property {string} [append=''] Text to append to every piece except the last
    108    */
    109 
    110   /**
    111    * Sends a message to this channel.
    112    * @param {StringResolvable|APIMessage} [content=''] The content to send
    113    * @param {MessageOptions|MessageAdditions} [options={}] The options to provide
    114    * @returns {Promise<Message|Message[]>}
    115    * @example
    116    * // Send a basic message
    117    * channel.send('hello!')
    118    *   .then(message => console.log(`Sent message: ${message.content}`))
    119    *   .catch(console.error);
    120    * @example
    121    * // Send a remote file
    122    * channel.send({
    123    *   files: ['https://cdn.discordapp.com/icons/222078108977594368/6e1019b3179d71046e463a75915e7244.png?size=2048']
    124    * })
    125    *   .then(console.log)
    126    *   .catch(console.error);
    127    * @example
    128    * // Send a local file
    129    * channel.send({
    130    *   files: [{
    131    *     attachment: 'entire/path/to/file.jpg',
    132    *     name: 'file.jpg'
    133    *   }]
    134    * })
    135    *   .then(console.log)
    136    *   .catch(console.error);
    137    * @example
    138    * // Send an embed with a local image inside
    139    * channel.send('This is an embed', {
    140    *   embed: {
    141    *     thumbnail: {
    142    *          url: 'attachment://file.jpg'
    143    *       }
    144    *    },
    145    *    files: [{
    146    *       attachment: 'entire/path/to/file.jpg',
    147    *       name: 'file.jpg'
    148    *    }]
    149    * })
    150    *   .then(console.log)
    151    *   .catch(console.error);
    152    */
    153   async send(content, options) {
    154     const User = require('../User');
    155     const GuildMember = require('../GuildMember');
    156 
    157     if (this instanceof User || this instanceof GuildMember) {
    158       return this.createDM().then(dm => dm.send(content, options));
    159     }
    160 
    161     let apiMessage;
    162 
    163     if (content instanceof APIMessage) {
    164       apiMessage = content.resolveData();
    165     } else {
    166       apiMessage = APIMessage.create(this, content, options).resolveData();
    167       if (Array.isArray(apiMessage.data.content)) {
    168         return Promise.all(apiMessage.split().map(this.send.bind(this)));
    169       }
    170     }
    171 
    172     const { data, files } = await apiMessage.resolveFiles();
    173     return this.client.api.channels[this.id].messages
    174       .post({ data, files })
    175       .then(d => this.client.actions.MessageCreate.handle(d).message);
    176   }
    177 
    178   /**
    179    * Starts a typing indicator in the channel.
    180    * @param {number} [count=1] The number of times startTyping should be considered to have been called
    181    * @returns {Promise} Resolves once the bot stops typing gracefully, or rejects when an error occurs
    182    * @example
    183    * // Start typing in a channel, or increase the typing count by one
    184    * channel.startTyping();
    185    * @example
    186    * // Start typing in a channel with a typing count of five, or set it to five
    187    * channel.startTyping(5);
    188    */
    189   startTyping(count) {
    190     if (typeof count !== 'undefined' && count < 1) throw new RangeError('TYPING_COUNT');
    191     if (this.client.user._typing.has(this.id)) {
    192       const entry = this.client.user._typing.get(this.id);
    193       entry.count = count || entry.count + 1;
    194       return entry.promise;
    195     }
    196 
    197     const entry = {};
    198     entry.promise = new Promise((resolve, reject) => {
    199       const endpoint = this.client.api.channels[this.id].typing;
    200       Object.assign(entry, {
    201         count: count || 1,
    202         interval: this.client.setInterval(() => {
    203           endpoint.post().catch(error => {
    204             this.client.clearInterval(entry.interval);
    205             this.client.user._typing.delete(this.id);
    206             reject(error);
    207           });
    208         }, 9000),
    209         resolve,
    210       });
    211       endpoint.post().catch(error => {
    212         this.client.clearInterval(entry.interval);
    213         this.client.user._typing.delete(this.id);
    214         reject(error);
    215       });
    216       this.client.user._typing.set(this.id, entry);
    217     });
    218     return entry.promise;
    219   }
    220 
    221   /**
    222    * Stops the typing indicator in the channel.
    223    * The indicator will only stop if this is called as many times as startTyping().
    224    * <info>It can take a few seconds for the client user to stop typing.</info>
    225    * @param {boolean} [force=false] Whether or not to reset the call count and force the indicator to stop
    226    * @example
    227    * // Reduce the typing count by one and stop typing if it reached 0
    228    * channel.stopTyping();
    229    * @example
    230    * // Force typing to fully stop regardless of typing count
    231    * channel.stopTyping(true);
    232    */
    233   stopTyping(force = false) {
    234     if (this.client.user._typing.has(this.id)) {
    235       const entry = this.client.user._typing.get(this.id);
    236       entry.count--;
    237       if (entry.count <= 0 || force) {
    238         this.client.clearInterval(entry.interval);
    239         this.client.user._typing.delete(this.id);
    240         entry.resolve();
    241       }
    242     }
    243   }
    244 
    245   /**
    246    * Whether or not the typing indicator is being shown in the channel
    247    * @type {boolean}
    248    * @readonly
    249    */
    250   get typing() {
    251     return this.client.user._typing.has(this.id);
    252   }
    253 
    254   /**
    255    * Number of times `startTyping` has been called
    256    * @type {number}
    257    * @readonly
    258    */
    259   get typingCount() {
    260     if (this.client.user._typing.has(this.id)) return this.client.user._typing.get(this.id).count;
    261     return 0;
    262   }
    263 
    264   /**
    265    * Creates a Message Collector.
    266    * @param {CollectorFilter} filter The filter to create the collector with
    267    * @param {MessageCollectorOptions} [options={}] The options to pass to the collector
    268    * @returns {MessageCollector}
    269    * @example
    270    * // Create a message collector
    271    * const filter = m => m.content.includes('discord');
    272    * const collector = channel.createMessageCollector(filter, { time: 15000 });
    273    * collector.on('collect', m => console.log(`Collected ${m.content}`));
    274    * collector.on('end', collected => console.log(`Collected ${collected.size} items`));
    275    */
    276   createMessageCollector(filter, options = {}) {
    277     return new MessageCollector(this, filter, options);
    278   }
    279 
    280   /**
    281    * An object containing the same properties as CollectorOptions, but a few more:
    282    * @typedef {MessageCollectorOptions} AwaitMessagesOptions
    283    * @property {string[]} [errors] Stop/end reasons that cause the promise to reject
    284    */
    285 
    286   /**
    287    * Similar to createMessageCollector but in promise form.
    288    * Resolves with a collection of messages that pass the specified filter.
    289    * @param {CollectorFilter} filter The filter function to use
    290    * @param {AwaitMessagesOptions} [options={}] Optional options to pass to the internal collector
    291    * @returns {Promise<Collection<Snowflake, Message>>}
    292    * @example
    293    * // Await !vote messages
    294    * const filter = m => m.content.startsWith('!vote');
    295    * // Errors: ['time'] treats ending because of the time limit as an error
    296    * channel.awaitMessages(filter, { max: 4, time: 60000, errors: ['time'] })
    297    *   .then(collected => console.log(collected.size))
    298    *   .catch(collected => console.log(`After a minute, only ${collected.size} out of 4 voted.`));
    299    */
    300   awaitMessages(filter, options = {}) {
    301     return new Promise((resolve, reject) => {
    302       const collector = this.createMessageCollector(filter, options);
    303       collector.once('end', (collection, reason) => {
    304         if (options.errors && options.errors.includes(reason)) {
    305           reject(collection);
    306         } else {
    307           resolve(collection);
    308         }
    309       });
    310     });
    311   }
    312 
    313   /**
    314    * Bulk deletes given messages that are newer than two weeks.
    315    * @param {Collection<Snowflake, Message>|Message[]|Snowflake[]|number} messages
    316    * Messages or number of messages to delete
    317    * @param {boolean} [filterOld=false] Filter messages to remove those which are older than two weeks automatically
    318    * @returns {Promise<Collection<Snowflake, Message>>} Deleted messages
    319    * @example
    320    * // Bulk delete messages
    321    * channel.bulkDelete(5)
    322    *   .then(messages => console.log(`Bulk deleted ${messages.size} messages`))
    323    *   .catch(console.error);
    324    */
    325   async bulkDelete(messages, filterOld = false) {
    326     if (Array.isArray(messages) || messages instanceof Collection) {
    327       let messageIDs = messages instanceof Collection ? messages.keyArray() : messages.map(m => m.id || m);
    328       if (filterOld) {
    329         messageIDs = messageIDs.filter(id => Date.now() - Snowflake.deconstruct(id).date.getTime() < 1209600000);
    330       }
    331       if (messageIDs.length === 0) return new Collection();
    332       if (messageIDs.length === 1) {
    333         await this.client.api
    334           .channels(this.id)
    335           .messages(messageIDs[0])
    336           .delete();
    337         const message = this.client.actions.MessageDelete.getMessage(
    338           {
    339             message_id: messageIDs[0],
    340           },
    341           this,
    342         );
    343         return message ? new Collection([[message.id, message]]) : new Collection();
    344       }
    345       await this.client.api.channels[this.id].messages['bulk-delete'].post({ data: { messages: messageIDs } });
    346       return messageIDs.reduce(
    347         (col, id) =>
    348           col.set(
    349             id,
    350             this.client.actions.MessageDeleteBulk.getMessage(
    351               {
    352                 message_id: id,
    353               },
    354               this,
    355             ),
    356           ),
    357         new Collection(),
    358       );
    359     }
    360     if (!isNaN(messages)) {
    361       const msgs = await this.messages.fetch({ limit: messages });
    362       return this.bulkDelete(msgs, filterOld);
    363     }
    364     throw new TypeError('MESSAGE_BULK_DELETE_TYPE');
    365   }
    366 
    367   static applyToClass(structure, full = false, ignore = []) {
    368     const props = ['send'];
    369     if (full) {
    370       props.push(
    371         'lastMessage',
    372         'lastPinAt',
    373         'bulkDelete',
    374         'startTyping',
    375         'stopTyping',
    376         'typing',
    377         'typingCount',
    378         'createMessageCollector',
    379         'awaitMessages',
    380       );
    381     }
    382     for (const prop of props) {
    383       if (ignore.includes(prop)) continue;
    384       Object.defineProperty(
    385         structure.prototype,
    386         prop,
    387         Object.getOwnPropertyDescriptor(TextBasedChannel.prototype, prop),
    388       );
    389     }
    390   }
    391 }
    392 
    393 module.exports = TextBasedChannel;
    394 
    395 // Fixes Circular
    396 const MessageManager = require('../../managers/MessageManager');