import EventEmitter from "events"; import zlib from "zlib"; import { WebSocket } from "ws"; import fetch from "node-fetch"; const opcodes = { EVENT: 0, CLIENT_HEARTBEAT: 1, IDENTIFY: 2, RESUME: 6, INVALID_SESSION: 9, HELLO: 10, HEARTBEAT_ACK: 11, }; const reconnectOnCloseCodes = [ 1000, 1001, 4000, 4001, 4002, 4003, 4005, 4007, 4008, 4009 ]; class DiscordClient extends EventEmitter { constructor(token, { intents, baseDomain="discord.com", 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 } }; } _getResumePayload() { return { token: this.token, session_id: this.sessionId, seq: this.seq }; } _handleGatewayMessage(ws, message) { try { message = JSON.parse(message); } catch(e) { console.error("error: DiscordClient: 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: { this._setHeartbeat(payload.heartbeat_interval); if (this.resuming) { console.warn("DiscordClient: resuming..."); this.resuming = false; ws.send(JSON.stringify({ op: opcodes.RESUME, d: this._getResumePayload() })); } else { ws.send(JSON.stringify({ op: opcodes.IDENTIFY, d: this._getIdentifyPayload() })); } break; } case opcodes.EVENT: { switch (message.t) { case "READY": { console.log("DiscordClient: 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: { if (message.d) { // connection is resumable, we are going to resume the connection this.resuming = true; this.connect(); } else { // connection is not resumable, wait some time and then send a new IDENTIFY payload setTimeout(() => { ws.send(JSON.stringify({ op: opcodes.IDENTIFY, d: this._getIdentifyPayload() })); }, 3500); } break; } default: { console.warn(`warn: DiscordClient: got unhandled opcode "${message.op}"`); break; } } } connect() { if (this.ws) { 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("close", (code, reason) => { reason = reason.toString(); console.error(`DiscordClient: on \`close\`: disconnected from gateway: code \`${code}\`, reason \`${reason}\``); this.emit("close", code, reason); this._setHeartbeat(-1); if (reconnectOnCloseCodes.includes(code)) { this.resuming = true; this.connect(); } }); ws.on("error", (e) => { console.error("DiscordClient: websocket error:", e); console.log("DiscordClient: reconnecting?"); this._setHeartbeat(-1); this.resuming = true; 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;