buddy

node MVC discord bot
Log | Files | Refs | README

Client.js (15458B)


      1 'use strict';
      2 
      3 const BaseClient = require('./BaseClient');
      4 const ActionsManager = require('./actions/ActionsManager');
      5 const ClientVoiceManager = require('./voice/ClientVoiceManager');
      6 const WebSocketManager = require('./websocket/WebSocketManager');
      7 const { Error, TypeError, RangeError } = require('../errors');
      8 const ChannelManager = require('../managers/ChannelManager');
      9 const GuildEmojiManager = require('../managers/GuildEmojiManager');
     10 const GuildManager = require('../managers/GuildManager');
     11 const UserManager = require('../managers/UserManager');
     12 const ShardClientUtil = require('../sharding/ShardClientUtil');
     13 const ClientApplication = require('../structures/ClientApplication');
     14 const GuildPreview = require('../structures/GuildPreview');
     15 const Invite = require('../structures/Invite');
     16 const VoiceRegion = require('../structures/VoiceRegion');
     17 const Webhook = require('../structures/Webhook');
     18 const Collection = require('../util/Collection');
     19 const { Events, browser, DefaultOptions } = require('../util/Constants');
     20 const DataResolver = require('../util/DataResolver');
     21 const Intents = require('../util/Intents');
     22 const Permissions = require('../util/Permissions');
     23 const Structures = require('../util/Structures');
     24 
     25 /**
     26  * The main hub for interacting with the Discord API, and the starting point for any bot.
     27  * @extends {BaseClient}
     28  */
     29 class Client extends BaseClient {
     30   /**
     31    * @param {ClientOptions} [options] Options for the client
     32    */
     33   constructor(options = {}) {
     34     super(Object.assign({ _tokenType: 'Bot' }, options));
     35 
     36     // Obtain shard details from environment or if present, worker threads
     37     let data = process.env;
     38     try {
     39       // Test if worker threads module is present and used
     40       data = require('worker_threads').workerData || data;
     41     } catch {
     42       // Do nothing
     43     }
     44 
     45     if (this.options.shards === DefaultOptions.shards) {
     46       if ('SHARDS' in data) {
     47         this.options.shards = JSON.parse(data.SHARDS);
     48       }
     49     }
     50 
     51     if (this.options.shardCount === DefaultOptions.shardCount) {
     52       if ('SHARD_COUNT' in data) {
     53         this.options.shardCount = Number(data.SHARD_COUNT);
     54       } else if (Array.isArray(this.options.shards)) {
     55         this.options.shardCount = this.options.shards.length;
     56       }
     57     }
     58 
     59     const typeofShards = typeof this.options.shards;
     60 
     61     if (typeofShards === 'undefined' && typeof this.options.shardCount === 'number') {
     62       this.options.shards = Array.from({ length: this.options.shardCount }, (_, i) => i);
     63     }
     64 
     65     if (typeofShards === 'number') this.options.shards = [this.options.shards];
     66 
     67     if (Array.isArray(this.options.shards)) {
     68       this.options.shards = [
     69         ...new Set(
     70           this.options.shards.filter(item => !isNaN(item) && item >= 0 && item < Infinity && item === (item | 0)),
     71         ),
     72       ];
     73     }
     74 
     75     this._validateOptions();
     76 
     77     /**
     78      * The WebSocket manager of the client
     79      * @type {WebSocketManager}
     80      */
     81     this.ws = new WebSocketManager(this);
     82 
     83     /**
     84      * The action manager of the client
     85      * @type {ActionsManager}
     86      * @private
     87      */
     88     this.actions = new ActionsManager(this);
     89 
     90     /**
     91      * The voice manager of the client (`null` in browsers)
     92      * @type {?ClientVoiceManager}
     93      */
     94     this.voice = !browser ? new ClientVoiceManager(this) : null;
     95 
     96     /**
     97      * Shard helpers for the client (only if the process was spawned from a {@link ShardingManager})
     98      * @type {?ShardClientUtil}
     99      */
    100     this.shard =
    101       !browser && process.env.SHARDING_MANAGER
    102         ? ShardClientUtil.singleton(this, process.env.SHARDING_MANAGER_MODE)
    103         : null;
    104 
    105     /**
    106      * All of the {@link User} objects that have been cached at any point, mapped by their IDs
    107      * @type {UserManager}
    108      */
    109     this.users = new UserManager(this);
    110 
    111     /**
    112      * All of the guilds the client is currently handling, mapped by their IDs -
    113      * as long as sharding isn't being used, this will be *every* guild the bot is a member of
    114      * @type {GuildManager}
    115      */
    116     this.guilds = new GuildManager(this);
    117 
    118     /**
    119      * All of the {@link Channel}s that the client is currently handling, mapped by their IDs -
    120      * as long as sharding isn't being used, this will be *every* channel in *every* guild the bot
    121      * is a member of. Note that DM channels will not be initially cached, and thus not be present
    122      * in the Manager without their explicit fetching or use.
    123      * @type {ChannelManager}
    124      */
    125     this.channels = new ChannelManager(this);
    126 
    127     const ClientPresence = Structures.get('ClientPresence');
    128     /**
    129      * The presence of the Client
    130      * @private
    131      * @type {ClientPresence}
    132      */
    133     this.presence = new ClientPresence(this);
    134 
    135     Object.defineProperty(this, 'token', { writable: true });
    136     if (!browser && !this.token && 'DISCORD_TOKEN' in process.env) {
    137       /**
    138        * Authorization token for the logged in bot
    139        * <warn>This should be kept private at all times.</warn>
    140        * @type {?string}
    141        */
    142       this.token = process.env.DISCORD_TOKEN;
    143     } else {
    144       this.token = null;
    145     }
    146 
    147     /**
    148      * User that the client is logged in as
    149      * @type {?ClientUser}
    150      */
    151     this.user = null;
    152 
    153     /**
    154      * Time at which the client was last regarded as being in the `READY` state
    155      * (each time the client disconnects and successfully reconnects, this will be overwritten)
    156      * @type {?Date}
    157      */
    158     this.readyAt = null;
    159 
    160     if (this.options.messageSweepInterval > 0) {
    161       this.setInterval(this.sweepMessages.bind(this), this.options.messageSweepInterval * 1000);
    162     }
    163   }
    164 
    165   /**
    166    * All custom emojis that the client has access to, mapped by their IDs
    167    * @type {GuildEmojiManager}
    168    * @readonly
    169    */
    170   get emojis() {
    171     const emojis = new GuildEmojiManager({ client: this });
    172     for (const guild of this.guilds.cache.values()) {
    173       if (guild.available) for (const emoji of guild.emojis.cache.values()) emojis.cache.set(emoji.id, emoji);
    174     }
    175     return emojis;
    176   }
    177 
    178   /**
    179    * Timestamp of the time the client was last `READY` at
    180    * @type {?number}
    181    * @readonly
    182    */
    183   get readyTimestamp() {
    184     return this.readyAt ? this.readyAt.getTime() : null;
    185   }
    186 
    187   /**
    188    * How long it has been since the client last entered the `READY` state in milliseconds
    189    * @type {?number}
    190    * @readonly
    191    */
    192   get uptime() {
    193     return this.readyAt ? Date.now() - this.readyAt : null;
    194   }
    195 
    196   /**
    197    * Logs the client in, establishing a websocket connection to Discord.
    198    * @param {string} token Token of the account to log in with
    199    * @returns {Promise<string>} Token of the account used
    200    * @example
    201    * client.login('my token');
    202    */
    203   async login(token = this.token) {
    204     if (!token || typeof token !== 'string') throw new Error('TOKEN_INVALID');
    205     this.token = token = token.replace(/^(Bot|Bearer)\s*/i, '');
    206     this.emit(
    207       Events.DEBUG,
    208       `Provided token: ${token
    209         .split('.')
    210         .map((val, i) => (i > 1 ? val.replace(/./g, '*') : val))
    211         .join('.')}`,
    212     );
    213 
    214     if (this.options.presence) {
    215       this.options.ws.presence = await this.presence._parse(this.options.presence);
    216     }
    217 
    218     this.emit(Events.DEBUG, 'Preparing to connect to the gateway...');
    219 
    220     try {
    221       await this.ws.connect();
    222       return this.token;
    223     } catch (error) {
    224       this.destroy();
    225       throw error;
    226     }
    227   }
    228 
    229   /**
    230    * Logs out, terminates the connection to Discord, and destroys the client.
    231    * @returns {void}
    232    */
    233   destroy() {
    234     super.destroy();
    235     this.ws.destroy();
    236     this.token = null;
    237   }
    238 
    239   /**
    240    * Obtains an invite from Discord.
    241    * @param {InviteResolvable} invite Invite code or URL
    242    * @returns {Promise<Invite>}
    243    * @example
    244    * client.fetchInvite('https://discord.gg/bRCvFy9')
    245    *   .then(invite => console.log(`Obtained invite with code: ${invite.code}`))
    246    *   .catch(console.error);
    247    */
    248   fetchInvite(invite) {
    249     const code = DataResolver.resolveInviteCode(invite);
    250     return this.api
    251       .invites(code)
    252       .get({ query: { with_counts: true } })
    253       .then(data => new Invite(this, data));
    254   }
    255 
    256   /**
    257    * Obtains a webhook from Discord.
    258    * @param {Snowflake} id ID of the webhook
    259    * @param {string} [token] Token for the webhook
    260    * @returns {Promise<Webhook>}
    261    * @example
    262    * client.fetchWebhook('id', 'token')
    263    *   .then(webhook => console.log(`Obtained webhook with name: ${webhook.name}`))
    264    *   .catch(console.error);
    265    */
    266   fetchWebhook(id, token) {
    267     return this.api
    268       .webhooks(id, token)
    269       .get()
    270       .then(data => new Webhook(this, data));
    271   }
    272 
    273   /**
    274    * Obtains the available voice regions from Discord.
    275    * @returns {Promise<Collection<string, VoiceRegion>>}
    276    * @example
    277    * client.fetchVoiceRegions()
    278    *   .then(regions => console.log(`Available regions are: ${regions.map(region => region.name).join(', ')}`))
    279    *   .catch(console.error);
    280    */
    281   fetchVoiceRegions() {
    282     return this.api.voice.regions.get().then(res => {
    283       const regions = new Collection();
    284       for (const region of res) regions.set(region.id, new VoiceRegion(region));
    285       return regions;
    286     });
    287   }
    288 
    289   /**
    290    * Sweeps all text-based channels' messages and removes the ones older than the max message lifetime.
    291    * If the message has been edited, the time of the edit is used rather than the time of the original message.
    292    * @param {number} [lifetime=this.options.messageCacheLifetime] Messages that are older than this (in seconds)
    293    * will be removed from the caches. The default is based on {@link ClientOptions#messageCacheLifetime}
    294    * @returns {number} Amount of messages that were removed from the caches,
    295    * or -1 if the message cache lifetime is unlimited
    296    * @example
    297    * // Remove all messages older than 1800 seconds from the messages cache
    298    * const amount = client.sweepMessages(1800);
    299    * console.log(`Successfully removed ${amount} messages from the cache.`);
    300    */
    301   sweepMessages(lifetime = this.options.messageCacheLifetime) {
    302     if (typeof lifetime !== 'number' || isNaN(lifetime)) {
    303       throw new TypeError('INVALID_TYPE', 'lifetime', 'number');
    304     }
    305     if (lifetime <= 0) {
    306       this.emit(Events.DEBUG, "Didn't sweep messages - lifetime is unlimited");
    307       return -1;
    308     }
    309 
    310     const lifetimeMs = lifetime * 1000;
    311     const now = Date.now();
    312     let channels = 0;
    313     let messages = 0;
    314 
    315     for (const channel of this.channels.cache.values()) {
    316       if (!channel.messages) continue;
    317       channels++;
    318 
    319       messages += channel.messages.cache.sweep(
    320         message => now - (message.editedTimestamp || message.createdTimestamp) > lifetimeMs,
    321       );
    322     }
    323 
    324     this.emit(
    325       Events.DEBUG,
    326       `Swept ${messages} messages older than ${lifetime} seconds in ${channels} text-based channels`,
    327     );
    328     return messages;
    329   }
    330 
    331   /**
    332    * Obtains the OAuth Application of this bot from Discord.
    333    * @returns {Promise<ClientApplication>}
    334    */
    335   fetchApplication() {
    336     return this.api.oauth2
    337       .applications('@me')
    338       .get()
    339       .then(app => new ClientApplication(this, app));
    340   }
    341 
    342   /**
    343    * Obtains a guild preview from Discord, only available for public guilds.
    344    * @param {GuildResolvable} guild The guild to fetch the preview for
    345    * @returns {Promise<GuildPreview>}
    346    */
    347   fetchGuildPreview(guild) {
    348     const id = this.guilds.resolveID(guild);
    349     if (!id) throw new TypeError('INVALID_TYPE', 'guild', 'GuildResolvable');
    350     return this.api
    351       .guilds(id)
    352       .preview.get()
    353       .then(data => new GuildPreview(this, data));
    354   }
    355 
    356   /**
    357    * Generates a link that can be used to invite the bot to a guild.
    358    * @param {PermissionResolvable} [permissions] Permissions to request
    359    * @returns {Promise<string>}
    360    * @example
    361    * client.generateInvite(['SEND_MESSAGES', 'MANAGE_GUILD', 'MENTION_EVERYONE'])
    362    *   .then(link => console.log(`Generated bot invite link: ${link}`))
    363    *   .catch(console.error);
    364    */
    365   async generateInvite(permissions) {
    366     permissions = Permissions.resolve(permissions);
    367     const application = await this.fetchApplication();
    368     const query = new URLSearchParams({
    369       client_id: application.id,
    370       permissions: permissions,
    371       scope: 'bot',
    372     });
    373     return `${this.options.http.api}${this.api.oauth2.authorize}?${query}`;
    374   }
    375 
    376   toJSON() {
    377     return super.toJSON({
    378       readyAt: false,
    379       presences: false,
    380     });
    381   }
    382 
    383   /**
    384    * Calls {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/eval} on a script
    385    * with the client as `this`.
    386    * @param {string} script Script to eval
    387    * @returns {*}
    388    * @private
    389    */
    390   _eval(script) {
    391     return eval(script);
    392   }
    393 
    394   /**
    395    * Validates the client options.
    396    * @param {ClientOptions} [options=this.options] Options to validate
    397    * @private
    398    */
    399   _validateOptions(options = this.options) {
    400     if (typeof options.ws.intents !== 'undefined') {
    401       options.ws.intents = Intents.resolve(options.ws.intents);
    402     }
    403     if (typeof options.shardCount !== 'number' || isNaN(options.shardCount) || options.shardCount < 1) {
    404       throw new TypeError('CLIENT_INVALID_OPTION', 'shardCount', 'a number greater than or equal to 1');
    405     }
    406     if (options.shards && !(options.shards === 'auto' || Array.isArray(options.shards))) {
    407       throw new TypeError('CLIENT_INVALID_OPTION', 'shards', "'auto', a number or array of numbers");
    408     }
    409     if (options.shards && !options.shards.length) throw new RangeError('CLIENT_INVALID_PROVIDED_SHARDS');
    410     if (typeof options.messageCacheMaxSize !== 'number' || isNaN(options.messageCacheMaxSize)) {
    411       throw new TypeError('CLIENT_INVALID_OPTION', 'messageCacheMaxSize', 'a number');
    412     }
    413     if (typeof options.messageCacheLifetime !== 'number' || isNaN(options.messageCacheLifetime)) {
    414       throw new TypeError('CLIENT_INVALID_OPTION', 'The messageCacheLifetime', 'a number');
    415     }
    416     if (typeof options.messageSweepInterval !== 'number' || isNaN(options.messageSweepInterval)) {
    417       throw new TypeError('CLIENT_INVALID_OPTION', 'messageSweepInterval', 'a number');
    418     }
    419     if (typeof options.fetchAllMembers !== 'boolean') {
    420       throw new TypeError('CLIENT_INVALID_OPTION', 'fetchAllMembers', 'a boolean');
    421     }
    422     if (typeof options.disableMentions !== 'string') {
    423       throw new TypeError('CLIENT_INVALID_OPTION', 'disableMentions', 'a string');
    424     }
    425     if (!Array.isArray(options.partials)) {
    426       throw new TypeError('CLIENT_INVALID_OPTION', 'partials', 'an Array');
    427     }
    428     if (typeof options.restWsBridgeTimeout !== 'number' || isNaN(options.restWsBridgeTimeout)) {
    429       throw new TypeError('CLIENT_INVALID_OPTION', 'restWsBridgeTimeout', 'a number');
    430     }
    431     if (typeof options.restRequestTimeout !== 'number' || isNaN(options.restRequestTimeout)) {
    432       throw new TypeError('CLIENT_INVALID_OPTION', 'restRequestTimeout', 'a number');
    433     }
    434     if (typeof options.restSweepInterval !== 'number' || isNaN(options.restSweepInterval)) {
    435       throw new TypeError('CLIENT_INVALID_OPTION', 'restSweepInterval', 'a number');
    436     }
    437     if (typeof options.retryLimit !== 'number' || isNaN(options.retryLimit)) {
    438       throw new TypeError('CLIENT_INVALID_OPTION', 'retryLimit', 'a number');
    439     }
    440   }
    441 }
    442 
    443 module.exports = Client;
    444 
    445 /**
    446  * Emitted for general warnings.
    447  * @event Client#warn
    448  * @param {string} info The warning
    449  */
    450 
    451 /**
    452  * Emitted for general debugging information.
    453  * @event Client#debug
    454  * @param {string} info The debug information
    455  */