bridgecord/scripts/minecraft.js

417 lines
16 KiB
JavaScript

// 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 = /^\[(?:.*?)\]: \<(?<username>[a-zA-Z0-9]+)\> (?<message>.*)/;
const joinNotificationRegex = /^\[(?:.*?)\]: (?<username>[a-zA-Z0-9]+) joined the game/;
const leaveNotificationRegex = /^\[(?:.*?)\]: (?<username>[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: <t:${Math.floor(this.serverStartedAt/1000)}:R>` : "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: `<replying to ${referencedMessage.author.username}: ${trimmedContent}>`,
color: "gray"
});
tellrawPayload.push({
text: " "
});
}
attachments.forEach((e) => {
tellrawPayload.push({
text: `<open attachment: ${e.filename || "[unknown]"}>`,
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();