// This script aims to bridge Minecraft to a guild of your choice by piping // the Minecraft output into this script. RCON isrequired for messages to // be sent to Minecraft. // Running example - working directory: project root: // (cd ~/minecraft_server/ ; exec ~/minecraft_server/start.sh) | RCON_PASSWORD="your rcon password" node scripts/minecraft.js 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 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, YOO: 1, READY: 2, EVENT: 3 }; const createServerChildProcess = () => { const serverJar = process.env.SERVER_JAR; if (!serverJar) { throw new Error("SERVER_JAR not found in env"); } const serverPwd = process.env.SERVER_PWD; if (!serverPwd) { throw new Error("SERVER_PWD not found in env"); } 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; this.token = null; this.user = null; this.wasEverReady = false; this.isReady = false; this.onEvent = (e) => {}; } connect(token) { if (!token) token = this.token; if (this.ws) { console.log("gateway: connect() but connection already exists, killing existing connection..."); try { this.ws.removeAllListeners(); this.ws.close(); this.ws = null; } catch (e) { console.log("gateway: error while closing existing connection - it might not be established yet"); } } console.log("gateway: connecting"); this.ws = new WebSocket(this.gatewayPath); this.ws.on("message", (data, isBinary) => { if (isBinary) { console.warn("gateway: got binary data from server, ignoring..."); return; } let message = data.toString(); try { message = JSON.parse(data); } catch(e) { console.warn("gateway: got invalid JSON from server (failed to parse), ignoring..."); return; } if (!this._checkMessageSchema(message)) { console.warn("gateway: got invalid JSON from server (does not match schema), ignoring..."); return; } switch (message.t) { case messageTypes.HELLO: { console.log("gateway: HELLO"); this.ws.send(JSON.stringify({ t: messageTypes.YOO, d: { token } })); break; } case messageTypes.READY: { console.log("gateway: READY"); this.user = message.d.user; this.wasEverReady = true; this.isReady = true; break; } case messageTypes.EVENT: { this.onEvent(message.d); break; } default: { console.warn("gateway: got invalid JSON from server (invalid type), ignoring..."); return; } } }); this.ws.on("open", () => { console.log("gateway: open"); }); this.ws.on("close", () => { console.log("gateway: closed, reconnecting in 4000ms"); this.isReady = false; setTimeout(() => { console.log("gateway: reconnecting"); this.connect(token); }, 4000); }); this.ws.on("error", (e) => { console.error("gateway: error", e); console.log("gateway: reconnecting in 4000ms due to previous error"); this.isReady = false; setTimeout(() => { console.log("gateway: reconnecting"); this.connect(token); }, 4000); }); } _checkMessageSchema(message) { for (const [key, value] of Object.entries(message)) { if (!messageSchema[key]) return false; if (typeof value !== messageSchema[key]) return false; } return true; } } class Bridge { constructor() { this.gatewayConnection = new GatewayClient(GATEWAY_ORIGIN); this.process = null; this.rconConnection = null; this.playerCount = 0; this.serverStartedAt = null; 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") { const message = `:scroll: **Server information** Player Count: **${this.playerCount}** ${this.serverStartedAt ? `Started: ` : "Started: [server is closed]"} :gear: **Runtime Information**: rconConnection exists: \`${!!this.rconConnection}\` process exists: \`${!!this.process}\` gatewayConnection.user exists: \`${!!this.gatewayConnection.user}\` rconConnection.hasAuthed: \`${this.rconConnection.hasAuthed}\` gatewayConnection.isReady: \`${this.gatewayConnection.isReady}\` gatewayConnection.wasEverReady: \`${this.gatewayConnection.wasEverReady}\` `; this.sendBridgeMessageAs(TARGET_GUILD_ID, TARGET_CHANNEL_ID, message, 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; } 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; } }); this.rconConnection.on("connect", () => { this.serverStartedAt = Date.now(); this.sendBridgeMessageAs(TARGET_GUILD_ID, TARGET_CHANNEL_ID, ":zap: Server started!", null, null); }); } 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); } 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(); process.stdin.pipe(this.process.stdin); 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}`); 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); } }); 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; this.serverStartedAt = null; }); 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; this.serverStartedAt = 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: " " }); } 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 { 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..."); if (this.process) { this.process.kill("SIGINT"); } 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(); const onServerClosing = () => { if (bridge.process) bridge.process.kill("SIGINT"); process.exit(); }; ['exit', 'SIGINT', 'SIGUSR1', 'SIGUSR2', 'uncaughtException', 'SIGTERM'].forEach((eventType) => { process.on(eventType, onServerClosing); }); } main();