bridgecord/src/DiscordClient.js

302 lines
10 KiB
JavaScript
Raw Normal View History

import EventEmitter from "events";
import zlib from "zlib";
import { WebSocket } from "ws";
import fetch from "node-fetch";
2022-02-15 14:24:46 +02:00
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,
2022-02-15 14:24:46 +02:00
RECONNECT: 7,
INVALID_SESSION: 9,
HELLO: 10,
HEARTBEAT_ACK: 11,
};
const skipReconnectFor = [
4004, 4010, 4011, 4012, 4013, 4014
];
const CLOSE_CONNECTION_ON_NO_ACK = false;
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;
2022-02-02 20:24:14 +02:00
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 (CLOSE_CONNECTION_ON_NO_ACK && !this.gotServerHeartbeatACK) {
logError("Closing due to no heartbeat ACK...");
2022-02-02 20:24:14 +02:00
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) {
2022-02-15 14:24:46 +02:00
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: {
2022-02-15 14:24:46 +02:00
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": {
2022-02-15 14:24:46 +02:00
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: {
2022-02-15 14:24:46 +02:00
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: {
2022-02-15 14:24:46 +02:00
logWarn(`got unhandled opcode "${message.op}"`);
break;
}
}
}
connect() {
2022-02-15 14:24:46 +02:00
log("connecting...");
if (this.ws) {
2022-02-15 14:24:46 +02:00
log("a websocket connection already exists, killing...");
this.ws.removeAllListeners();
this._setHeartbeat(-1);
this.ws.close();
this.ws = null;
}
const ws = new WebSocket(this.gatewayUrl);
this.ws = ws;
2022-02-02 20:24:14 +02:00
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", () => {
2022-02-15 14:24:46 +02:00
log("WebSocket 'open'");
});
ws.on("close", (code, reason) => {
reason = reason.toString();
2022-02-15 14:24:46 +02:00
logError(`on 'close': disconnected from gateway: code '${code}', reason '${reason}'`);
this.emit("close", code, reason);
this._setHeartbeat(-1);
if (skipReconnectFor.includes(code)) {
2022-02-15 14:24:46 +02:00
logError("on 'close': the exit code above is in skipReconnectFor, and thus the server will not reconnect.");
} else {
2022-02-15 14:24:46 +02:00
log("on 'close': the client will now attempt to reconnect...");
this.connect();
}
});
ws.on("error", (e) => {
2022-02-15 14:24:46 +02:00
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;