royal-selfbot
Version:
A Discord self-bot library. USE WITH EXTREME CAUTION - AGAINST DISCORD TOS.
712 lines (637 loc) • 31.5 kB
JavaScript
// src/gateway/WebSocketManager.js
const WebSocket = require('ws');
const EventEmitter = require('events');
const { Gateway, IdentifyProperties } = require('../util/Constants');
const ZlibSync = require('zlib-sync'); // Requires: npm install zlib-sync
/**
* Handles the WebSocket connection to the Discord Gateway.
* Manages heartbeating, identifying, resuming, and dispatching events.
* @extends {EventEmitter}
*/
class WebSocketManager extends EventEmitter {
/**
* @param {Client} client The instantiating client
*/
constructor(client) {
super();
this.client = client;
this.ws = null;
this.heartbeatInterval = null;
this.heartbeatTimer = null;
this.lastHeartbeatAck = true; // Assume true initially
this.lastHeartbeatSent = 0;
this.latency = Infinity;
this.sequence = null; // s value from Discord for resuming and heartbeats
this.sessionId = null; // For resuming sessions
this.token = null; // Store the token used for the connection
this.gatewayUrl = Gateway.GATEWAY_URL; // Get URL from Constants (includes compress=zlib-stream)
this.zlib = null; // For handling compressed packets
this.isReady = false; // Flag to indicate if the READY event has been received
this.isReconnecting = false; // Flag to prevent multiple reconnect attempts
this._currentReconnectDelay = undefined; // Used for exponential backoff tracking
}
/**
* Initiates connection to the gateway.
* @param {string} token The user token
*/
connect(token) {
if (this.ws && this.ws.readyState !== WebSocket.CLOSED) {
console.warn('[Gateway Connect] Warning: Attempting to connect while an existing WebSocket connection might be open or closing. Forcing cleanup.');
this.destroy({ code: 1000, reason: 'New connection requested', log: false }); // Clean up existing connection first
}
if (!token) throw new Error('Token is required to connect to the Gateway.');
this.token = token;
this.isReady = false;
// Do not reset isReconnecting here, let onOpen handle it upon success
// Initialize zlib-sync for decompression
// Ensure previous instance is closed if somehow lingering
if (this.zlib) {
try { this.zlib.close(); } catch(e) {/* Ignore */}
this.zlib = null;
}
try {
this.zlib = new ZlibSync.Inflate({ chunkSize: 65535 }); // Standard chunk size
// console.log('[Gateway] zlib-sync inflate stream initialized.');
} catch (e) {
console.warn('[Gateway] zlib-sync inflate stream failed to initialize. Compression disabled.', e);
this.zlib = null;
}
console.log(`[Gateway] Connecting to ${this.gatewayUrl}`);
try {
this.ws = new WebSocket(this.gatewayUrl, {
// Optional WebSocket options
handshakeTimeout: 30000, // Timeout for WebSocket handshake
});
this._bindWSEvents();
} catch (error) {
console.error('[Gateway] Connection Error (WebSocket constructor):', error);
this.emit(Gateway.Events.ERROR, error); // Emit error
this.isReconnecting = false; // Allow reconnect attempt if constructor fails
this._attemptReconnect(); // Attempt to reconnect after a failure
}
}
/**
* Binds listeners to the WebSocket instance.
* @private
*/
_bindWSEvents() {
if (!this.ws) return;
// Clear previous listeners if any somehow remained (defensive)
this.ws.removeAllListeners();
this.ws.on('open', this.onOpen.bind(this));
this.ws.on('message', this.onMessage.bind(this));
this.ws.on('close', this.onClose.bind(this));
this.ws.on('error', this.onError.bind(this));
}
/**
* Handles the WebSocket 'open' event.
* @private
*/
onOpen() {
console.log('[Gateway] WebSocket connection opened successfully.');
this.isReconnecting = false; // Successfully opened, reset reconnect flag
this._currentReconnectDelay = undefined; // Reset backoff delay on success
}
/**
* Decompresses incoming data using zlib-sync.
* @param {Buffer|ArrayBuffer|Buffer[]} data The raw data received from WebSocket.
* @returns {?Buffer} The decompressed data as a Buffer, or null on error, or original Buffer if not compressed/suffix missing.
* @private
*/
inflateData(data) {
if (!this.zlib) {
// Pass through if zlib isn't active
return data instanceof Buffer ? data : Buffer.from(data);
}
if (!(data instanceof Buffer)) {
console.warn('[Gateway] Received non-Buffer data for inflation.');
try { data = Buffer.from(data); } catch { return null; }
}
const ZLIB_SUFFIX = Buffer.from([0x00, 0x00, 0xff, 0xff]);
if (data.length < 4 || !data.slice(data.length - 4).equals(ZLIB_SUFFIX)) {
// Assume uncompressed text JSON or fragment
return data;
}
this.zlib.push(data, ZlibSync.Z_SYNC_FLUSH);
if (this.zlib.err) {
console.error('[Gateway] Zlib Inflate Error:', this.zlib.msg);
// Reset zlib state
try { this.zlib.close(); } catch(e) {/* Ignore */}
this.zlib = new ZlibSync.Inflate({ chunkSize: 65535 });
return null; // Indicate error
}
const decompressedData = this.zlib.result;
if (!decompressedData || decompressedData.length === 0) {
// May occur if only suffix was received?
// console.warn('[Gateway] Zlib inflation resulted in empty or null data.');
return null;
}
return decompressedData; // Return the decompressed Buffer
}
/**
* Handles incoming WebSocket messages.
* @param {Buffer|ArrayBuffer|Buffer[]} data Raw message data
* @param {boolean} isBinary Indicates if data is binary
* @private
*/
onMessage(data, isBinary) {
let packet;
let rawData = data; // For logging
try {
const inflated = this.inflateData(data);
if (inflated === null) {
// Error during inflation or empty result, can't process
console.error('[Gateway] Inflation failed or resulted in null, skipping message processing.');
return;
}
const textData = inflated instanceof Buffer ? inflated.toString('utf8') : inflated;
rawData = textData;
if (!textData) {
// Empty string after inflation/conversion, nothing to parse
// console.debug('[Gateway] Received empty text data after inflation/conversion.');
return;
}
packet = JSON.parse(textData);
} catch (err) {
console.error('[Gateway] Error processing message:', err);
if (typeof rawData === 'string') {
console.error('[Gateway] Text data that failed parsing:', rawData.substring(0, 1000) + (rawData.length > 1000 ? '...' : ''));
} else {
console.error('[Gateway] Original buffer data received (possibly compressed):', data);
}
return;
}
// Process the valid packet
this.handlePacket(packet);
}
/**
* Routes the packet based on its OpCode.
* @param {object} packet The parsed packet data.
* @private
*/
handlePacket(packet) {
if (packet.s) {
this.sequence = packet.s;
}
switch (packet.op) {
case Gateway.OpCodes.DISPATCH:
this.handleDispatch(packet);
break;
case Gateway.OpCodes.HEARTBEAT:
console.log('[Gateway] Received Heartbeat request (OP 1). Sending heartbeat.');
this.sendHeartbeat();
break;
case Gateway.OpCodes.RECONNECT:
console.warn('[Gateway] Received Reconnect request (OP 7). Closing connection for reconnect.');
this.destroy({ code: 4000, reason: 'Reconnect requested by Discord' });
this._attemptReconnect(true); // Attempt resume
break;
case Gateway.OpCodes.INVALID_SESSION:
console.warn(`[Gateway] Received Invalid Session (OP 9). Resumable: ${packet.d}`);
// Short delay before reconnecting after invalid session
const delay = Math.floor(Math.random() * 4000) + 1000; // 1-5 seconds delay
if (packet.d) {
this._attemptReconnect(true, delay); // Reconnect and try to resume after delay
} else {
this.sequence = null;
this.sessionId = null;
this._attemptReconnect(false, delay); // Reconnect and identify after delay
}
break;
case Gateway.OpCodes.HELLO:
console.log(`[Gateway] Received HELLO (OP 10). Heartbeat interval: ${packet.d.heartbeat_interval}ms`);
this.lastHeartbeatAck = true;
this.startHeartbeat(packet.d.heartbeat_interval);
if (this.sessionId && this.sequence !== null) { // Check sequence specifically
this.resume();
} else {
this.identify();
}
break;
case Gateway.OpCodes.HEARTBEAT_ACK:
this.lastHeartbeatAck = true;
this.latency = Date.now() - this.lastHeartbeatSent;
// console.debug(`[Gateway] Heartbeat Acknowledged (OP 11). Latency: ${this.latency}ms`);
break;
default:
console.warn(`[Gateway] Received Unhandled OpCode: ${packet.op}`);
console.debug('[Gateway] Unhandled Packet Data:', packet.d);
}
}
/**
* Handles DISPATCH (OpCode 0) events.
* @param {object} packet The dispatch packet.
* @private
*/
handleDispatch(packet) {
const { t: eventName, d: data } = packet;
// console.debug(`[Gateway] Dispatch: ${eventName}, Sequence: ${this.sequence}`);
if (eventName === 'READY') {
this.isReady = true;
this.sessionId = data.session_id;
this.client._patch(data);
this.emit(Gateway.Events.READY); // Internal event for Client.js
console.log(`[Gateway] Received READY. Session ID: ${this.sessionId}. User: ${data.user.username}#${data.user.discriminator}`);
this.isReconnecting = false; // Reset reconnect flag on successful READY
return; // Avoid processing other events before client emits 'ready'
}
if (eventName === 'RESUMED') {
this.isReady = true;
console.log(`[Gateway] Successfully RESUMED session ${this.sessionId}. Sequence: ${this.sequence}`);
this.emit(Gateway.Events.RESUMED); // Internal event
this.isReconnecting = false; // Reset reconnect flag on successful RESUME
this.client.emit('ready', this.client); // Also emit public ready after resume
return;
}
// Discard events before READY/RESUMED
if (!this.isReady) {
console.warn(`[Gateway] Discarding event '${eventName}' received before session was ready.`);
return;
}
// --- Route event data to client/managers ---
try {
// Special handling for voice events to forward to VoiceManager
if (eventName === 'VOICE_STATE_UPDATE') {
this.client.voice?.onVoiceStateUpdate(data); // Forward to VoiceManager first
const guild = this.client.guilds.cache.get(data.guild_id);
guild?._updateVoiceState?.(data); // Update Guild's internal state cache
// Construct and emit VoiceState object if structure exists
try {
const VoiceState = require('../structures/VoiceState');
const voiceState = new VoiceState(this.client, data);
this.client.emit(Gateway.Events.VOICE_STATE_UPDATE, voiceState);
} catch (e) {
// Fallback or log error if VoiceState structure is missing/broken
console.warn('[Gateway] Failed to construct VoiceState object, emitting raw data.', e.message);
this.client.emit(Gateway.Events.VOICE_STATE_UPDATE, data);
}
return; // Handled
}
if (eventName === 'VOICE_SERVER_UPDATE') {
this.client.voice?.onVoiceServerUpdate(data); // Forward to VoiceManager
// Emit raw data - usually only needed internally by voice libs
this.client.emit(Gateway.Events.VOICE_SERVER_UPDATE, data);
return; // Handled
}
// --- Generic Event Handling ---
// Find appropriate manager/structure method or emit directly
switch (eventName) {
case 'MESSAGE_CREATE': {
const message = this.client.messages._add(data); // Assumes global/channel MessageManager._add exists
if (message) this.client.emit(Gateway.Events.MESSAGE_CREATE, message);
break;
}
case 'MESSAGE_UPDATE': {
let oldMessage = this.client.messages.resolve(data.id); // Use resolve which checks cache
let newMessage = this.client.messages._add(data); // May create/update cache
if (newMessage) this.client.emit(Gateway.Events.MESSAGE_UPDATE, oldMessage, newMessage);
break;
}
case 'MESSAGE_DELETE': {
let deletedMessage = this.client.messages.resolve(data.id);
if (deletedMessage) this.client.messages.remove(data.id); // Remove from cache
this.client.emit(Gateway.Events.MESSAGE_DELETE, deletedMessage || data);
break;
}
case 'GUILD_CREATE': {
const guild = this.client.guilds._add(data);
if (guild) this.client.emit(Gateway.Events.GUILD_CREATE, guild);
break;
}
case 'GUILD_UPDATE': {
let oldGuild = this.client.guilds.resolve(data.id);
let newGuild = this.client.guilds._add(data);
if (newGuild) this.client.emit(Gateway.Events.GUILD_UPDATE, oldGuild, newGuild);
break;
}
case 'GUILD_DELETE': {
let deletedGuild = this.client.guilds.resolve(data.id);
if (deletedGuild) {
deletedGuild.unavailable = true; // Mark as unavailable
this.client.guilds.remove(data.id); // Remove from cache
}
this.client.emit(Gateway.Events.GUILD_DELETE, deletedGuild || data);
break;
}
case 'CHANNEL_CREATE': {
const channel = this.client.channels._add(data);
if (channel) this.client.emit(Gateway.Events.CHANNEL_CREATE, channel);
break;
}
case 'CHANNEL_UPDATE': {
let oldChannel = this.client.channels.resolve(data.id);
let newChannel = this.client.channels._add(data);
if (newChannel) this.client.emit(Gateway.Events.CHANNEL_UPDATE, oldChannel, newChannel);
break;
}
case 'CHANNEL_DELETE': {
let deletedChannel = this.client.channels.resolve(data.id);
if (deletedChannel) this.client.channels.remove(data.id);
this.client.emit(Gateway.Events.CHANNEL_DELETE, deletedChannel || data);
break;
}
// Add cases for ALL other events you want the library to handle...
// GUILD_MEMBER_*, PRESENCE_UPDATE, TYPING_START, USER_UPDATE etc.
default:
// Emit unhandled events directly with the raw Discord event name
this.client.emit(eventName, data);
break;
}
} catch (error) {
console.error(`[Gateway] Error handling dispatch event '${eventName}':`, error);
console.error(`[Gateway] Event Data:`, JSON.stringify(data).substring(0, 500) + '...'); // Log data safely
this.emit(Gateway.Events.ERROR, error); // Emit internal error event
}
}
/**
* Sends the IDENTIFY payload (OpCode 2).
* @private
*/
identify() {
console.log('[Gateway] Sending IDENTIFY payload.');
const payload = {
op: Gateway.OpCodes.IDENTIFY,
d: {
token: this.token,
properties: IdentifyProperties, // Critical: Use accurate values
compress: !!this.zlib,
presence: {
activities: [],
status: "online",
since: null,
afk: false,
},
// intents are NOT used by user accounts/self-bots
// capabilities might be needed but are unstable/undocumented
},
};
this.send(payload);
}
/**
* Sends the RESUME payload (OpCode 6).
* @private
*/
resume() {
if (!this.sessionId || this.sequence === null) {
console.error('[Gateway] Cannot RESUME without sessionId and sequence. Identifying instead.');
this.identify();
return;
}
console.log(`[Gateway] Attempting to RESUME session ${this.sessionId} at sequence ${this.sequence}`);
const payload = {
op: Gateway.OpCodes.RESUME,
d: {
token: this.token,
session_id: this.sessionId,
seq: this.sequence,
},
};
this.send(payload);
}
/**
* Starts the heartbeat interval.
* @param {number} interval The interval (in ms) provided by HELLO payload.
* @private
*/
startHeartbeat(interval) {
this.stopHeartbeat();
// console.debug(`[Gateway] Starting heartbeat interval: ${interval}ms`);
this.heartbeatInterval = interval;
// Send first heartbeat immediately
this.sendHeartbeat(false); // Don't check ACK for first one
this.heartbeatTimer = setInterval(() => {
this.sendHeartbeat(true); // Check ACK for subsequent ones
}, interval);
}
/**
* Stops the heartbeat interval.
* @private
*/
stopHeartbeat() {
if (this.heartbeatTimer) {
// console.debug('[Gateway] Stopping heartbeat timer.');
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
this.heartbeatInterval = null;
this.lastHeartbeatAck = true; // Assume okay when stopped
}
/**
* Sends a heartbeat payload (OpCode 1).
* Checks if the previous heartbeat was acknowledged if checkAck is true.
* @param {boolean} [checkAck=false] Whether to check if the last heartbeat was acknowledged.
* @private
*/
sendHeartbeat(checkAck = false) {
// Check WebSocket state before sending heartbeat
if (this.ws?.readyState !== WebSocket.OPEN) {
console.warn('[Gateway] Tried to send heartbeat but WebSocket is not open.');
// Attempting to heartbeat on a closed connection might indicate a need to reconnect
this.stopHeartbeat(); // Stop trying to heartbeat
if (!this.isReconnecting) {
console.warn('[Gateway] WebSocket not open during heartbeat, initiating reconnect.');
this._attemptReconnect(true); // Assume resumable unless told otherwise
}
return;
}
if (checkAck && !this.lastHeartbeatAck) {
console.warn('[Gateway] Heartbeat ACK not received. Connection might be zombied. Reconnecting.');
this.stopHeartbeat();
// Use code 1006 or similar to indicate abnormal closure for zombie state
this.destroy({ code: 1006, reason: 'Heartbeat ACK missed (Zombie Connection)', log: false });
this._attemptReconnect(true); // Attempt resume
return;
}
// console.debug('[Gateway] Sending Heartbeat (OP 1), Sequence:', this.sequence);
try {
this.send({ op: Gateway.OpCodes.HEARTBEAT, d: this.sequence });
this.lastHeartbeatSent = Date.now();
this.lastHeartbeatAck = false;
} catch (error) {
console.error("[Gateway] Error sending heartbeat payload:", error);
// Consider reconnecting if send fails critically
if (!this.isReconnecting) {
this._attemptReconnect(true);
}
}
}
/**
* Sends a payload to the WebSocket server.
* @param {object} payload The data payload to send.
*/
send(payload) {
if (this.ws?.readyState === WebSocket.OPEN) {
const data = JSON.stringify(payload);
// console.debug('[Gateway SEND]', data.substring(0, 200)); // Log truncated data
this.ws.send(data, (err) => {
if (err) {
console.error('[Gateway] Error sending payload:', err);
this.emit(Gateway.Events.ERROR, err);
// Check if error indicates closed connection, potentially trigger reconnect
if (!this.isReconnecting && this.ws?.readyState !== WebSocket.OPEN) {
this._attemptReconnect(true);
}
}
});
} else {
console.warn('[Gateway] Attempted to send payload while WebSocket is not open. State:', this.ws?.readyState);
}
}
/**
* Handles the WebSocket 'close' event.
* @param {number} code The close code.
* @param {Buffer} reason The reason buffer.
* @private
*/
onClose(code, reason) {
const reasonString = reason.toString();
console.warn(`[Gateway] WebSocket closed. Code: ${code}, Reason: ${reasonString || 'No reason provided'}`);
this.stopHeartbeat();
this.isReady = false;
this.ws = null; // Clear the closed instance
// Determine if session is invalid or resumable based on close code
const resumableCodes = [1001, 1006]; // Codes that *might* allow resume (Going Away, Abnormal Closure)
const nonResumableCodes = [
Gateway.CloseCodes.UNKNOWN_ERROR, // 4000+ are generally non-resumable
Gateway.CloseCodes.UNKNOWN_OPCODE,
Gateway.CloseCodes.DECODE_ERROR,
Gateway.CloseCodes.NOT_AUTHENTICATED,
Gateway.CloseCodes.AUTHENTICATION_FAILED,
Gateway.CloseCodes.ALREADY_AUTHENTICATED,
Gateway.CloseCodes.INVALID_SEQUENCE, // Sequence issue requires fresh identify
Gateway.CloseCodes.RATE_LIMITED, // Rate limit might allow resume later, but safer to identify
Gateway.CloseCodes.SESSION_TIMED_OUT, // Explicitly requires new session
Gateway.CloseCodes.INVALID_SHARD,
Gateway.CloseCodes.SHARDING_REQUIRED,
Gateway.CloseCodes.INVALID_API_VERSION,
Gateway.CloseCodes.INVALID_INTENTS,
Gateway.CloseCodes.DISALLOWED_INTENTS,
];
let resume = true; // Default to trying resume unless known non-resumable code
if (nonResumableCodes.includes(code)) {
resume = false;
} else if (code === 1000 || code === 1005) {
// 1000 Normal Closure (e.g., client.destroy()), 1005 No Status Received
// Don't automatically reconnect on explicit normal closure or if no code given.
console.log(`[Gateway] Normal closure (Code ${code}), not attempting automatic reconnect.`);
this.isReconnecting = false; // Ensure reconnect state is false
return;
}
if (!this.isReconnecting) {
this._attemptReconnect(resume);
} else {
console.log('[Gateway] Close event occurred while already attempting to reconnect.');
}
}
/**
* Handles WebSocket 'error' events.
* @param {Error} error The error object.
* @private
*/
onError(error) {
console.error('[Gateway] WebSocket Error:', error.message || error);
this.emit(Gateway.Events.ERROR, error);
// Errors often precede a close event (Code 1006 usually).
// If the connection is still somehow open but errored, we might force a close & reconnect.
// However, the onClose handler is usually sufficient.
if (!this.isReconnecting && this.ws && this.ws.readyState !== WebSocket.CLOSED && this.ws.readyState !== WebSocket.CLOSING) {
console.warn('[Gateway] Error occurred on potentially open socket, forcing closure and reconnect attempt.');
this.destroy({ code: 1006, reason: `WebSocket Error: ${error.message}` });
this._attemptReconnect(true); // Assume resumable after generic error
}
}
/**
* Attempts to reconnect to the Gateway with exponential backoff.
* @param {boolean} [resume=true] Whether to attempt resuming the session.
* @param {number} [initialDelay=2000] Initial timeout in ms.
* @private
*/
_attemptReconnect(resume = true, initialDelay = 2000) {
if (this.isReconnecting) {
console.log('[Gateway Reconnect] Attempt already in progress.');
return;
}
console.log(`[Gateway Reconnect] Starting reconnect attempt... Resume: ${resume}`);
this.isReconnecting = true;
this.stopHeartbeat(); // Ensure heartbeat is stopped
// Clean up existing WebSocket instance *before* scheduling reconnect
if (this.ws) {
if (this.ws.readyState !== WebSocket.CLOSED) {
console.log('[Gateway Reconnect] Terminating existing WebSocket before scheduling reconnect.');
this.ws.terminate();
}
this.ws = null;
}
if (!resume) {
console.log('[Gateway Reconnect] Session invalidated or resume not possible. Resetting session state.');
this.sequence = null;
this.sessionId = null;
}
// Use exponential backoff with jitter
const maxTimeout = 60000; // Max 1 minute
this._currentReconnectDelay = this._currentReconnectDelay ? Math.min(this._currentReconnectDelay * 2, maxTimeout) : initialDelay;
const jitter = Math.floor(Math.random() * 1000); // Add up to 1s jitter
const finalDelay = this._currentReconnectDelay + jitter;
console.log(`[Gateway Reconnect] Scheduling connection attempt in ${(finalDelay / 1000).toFixed(1)}s...`);
setTimeout(() => {
// Check if still supposed to be reconnecting (e.g., client wasn't manually destroyed)
if (!this.isReconnecting) {
console.log('[Gateway Reconnect] Reconnect attempt cancelled (isReconnecting is false).');
return;
}
if (!this.token) {
console.error('[Gateway Reconnect] Cannot reconnect without a token.');
this.isReconnecting = false;
return;
}
console.log('[Gateway Reconnect] Executing scheduled connection attempt...');
// `connect` handles the actual connection. It resets `isReconnecting` on successful 'open'.
// If `connect` fails (e.g., constructor error, immediate close/error), its handlers or the
// `onClose`/`onError` listeners should trigger `_attemptReconnect` again with the next delay.
this.connect(this.token);
}, finalDelay);
}
/**
* Destroys the WebSocket connection and cleans up.
* @param {object} [options={}] Options for destroying.
* @param {number} [options.code=1000] The close code to send.
* @param {string} [options.reason='Client Destroy'] The close reason.
* @param {boolean} [options.log=true] Whether to log the destruction.
*/
destroy({ code = 1000, reason = 'Client Destroy', log = true } = {}) {
if (log) console.log(`[Gateway] Destroying WebSocket connection... Code: ${code}, Reason: ${reason}`);
this.stopHeartbeat();
this.isReady = false;
this.isReconnecting = false; // Stop any reconnection attempts
// Clear sequence and session ID *only* if we are explicitly destroying without intending to reconnect later.
// Usually, these should persist for potential future resumes unless invalid.
// For a full client destroy, clearing them is appropriate.
this.sequence = null;
this.sessionId = null;
if (this.ws) {
const wsInstance = this.ws; // Capture instance
this.ws = null; // Clear immediately to prevent race conditions
// Remove listeners
wsInstance.removeAllListeners();
if (wsInstance.readyState === WebSocket.OPEN) {
if (log) console.log('[Gateway] Sending close frame...');
wsInstance.close(code, reason);
} else if (wsInstance.readyState !== WebSocket.CLOSED) {
if (log) console.warn(`[Gateway] Terminating WebSocket in state ${wsInstance.readyState}.`);
wsInstance.terminate();
} else {
if (log) console.log('[Gateway] WebSocket already closed during destroy.');
}
} else {
if (log) console.log('[Gateway] No active WebSocket instance to destroy.');
}
// Close zlib stream
if (this.zlib) {
try { this.zlib.close(); } catch (e) { /* Ignore */ }
this.zlib = null;
}
this.latency = Infinity;
// Explicitly do not clear token here, Client.destroy handles that
}
}
module.exports = WebSocketManager;