commit cc2b2a367825369fd348050e13dbf408e4426a12 Author: hippoz <10706925-hippoz@users.noreply.gitlab.com> Date: Thu Aug 11 23:22:33 2022 +0300 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/DiscordClient.js b/DiscordClient.js new file mode 100644 index 0000000..7aa4293 --- /dev/null +++ b/DiscordClient.js @@ -0,0 +1,291 @@ +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; diff --git a/common.js b/common.js new file mode 100644 index 0000000..8cf0fc9 --- /dev/null +++ b/common.js @@ -0,0 +1,43 @@ +const logContextMap = { + DiscordClient: { + log: true, + warn: true, + error: true + }, + Bridge: { + 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/index.js b/index.js new file mode 100644 index 0000000..e1f5792 --- /dev/null +++ b/index.js @@ -0,0 +1,285 @@ +import fetch from "node-fetch"; +import { spawn } from "node:child_process"; +import DiscordClient from "./DiscordClient.js"; +import { logger } from "./common.js"; + +const log = logger("log", "Bridge"); +const logError = logger("error", "Bridge"); + +const DISCORD_TOKEN = process.env.DISCORD_TOKEN; +const WEBHOOK_URL = process.env.WEBHOOK_URL; +const TARGET_GUILD_ID = process.env.TARGET_GUILD_ID; +const TARGET_CHANNEL_ID = process.env.TARGET_CHANNEL_ID; +const SERVER_JAR = process.env.SERVER_JAR; +const SERVER_PWD = process.env.SERVER_PWD; +const requiredEnv = { DISCORD_TOKEN, WEBHOOK_URL, TARGET_GUILD_ID, TARGET_CHANNEL_ID, SERVER_JAR, SERVER_PWD }; + + +const chatMessageRegex = /^\[(?:.*?)\]: \<(?[a-zA-Z0-9]+)\> (?.*)/; +const joinNotificationRegex = /^\[(?:.*?)\]: (?[a-zA-Z0-9]+) joined the game/; +const leaveNotificationRegex = /^\[(?:.*?)\]: (?[a-zA-Z0-9]+) left the game/; + +const createServerChildProcess = () => { + return spawn("java", [ + "-Xms1G", "-Xmx5G", "-XX:+UseG1GC", "-XX:+ParallelRefProcEnabled", "-XX:MaxGCPauseMillis=200", "-XX:+UnlockExperimentalVMOptions", "-XX:+DisableExplicitGC", "-XX:+AlwaysPreTouch", "-XX:G1NewSizePercent=30", + "-XX:G1MaxNewSizePercent=40", "-XX:G1HeapRegionSize=8M", "-XX:G1ReservePercent=20", "-XX:G1HeapWastePercent=5", "-XX:G1MixedGCCountTarget=4", "-XX:InitiatingHeapOccupancyPercent=15", "-XX:G1MixedGCLiveThresholdPercent=90", + "-XX:G1RSetUpdatingPauseTimePercent=5", "-XX:SurvivorRatio=32", "-XX:+PerfDisableSharedMem", "-XX:MaxTenuringThreshold=1", "-Dusing.aikars.flags=https://mcflags.emc.gs", + "-Daikars.new.flags=true", "-jar", SERVER_JAR, "nogui" + ], { + cwd: SERVER_PWD + }); +}; + +class Bridge { + constructor() { + this.gatewayConnection = new DiscordClient(DISCORD_TOKEN, { + intents: 0 | (1 << 0) | (1 << 9) | (1 << 15) // GUILDS | GUILD_MESSAGES | MESSAGE_CONTENT + }); + this.process = null; + this.rconConnection = null; + this.playerCount = 0; + this.serverStartedAt = null; + this.hasSentHelloMessage = false; + this.players = []; + + this.gatewayConnection.on("MESSAGE_CREATE", (e) => { + if ( + e.channel_id === TARGET_CHANNEL_ID && + e.guild_id === TARGET_GUILD_ID && + !e.webhook_id + ) { + if (e.content === "!mstart") { + this.userRequestedServerJob(); + } else if (e.content === "!mstatus") { + const message = `:scroll: **Server Information**\n` + + `Player Count: **${this.playerCount}**\n` + + `${this.serverStartedAt ? `Started: ` : "Started: [server is closed]"}\n` + + `\n` + + `:busts_in_silhouette: **Players** (${this.players.length})\n` + + this.players.length > 0 ? this.players.map(p => `${p}\n`) : "[no players]\n" + + `:gear: **Runtime Information**\n` + + `process exists: \`${!!this.process}\`\n` + + `gatewayConnection.user exists: \`${!!this.gatewayConnection.user}\``; + + this.sendExternalMessage(message); + } else { + this.sendMinecraftMessage( + e.author.username, + e.content, + e.attachments, + e.referenced_message + ); + } + } + }); + + this.gatewayConnection.on("READY", () => { + if (!this.hasSentHelloMessage) { + this.hasSentHelloMessage = true; + this.sendExternalMessage(":bridge_at_night: The bridge server has started! Type `!mstart` in the chat to start the Minecraft server. Any further updates related to the server's status will be sent in this channel."); + } + }); + } + + executeMinecraftCommand(cmd) { + if (!this.process) { + return false; + } + + this.process.stdin.write(`${cmd}\n`); + } + + start() { + this.gatewayConnection.connect(); + } + + spawnProcess() { + log("server job: spawnProcess(): spawning"); + + if (this.process) { + log("server job: spawnProcess(): another instance already exists"); + this.sendExternalMessage(":thinking: It seems like the server is already running! If you think this is a mistake, contact the server manager."); + return; + } + + this.process = createServerChildProcess(); + process.stdin.pipe(this.process.stdin); + + this.process.stderr.on("data", async (rawDataBuffer) => { + const stringData = rawDataBuffer.toString().trim(); + logError(`[server process stderr]: ${stringData}`); + }); + + this.process.stdout.on("data", async (rawDataBuffer) => { + const stringData = rawDataBuffer.toString().trim(); + log(`[server process stdout]: ${stringData}`); + + const joinResult = joinNotificationRegex.exec(stringData); + if (joinResult) { + this.playerCountUpdate(1); + this.players.push(joinResult.groups.username); + await this.sendExternalMessage(`:door: **${joinResult.groups.username}** joined the game. Player count is **${this.playerCount}**.`); + return; + } + + const leaveResult = leaveNotificationRegex.exec(stringData); + if (leaveResult) { + this.playerCountUpdate(-1); + const existingPlayerIndex = this.players.indexOf(leaveResult.groups.username); + if (existingPlayerIndex !== -1) { + this.players.splice(existingPlayerIndex, 1); + } + await this.sendExternalMessage(`:door: **${leaveResult.groups.username}** left the game. Player count is **${this.playerCount}**.`); + return; + } + + const messageResult = chatMessageRegex.exec(stringData); + if (messageResult) { + await this.sendExternalMessage(messageResult.groups.message, messageResult.groups.username); + return; + } + }); + + this.process.on("spawn", () => { + log("server process: spawn"); + this.serverStartedAt = performance.now(); + this.process.stdout.resume(); + this.process.stderr.resume(); + this.sendExternalMessage(":zap: Server started. It might take some time before it fully initializes."); + }); + + this.process.on("exit", (code) => { + log(`server process: exited with code ${code}`); + this.process = null; + this.serverStartedAt = null; + this.sendExternalMessage(":zap: Server is now closed."); + }); + + this.process.on("error", (e) => { + logError("server process: error", e); + this.process = null; + this.serverStartedAt = null; + this.sendExternalMessage(":flushed: Server process error."); + }); + } + + stopProcess() { + if (this.process) { + log("server job: closing process..."); + this.process.kill("SIGINT"); + } else { + log("server job: no process to kill"); + } + } + + async sendExternalMessage(content, username=null, avatarURL=null) { + // try to generate an avatar for a specific username + if (username && !avatarURL) { + avatarURL = `https://avatars.dicebear.com/api/identicon/${username.substring(0, 2)}.jpg`; + } + return await fetch(WEBHOOK_URL, { + method: "POST", + body: JSON.stringify({ + content, + username, + avatar_url: avatarURL, + allowed_mentions: { + parse: ["users"], + users: [] + } + }), + headers: { + "content-type": "application/json", + } + }); + } + + sendMinecraftMessage(username, content, attachments=[], referencedMessage=null) { + const tellrawPayload = [ + { text: "[" }, + { text: `${username}`, color: "gray" }, + { text: "]" }, + { text: " " }, + ]; + + if (referencedMessage) { + let trimmedContent = referencedMessage.content.substring(0, 70); + if (trimmedContent !== referencedMessage.content) { + trimmedContent += "..."; + } + tellrawPayload.push({ + text: ``, + color: "gray" + }); + tellrawPayload.push({ + text: " " + }); + } + + attachments.forEach((e) => { + tellrawPayload.push({ + text: ``, + color: "gray", + clickEvent: { + action: "open_url", + value: e.proxy_url + } + }); + tellrawPayload.push({ + text: " " + }); + }); + + tellrawPayload.push({ text: content }); + + if (this.playerCount > 0) { + this.executeMinecraftCommand(`tellraw @a ${JSON.stringify(tellrawPayload)}`); + } + } + + playerCountUpdate(amount) { + this.playerCount += amount; + + if (this.playerCount === 0) { + log("server job: playerCount has reached 0, will close child server process"); + this.sendExternalMessage(":wave: Everyone has left the server. Closing..."); + this.stopProcess(); + } + } + + userRequestedServerJob() { + log("server job: user requested server job, spawning..."); + this.spawnProcess(); + + // wait for 5 minutes - if no one has joined the server in that time, close it + setTimeout(() => { + if (this.playerCount === 0) { + log("server job: no players on server after 5 minutes, closing..."); + this.sendExternalMessage(":pensive: Server was started, yet no one joined after 5 minutes. Closing again..."); + this.stopProcess(); + } + }, 300000); + } +} + + +function main() { + for (const [name, value] of Object.entries(requiredEnv)) { + if (value === undefined) { + throw new Error(`Required env variable ${name} was not found`); + } + } + + const bridge = new Bridge(); + bridge.start(); + + ["exit", "SIGINT", "SIGUSR1", "SIGUSR2", "uncaughtException", "SIGTERM"].forEach((eventType) => { + process.on(eventType, () => { + bridge.stopProcess(); + process.exit(); + }); + }); +} + + +main(); diff --git a/package.json b/package.json new file mode 100644 index 0000000..1d4740f --- /dev/null +++ b/package.json @@ -0,0 +1,11 @@ +{ + "name": "minecraft-bridge", + "version": "1.0.0", + "main": "index.js", + "license": "MIT", + "type": "module", + "dependencies": { + "node-fetch": "^3.2.6", + "ws": "^8.8.0" + } +} diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 0000000..4a8f170 --- /dev/null +++ b/yarn.lock @@ -0,0 +1,47 @@ +# 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== + +fetch-blob@^3.1.2, fetch-blob@^3.1.4: + version "3.2.0" + resolved "https://registry.yarnpkg.com/fetch-blob/-/fetch-blob-3.2.0.tgz#f09b8d4bbd45adc6f0c20b7e787e793e309dcce9" + integrity sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ== + 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.6: + version "3.2.6" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-3.2.6.tgz#6d4627181697a9d9674aae0d61548e0d629b31b9" + integrity sha512-LAy/HZnLADOVkVPubaxHDft29booGglPFDr2Hw0J1AercRh01UiVFm++KMDnJeH9sHgNB4hsXPii7Sgym/sTbw== + 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.8.0: + version "8.8.0" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.8.0.tgz#8e71c75e2f6348dbf8d78005107297056cb77769" + integrity sha512-JDAgSYQ1ksuwqfChJusw1LSJ8BizJ2e/vVu5Lxjq3YvNJNlROv1ui4i+c/kUUrPheBvQl4c5UbERhTwKa6QBJQ==