'use strict'; const process = require('node:process'); const { Collection } = require('@discordjs/collection'); const { OAuth2Scopes, Routes } = require('discord-api-types/v9'); const BaseClient = require('./BaseClient'); const ActionsManager = require('./actions/ActionsManager'); const ClientVoiceManager = require('./voice/ClientVoiceManager'); const WebSocketManager = require('./websocket/WebSocketManager'); const { Error, TypeError, RangeError } = require('../errors'); const BaseGuildEmojiManager = require('../managers/BaseGuildEmojiManager'); const ChannelManager = require('../managers/ChannelManager'); const GuildManager = require('../managers/GuildManager'); const UserManager = require('../managers/UserManager'); const FriendsManager = require('../managers/FriendsManager'); const BlockedManager = require('../managers/BlockedManager'); const ClientUserSettingManager = require('../managers/ClientUserSettingManager'); const ShardClientUtil = require('../sharding/ShardClientUtil'); const ClientPresence = require('../structures/ClientPresence'); const GuildPreview = require('../structures/GuildPreview'); const GuildTemplate = require('../structures/GuildTemplate'); const Invite = require('../structures/Invite'); const { Sticker } = require('../structures/Sticker'); const StickerPack = require('../structures/StickerPack'); const VoiceRegion = require('../structures/VoiceRegion'); const Webhook = require('../structures/Webhook'); const Widget = require('../structures/Widget'); const DataResolver = require('../util/DataResolver'); const Events = require('../util/Events'); const IntentsBitField = require('../util/IntentsBitField'); const Options = require('../util/Options'); const PermissionsBitField = require('../util/PermissionsBitField'); const Status = require('../util/Status'); const Sweepers = require('../util/Sweepers'); /** * The main hub for interacting with the Discord API, and the starting point for any bot. * @extends {BaseClient} */ class Client extends BaseClient { /** * @param {ClientOptions} options Options for the client */ constructor(options) { super(options); const data = require('node:worker_threads').workerData ?? process.env; const defaults = Options.createDefault(); if (this.options.shards === defaults.shards) { if ('SHARDS' in data) { this.options.shards = JSON.parse(data.SHARDS); } } 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; } } const typeofShards = typeof this.options.shards; if (typeofShards === 'undefined' && typeof this.options.shardCount === 'number') { this.options.shards = Array.from({ length: this.options.shardCount }, (_, i) => i); } if (typeofShards === 'number') this.options.shards = [this.options.shards]; 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 WebSocket manager of the client * @type {WebSocketManager} */ this.ws = new WebSocketManager(this); /** * The action manager of the client * @type {ActionsManager} * @private */ 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} */ this.shard = process.env.SHARDING_MANAGER ? ShardClientUtil.singleton(this, process.env.SHARDING_MANAGER_MODE) : null; /** * All of the {@link User} objects that have been cached at any point, mapped by their ids * @type {UserManager} */ this.users = new UserManager(this); this.friends = new FriendsManager(this); this.blocked = new BlockedManager(this); this.setting = new ClientUserSettingManager(this); /** * All of the guilds the client is currently handling, mapped by their ids - * 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 Channel}s that the client is currently handling, mapped by their ids - * 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} */ this.channels = new ChannelManager(this); /** * The sweeping functions and their intervals used to periodically sweep caches * @type {Sweepers} */ 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) { /** * Authorization token for the logged in bot. * If present, this defaults to `process.env.DISCORD_TOKEN` when instantiating the client * This should be kept private at all times. * @type {?string} */ this.token = process.env.DISCORD_TOKEN; } else { this.token = null; } /** * used for interacitons * @type {?String} */ this.session_id = null; /** * User that the client is logged in as * @type {?ClientUser} */ this.user = null; /** * The application of this bot * @type {?ClientApplication} */ this.application = null; this.bot = null; /** * Timestamp of the time the client was last `READY` at * @type {?number} */ this.readyTimestamp = null; } /** * All custom emojis that the client has access to, mapped by their ids * @type {BaseGuildEmojiManager} * @readonly */ get emojis() { const emojis = new BaseGuildEmojiManager(this); for (const guild of this.guilds.cache.values()) { if (guild.available) for (const emoji of guild.emojis.cache.values()) emojis.cache.set(emoji.id, emoji); } return emojis; } /** * Time at which the client was last regarded as being in the `READY` state * (each time the client disconnects and successfully reconnects, this will be overwritten) * @type {?Date} * @readonly */ get readyAt() { return this.readyTimestamp && new Date(this.readyTimestamp); } /** * How long it has been since the client last entered the `READY` state in milliseconds * @type {?number} * @readonly */ get uptime() { return this.readyTimestamp && Date.now() - this.readyTimestamp; } /** * Logs the client in, establishing a WebSocket connection to Discord. * @param {string} [token=this.token] Token of the account to log in with * @param {Boolean} [bot=false] Wether the token used is a bot account or not * @returns {Promise} Token of the account used * @example * client.login('my token'); */ async login(token = this.token, bot = false) { if (!token || typeof token !== 'string') throw new Error('TOKEN_INVALID'); this.token = token = token.replace(/^(Bot|Bearer)\s*/i, ''); this.bot = bot; this.emit( Events.Debug, `Provided token: ${token .split('.') .map((val, i) => (i > 1 ? val.replace(/./g, '*') : val)) .join('.')}`, ); if (this.options.presence) { this.options.ws.presence = this.presence._parse(this.options.presence); } this.emit(Events.Debug, 'Preparing to connect to the gateway...'); try { await this.ws.connect(); return this.token; } catch (error) { this.destroy(); throw error; } } /** * Returns whether the client has logged in, indicative of being able to access * properties such as `user` and `application`. * @returns {boolean} */ isReady() { return this.ws.status === Status.Ready; } /** * Logs out, terminates the connection to Discord, and destroys the client. * @returns {void} */ destroy() { super.destroy(); this.sweepers.destroy(); this.ws.destroy(); this.token = null; //this.rest.setToken(null); } /** * Options used when fetching an invite from Discord. * @typedef {Object} ClientFetchInviteOptions * @property {Snowflake} [guildScheduledEventId] The id of the guild scheduled event to include with * the invite */ /** * Obtains an invite from Discord. * @param {InviteResolvable} invite Invite code or URL * @param {ClientFetchInviteOptions} [options] Options for fetching the invite * @returns {Promise} * @example * client.fetchInvite('https://discord.gg/djs') * .then(invite => console.log(`Obtained invite with code: ${invite.code}`)) * .catch(console.error); */ async fetchInvite(invite, options) { const code = DataResolver.resolveInviteCode(invite); const query = new URLSearchParams({ with_counts: true, with_expiration: true, }); if (options?.guildScheduledEventId) { query.set('guild_scheduled_event_id', options.guildScheduledEventId); } const data = await this.api.invites(code).get({ query }); return new Invite(this, data); } /** * Obtains a template from Discord. * @param {GuildTemplateResolvable} template Template code or URL * @returns {Promise} * @example * client.fetchGuildTemplate('https://discord.new/FKvmczH2HyUf') * .then(template => console.log(`Obtained template with code: ${template.code}`)) * .catch(console.error); */ async fetchGuildTemplate(template) { const code = DataResolver.resolveGuildTemplateCode(template); const data = await this.api.guilds.templates(code).get(); return new GuildTemplate(this, data); } /** * Obtains a webhook from Discord. * @param {Snowflake} id The webhook's id * @param {string} [token] Token for the webhook * @returns {Promise} * @example * client.fetchWebhook('id', 'token') * .then(webhook => console.log(`Obtained webhook with name: ${webhook.name}`)) * .catch(console.error); */ async fetchWebhook(id, token) { const data = await this.api.webhook(id, token).get(); return new Webhook(this, { token, ...data }); } /** * Obtains the available voice regions from Discord. * @returns {Promise>} * @example * client.fetchVoiceRegions() * .then(regions => console.log(`Available regions are: ${regions.map(region => region.name).join(', ')}`)) * .catch(console.error); */ async fetchVoiceRegions() { const apiRegions = await this.api.voice.regions.get(); const regions = new Collection(); for (const region of apiRegions) regions.set(region.id, new VoiceRegion(region)); return regions; } /** * Obtains a sticker from Discord. * @param {Snowflake} id The sticker's id * @returns {Promise} * @example * client.fetchSticker('id') * .then(sticker => console.log(`Obtained sticker with name: ${sticker.name}`)) * .catch(console.error); */ async fetchSticker(id) { const data = await this.api.stickers(id).get(); return new Sticker(this, data); } /** * Obtains the list of sticker packs available to Nitro subscribers from Discord. * @returns {Promise>} * @example * client.fetchPremiumStickerPacks() * .then(packs => console.log(`Available sticker packs are: ${packs.map(pack => pack.name).join(', ')}`)) * .catch(console.error); */ async fetchPremiumStickerPacks() { const data = await this.api('sticker-packs').get(); return new Collection(data.sticker_packs.map(p => [p.id, new StickerPack(this, p)])); } /** * 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 * @returns {Promise} */ async fetchGuildPreview(guild) { const id = this.guilds.resolveId(guild); if (!id) throw new TypeError('INVALID_TYPE', 'guild', 'GuildResolvable'); const data = await this.api.guilds(id).preview.get(); return new GuildPreview(this, data); } /** * Obtains the widget data of a guild from Discord, available for guilds with the widget enabled. * @param {GuildResolvable} guild The guild to fetch the widget data for * @returns {Promise} */ async fetchGuildWidget(guild) { const id = this.guilds.resolveId(guild); if (!id) throw new TypeError('INVALID_TYPE', 'guild', 'GuildResolvable'); const data = await this.api.guilds(id, 'widget.json').get(); return new Widget(this, data); } /** * Options for {@link Client#generateInvite}. * @typedef {Object} InviteGenerationOptions * @property {OAuth2Scopes[]} scopes Scopes that should be requested * @property {PermissionResolvable} [permissions] Permissions to request * @property {GuildResolvable} [guild] Guild to preselect * @property {boolean} [disableGuildSelect] Whether to disable the guild selection */ /** * Generates a link that can be used to invite the bot to a guild. * @param {InviteGenerationOptions} [options={}] Options for the invite * @returns {string} * @example * const link = client.generateInvite({ * scopes: [OAuth2Scopes.ApplicationsCommands], * }); * console.log(`Generated application invite link: ${link}`); * @example * const link = client.generateInvite({ * permissions: [ * PermissionFlagsBits.SendMessages, * PermissionFlagsBits.ManageGuild, * PermissionFlagsBits.MentionEveryone, * ], * scopes: [OAuth2Scopes.Bot], * }); * console.log(`Generated bot invite link: ${link}`); */ generateInvite(options = {}) { if (typeof options !== 'object') throw new TypeError('INVALID_TYPE', 'options', 'object', true); if (!this.application) throw new Error('CLIENT_NOT_READY', 'generate an invite link'); const query = new URLSearchParams({ client_id: this.application.id, }); const { scopes } = options; if (typeof scopes === 'undefined') { throw new TypeError('INVITE_MISSING_SCOPES'); } if (!Array.isArray(scopes)) { throw new TypeError('INVALID_TYPE', 'scopes', 'Array of Invite Scopes', true); } if (!scopes.some(scope => [OAuth2Scopes.Bot, OAuth2Scopes.ApplicationsCommands].includes(scope))) { throw new TypeError('INVITE_MISSING_SCOPES'); } const validScopes = Object.values(OAuth2Scopes); const invalidScope = scopes.find(scope => !validScopes.includes(scope)); if (invalidScope) { throw new TypeError('INVALID_ELEMENT', 'Array', 'scopes', invalidScope); } query.set('scope', scopes.join(' ')); if (options.permissions) { const permissions = PermissionsBitField.resolve(options.permissions); if (permissions) query.set('permissions', permissions); } if (options.disableGuildSelect) { query.set('disable_guild_select', true); } if (options.guild) { const guildId = this.guilds.resolveId(options.guild); if (!guildId) throw new TypeError('INVALID_TYPE', 'options.guild', 'GuildResolvable'); query.set('guild_id', guildId); } return `${this.options.rest.api}${Routes.oauth2Authorization()}?${query}`; } toJSON() { return super.toJSON({ readyAt: false, }); } /** * Calls {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/eval} on a script * with the client as `this`. * @param {string} script Script to eval * @returns {*} * @private */ _eval(script) { return eval(script); } /** * Validates the client options. * @param {ClientOptions} [options=this.options] Options to validate * @private */ _validateOptions(options = this.options) { if (typeof options.intents === 'undefined') { throw new TypeError('CLIENT_MISSING_INTENTS'); } else { options.intents = IntentsBitField.resolve(options.intents); } if (typeof options.shardCount !== 'number' || isNaN(options.shardCount) || options.shardCount < 1) { throw new TypeError('CLIENT_INVALID_OPTION', 'shardCount', 'a number greater than or equal to 1'); } if (options.shards && !(options.shards === 'auto' || Array.isArray(options.shards))) { throw new TypeError('CLIENT_INVALID_OPTION', 'shards', "'auto', a number or array of numbers"); } if (options.shards && !options.shards.length) throw new RangeError('CLIENT_INVALID_PROVIDED_SHARDS'); if (typeof options.makeCache !== 'function') { throw new TypeError('CLIENT_INVALID_OPTION', 'makeCache', 'a function'); } if (typeof options.sweepers !== 'object' || options.sweepers === null) { throw new TypeError('CLIENT_INVALID_OPTION', 'sweepers', 'an object'); } if (!Array.isArray(options.partials)) { throw new TypeError('CLIENT_INVALID_OPTION', 'partials', 'an Array'); } if (typeof options.waitGuildTimeout !== 'number' || isNaN(options.waitGuildTimeout)) { throw new TypeError('CLIENT_INVALID_OPTION', 'waitGuildTimeout', 'a number'); } if (typeof options.failIfNotExists !== 'boolean') { throw new TypeError('CLIENT_INVALID_OPTION', 'failIfNotExists', 'a boolean'); } } } module.exports = Client; /** * A {@link https://developer.twitter.com/en/docs/twitter-ids Twitter snowflake}, * except the epoch is 2015-01-01T00:00:00.000Z. * * If we have a snowflake '266241948824764416' we can represent it as binary: * ``` * 64 22 17 12 0 * 000000111011000111100001101001000101000000 00001 00000 000000000000 * number of milliseconds since Discord epoch worker pid increment * ``` * @typedef {string} Snowflake */ /** * Emitted for general debugging information. * @event Client#debug * @param {string} info The debug information */ /** * Emitted for general warnings. * @event Client#warn * @param {string} info The warning */ /** * @external Collection * @see {@link https://discord.js.org/#/docs/collection/main/class/Collection} */ /** * @external ImageURLOptions * @see {@link https://discord.js.org/#/docs/rest/main/typedef/ImageURLOptions} */ /** * @external BaseImageURLOptions * @see {@link https://discord.js.org/#/docs/rest/main/typedef/BaseImageURLOptions} */