From 19d121a572523125ba8cf292c2655e95cdb387d4 Mon Sep 17 00:00:00 2001 From: hippoz <10706925-hippoz@users.noreply.gitlab.com> Date: Sun, 29 May 2022 16:15:50 +0300 Subject: [PATCH] initial commit, --- .gitignore | 2 + package.json | 12 ++ src/DiscordClient.js | 255 ++++++++++++++++++++++++++++++++++++ src/bot.js | 24 ++++ src/commands/commands.js | 8 ++ src/commands/interperter.js | 206 +++++++++++++++++++++++++++++ src/common.js | 46 +++++++ src/index.js | 9 ++ yarn.lock | 52 ++++++++ 9 files changed, 614 insertions(+) create mode 100644 .gitignore create mode 100644 package.json create mode 100644 src/DiscordClient.js create mode 100644 src/bot.js create mode 100644 src/commands/commands.js create mode 100644 src/commands/interperter.js create mode 100644 src/common.js create mode 100644 src/index.js create mode 100644 yarn.lock diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2d7ec5c --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.env +node_modules/ diff --git a/package.json b/package.json new file mode 100644 index 0000000..e5ff8f2 --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "name": "unix-chat", + "version": "1.0.0", + "main": "index.js", + "license": "MIT", + "type": "module", + "dependencies": { + "dotenv": "^16.0.1", + "node-fetch": "^3.2.4", + "ws": "^8.6.0" + } +} diff --git a/src/DiscordClient.js b/src/DiscordClient.js new file mode 100644 index 0000000..bd11685 --- /dev/null +++ b/src/DiscordClient.js @@ -0,0 +1,255 @@ +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=9&encoding=json", apiBase="https://discord.com/api/v9" } = {}) { + 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": "", + "$browser": "", + "$device": "" + }, + 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": { + 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_UPDATE 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_DELETE 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) + return; + + 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=false) { + 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; diff --git a/src/bot.js b/src/bot.js new file mode 100644 index 0000000..df4d509 --- /dev/null +++ b/src/bot.js @@ -0,0 +1,24 @@ +import { handleCommand } from "./commands/interperter.js"; +import DiscordClient from "./DiscordClient.js"; + +export function makeBot() { + const client = new DiscordClient( + process.env.DISCORD_TOKEN, + { + intents: (0 | (1 << 0) | (1 << 9)), // GUILDS & GUILD_MESSAGES + } + ); + + client.on("MESSAGE_CREATE", (message) => { + if (message.content.startsWith("$ ")) { + const command = message.content.substring(2, message.content.length); + handleCommand(command, (_streamName, streamContent) => { + client.api("POST", `/channels/${message.channel_id}/messages`, { + content: streamContent + }); + }); + } + }); + + return client; +} diff --git a/src/commands/commands.js b/src/commands/commands.js new file mode 100644 index 0000000..6d15a78 --- /dev/null +++ b/src/commands/commands.js @@ -0,0 +1,8 @@ +export default { + echo: function(ctx) { + return ctx.argv.join(" "); + }, + wine: function(ctx) { + return `${ctx.streamInput + " " ?? ""}:wine_glass:`; + } +} diff --git a/src/commands/interperter.js b/src/commands/interperter.js new file mode 100644 index 0000000..eea4a55 --- /dev/null +++ b/src/commands/interperter.js @@ -0,0 +1,206 @@ +import commands from "./commands.js"; + +const MAX_INSTRUCTIONS = 32; +const MAX_STREAMS = 6; + +const TokenType = { + Command: "Command", + Argument: "Argument", + Pipe: "Pipe" +}; + +const InstructionType = { + Run: "Run", + CreateStream: "CreateStream", + DisplayStream: "DisplayStream" +}; + +const Expected = { + Command: 0, + AfterCommand: 1 +}; + +function token(type, value) { + return { type, value }; +} + +function tokenize(message) { + const tokens = []; + let val = ""; + let expecting = Expected.Command; + // length +1 so we get char === undefined at the end of the string + for (let i = 0; i < message.length + 1; i++) { + const char = message[i]; + + if (char === " " || (char === undefined && val !== "")) { // if the character is a string OR if we are at the end of the string and still have a value + if (expecting === Expected.AfterCommand) { + if (val === "|") { + tokens.push(token(TokenType.Pipe, "|")); + expecting = Expected.Command; + } else { + tokens.push(token(TokenType.Argument, val)); + expecting = Expected.AfterCommand; + } + } else if (expecting === Expected.Command) { + tokens.push(token(TokenType.Command, val)); + expecting = Expected.AfterCommand; + } else { + return { + error: "Unexpected 'expecting' value, this is probably a bug in the parser", + tokens: null, + }; + } + val = ""; + } else { + val += char; + } + } + if (expecting !== Expected.AfterCommand) { + return { + error: "Unexpected end of input", + tokens: null, + }; + } + return { + error: null, + tokens + }; +} + +function tokensToInstructions(tokens) { + const streamName = "_commandStream" + const instructions = [ + { type: InstructionType.CreateStream, name: streamName } + ]; + + let commandName = null; + let commandArgs = []; + const endCommand = () => { + instructions.push({ + type: InstructionType.Run, + name: commandName, + args: commandArgs, + inStream: streamName, + outStream: streamName + }); + commandName = null; + commandArgs = []; + }; + + for (let i = 0; i < tokens.length; i++) { + const tok = tokens[i]; + if (tok.type === TokenType.Command) { + commandName = tok.value; + if (!commands[commandName]) { + return { + error: "Unknown command", + instructions: null + } + } + } else if (tok.type === TokenType.Argument) { + if (!commandName) { + return { + error: "Got TokenType.Argument, however commandName is null", + instructions: null + } + } + commandArgs.push(tok.value); + } else if (tok.type === TokenType.Pipe) { + if (!commandName) { + return { + error: "Got TokenType.Pipe, however commandName is null", + instructions: null + } + } + endCommand(); + } else { + return { + error: "Unknown tok.type", + instructions: null + } + } + + if ((i + 1) === tokens.length) { + if (commandName === null) { + return { + error: "End of input, yet commandName is null", + instructions: null + } + } + endCommand(); + } + } + + instructions.push({ type: InstructionType.DisplayStream, name: streamName }); + + return { + error: null, + instructions + }; +} + +function interpretInstructions(instructions, displayFunc) { + if (instructions.length > MAX_INSTRUCTIONS) { + return { error: `Arbitrary limit of ${MAX_INSTRUCTIONS} instructions reached` }; + } + + const streams = new Map(); + for (let i = 0; i < instructions.length; i++) { + const inst = instructions[i]; + switch (inst.type) { + case InstructionType.CreateStream: { + if (streams.size > MAX_STREAMS) { + return { error: `Arbitrary limit of ${MAX_STREAMS} streams reached` }; + } + streams.set(inst.name, ""); + break; + } + case InstructionType.Run: { + const commandFunc = commands[inst.name]; + if (!commandFunc) { + return { error: "Command not found" }; + } + let streamInput; + if (inst.inStream) { + streamInput = streams.get(inst.inStream); + } + let streamOutput = commandFunc({ + argv: inst.args, + argc: inst.args.length, + streamInput + }); + if (inst.outStream) { + streams.set(inst.outStream, streamOutput); + } + break; + } + case InstructionType.DisplayStream: { + displayFunc(inst.name, streams.get(inst.name)); + break; + } + default: { + return { error: "Unknown inst.type" }; + } + } + } + + return {}; +} + +export function handleCommand(message, displayFunc) { + const { error: tokError, tokens } = tokenize(message); + if (tokError) { + return { + error: `error: tokenize: ${tokError}` + }; + } + const { error: insError, instructions } = tokensToInstructions(tokens); + console.log(instructions); + if (insError) { + return { + error: `error: tokensToInstructions: ${insError}` + }; + } + + return interpretInstructions(instructions, displayFunc); +} diff --git a/src/common.js b/src/common.js new file mode 100644 index 0000000..b93384a --- /dev/null +++ b/src/common.js @@ -0,0 +1,46 @@ +const logContextMap = { + DiscordClient: { + log: true, + warn: true, + error: true, + }, + ServerMain: { + log: true, + warn: true, + error: true, + }, + API: { + log: true, + warn: true, + error: true, + } +}; + +export function logger(sink, context) { + let sinkFunction; + switch (sink) { + case "log": { + sinkFunction = console.log; + break; + } + case "warn": { + sinkFunction = console.warn; + break; + } + case "error": { + sinkFunction = console.error; + break; + } + default: { + sinkFunction = () => {}; + break; + } + } + if (logContextMap[context] && logContextMap[context][sink]) { + return (...e) => { + sinkFunction(`[${context}]`, ...e); + }; + } else { + return (...e) => {}; + } +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..e35fb7c --- /dev/null +++ b/src/index.js @@ -0,0 +1,9 @@ +import "dotenv/config"; +import { makeBot } from "./bot.js"; + +async function main() { + const bot = makeBot(); + bot.connect(); +} + +await main(); diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 0000000..d7bcf73 --- /dev/null +++ b/yarn.lock @@ -0,0 +1,52 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +data-uri-to-buffer@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/data-uri-to-buffer/-/data-uri-to-buffer-4.0.0.tgz#b5db46aea50f6176428ac05b73be39a57701a64b" + integrity sha512-Vr3mLBA8qWmcuschSLAOogKgQ/Jwxulv3RNE4FXnYWRGujzrRWQI4m12fQqRkwX06C0KanhLr4hK+GydchZsaA== + +dotenv@^16.0.1: + version "16.0.1" + resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.0.1.tgz#8f8f9d94876c35dac989876a5d3a82a267fdce1d" + integrity sha512-1K6hR6wtk2FviQ4kEiSjFiH5rpzEVi8WW0x96aztHVMhEspNpc4DVOUTEHtEva5VThQ8IaBX1Pe4gSzpVVUsKQ== + +fetch-blob@^3.1.2, fetch-blob@^3.1.4: + version "3.1.5" + resolved "https://registry.yarnpkg.com/fetch-blob/-/fetch-blob-3.1.5.tgz#0077bf5f3fcdbd9d75a0b5362f77dbb743489863" + integrity sha512-N64ZpKqoLejlrwkIAnb9iLSA3Vx/kjgzpcDhygcqJ2KKjky8nCgUQ+dzXtbrLaWZGZNmNfQTsiQ0weZ1svglHg== + dependencies: + node-domexception "^1.0.0" + web-streams-polyfill "^3.0.3" + +formdata-polyfill@^4.0.10: + version "4.0.10" + resolved "https://registry.yarnpkg.com/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz#24807c31c9d402e002ab3d8c720144ceb8848423" + integrity sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g== + dependencies: + fetch-blob "^3.1.2" + +node-domexception@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/node-domexception/-/node-domexception-1.0.0.tgz#6888db46a1f71c0b76b3f7555016b63fe64766e5" + integrity sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ== + +node-fetch@^3.2.4: + version "3.2.4" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-3.2.4.tgz#3fbca2d8838111048232de54cb532bd3cf134947" + integrity sha512-WvYJRN7mMyOLurFR2YpysQGuwYrJN+qrrpHjJDuKMcSPdfFccRUla/kng2mz6HWSBxJcqPbvatS6Gb4RhOzCJw== + dependencies: + data-uri-to-buffer "^4.0.0" + fetch-blob "^3.1.4" + formdata-polyfill "^4.0.10" + +web-streams-polyfill@^3.0.3: + version "3.2.1" + resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz#71c2718c52b45fd49dbeee88634b3a60ceab42a6" + integrity sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q== + +ws@^8.6.0: + version "8.6.0" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.6.0.tgz#e5e9f1d9e7ff88083d0c0dd8281ea662a42c9c23" + integrity sha512-AzmM3aH3gk0aX7/rZLYvjdvZooofDu3fFOzGqcSnQ1tOcTWwhM/o+q++E8mAyVVIyUdajrkzWUGftaVSDLn1bw==