333 lines
11 KiB
JavaScript
333 lines
11 KiB
JavaScript
import EventEmitter from "events";
|
|
import { WebSocket } from "ws";
|
|
import fetch from "node-fetch";
|
|
import logger from "./logging.js";
|
|
|
|
const log = logger("DiscordClient");
|
|
|
|
const opcodes = {
|
|
EVENT: 0,
|
|
CLIENT_HEARTBEAT: 1,
|
|
IDENTIFY: 2,
|
|
UPDATE_PRESENCE: 3,
|
|
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", presence = { activities: [{name: "the voices", type: 2}], status: "online", afk: false } } = {}) {
|
|
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;
|
|
this.defaultPresence = presence;
|
|
this.connectEnabled = true;
|
|
|
|
this.knownWebhooks = new Map();
|
|
}
|
|
|
|
_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) {
|
|
log("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: this.defaultPresence
|
|
};
|
|
}
|
|
|
|
_handleGatewayMessage(ws, message) {
|
|
try {
|
|
message = JSON.parse(message);
|
|
} catch(e) {
|
|
log("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: {
|
|
log("INVALID_SESSION - please check your authentication token");
|
|
log("INVALID_SESSION: will not reconnect");
|
|
break;
|
|
}
|
|
|
|
case opcodes.RECONNECT: {
|
|
log("gateway is requesting reconnect (payload RECONNECT)");
|
|
this.connect();
|
|
break;
|
|
}
|
|
|
|
default: {
|
|
log(`got unhandled opcode "${message.op}"`);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
connect() {
|
|
if (!this.connectEnabled) {
|
|
return;
|
|
}
|
|
|
|
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();
|
|
log(`on 'close': disconnected from gateway: code '${code}', reason '${reason}'`);
|
|
|
|
this.emit("close", code, reason);
|
|
this._setHeartbeat(-1);
|
|
if (skipReconnectFor.includes(code)) {
|
|
log("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) => {
|
|
log("on 'error': websocket error:", e);
|
|
log("on 'error': reconnecting due to previous websocket error...");
|
|
this._setHeartbeat(-1);
|
|
this.connect();
|
|
});
|
|
}
|
|
|
|
close() {
|
|
this.connectEnabled = false;
|
|
this._setHeartbeat(-1);
|
|
this.ws.close();
|
|
}
|
|
|
|
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);
|
|
let json = {};
|
|
|
|
try {
|
|
json = await response.json();
|
|
} catch(o_O) {}
|
|
|
|
if (!response.ok && throwOnError) {
|
|
throw new Error(`API request returned non-success status ${response.status}, with JSON body ${JSON.stringify(json)}`);
|
|
}
|
|
|
|
return json;
|
|
}
|
|
|
|
async sendMessageAs(channelId, content, username, avatarUrl=null) {
|
|
if (typeof content !== "string") {
|
|
return;
|
|
}
|
|
|
|
content = content.trim();
|
|
|
|
if (content.length < 1 || content.length > 2000) {
|
|
return;
|
|
}
|
|
|
|
let webhook = this.knownWebhooks.get(channelId);
|
|
if (!webhook) {
|
|
webhook = (await this.api("GET", `/channels/${channelId}/webhooks`)).find(e => e.name === "well_known__bridge");
|
|
|
|
if (!webhook) {
|
|
webhook = await this.api("POST", `/channels/${channelId}/webhooks`, {
|
|
name: "well_known__bridge"
|
|
});
|
|
}
|
|
|
|
this.knownWebhooks.set(channelId, webhook);
|
|
}
|
|
|
|
await this.api("POST", `/webhooks/${webhook.id}/${webhook.token}?wait=false`, {
|
|
content,
|
|
username,
|
|
avatar_url: avatarUrl,
|
|
tts: false,
|
|
allowed_mentions: {
|
|
parse: ["users"]
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
export default DiscordClient;
|