diff --git a/packages/discord.js/src/client/Client.js b/packages/discord.js/src/client/Client.js index 7d528b37c6ca..8a7b69b58306 100644 --- a/packages/discord.js/src/client/Client.js +++ b/packages/discord.js/src/client/Client.js @@ -1,16 +1,14 @@ 'use strict'; const process = require('node:process'); -const { clearTimeout, setImmediate, setTimeout } = require('node:timers'); const { Collection } = require('@discordjs/collection'); const { makeURLSearchParams } = require('@discordjs/rest'); -const { WebSocketManager, WebSocketShardEvents, WebSocketShardStatus } = require('@discordjs/ws'); -const { GatewayDispatchEvents, GatewayIntentBits, OAuth2Scopes, Routes } = require('discord-api-types/v10'); +const { OAuth2Scopes, Routes } = require('discord-api-types/v10'); const BaseClient = require('./BaseClient'); const ActionsManager = require('./actions/ActionsManager'); const ClientVoiceManager = require('./voice/ClientVoiceManager'); -const PacketHandlers = require('./websocket/handlers'); -const { DiscordjsError, DiscordjsTypeError, ErrorCodes } = require('../errors'); +const WebSocketManager = require('./websocket/WebSocketManager'); +const { DiscordjsError, DiscordjsTypeError, DiscordjsRangeError, ErrorCodes } = require('../errors'); const BaseGuildEmojiManager = require('../managers/BaseGuildEmojiManager'); const ChannelManager = require('../managers/ChannelManager'); const GuildManager = require('../managers/GuildManager'); @@ -33,16 +31,7 @@ const PermissionsBitField = require('../util/PermissionsBitField'); const Status = require('../util/Status'); const Sweepers = require('../util/Sweepers'); -const WaitingForGuildEvents = [GatewayDispatchEvents.GuildCreate, GatewayDispatchEvents.GuildDelete]; -const BeforeReadyWhitelist = [ - GatewayDispatchEvents.Ready, - GatewayDispatchEvents.Resumed, - GatewayDispatchEvents.GuildCreate, - GatewayDispatchEvents.GuildDelete, - GatewayDispatchEvents.GuildMembersChunk, - GatewayDispatchEvents.GuildMemberAdd, - GatewayDispatchEvents.GuildMemberRemove, -]; +let deprecationEmittedForPremiumStickerPacks = false; /** * The main hub for interacting with the Discord API, and the starting point for any bot. @@ -58,45 +47,43 @@ class Client extends BaseClient { const data = require('node:worker_threads').workerData ?? process.env; const defaults = Options.createDefault(); - if (this.options.ws.shardIds === defaults.ws.shardIds && 'SHARDS' in data) { - this.options.ws.shardIds = JSON.parse(data.SHARDS); + if (this.options.shards === defaults.shards) { + if ('SHARDS' in data) { + this.options.shards = JSON.parse(data.SHARDS); + } } - if (this.options.ws.shardCount === defaults.ws.shardCount && 'SHARD_COUNT' in data) { - this.options.ws.shardCount = Number(data.SHARD_COUNT); + if (this.options.shardCount === defaults.shardCount) { + if ('SHARD_COUNT' in data) { + this.options.shardCount = Number(data.SHARD_COUNT); + } else if (Array.isArray(this.options.shards)) { + this.options.shardCount = this.options.shards.length; + } } - /** - * The presence of the Client - * @private - * @type {ClientPresence} - */ - this.presence = new ClientPresence(this, this.options.ws.initialPresence ?? this.options.presence); + const typeofShards = typeof this.options.shards; - this._validateOptions(); + if (typeofShards === 'undefined' && typeof this.options.shardCount === 'number') { + this.options.shards = Array.from({ length: this.options.shardCount }, (_, i) => i); + } - /** - * The current status of this Client - * @type {Status} - * @private - */ - this.status = Status.Idle; + if (typeofShards === 'number') this.options.shards = [this.options.shards]; - /** - * A set of guild ids this Client expects to receive - * @name Client#expectedGuilds - * @type {Set} - * @private - */ - Object.defineProperty(this, 'expectedGuilds', { value: new Set(), writable: true }); + if (Array.isArray(this.options.shards)) { + this.options.shards = [ + ...new Set( + this.options.shards.filter(item => !isNaN(item) && item >= 0 && item < Infinity && item === (item | 0)), + ), + ]; + } + + this._validateOptions(); /** - * The ready timeout - * @name Client#readyTimeout - * @type {?NodeJS.Timeout} - * @private + * The WebSocket manager of the client + * @type {WebSocketManager} */ - Object.defineProperty(this, 'readyTimeout', { value: null, writable: true }); + this.ws = new WebSocketManager(this); /** * The action manager of the client @@ -105,6 +92,12 @@ class Client extends BaseClient { */ this.actions = new ActionsManager(this); + /** + * The voice manager of the client + * @type {ClientVoiceManager} + */ + this.voice = new ClientVoiceManager(this); + /** * Shard helpers for the client (only if the process was spawned from a {@link ShardingManager}) * @type {?ShardClientUtil} @@ -114,21 +107,21 @@ class Client extends BaseClient { : null; /** - * All of the {@link User} objects that have been cached at any point, mapped by their ids + * The user manager of this client * @type {UserManager} */ this.users = new UserManager(this); /** - * All of the guilds the client is currently handling, mapped by their ids - + * A manager of all the guilds the client is currently handling - * as long as sharding isn't being used, this will be *every* guild the bot is a member of * @type {GuildManager} */ this.guilds = new GuildManager(this); /** - * All of the {@link BaseChannel}s that the client is currently handling, mapped by their ids - - * as long as no sharding manager is being used, this will be *every* channel in *every* guild the bot + * A manager of all the {@link BaseChannel}s that the client is currently handling - + * as long as sharding isn't being used, this will be *every* channel in *every* guild the bot * is a member of. Note that DM channels will not be initially cached, and thus not be present * in the Manager without their explicit fetching or use. * @type {ChannelManager} @@ -141,6 +134,13 @@ class Client extends BaseClient { */ this.sweepers = new Sweepers(this, this.options.sweepers); + /** + * The presence of the Client + * @private + * @type {ClientPresence} + */ + this.presence = new ClientPresence(this, this.options.presence); + Object.defineProperty(this, 'token', { writable: true }); if (!this.token && 'DISCORD_TOKEN' in process.env) { /** @@ -150,32 +150,10 @@ class Client extends BaseClient { * @type {?string} */ this.token = process.env.DISCORD_TOKEN; - } else if (this.options.ws.token) { - this.token = this.options.ws.token; } else { this.token = null; } - const wsOptions = { - ...this.options.ws, - intents: this.options.intents.bitfield, - rest: this.rest, - // Explicitly nulled to always be set using `setToken` in `login` - token: null, - }; - - /** - * The WebSocket manager of the client - * @type {WebSocketManager} - */ - this.ws = new WebSocketManager(wsOptions); - - /** - * The voice manager of the client - * @type {ClientVoiceManager} - */ - this.voice = new ClientVoiceManager(this); - /** * User that the client is logged in as * @type {?ClientUser} @@ -188,37 +166,15 @@ class Client extends BaseClient { */ this.application = null; - /** - * The latencies of the WebSocketShard connections - * @type {Collection} - */ - this.pings = new Collection(); - - /** - * The last time a ping was sent (a timestamp) for each WebSocketShard connection - * @type {Collection} - */ - this.lastPingTimestamps = new Collection(); - /** * Timestamp of the time the client was last {@link Status.Ready} at * @type {?number} */ this.readyTimestamp = null; - - /** - * An array of queued events before this Client became ready - * @type {Object[]} - * @private - * @name Client#incomingPacketQueue - */ - Object.defineProperty(this, 'incomingPacketQueue', { value: [] }); - - this._attachEvents(); } /** - * All custom emojis that the client has access to, mapped by their ids + * A manager of all the custom emojis that the client has access to * @type {BaseGuildEmojiManager} * @readonly */ @@ -258,15 +214,16 @@ class Client extends BaseClient { */ async login(token = this.token) { if (!token || typeof token !== 'string') throw new DiscordjsError(ErrorCodes.TokenInvalid); - this.token = token.replace(/^(Bot|Bearer)\s*/i, ''); + this.token = token = token.replace(/^(Bot|Bearer)\s*/i, ''); + this.rest.setToken(token); + this.emit(Events.Debug, `Provided token: ${this._censoredToken}`); - this.rest.setToken(this.token); + if (this.options.presence) { + this.options.ws.presence = this.presence._parse(this.options.presence); + } - this.emit(Events.Debug, `Provided token: ${this._censoredToken}`); this.emit(Events.Debug, 'Preparing to connect to the gateway...'); - this.ws.setToken(this.token); - try { await this.ws.connect(); return this.token; @@ -276,150 +233,13 @@ class Client extends BaseClient { } } - /** - * Checks if the client can be marked as ready - * @private - */ - async _checkReady() { - // Step 0. Clear the ready timeout, if it exists - if (this.readyTimeout) { - clearTimeout(this.readyTimeout); - this.readyTimeout = null; - } - // Step 1. If we don't have any other guilds pending, we are ready - if ( - !this.expectedGuilds.size && - (await this.ws.fetchStatus()).every(status => status === WebSocketShardStatus.Ready) - ) { - this.emit(Events.Debug, 'Client received all its guilds. Marking as fully ready.'); - this.status = Status.Ready; - - this._triggerClientReady(); - return; - } - const hasGuildsIntent = this.options.intents.has(GatewayIntentBits.Guilds); - // Step 2. Create a timeout that will mark the client as ready if there are still unavailable guilds - // * The timeout is 15 seconds by default - // * This can be optionally changed in the client options via the `waitGuildTimeout` option - // * a timeout time of zero will skip this timeout, which potentially could cause the Client to miss guilds. - - this.readyTimeout = setTimeout( - () => { - this.emit( - Events.Debug, - `${ - hasGuildsIntent - ? `Client did not receive any guild packets in ${this.options.waitGuildTimeout} ms.` - : 'Client will not receive anymore guild packets.' - }\nUnavailable guild count: ${this.expectedGuilds.size}`, - ); - - this.readyTimeout = null; - this.status = Status.Ready; - - this._triggerClientReady(); - }, - hasGuildsIntent ? this.options.waitGuildTimeout : 0, - ).unref(); - } - - /** - * Attaches event handlers to the WebSocketShardManager from `@discordjs/ws`. - * @private - */ - _attachEvents() { - this.ws.on(WebSocketShardEvents.Debug, (message, shardId) => - this.emit(Events.Debug, `[WS => ${typeof shardId === 'number' ? `Shard ${shardId}` : 'Manager'}] ${message}`), - ); - this.ws.on(WebSocketShardEvents.Dispatch, this._handlePacket.bind(this)); - - this.ws.on(WebSocketShardEvents.Ready, data => { - for (const guild of data.guilds) { - this.expectedGuilds.add(guild.id); - } - this.status = Status.WaitingForGuilds; - this._checkReady(); - }); - - this.ws.on(WebSocketShardEvents.HeartbeatComplete, ({ heartbeatAt, latency }, shardId) => { - this.emit(Events.Debug, `[WS => Shard ${shardId}] Heartbeat acknowledged, latency of ${latency}ms.`); - this.lastPingTimestamps.set(shardId, heartbeatAt); - this.pings.set(shardId, latency); - }); - } - - /** - * Processes a packet and queues it if this WebSocketManager is not ready. - * @param {GatewayDispatchPayload} packet The packet to be handled - * @param {number} shardId The shardId that received this packet - * @private - */ - _handlePacket(packet, shardId) { - if (this.status !== Status.Ready && !BeforeReadyWhitelist.includes(packet.t)) { - this.incomingPacketQueue.push({ packet, shardId }); - } else { - if (this.incomingPacketQueue.length) { - const item = this.incomingPacketQueue.shift(); - setImmediate(() => { - this._handlePacket(item.packet, item.shardId); - }).unref(); - } - - if (PacketHandlers[packet.t]) { - PacketHandlers[packet.t](this, packet, shardId); - } - - if (this.status === Status.WaitingForGuilds && WaitingForGuildEvents.includes(packet.t)) { - this.expectedGuilds.delete(packet.d.id); - this._checkReady(); - } - } - } - - /** - * Broadcasts a packet to every shard of this client handles. - * @param {Object} packet The packet to send - * @private - */ - async _broadcast(packet) { - const shardIds = await this.ws.getShardIds(); - return Promise.all(shardIds.map(shardId => this.ws.send(shardId, packet))); - } - - /** - * Causes the client to be marked as ready and emits the ready event. - * @private - */ - _triggerClientReady() { - this.status = Status.Ready; - - this.readyTimestamp = Date.now(); - - /** - * Emitted when the client becomes ready to start working. - * @event Client#clientReady - * @param {Client} client The client - */ - this.emit(Events.ClientReady, this); - } - /** * Returns whether the client has logged in, indicative of being able to access * properties such as `user` and `application`. * @returns {boolean} */ isReady() { - return this.status === Status.Ready; - } - - /** - * The average ping of all WebSocketShards - * @type {number} - * @readonly - */ - get ping() { - const sum = this.pings.reduce((a, b) => a + b, 0); - return sum / this.pings.size; + return !this.ws.destroyed && this.ws.status === Status.Ready; } /** @@ -552,6 +372,24 @@ class Client extends BaseClient { return new Collection(data.sticker_packs.map(stickerPack => [stickerPack.id, new StickerPack(this, stickerPack)])); } + /** + * Obtains the list of available sticker packs. + * @returns {Promise>} + * @deprecated Use {@link Client#fetchStickerPacks} instead. + */ + fetchPremiumStickerPacks() { + if (!deprecationEmittedForPremiumStickerPacks) { + process.emitWarning( + 'The Client#fetchPremiumStickerPacks() method is deprecated. Use Client#fetchStickerPacks() instead.', + 'DeprecationWarning', + ); + + deprecationEmittedForPremiumStickerPacks = true; + } + + return this.fetchStickerPacks(); + } + /** * Obtains a guild preview from Discord, available for all guilds the bot is in and all Discoverable guilds. * @param {GuildResolvable} guild The guild to fetch the preview for @@ -687,10 +525,20 @@ class Client extends BaseClient { * @private */ _validateOptions(options = this.options) { - if (options.intents === undefined && options.ws?.intents === undefined) { + if (options.intents === undefined) { throw new DiscordjsTypeError(ErrorCodes.ClientMissingIntents); } else { - options.intents = new IntentsBitField(options.intents ?? options.ws.intents).freeze(); + options.intents = new IntentsBitField(options.intents).freeze(); + } + if (typeof options.shardCount !== 'number' || isNaN(options.shardCount) || options.shardCount < 1) { + throw new DiscordjsTypeError(ErrorCodes.ClientInvalidOption, 'shardCount', 'a number greater than or equal to 1'); + } + if (options.shards && !(options.shards === 'auto' || Array.isArray(options.shards))) { + throw new DiscordjsTypeError(ErrorCodes.ClientInvalidOption, 'shards', "'auto', a number or array of numbers"); + } + if (options.shards && !options.shards.length) throw new DiscordjsRangeError(ErrorCodes.ClientInvalidProvidedShards); + if (typeof options.makeCache !== 'function') { + throw new DiscordjsTypeError(ErrorCodes.ClientInvalidOption, 'makeCache', 'a function'); } if (typeof options.sweepers !== 'object' || options.sweepers === null) { throw new DiscordjsTypeError(ErrorCodes.ClientInvalidOption, 'sweepers', 'an object'); @@ -713,17 +561,12 @@ class Client extends BaseClient { ) { throw new DiscordjsTypeError(ErrorCodes.ClientInvalidOption, 'allowedMentions', 'an object'); } + if (typeof options.presence !== 'object' || options.presence === null) { + throw new DiscordjsTypeError(ErrorCodes.ClientInvalidOption, 'presence', 'an object'); + } if (typeof options.ws !== 'object' || options.ws === null) { throw new DiscordjsTypeError(ErrorCodes.ClientInvalidOption, 'ws', 'an object'); } - if ( - (typeof options.presence !== 'object' || options.presence === null) && - options.ws.initialPresence === undefined - ) { - throw new DiscordjsTypeError(ErrorCodes.ClientInvalidOption, 'presence', 'an object'); - } else { - options.ws.initialPresence = options.ws.initialPresence ?? this.presence._parse(this.options.presence); - } if (typeof options.rest !== 'object' || options.rest === null) { throw new DiscordjsTypeError(ErrorCodes.ClientInvalidOption, 'rest', 'an object'); }