UNPKG

royal-selfbot

Version:

A Discord self-bot library. USE WITH EXTREME CAUTION - AGAINST DISCORD TOS.

712 lines (637 loc) 31.5 kB
// 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;