import EventEmitter from "events"; import zlib from "zlib"; import { WebSocket } from "ws"; import fetch from "node-fetch"; import { logger } from "./common.js"; const log = logger("log", "DiscordClient"); const logError = logger("error", "DiscordClient"); const logWarn = logger("warn", "DiscordClient"); const opcodes = { EVENT: 0, CLIENT_HEARTBEAT: 1, IDENTIFY: 2, RECONNECT: 7, INVALID_SESSION: 9, HELLO: 10, HEARTBEAT_ACK: 11, }; const skipReconnectFor = [ 4004, 4010, 4011, 4012, 4013, 4014 ]; class DiscordClient extends EventEmitter { constructor(token, { intents, gatewayUrl="wss://gateway.discord.gg/?v=9&encoding=json&compress=zlib-stream", apiBase="https://discord.com/api/v9" } = {}) { super(); this.token = token; this.gatewayUrl = gatewayUrl; this.apiBase = apiBase; this.inflate = null; this.ws = null; this.intents = intents; this.user = null; this.guilds = []; this.sessionId = null; this.seq = null; this.gotServerHeartbeatACK = true; } _setHeartbeat(interval) { this._heartbeatIntervalTime = interval; if (interval < 0 && this._heartbeatInterval) { clearInterval(this._heartbeatInterval); return; } this._heartbeatInterval = setInterval(() => { if (!this.gotServerHeartbeatACK) { this.ws.close(1000, "No heartbeat ACK."); return; } this.gotServerHeartbeatACK = false; this.ws.send(JSON.stringify({ op: opcodes.CLIENT_HEARTBEAT, d: this.seq })); }, this._heartbeatIntervalTime); } _getIdentifyPayload() { return { token: this.token, intents: this.intents, properties: { "$os": "linux", "$browser": "generic", "$device": "generic" }, presence: { since: Date.now(), activities: [ { type: 2, // LISTENING name: "the voices" } ], status: "online", afk: false } }; } _handleGatewayMessage(ws, message) { try { message = JSON.parse(message); } catch(e) { logError("on 'message': failed to parse incoming message as JSON", e); return; } if (message.s) { this.seq = message.s; } const payload = message.d; switch (message.op) { case opcodes.HELLO: { log(`HELLO; heartbeat_interval=${payload.heartbeat_interval}`); this._setHeartbeat(payload.heartbeat_interval); ws.send(JSON.stringify({ op: opcodes.IDENTIFY, d: this._getIdentifyPayload() })); break; } case opcodes.EVENT: { switch (message.t) { case "READY": { log("READY"); this.user = payload.user; this.sessionId = payload.session_id; this.guilds = payload.guilds; break; } case "GUILD_CREATE": { const targetGuildIndex = this.guilds.findIndex(e => e.id === payload.id); if (targetGuildIndex < 0) { this.guilds.push(payload); break; } // The guild already exists in our array. This means that // this GUILD_CREATE event is completing our `Unavailable Guild` // objects that we got from the initial READY. this.guilds[targetGuildIndex] = payload; break; } case "GUILD_UPDATE": { const targetGuildIndex = this.guilds.findIndex(e => e.id === payload.id); if (targetGuildIndex < 0) { // tried to update a guild that doesn't exist??? this.emit("warn", "got GUILD_UPDATE for a guild that doesn't exist"); break; } this.guilds[targetGuildIndex] = payload; break; } case "GUILD_DELETE": { const targetGuildIndex = this.guilds.findIndex(e => e.id === payload.id); if (targetGuildIndex < 0) { // tried to delete a guild that doesn't exist??? this.emit("warn", "got GUILD_DELETE for a guild that doesn't exist"); break; } this.guilds.splice(targetGuildIndex, 1); break; } case "CHANNEL_CREATE": { const parentGuildIndex = this.guilds.findIndex(e => e.id === payload.guild_id); if (parentGuildIndex < 0) { this.emit("warn", "got CHANNEL_CREATE for a guild that doesn't exist"); break; } this.guilds[parentGuildIndex].channels.push(payload); break; } case "CHANNEL_UPDATE": { const parentGuildIndex = this.guilds.findIndex(e => e.id === payload.guild_id); if (parentGuildIndex < 0) { this.emit("warn", "got CHANNEL_CREATE for a guild that doesn't exist"); break; } const relevantChannelIndex = this.guilds[parentGuildIndex].channels.findIndex(e => e.id === payload.id); this.guilds[parentGuildIndex].channels[relevantChannelIndex] = payload; break; } case "CHANNEL_DELETE": { const parentGuildIndex = this.guilds.findIndex(e => e.id === payload.guild_id); if (parentGuildIndex < 0) { this.emit("warn", "got CHANNEL_CREATE for a guild that doesn't exist"); break; } const relevantChannelIndex = this.guilds[parentGuildIndex].channels.findIndex(e => e.id === payload.id); this.guilds[parentGuildIndex].channels.splice(relevantChannelIndex, 1); break; } } this.emit(message.t, payload); break; } case opcodes.HEARTBEAT_ACK: { this.gotServerHeartbeatACK = true; break; } case opcodes.INVALID_SESSION: { logError("INVALID_SESSION - please check your authentication token"); logError("INVALID_SESSION: will not reconnect"); break; } case opcodes.RECONNECT: { log("gateway is requesting reconnect (payload RECONNECT)"); this.connect(); break; } default: { logWarn(`got unhandled opcode "${message.op}"`); break; } } } connect() { log("connecting..."); if (this.ws) { log("a websocket connection already exists, killing..."); this.ws.removeAllListeners(); this.ws.close(); this.ws = null; } const ws = new WebSocket(this.gatewayUrl); this.ws = ws; this.inflate = zlib.createInflate({ chunkSize: 128 * 1024 }); // we decompressed the data, send it to the handler now this.inflate.on("data", (message) => this._handleGatewayMessage(ws, message) ); ws.on("message", (data, isBinary) => { // pass the data to the decompressor this.inflate.write(data); }); ws.on("open", () => { log("WebSocket 'open'"); }); ws.on("close", (code, reason) => { reason = reason.toString(); logError(`on 'close': disconnected from gateway: code '${code}', reason '${reason}'`); this.emit("close", code, reason); this._setHeartbeat(-1); if (skipReconnectFor.includes(code)) { logError("on 'close': the exit code above is in skipReconnectFor, and thus the server will not reconnect."); } else { log("on 'close': the client will now attempt to reconnect..."); this.connect(); } }); ws.on("error", (e) => { logError("on 'error': websocket error:", e); log("on 'error': reconnecting due to previous websocket error..."); this._setHeartbeat(-1); this.connect(); }); } async api([method, path], body=undefined, throwOnError=true) { const options = { method, headers: { "authorization": `Bot ${this.token}` } }; if (method !== "GET" && method !== "HEAD" && typeof body === "object") { options.headers["content-type"] = "application/json"; options.body = JSON.stringify(body); } const response = await fetch(`${this.apiBase}${path}`, options); const json = await response.json(); if (!response.ok && throwOnError) { throw new Error(`API request returned non-success status ${response.status}, with JSON body ${JSON.stringify(json)}`); } return json; } } export default DiscordClient;