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();