diff --git a/scripts/minecraft.js b/scripts/minecraft.js index da76e1c..11e3381 100644 --- a/scripts/minecraft.js +++ b/scripts/minecraft.js @@ -7,14 +7,21 @@ import fetch from "node-fetch"; import { WebSocket } from "ws"; +import { spawn } from "node:child_process"; import Rcon from "rcon"; + + const TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InN0YWdpbmctbWluZWNyYWZ0LWJyaWRnZSIsImF2YXRhclVSTCI6bnVsbCwiZGlzY29yZElEIjoiMCIsImd1aWxkQWNjZXNzIjpbIjczNjI5MjUwOTEzNDc0OTgwNyJdLCJpc1N1cGVyVG9rZW4iOnRydWUsImlhdCI6MTY0NDQ1MzM4MX0.-XIBl6VLnXVwve9iqhWs51ABZkm1i_v1tS6X01SPk3U"; // A supertoken is required to send messages from Minecraft. const TARGET_GUILD_ID = "_"; const TARGET_CHANNEL_ID = "_"; const ORIGIN = "http://localhost:4050"; const GATEWAY_ORIGIN = "ws://localhost:4050/gateway"; -const FRONTEND_ORIGIN = "http://localhost:4050/"; + + +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 messageSchema = { t: "number", d: "object" }; const messageTypes = { HELLO: 0, @@ -22,17 +29,29 @@ const messageTypes = { READY: 2, EVENT: 3 }; -const EXPRIMENTAL_ACCOUNT_LINK = false; -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 rconConnection = new Rcon("localhost", "25575", process.env.RCON_PASSWORD); +const createServerChildProcess = () => { + const serverJar = process.env.SERVER_JAR; + if (!serverJar) { + throw new Error("SERVER_JAR not found in env"); + } -const pendingHandoffs = new Map(); -const usernameToToken = new Map(); + const serverPwd = process.env.SERVER_PWD; + if (!serverPwd) { + throw new Error("SERVER_PWD not found in env"); + } -export default class GatewayClient { + 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", serverJar, "nogui" + ], { + cwd: serverPwd + }); +}; + +class GatewayClient { constructor(gatewayPath) { this.gatewayPath = gatewayPath; this.ws = null; @@ -137,156 +156,234 @@ export default class GatewayClient { } } +class Bridge { + constructor() { + this.gatewayConnection = new GatewayClient(GATEWAY_ORIGIN); + this.process = null; + this.rconConnection = null; + this.playerCount = 0; + this.serverStartedAt = null; -async function sendBridgeMessageAs(guildId, channelId, content, username=undefined, avatarURL=undefined) { - return await fetch(`${ORIGIN}/api/v1/guilds/${guildId}/channels/${channelId}/messages/create`, { - method: "POST", - body: JSON.stringify({ - content, - username, - avatarURL - }), - headers: { - "content-type": "application/json", - "authorization": TOKEN + this.gatewayConnection.onEvent = (e) => { + if ( + e.eventType === "MESSAGE_CREATE" && + e.message.channel_id === TARGET_CHANNEL_ID && + e.message.guild_id === TARGET_GUILD_ID && + !e.message.webhook_id + ) { + if (e.message.content === "!:mc-start") { + this.userRequestedServerJob(); + return; + } else if (e.message.content === "!:mc-status") { + this.sendBridgeMessageAs(TARGET_GUILD_ID, + TARGET_CHANNEL_ID, + `:scroll: **Server information**\n**Player Count**: ${this.playerCount}\n**Started**: `, + null, + null + ); + return; + } + + this.sendMinecraftMessageAs( + e.message.author.username, + e.message.content, + e.message.attachments, + e.message.referenced_message + ); + } + }; + } + + rconConnect() { + if (this.rconConnection) { + this.rconConnection.disconnect(); + this.rconConnection = null; } - }); -} -async function sendMinecraftMessageAs(rcon, username, content, attachments, referencedMessage=null) { - if (!attachments) { - attachments = []; - } - - const tellrawPayload = [ - { text: "[" }, - { text: `${username}`, color: "gray" }, - { text: "]" }, - { text: " " }, - ]; - - if (referencedMessage) { - let trimmedContent = referencedMessage.content.substring(0, 50); - if (trimmedContent !== referencedMessage.content) { - trimmedContent += "..."; - } - tellrawPayload.push({ - text: `` + this.rconConnection = new Rcon("localhost", "25575", process.env.RCON_PASSWORD); + this.rconConnection.connect(); + this.rconConnection.on("error", (e) => { + if (e.code === "ECONNREFUSED" && e.syscall === "connect") { + console.warn("rcon: ECONNREFUSED, reconnecting in 5s..."); + setTimeout(() => { + console.log("rcon: reconnecting..."); + this.rconConnect(); + }, 5000); + } else { + console.error("rcon: got error", e); + console.error("rcon: don't know what to do, disconnecting rcon due to previous error"); + this.rconConnection.disconnect(); + this.rconConnection = null; + } }); - tellrawPayload.push({ - text: " " + this.rconConnection.on("connect", () => { + this.serverStartedAt = Date.now(); + this.sendBridgeMessageAs(TARGET_GUILD_ID, TARGET_CHANNEL_ID, ":zap: Server started!", null, null); }); } - attachments.forEach((e) => { - tellrawPayload.push({ - text: ``, - color: "gray", - clickEvent: { - action: "open_url", - value: e.proxy_url - } - }); - tellrawPayload.push({ - text: " " - }); - }); + start() { + this.sendBridgeMessageAs(TARGET_GUILD_ID, TARGET_CHANNEL_ID, ":bridge_at_night: The bridge server has started! Type `!:mc-start` in the chat to start the Minecraft server. Any further updates related to the server's status will be sent in this channel.", null, null); + this.gatewayConnection.connect(TOKEN); + } - tellrawPayload.push({ text: content }); + spawnProcess() { + if (this.process) { + console.warn("server job: spawnProcess(): another instance already exists"); + this.sendBridgeMessageAs(TARGET_GUILD_ID, TARGET_CHANNEL_ID, ":thinking: It seems like the server is already running! If you think this is a mistake, contact the server manager.", null, null); + return; + } + + console.log("server job: spawning"); + this.sendBridgeMessageAs(TARGET_GUILD_ID, TARGET_CHANNEL_ID, ":clock5: Starting server...", null, null); + + this.process = createServerChildProcess(); + + this.process.stderr.on("data", async (rawDataBuffer) => { + const stringData = rawDataBuffer.toString().trim(); + console.error(`[server process stderr]: ${stringData}`); + }); + + this.process.stdout.on("data", async (rawDataBuffer) => { + const stringData = rawDataBuffer.toString().trim(); + console.log(`[server process stdout]: ${stringData}`); - rcon.send(`tellraw @a ${JSON.stringify(tellrawPayload)}`); -} - -async function startHandoffForUser(username) { - const handoffRes = await fetch(`${ORIGIN}/api/v1/tokens/handoff/create`, { - method: "POST", - body: JSON.stringify({}), - headers: { - "content-type": "application/json", - "authorization": TOKEN - } - }); - const { token: handoffToken, dispatchUUID } = (await handoffRes.json()); - - pendingHandoffs.set(dispatchUUID, username); - // TODO: probably not a good idea - setTimeout(() => { - pendingHandoffs.delete(dispatchUUID); - }, 40000); - - return handoffToken; -} - -async function main() { - rconConnection.on("error", (e) => { - console.error("rcon: got error", e); - if (!rconConnection.hasAuthed) { - console.log("rcon: reconnecting in 5000ms due to error before hasAuthed (server might not be up yet?)"); - setTimeout(() => { - rconConnection.connect(); - }, 5000); - } - }); - - const gateway = new GatewayClient(GATEWAY_ORIGIN); - gateway.onEvent = (e) => { - if (e.eventType === "$BRIDGE_HANDOFF_FULFILLMENT") { - const username = pendingHandoffs.get(e.dispatchUUID); - if (!username) + const joinResult = joinNotificationRegex.exec(stringData); + if (joinResult) { + this.onPlayerCountUpdate(1); + await this.sendBridgeMessageAs(TARGET_GUILD_ID, TARGET_CHANNEL_ID, `:door: **${joinResult.groups.username}** joined the game. Player count is ${this.playerCount}.`, null, null); return; + } + + const leaveResult = leaveNotificationRegex.exec(stringData); + if (leaveResult) { + this.onPlayerCountUpdate(-1); + await this.sendBridgeMessageAs(TARGET_GUILD_ID, TARGET_CHANNEL_ID, `:door: **${leaveResult.groups.username}** left the game. Player count is ${this.playerCount}.`, null, null); + return; + } + + const messageResult = chatMessageRegex.exec(stringData); + if (messageResult) { + await this.sendBridgeMessageAs(TARGET_GUILD_ID, TARGET_CHANNEL_ID, messageResult.groups.message, messageResult.groups.username, null); + } + }); - usernameToToken.set(username, e.token); + this.process.on("spawn", () => { + console.log("server process: spawn"); + this.process.stdout.resume(); + this.process.stderr.resume(); + this.rconConnect(); + }); + + this.process.on("exit", (code) => { + console.log(`server process: exited with code ${code}`); + this.sendBridgeMessageAs(TARGET_GUILD_ID, TARGET_CHANNEL_ID, ":zap: Server is now closed.", null, null); + this.process = null; + }); - sendMinecraftMessageAs(rconConnection, "Minecraft Bridge System", `${username} has been successfully linked to a bridge token.`, null, null); - } else if (e.eventType === "MESSAGE_CREATE" && e.message.channel_id === TARGET_CHANNEL_ID && e.message.guild_id === TARGET_GUILD_ID && !e.message.webhook_id) { - sendMinecraftMessageAs(rconConnection, e.message.author.username, e.message.content, e.message.attachments, e.message.referenced_message); + this.process.on("error", (e) => { + console.error("server process: error", e); + this.sendBridgeMessageAs(TARGET_GUILD_ID, TARGET_CHANNEL_ID, ":flushed: Server process error.", null, null); + this.process = null; + }); + } + + async sendBridgeMessageAs(guildId, channelId, content, username=undefined, avatarURL=undefined) { + return await fetch(`${ORIGIN}/api/v1/guilds/${guildId}/channels/${channelId}/messages/create`, { + method: "POST", + body: JSON.stringify({ + content, + username, + avatarURL + }), + headers: { + "content-type": "application/json", + "authorization": TOKEN + } + }); + } + + sendMinecraftMessageAs(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: " " + }); } - }; - rconConnection.connect(); - gateway.connect(TOKEN); - - process.stdin.resume(); - process.stdin.on("data", async (rawDataBuffer) => { - const stringData = rawDataBuffer.toString().trim(); - console.log(stringData); - - const joinResult = joinNotificationRegex.exec(stringData); - if (joinResult) { - await sendBridgeMessageAs(TARGET_GUILD_ID, TARGET_CHANNEL_ID, `**${joinResult.groups.username}** joined the game`, null, null); - return; - } - - const leaveResult = leaveNotificationRegex.exec(stringData); - if (leaveResult) { - await sendBridgeMessageAs(TARGET_GUILD_ID, TARGET_CHANNEL_ID, `**${leaveResult.groups.username}** left the game`, null, null); - return; - } - - const messageResult = chatMessageRegex.exec(stringData); - if (messageResult) { - if (EXPRIMENTAL_ACCOUNT_LINK && messageResult.groups.message === "!:minecraft-link") { - try { - const handoffToken = await startHandoffForUser(messageResult.groups.username); - const handoffLink = `${FRONTEND_ORIGIN}#token_handoff,,${handoffToken}`; - sendMinecraftMessageAs( - rconConnection, - "Minecraft Bridge System", - `${messageResult.groups.username}: Click on the attached link to finish linking your account. You have 40 seconds.`, - [ - { - filename: `link account to ${messageResult.groups.username}`, - proxy_url: handoffLink - } - ] - ); - } catch(e) { - sendMinecraftMessageAs(rconConnection, "Minecraft Bridge System", `Failed to create link for ${messageResult.groups.username}.`, null, null); + + 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.rconConnection) + this.rconConnection.send(`tellraw @a ${JSON.stringify(tellrawPayload)}`); + } + + onPlayerCountUpdate(amount) { + this.playerCount += amount; + + if (this.playerCount === 0) { + this.sendBridgeMessageAs(TARGET_GUILD_ID, TARGET_CHANNEL_ID, ":wave: Everyone has left the server. Closing...", null, null); + console.log("server job: playerCount has reached 0, will close child server process"); + if (this.process) { + console.log("server job: closing process..."); + this.process.kill("SIGINT"); } else { - await sendBridgeMessageAs(TARGET_GUILD_ID, TARGET_CHANNEL_ID, messageResult.groups.message, messageResult.groups.username, null); + console.warn("server job: no process to kill"); } } + } + + userRequestedServerJob() { + console.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) { + console.log("server job: no players on server after 5 minutes, closing..."); + this.sendBridgeMessageAs(TARGET_GUILD_ID, TARGET_CHANNEL_ID, ":pensive: Server was started, yet no one joined after 5 minutes. Closing again...", null, null); + } + }, 300000); + } +} + +function main() { + const bridge = new Bridge(); + bridge.start(); + + process.on("beforeExit", () => { + if (bridge.process) { + console.log("server process: killing on parent exit"); + bridge.process.kill("SIGINT"); + } }); } -await main(); +main();