require("dotenv").config(); var child_process = require("child_process"); var EventEmitter = require("events").EventEmitter; var colors = require("colors"); var Discord = require("discord.js"); process.chdir("/srv/jpland"); String.prototype.capitalize = function() { return this[0].toUpperCase() + this.substring(1); }; var MAX_IDLE_MINUTES = 60; var CMD_PREFIX = '%'; class MinecraftServer extends EventEmitter { constructor (cwd, jar) { super(); this.cwd = cwd; this.java = "java"; this.jvm_args = ["-Xmx4G"]; this.jar = jar; this.idleMinutes = 0; this.locked = false; // paper 1.16.2 this.listCommand = "minecraft:list"; this.listEmptyRegex = /^\[\d\d:\d\d:\d\d INFO\]: There are 0 of a max of \d{1,} players online:$/; this.listNotEmptyRegex = /^\[\d\d:\d\d:\d\d INFO\]: There are [1-9]\d{0,} of a max of \d{1,} players online:/; } start() { this._log("Starting server".green); this.process = child_process.spawn("nice", ["-n", "1", this.java].concat(this.jvm_args).concat(["-jar", this.jar]), {cwd: this.cwd}); this.process.on("error", error => { this.process.emit("exit"); this._log(error.stack.red, true); }); this.process.stdout.on("data", data => { data = data.toString().trim().split("\n"); data.forEach(data => { this._log(data); this._handleOutput(data); }); }); this.process.stderr.on("data", data => { data = data.toString().trim().split("\n"); this._log(data, true); }); this.process.once("exit", () => { this.process = undefined; clearInterval(this.listInterval); this._log("Server has exited".red); }); this.idleMinutes = 0; this.listInterval = setInterval(()=>{ this.process.stdin.write(this.listCommand + "\n"); }, 60000); return this; } stop() { return this.process && this.process.stdin.write("stop\n"); } restart() { if (!this.process) return; this.process.on("exit", () => this.start()); return this.stop(); } _log(msg, isError) { return console[isError ? "error" : "log"](`[${this.cwd}]`[isError ? "red" : "green"], msg); } _handleOutput(line) { if (this._testIfConsoleLineIndicatesNoPlayersOnline(line)) { // no players are online this._log("Detected no players online; incrementing idleMinutes".yellow); this.idleMinutes++; if (this.idleMinutes >= MAX_IDLE_MINUTES) { if (!this.locked) { this.idleMinutes = 0; this.stop(); this._log("Shutting down due to inactivity".red); this.emit("idle timeout"); } else { this._log("Idle timeout exceeded but ignoring because server is locked".yellow); } } } else if (this._testIfConsoleLineIndicatesPlayersOnline(line)) { // players are online this._log("Detected players online; resetting idle minutes".yellow); this.idleMinutes = 0; } } _testIfConsoleLineIndicatesPlayersOnline(line) { return this.listNotEmptyRegex.test(line); } _testIfConsoleLineIndicatesNoPlayersOnline(line) { return this.listEmptyRegex.test(line); } } var servers = { creative: new MinecraftServer("creative", "paper.jar"), survival: new MinecraftServer("survival", "paper.jar"), modded: new MinecraftServer("forge", "forge.jar"), modded2: new MinecraftServer("modded2", "forge.jar"), multiverse: new MinecraftServer("multiverse", "paper-1618.jar"), }; //TODO regex but if it's not broken, don't fix it 🤷 servers.modded._testIfConsoleLineIndicatesPlayersOnline = function(line) { var fojat = line.substr(line.indexOf("] [Server thread/INFO] [minecraft/DedicatedServer]: There are ")); return (fojat.startsWith("] [Server thread/INFO] [minecraft/DedicatedServer]: There are ") && fojat.endsWith(" players online:")); } servers.modded._testIfConsoleLineIndicatesNoPlayersOnline = function(line) { return line.endsWith("] [Server thread/INFO] [minecraft/DedicatedServer]: There are 0/20 players online:"); } servers.modded.listCommand = "list"; servers.modded.java = "/usr/bin/java"; // java 8 servers.modded2._testIfConsoleLineIndicatesPlayersOnline = servers.modded._testIfConsoleLineIndicatesPlayersOnline; servers.modded2._testIfConsoleLineIndicatesNoPlayersOnline = servers.modded._testIfConsoleLineIndicatesNoPlayersOnline; servers.modded2.listCommand = servers.modded.listCommand; servers.modded2.java = "/usr/bin/java"; servers.multiverse.listEmptyRegex = /^\[\d\d:\d\d:\d\d INFO\]: There are 0\/\d{1,} players online:$/; servers.multiverse.listNotEmptyRegex = /^\[\d\d:\d\d:\d\d INFO\]: There are [1-9]\d{0,}\/\d{1,} players online:$/; function commandHandler(input, priviledged) { var args = input.split(' '); var cmd = args[0]; var unauthorized = "You are not permitted to perform this command."; var serverName = args[1] && args[1].toLowerCase(); var server = servers[serverName]; if (cmd == "start") { if (!server) return `Unknown server ${serverName}` if (server.process) return `${serverName.capitalize()} server is already running.`; server.start(); return `Starting ${serverName} server.`; } else if (cmd == "stop") { if (!priviledged) return unauthorized; if (!server) return `Unknown server ${serverName}` if (!server.process) return `${serverName.capitalize()} server is not running.`; server.stop(); return `Stopping ${serverName} server.`; } else if (cmd == "input") { if (!priviledged) return unauthorized; if (!server) return `Unknown server ${serverName}` if (!server.process) return `${serverName.capitalize()} server is not running.`; if (args[2] == "list" || args[2] == "minecraft:list") return "`{CMD_PREFIX}list` command is prohibited from running in console because it would interfere with idle minute counting."; server.process.stdin.write(args.slice(2).join(" ") + '\n'); return; } else if (cmd == "list") { return `Servers: ${Object.keys(servers).map(x => `${x} (${servers[x].process ? 'running' : 'stopped'})`).join(', ')}`; } else if (cmd == "lock") { if (!priviledged) return unauthorized; if (!server) return `Unknown server ${serverName}`; if (server.locked = !server.locked) { return `${serverName.capitalize()} server will be exempt from idle timeout.`; } else { return `${serverName.capitalize()} server will no longer be exempt from idle timeout.`; } } else if (cmd == "eval") { if (!priviledged) return unauthorized; try { return String(eval(args.slice(1).join(' '))); } catch (error) { return String(error); } } else if (cmd == "help") { return "JPLand Manager automatically shuts down Minecraft servers after "+MAX_IDLE_MINUTES+" minutes to save resources, and allows you to start servers again using the `{CMD_PREFIX}start ` command.\n" + "Use `{CMD_PREFIX}list` to see the list of servers and their statuses.\n" + (priviledged ? "\nYou are an admin and may also use these commands: `{CMD_PREFIX}stop `, `{CMD_PREFIX}input ` (input a command into a server's console), `{CMD_PREFIX}lock ` (prevent automatic shutdown), `{CMD_PREFIX}eval ` (evaluate javascript in the Node.js process)." : ""); } else { return `Unknown command \`{CMD_PREFIX}${cmd}\`, use \`{CMD_PREFIX}help\` for the list of commands.`; } } process.openStdin(); process.stdin.on("data", data => { data = data.toString().trim(); var response = commandHandler(data, true); if (response) console.log(response.replace(/{CMD_PREFIX}/g, '').blue); }); if (process.env.DISCORD_TOKEN) { var dClient = new Discord.Client(); dClient.login(process.env.DISCORD_TOKEN); dClient.on("error", error => console.error(colors.red("Discord client error: " + error.message))); function setStatus() { dClient.user.setActivity(`${CMD_PREFIX}help`); } setInterval(setStatus, 1000*60*30); dClient.on("ready", () => { console.log("Discord client is ready.".green); setStatus(); }); dClient.on("message", message => { if (message.content.startsWith(CMD_PREFIX)) { let response = commandHandler(message.content.substr(CMD_PREFIX.length), message.member && message.member.guild.id == "357038384121905152" && message.member.roles.map(x => x.name).includes("Minecraft admin")); if (response) message.channel.send(response.replace(/{CMD_PREFIX}/g, CMD_PREFIX)); } }); for (let serverName in servers) { let server = servers[serverName]; server.on("idle timeout", () => { let channel = dClient.channels.get("452025433328975872"); if (channel) channel.send(`${serverName.capitalize()} server has been shut down due to 1 hour of inactivity. Run \`${CMD_PREFIX}start ${serverName}\` when you want to play on it again.`); }); } } console.log("JPLand Manager is now running.".green);