diff --git a/DiscordClient.js b/DiscordClient.js new file mode 100644 index 0000000..4645376 --- /dev/null +++ b/DiscordClient.js @@ -0,0 +1,238 @@ +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, + HELLO: 10, + HEARTBEAT_ACK: 11, +}; + +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 = zlib.createInflate({ + chunkSize: 128 * 1024 + }); + 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.emit("error", "NO_HEARTBEAT_ACK"); + return; + } + this.gotServerHeartbeatACK = false; + + this.ws.send(JSON.stringify({ + op: opcodes.CLIENT_HEARTBEAT, + d: this.seq + })); + }, this._heartbeatIntervalTime); + } + + _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); + + ws.send(JSON.stringify({ + op: opcodes.IDENTIFY, + d: { + 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 + } + } + })); + 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; + } + + default: { + console.warn(`warn: DiscordClient: got unhandled opcode "${message.op}"`); + break; + } + } + } + + connect() { + const ws = new WebSocket(this.gatewayUrl); + + this.ws = ws; + + // 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}\``); + }) + } + + 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; \ No newline at end of file diff --git a/WatchedGuild.js b/WatchedGuild.js index 1796711..4f0960c 100644 --- a/WatchedGuild.js +++ b/WatchedGuild.js @@ -1,80 +1,83 @@ -const { EventEmitter } = require("events"); +import { EventEmitter } from "events"; class WatchedGuild extends EventEmitter { constructor() { super(); this.knownWebhooks = new Map(); - this.eventStack = []; this.upstreamGuildId = null; } pushEvent(e) { - this.eventStack.push(e); this.emit("pushed", e); } - consumeEvent() { - return this.eventStack.pop(); - } - - consumeAll() { - const events = [...this.eventStack]; - this.eventStack = []; - return events; - } - - hasEvents() { - return this.eventStack.length > 0; - } - - _pushMessageEvent(message) { - this.pushEvent({ - eventType: "messageCreate", - message: message.toJSON() + holdForEvent() { + return new Promise((resolve, reject) => { + // potential memory leak here when too many promises are created and waiting + this.once("pushed", (event) => { + resolve(event); + }); }); } discordConnect(bot) { this.bot = bot; - this.bot.on("messageCreate", (message) => { - if (message.guildId !== this.upstreamGuildId) + this.guildObject = this.bot.guilds.find(e => e.id === this.upstreamGuildId); + + if (!this.guildObject) { + throw new Error("Could not find guild object from bot cache by id (is the upstreamGuildId valid and does the bot have access to it?)"); + } + + this.bot.on("GUILD_CREATE", (guild) => { + if (guild.id === this.upstreamGuildId) + this.guildObject = guild; + }); + + this.bot.on("GUILD_UPDATE", (guild) => { + if (guild.id === this.upstreamGuildId) + this.guildObject = guild; + }); + + this.bot.on("MESSAGE_CREATE", (message) => { + if (message.guild_id !== this.upstreamGuildId) return; - this._pushMessageEvent(message); + this.pushEvent({ + eventType: "MESSAGE_CREATE", + message: message + }); }); } - async discordSendMessage(messageContent, channelId, username, avatarURL=undefined) { - if (!this.bot) - throw new Error("Bot not connected"); + userFacingChannelList() { + return this.guildObject.channels.map(channel => ({ id: channel.id, name: channel.name, position: channel.position, type: channel.type, nsfw: channel.nsfw })); + } + async discordSendMessage(content, channelId, username, avatarURL=undefined) { let webhook = this.knownWebhooks.get(channelId); if (!webhook) { - webhook = (await this.bot.getChannelWebhooks(channelId)) - .filter(w => w.name == "well_known__bridge")[0]; - + webhook = (await this.bot.api(["GET", `/channels/${channelId}/webhooks`])) + .find(e => e.name === "well_known__bridge"); + if (!webhook) - webhook = await this.bot.createChannelWebhook(channelId, { + webhook = await this.bot.api(["POST", `/channels/${channelId}/webhooks`], { name: "well_known__bridge" - }, "This webhook was created by the bridge API bot."); + }); this.knownWebhooks.set(channelId, webhook); } - await this.bot.executeWebhook(webhook.id, webhook.token, { - allowedMentions: { - everyone: false, - roles: false, - users: true - }, - content: messageContent, + await this.bot.api(["POST", `/webhooks/${webhook.id}/${webhook.token}?wait=true`], { + content, + username, + avatar_url: avatarURL, tts: false, - wait: true, - avatarURL, - username + allowed_mentions: { + parse: ["users"] + } }); } } -module.exports = WatchedGuild; +export default WatchedGuild; diff --git a/common.js b/common.js index cf0b699..348a4c8 100644 --- a/common.js +++ b/common.js @@ -1,17 +1,19 @@ -const Eris = require("eris"); -const { discordToken, watchedGuildIds } = require("./config"); -const WatchedGuild = require("./WatchedGuild"); +import { discordToken, watchedGuildIds } from "./config.js"; +import DiscordClient from "./DiscordClient.js"; +import WatchedGuild from "./WatchedGuild.js"; -const bot = new Eris(discordToken, { - intents: [ - "guildMessages" - ] +export const guildMap = new Map(); +export const bot = new DiscordClient(discordToken, { + intents: 0 | (1 << 0) | (1 << 9) // GUILDS & GUILD_MESSAGES }); -const guildMap = new Map(); +export function wait(time) { + return new Promise((resolve, reject) => { + setTimeout(() => resolve(), time); + }); +} -bot.on("ready", () => { - console.log("discord bot: ready"); +bot.on("READY", () => { watchedGuildIds.forEach(id => { const watchedGuild = new WatchedGuild(); watchedGuild.upstreamGuildId = id; @@ -19,10 +21,3 @@ bot.on("ready", () => { guildMap.set(id, watchedGuild); }); }); - -bot.connect(); - -module.exports = { - bot, - guildMap -}; diff --git a/config.js b/config.js index 1fcc5fe..91a5f98 100644 --- a/config.js +++ b/config.js @@ -1,7 +1,5 @@ -module.exports = { - mainHttpListenPort: 4050, - watchedGuildIds: ["822089558886842418"], - jwtSecret: process.env.JWT_SECRET, - discordToken: process.env.DISCORD_TOKEN, - dangerousAdminMode: true -}; +export const mainHttpListenPort = 4050; +export const watchedGuildIds = ["822089558886842418"]; +export const jwtSecret = process.env.JWT_SECRET; +export const discordToken = process.env.DISCORD_TOKEN; +export const dangerousAdminMode = true; diff --git a/index.js b/index.js index 9368aef..c754457 100644 --- a/index.js +++ b/index.js @@ -1,7 +1,9 @@ -const express = require("express"); -const { mainHttpListenPort } = require("./config"); +import express from "express"; +import apiRoute from "./routes/api.js"; +import { mainHttpListenPort } from "./config.js"; +import { bot } from "./common.js"; + const app = express(); -const apiRoute = require("./routes/api"); app.use(express.json()); app.use("/", express.static("public/")); @@ -13,4 +15,5 @@ app.get("/", (req, res) => { app.listen(mainHttpListenPort, () => { console.log(`server main: listen on ${mainHttpListenPort}`); + bot.connect(); }); \ No newline at end of file diff --git a/package.json b/package.json index 2ebc709..305b76d 100644 --- a/package.json +++ b/package.json @@ -3,9 +3,11 @@ "version": "1.0.0", "main": "index.js", "license": "MIT", + "type": "module", "dependencies": { - "eris": "^0.16.1", "express": "^4.17.2", - "jsonwebtoken": "^8.5.1" + "jsonwebtoken": "^8.5.1", + "node-fetch": "^3.2.0", + "ws": "^8.4.2" } } diff --git a/public/index.html b/public/index.html index 89b0668..24eed1d 100644 --- a/public/index.html +++ b/public/index.html @@ -7,8 +7,11 @@