292 lines
9.9 KiB
JavaScript
292 lines
9.9 KiB
JavaScript
|
import EventEmitter from "events";
|
||
|
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
|
||
|
];
|
||
|
|
||
|
const CLOSE_CONNECTION_ON_NO_ACK = false;
|
||
|
|
||
|
class DiscordClient extends EventEmitter {
|
||
|
constructor(token, { intents, gatewayUrl="wss://gateway.discord.gg/?v=10&encoding=json", apiBase="https://discord.com/api/v10" } = {}) {
|
||
|
super();
|
||
|
|
||
|
this.token = token;
|
||
|
this.gatewayUrl = gatewayUrl;
|
||
|
this.apiBase = apiBase;
|
||
|
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...");
|
||
|
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": {
|
||
|
this.user = payload.user;
|
||
|
this.sessionId = payload.session_id;
|
||
|
this.guilds = payload.guilds;
|
||
|
log(`READY. Connected as '${this.user.username}', got ${this.guilds.length} 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._setHeartbeat(-1);
|
||
|
this.ws.close();
|
||
|
this.ws = null;
|
||
|
}
|
||
|
|
||
|
const ws = new WebSocket(this.gatewayUrl);
|
||
|
this.ws = ws;
|
||
|
|
||
|
ws.on("message", (data, isBinary) => {
|
||
|
if (!isBinary) {
|
||
|
this._handleGatewayMessage(ws, data.toString());
|
||
|
}
|
||
|
});
|
||
|
|
||
|
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;
|