Discord.js v13.8.0
This commit is contained in:
March 7th
2022-06-06 11:21:36 +07:00
parent 832bc010c5
commit 305ccaac90
19 changed files with 382 additions and 240 deletions

View File

@@ -10,7 +10,7 @@ class WebhooksUpdate extends Action {
/**
* Emitted whenever a channel has its webhooks changed.
* @event Client#webhookUpdate
* @param {TextChannel|NewsChannel} channel The channel that had a webhook update
* @param {TextChannel|NewsChannel|VoiceChannel} channel The channel that had a webhook update
*/
if (channel) client.emit(Events.WEBHOOKS_UPDATE, channel);
}

View File

@@ -20,7 +20,7 @@ const BeforeReadyWhitelist = [
WSEvents.GUILD_MEMBER_REMOVE,
];
const UNRECOVERABLE_CLOSE_CODES = Object.keys(WSCodes).slice(1).map(Number);
const UNRECOVERABLE_CLOSE_CODES = Object.keys(WSCodes).slice(2).map(Number);
const UNRESUMABLE_CLOSE_CODES = [
RPCErrorCodes.UnknownError,
RPCErrorCodes.InvalidPermissions,
@@ -216,13 +216,8 @@ class WebSocketManager extends EventEmitter {
this.shardQueue.add(shard);
if (shard.sessionId) {
this.debug('Session id is present, attempting an immediate reconnect...', shard);
this.reconnect();
} else {
shard.destroy({ reset: true, emit: false, log: false });
this.reconnect();
}
if (shard.sessionId) this.debug(`Session id is present, attempting an immediate reconnect...`, shard);
this.reconnect();
});
shard.on(ShardEvents.INVALID_SESSION, () => {
@@ -352,6 +347,12 @@ class WebSocketManager extends EventEmitter {
} else if (packet) {
/* Debug mode */
// console.log(`Unhandled packet: ${packet.t}`, packet);
/**
* Emitted whenever a packet isn't handled.
* @event Client#unhandledPacket
* @param {Object} packet The packet (t: Event name, d: Data)
* @param {Number} shard The shard that received the packet (Auto = 0)
*/
this.client.emit(Events.UNHANDLED_PACKET, packet, shard);
}
return true;

View File

@@ -1,9 +1,9 @@
'use strict';
const EventEmitter = require('node:events');
const { setTimeout, setInterval } = require('node:timers');
const { setTimeout, setInterval, clearTimeout } = require('node:timers');
const WebSocket = require('../../WebSocket');
const { Status, Events, ShardEvents, Opcodes, WSEvents } = require('../../util/Constants');
const { Status, Events, ShardEvents, Opcodes, WSEvents, WSCodes } = require('../../util/Constants');
const Intents = require('../../util/Intents');
const STATUS_KEYS = Object.keys(Status);
@@ -81,6 +81,13 @@ class WebSocketShard extends EventEmitter {
*/
this.lastHeartbeatAcked = true;
/**
* Used to prevent calling {@link WebSocketShard#event:close} twice while closing or terminating the WebSocket.
* @type {boolean}
* @private
*/
this.closeEmitted = false;
/**
* Contains the rate limit queue and metadata
* @name WebSocketShard#ratelimit
@@ -126,6 +133,14 @@ class WebSocketShard extends EventEmitter {
*/
Object.defineProperty(this, 'helloTimeout', { value: null, writable: true });
/**
* The WebSocket timeout.
* @name WebSocketShard#wsCloseTimeout
* @type {?NodeJS.Timeout}
* @private
*/
Object.defineProperty(this, 'wsCloseTimeout', { value: null, writable: true });
/**
* If the manager attached its event handlers on the shard
* @name WebSocketShard#eventsAttached
@@ -250,10 +265,11 @@ class WebSocketShard extends EventEmitter {
this.status = this.status === Status.DISCONNECTED ? Status.RECONNECTING : Status.CONNECTING;
this.setHelloTimeout();
this.setWsCloseTimeout(-1);
this.connectedAt = Date.now();
const ws = (this.connection = WebSocket.create(gateway, wsQuery));
// Adding a handshake timeout to just make sure no zombie connection appears.
const ws = (this.connection = WebSocket.create(gateway, wsQuery, { handshakeTimeout: 30_000 }));
ws.onopen = this.onOpen.bind(this);
ws.onmessage = this.onMessage.bind(this);
ws.onerror = this.onError.bind(this);
@@ -340,21 +356,39 @@ class WebSocketShard extends EventEmitter {
* @private
*/
onClose(event) {
this.closeEmitted = true;
if (this.sequence !== -1) this.closeSequence = this.sequence;
this.sequence = -1;
this.setHeartbeatTimer(-1);
this.setHelloTimeout(-1);
// Clearing the WebSocket close timeout as close was emitted.
this.setWsCloseTimeout(-1);
// If we still have a connection object, clean up its listeners
if (this.connection) {
this._cleanupConnection();
// Having this after _cleanupConnection to just clean up the connection and not listen to ws.onclose
this.destroy({ reset: true, emit: false, log: false });
}
this.status = Status.DISCONNECTED;
this.emitClose(event);
}
/**
* This method is responsible to emit close event for this shard.
* This method helps the shard reconnect.
* @param {CloseEvent} [event] Close event that was received
*/
emitClose(
event = {
code: 1011,
reason: WSCodes[1011],
wasClean: false,
},
) {
this.debug(`[CLOSE]
Event Code: ${event.code}
Clean : ${event.wasClean}
Reason : ${event.reason ?? 'No reason received'}`);
this.setHeartbeatTimer(-1);
this.setHelloTimeout(-1);
// If we still have a connection object, clean up its listeners
if (this.connection) this._cleanupConnection();
this.status = Status.DISCONNECTED;
/**
* Emitted when a shard's WebSocket closes.
* @private
@@ -523,6 +557,47 @@ class WebSocketShard extends EventEmitter {
}, 20_000).unref();
}
/**
* Sets the WebSocket Close timeout.
* This method is responsible for detecting any zombie connections if the WebSocket fails to close properly.
* @param {number} [time] If set to -1, it will clear the timeout
* @private
*/
setWsCloseTimeout(time) {
if (this.wsCloseTimeout) {
this.debug('[WebSocket] Clearing the close timeout.');
clearTimeout(this.wsCloseTimeout);
}
if (time === -1) {
this.wsCloseTimeout = null;
return;
}
this.wsCloseTimeout = setTimeout(() => {
this.setWsCloseTimeout(-1);
this.debug(`[WebSocket] Close Emitted: ${this.closeEmitted}`);
// Check if close event was emitted.
if (this.closeEmitted) {
this.debug(
`[WebSocket] was closed. | WS State: ${
CONNECTION_STATE[this.connection?.readyState ?? WebSocket.CLOSED]
} | Close Emitted: ${this.closeEmitted}`,
);
// Setting the variable false to check for zombie connections.
this.closeEmitted = false;
return;
}
this.debug(
// eslint-disable-next-line max-len
`[WebSocket] did not close properly, assuming a zombie connection.\nEmitting close and reconnecting again.`,
);
this.emitClose();
// Setting the variable false to check for zombie connections.
this.closeEmitted = false;
}, time).unref();
}
/**
* Sets the heartbeat timer for this shard.
* @param {number} time If -1, clears the interval, any other number sets an interval
@@ -564,7 +639,7 @@ class WebSocketShard extends EventEmitter {
Connection State: ${this.connection ? CONNECTION_STATE[this.connection.readyState] : 'No Connection??'}`,
);
this.destroy({ closeCode: 4009, reset: true });
this.destroy({ reset: true, closeCode: 4009 });
return;
}
@@ -723,22 +798,30 @@ class WebSocketShard extends EventEmitter {
// Step 0: Remove all timers
this.setHeartbeatTimer(-1);
this.setHelloTimeout(-1);
this.debug(
`[WebSocket] Destroy: Attempting to close the WebSocket. | WS State: ${
CONNECTION_STATE[this.connection?.readyState ?? WebSocket.CLOSED]
}`,
);
// Step 1: Close the WebSocket connection, if any, otherwise, emit DESTROYED
if (this.connection) {
// If the connection is currently opened, we will (hopefully) receive close
if (this.connection.readyState === WebSocket.OPEN) {
this.connection.close(closeCode);
this.debug(`[WebSocket] Close: Tried closing. | WS State: ${CONNECTION_STATE[this.connection.readyState]}`);
} else {
// Connection is not OPEN
this.debug(`WS State: ${CONNECTION_STATE[this.connection.readyState]}`);
// Remove listeners from the connection
this._cleanupConnection();
// Attempt to close the connection just in case
try {
this.connection.close(closeCode);
} catch {
// No-op
} catch (err) {
this.debug(
`[WebSocket] Close: Something went wrong while closing the WebSocket: ${
err.message || err
}. Forcefully terminating the connection | WS State: ${CONNECTION_STATE[this.connection.readyState]}`,
);
this.connection.terminate();
}
// Emit the destroyed event if needed
if (emit) this._emitDestroyed();
@@ -748,6 +831,15 @@ class WebSocketShard extends EventEmitter {
this._emitDestroyed();
}
if (this.connection?.readyState === WebSocket.CLOSING || this.connection?.readyState === WebSocket.CLOSED) {
this.closeEmitted = false;
this.debug(
`[WebSocket] Adding a WebSocket close timeout to ensure a correct WS reconnect.
Timeout: ${this.manager.client.options.closeTimeout}ms`,
);
this.setWsCloseTimeout(this.manager.client.options.closeTimeout);
}
// Step 2: Null the connection object
this.connection = null;

View File

@@ -308,7 +308,7 @@ class GuildChannelManager extends CachedManager {
channel,
position,
relative,
this.guild._sortedChannels(this),
this.guild._sortedChannels(channel),
this.client.api.guilds(this.guild.id).channels,
reason,
);

View File

@@ -89,16 +89,6 @@ class BaseGuildTextChannel extends GuildChannel {
return this.edit({ defaultAutoArchiveDuration }, reason);
}
/**
* Sets whether this channel is flagged as NSFW.
* @param {boolean} [nsfw=true] Whether the channel should be considered NSFW
* @param {string} [reason] Reason for changing the channel's NSFW flag
* @returns {Promise<TextChannel>}
*/
setNSFW(nsfw = true, reason) {
return this.edit({ nsfw }, reason);
}
/**
* Sets the type of this channel (only conversion between text and news is supported)
* @param {string} type The new channel type
@@ -109,44 +99,6 @@ class BaseGuildTextChannel extends GuildChannel {
return this.edit({ type }, reason);
}
/**
* Fetches all webhooks for the channel.
* @returns {Promise<Collection<Snowflake, Webhook>>}
* @example
* // Fetch webhooks
* channel.fetchWebhooks()
* .then(hooks => console.log(`This channel has ${hooks.size} hooks`))
* .catch(console.error);
*/
fetchWebhooks() {
return this.guild.channels.fetchWebhooks(this.id);
}
/**
* Options used to create a {@link Webhook} in a {@link TextChannel} or a {@link NewsChannel}.
* @typedef {Object} ChannelWebhookCreateOptions
* @property {?(BufferResolvable|Base64Resolvable)} [avatar] Avatar for the webhook
* @property {string} [reason] Reason for creating the webhook
*/
/**
* Creates a webhook for the channel.
* @param {string} name The name of the webhook
* @param {ChannelWebhookCreateOptions} [options] Options for creating the webhook
* @returns {Promise<Webhook>} Returns the created Webhook
* @example
* // Create a webhook for the current channel
* channel.createWebhook('Snek', {
* avatar: 'https://i.imgur.com/mI8XcpG.jpg',
* reason: 'Needed a cool new Webhook'
* })
* .then(console.log)
* .catch(console.error)
*/
createWebhook(name, options = {}) {
return this.guild.channels.createWebhook(this.id, name, options);
}
/**
* Sets a new topic for the guild channel.
* @param {?string} topic The new topic for the guild channel
@@ -221,6 +173,10 @@ class BaseGuildTextChannel extends GuildChannel {
createMessageComponentCollector() {}
awaitMessageComponent() {}
bulkDelete() {}
fetchWebhooks() {}
createWebhook() {}
setRateLimitPerUser() {}
setNSFW() {}
}
TextBasedChannel.applyToClass(BaseGuildTextChannel, true);

View File

@@ -96,6 +96,8 @@ class DMChannel extends Channel {
createMessageComponentCollector() {}
awaitMessageComponent() {}
// Doesn't work on DM channels; bulkDelete() {}
// Doesn't work on DM channels; setRateLimitPerUser() {}
// Doesn't work on DM channels; setNSFW() {}
// Testing feature: Call
// URL: https://discord.com/api/v9/channels/DMchannelId/call/ring
/**
@@ -157,6 +159,12 @@ class DMChannel extends Channel {
}
}
TextBasedChannel.applyToClass(DMChannel, true, ['bulkDelete']);
TextBasedChannel.applyToClass(DMChannel, true, [
'bulkDelete',
'fetchWebhooks',
'createWebhook',
'setRateLimitPerUser',
'setNSFW',
]);
module.exports = DMChannel;

View File

@@ -388,7 +388,7 @@ class Message extends Base {
/**
* The channel that the message was sent in
* @type {TextChannel|DMChannel|NewsChannel|ThreadChannel}
* @type {TextBasedChannel}
* @readonly
*/
get channel() {

View File

@@ -309,7 +309,7 @@ module.exports = MessagePayload;
/**
* A target for a message.
* @typedef {TextChannel|DMChannel|User|GuildMember|Webhook|WebhookClient|Interaction|InteractionWebhook|
* @typedef {TextBasedChannel|DMChannel|User|GuildMember|Webhook|WebhookClient|Interaction|InteractionWebhook|
* Message|MessageManager} MessageTarget
*/

View File

@@ -264,6 +264,12 @@ class PartialGroupDMChannel extends Channel {
}
}
TextBasedChannel.applyToClass(PartialGroupDMChannel, false);
TextBasedChannel.applyToClass(PartialGroupDMChannel, true, [
'bulkDelete',
'fetchWebhooks',
'createWebhook',
'setRateLimitPerUser',
'setNSFW',
]);
module.exports = PartialGroupDMChannel;

View File

@@ -545,8 +545,10 @@ class ThreadChannel extends Channel {
createMessageComponentCollector() {}
awaitMessageComponent() {}
bulkDelete() {}
// Doesn't work on Thread channels; setRateLimitPerUser() {}
// Doesn't work on Thread channels; setNSFW() {}
}
TextBasedChannel.applyToClass(ThreadChannel, true);
TextBasedChannel.applyToClass(ThreadChannel, true, ['setRateLimitPerUser', 'setNSFW']);
module.exports = ThreadChannel;

View File

@@ -2,6 +2,8 @@
const process = require('node:process');
const BaseGuildVoiceChannel = require('./BaseGuildVoiceChannel');
const TextBasedChannel = require('./interfaces/TextBasedChannel');
const MessageManager = require('../managers/MessageManager');
const { VideoQualityModes } = require('../util/Constants');
const Permissions = require('../util/Permissions');
@@ -10,8 +12,21 @@ let deprecationEmittedForEditable = false;
/**
* Represents a guild voice channel on Discord.
* @extends {BaseGuildVoiceChannel}
* @implements {TextBasedChannel}
*/
class VoiceChannel extends BaseGuildVoiceChannel {
constructor(guild, data, client) {
super(guild, data, client, false);
/**
* A manager of the messages sent to this channel
* @type {MessageManager}
*/
this.messages = new MessageManager(this);
this._patch(data);
}
_patch(data) {
super._patch(data);
@@ -20,10 +35,30 @@ class VoiceChannel extends BaseGuildVoiceChannel {
* The camera video quality mode of the channel.
* @type {?VideoQualityMode}
*/
this.videoQualityMode = VideoQualityModes[data.videoQualityMode];
this.videoQualityMode = VideoQualityModes[data.video_quality_mode];
} else {
this.videoQualityMode ??= null;
}
if ('last_message_id' in data) {
/**
* The last message id sent in the channel, if one was sent
* @type {?Snowflake}
*/
this.lastMessageId = data.last_message_id;
}
if ('messages' in data) {
for (const message of data.messages) this.messages._add(message);
}
if ('rate_limit_per_user' in data) {
/**
* The rate limit per user (slowmode) for this channel in seconds
* @type {number}
*/
this.rateLimitPerUser = data.rate_limit_per_user;
}
}
/**
@@ -112,6 +147,21 @@ class VoiceChannel extends BaseGuildVoiceChannel {
return this.edit({ videoQualityMode }, reason);
}
// These are here only for documentation purposes - they are implemented by TextBasedChannel
/* eslint-disable no-empty-function */
get lastMessage() {}
send() {}
sendTyping() {}
createMessageCollector() {}
awaitMessages() {}
createMessageComponentCollector() {}
awaitMessageComponent() {}
bulkDelete() {}
fetchWebhooks() {}
createWebhook() {}
setRateLimitPerUser() {}
setNSFW() {}
/**
* Sets the RTC region of the channel.
* @name VoiceChannel#setRTCRegion
@@ -127,4 +177,6 @@ class VoiceChannel extends BaseGuildVoiceChannel {
*/
}
TextBasedChannel.applyToClass(VoiceChannel, true, ['lastPinAt']);
module.exports = VoiceChannel;

View File

@@ -331,6 +331,64 @@ class TextBasedChannel {
throw new TypeError('MESSAGE_BULK_DELETE_TYPE');
}
/**
* Fetches all webhooks for the channel.
* @returns {Promise<Collection<Snowflake, Webhook>>}
* @example
* // Fetch webhooks
* channel.fetchWebhooks()
* .then(hooks => console.log(`This channel has ${hooks.size} hooks`))
* .catch(console.error);
*/
fetchWebhooks() {
return this.guild.channels.fetchWebhooks(this.id);
}
/**
* Options used to create a {@link Webhook} in a guild text-based channel.
* @typedef {Object} ChannelWebhookCreateOptions
* @property {?(BufferResolvable|Base64Resolvable)} [avatar] Avatar for the webhook
* @property {string} [reason] Reason for creating the webhook
*/
/**
* Creates a webhook for the channel.
* @param {string} name The name of the webhook
* @param {ChannelWebhookCreateOptions} [options] Options for creating the webhook
* @returns {Promise<Webhook>} Returns the created Webhook
* @example
* // Create a webhook for the current channel
* channel.createWebhook('Snek', {
* avatar: 'https://i.imgur.com/mI8XcpG.jpg',
* reason: 'Needed a cool new Webhook'
* })
* .then(console.log)
* .catch(console.error)
*/
createWebhook(name, options = {}) {
return this.guild.channels.createWebhook(this.id, name, options);
}
/**
* Sets the rate limit per user (slowmode) for this channel.
* @param {number} rateLimitPerUser The new rate limit in seconds
* @param {string} [reason] Reason for changing the channel's rate limit
* @returns {Promise<this>}
*/
setRateLimitPerUser(rateLimitPerUser, reason) {
return this.edit({ rateLimitPerUser }, reason);
}
/**
* Sets whether this channel is flagged as NSFW.
* @param {boolean} [nsfw=true] Whether the channel should be considered NSFW
* @param {string} [reason] Reason for changing the channel's NSFW flag
* @returns {Promise<this>}
*/
setNSFW(nsfw = true, reason) {
return this.edit({ nsfw }, reason);
}
/**
* Send Slash to this channel
* @param {Snowflake} botId Bot Id
@@ -385,6 +443,10 @@ class TextBasedChannel {
'awaitMessages',
'createMessageComponentCollector',
'awaitMessageComponent',
'fetchWebhooks',
'createWebhook',
'setRateLimitPerUser',
'setNSFW',
);
}
for (const prop of props) {

View File

@@ -87,6 +87,7 @@ exports.randomUA = () => listUserAgent[Math.floor(Math.random() * listUserAgent.
exports.WSCodes = {
1000: 'WS_CLOSE_REQUESTED',
1011: 'INTERNAL_ERROR',
4004: 'TOKEN_INVALID',
4010: 'SHARDING_INVALID',
4011: 'SHARDING_REQUIRED',
@@ -677,7 +678,8 @@ exports.ChannelTypes = createEnum([
* * TextChannel
* * NewsChannel
* * ThreadChannel
* @typedef {DMChannel|TextChannel|NewsChannel|ThreadChannel} TextBasedChannels
* * VoiceChannel
* @typedef {DMChannel|TextChannel|NewsChannel|ThreadChannel|VoiceChannel} TextBasedChannels
*/
/**
@@ -695,6 +697,7 @@ exports.ChannelTypes = createEnum([
* * GUILD_NEWS_THREAD
* * GUILD_PUBLIC_THREAD
* * GUILD_PRIVATE_THREAD
* * GUILD_VOICE
* @typedef {string} TextBasedChannelTypes
*/
exports.TextBasedChannelTypes = [
@@ -704,6 +707,7 @@ exports.TextBasedChannelTypes = [
'GUILD_NEWS_THREAD',
'GUILD_PUBLIC_THREAD',
'GUILD_PRIVATE_THREAD',
'GUILD_VOICE',
];
/**
@@ -1156,6 +1160,7 @@ exports.ApplicationCommandTypes = createEnum([null, 'CHAT_INPUT', 'USER', 'MESSA
* * ROLE
* * MENTIONABLE
* * NUMBER
* * ATTACHMENT
* @typedef {string} ApplicationCommandOptionType
* @see {@link https://discord.com/developers/docs/interactions/application-commands#application-command-object-application-command-option-type}
*/

View File

@@ -34,6 +34,8 @@ const JSONBig = require('json-bigint');
* @property {number|number[]|string} [shards] The shard's id to run, or an array of shard ids. If not specified,
* the client will spawn {@link ClientOptions#shardCount} shards. If set to `auto`, it will fetch the
* recommended amount of shards from Discord and spawn that amount
* @property {number} [closeTimeout=5000] The amount of time in milliseconds to wait for the close frame to be received
* from the WebSocket. Don't have this too high/low. Its best to have it between 2_000-6_000 ms.
* @property {boolean} [checkUpdate=true] Check for module updates at startup
* @property {boolean} [readyStatus=true] Sync state with Discord Client
* @property {boolean} [autoCookie=true] Automatically add Cookies to Request on startup
@@ -138,6 +140,7 @@ class Options extends null {
static createDefault() {
return {
jsonTransformer: object => JSONBig.stringify(object),
closeTimeout: 5_000,
checkUpdate: true,
readyStatus: true,
autoCookie: true,