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 */