make the minecraft script fully manage the minecraft servert process on-demand
This commit is contained in:
parent
d59f1be9e7
commit
b24a2dbae9
1 changed files with 239 additions and 142 deletions
|
@ -7,14 +7,21 @@
|
||||||
|
|
||||||
import fetch from "node-fetch";
|
import fetch from "node-fetch";
|
||||||
import { WebSocket } from "ws";
|
import { WebSocket } from "ws";
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
import Rcon from "rcon";
|
import Rcon from "rcon";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InN0YWdpbmctbWluZWNyYWZ0LWJyaWRnZSIsImF2YXRhclVSTCI6bnVsbCwiZGlzY29yZElEIjoiMCIsImd1aWxkQWNjZXNzIjpbIjczNjI5MjUwOTEzNDc0OTgwNyJdLCJpc1N1cGVyVG9rZW4iOnRydWUsImlhdCI6MTY0NDQ1MzM4MX0.-XIBl6VLnXVwve9iqhWs51ABZkm1i_v1tS6X01SPk3U"; // A supertoken is required to send messages from Minecraft.
|
const TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InN0YWdpbmctbWluZWNyYWZ0LWJyaWRnZSIsImF2YXRhclVSTCI6bnVsbCwiZGlzY29yZElEIjoiMCIsImd1aWxkQWNjZXNzIjpbIjczNjI5MjUwOTEzNDc0OTgwNyJdLCJpc1N1cGVyVG9rZW4iOnRydWUsImlhdCI6MTY0NDQ1MzM4MX0.-XIBl6VLnXVwve9iqhWs51ABZkm1i_v1tS6X01SPk3U"; // A supertoken is required to send messages from Minecraft.
|
||||||
const TARGET_GUILD_ID = "_";
|
const TARGET_GUILD_ID = "_";
|
||||||
const TARGET_CHANNEL_ID = "_";
|
const TARGET_CHANNEL_ID = "_";
|
||||||
const ORIGIN = "http://localhost:4050";
|
const ORIGIN = "http://localhost:4050";
|
||||||
const GATEWAY_ORIGIN = "ws://localhost:4050/gateway";
|
const GATEWAY_ORIGIN = "ws://localhost:4050/gateway";
|
||||||
const FRONTEND_ORIGIN = "http://localhost:4050/";
|
|
||||||
|
|
||||||
|
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 messageSchema = { t: "number", d: "object" };
|
||||||
const messageTypes = {
|
const messageTypes = {
|
||||||
HELLO: 0,
|
HELLO: 0,
|
||||||
|
@ -22,17 +29,29 @@ const messageTypes = {
|
||||||
READY: 2,
|
READY: 2,
|
||||||
EVENT: 3
|
EVENT: 3
|
||||||
};
|
};
|
||||||
const EXPRIMENTAL_ACCOUNT_LINK = false;
|
|
||||||
|
|
||||||
const chatMessageRegex = /^\[(?:.*?)\]: \<(?<username>[a-zA-Z0-9]+)\> (?<message>.*)/;
|
const createServerChildProcess = () => {
|
||||||
const joinNotificationRegex = /^\[(?:.*?)\]: (?<username>[a-zA-Z0-9]+) joined the game/;
|
const serverJar = process.env.SERVER_JAR;
|
||||||
const leaveNotificationRegex = /^\[(?:.*?)\]: (?<username>[a-zA-Z0-9]+) left the game/;
|
if (!serverJar) {
|
||||||
const rconConnection = new Rcon("localhost", "25575", process.env.RCON_PASSWORD);
|
throw new Error("SERVER_JAR not found in env");
|
||||||
|
}
|
||||||
|
|
||||||
const pendingHandoffs = new Map();
|
const serverPwd = process.env.SERVER_PWD;
|
||||||
const usernameToToken = new Map();
|
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) {
|
constructor(gatewayPath) {
|
||||||
this.gatewayPath = gatewayPath;
|
this.gatewayPath = gatewayPath;
|
||||||
this.ws = null;
|
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) {
|
this.gatewayConnection.onEvent = (e) => {
|
||||||
return await fetch(`${ORIGIN}/api/v1/guilds/${guildId}/channels/${channelId}/messages/create`, {
|
if (
|
||||||
method: "POST",
|
e.eventType === "MESSAGE_CREATE" &&
|
||||||
body: JSON.stringify({
|
e.message.channel_id === TARGET_CHANNEL_ID &&
|
||||||
content,
|
e.message.guild_id === TARGET_GUILD_ID &&
|
||||||
username,
|
!e.message.webhook_id
|
||||||
avatarURL
|
) {
|
||||||
}),
|
if (e.message.content === "!:mc-start") {
|
||||||
headers: {
|
this.userRequestedServerJob();
|
||||||
"content-type": "application/json",
|
return;
|
||||||
"authorization": TOKEN
|
} 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**: <t:${this.serverStartedAt}:R>`,
|
||||||
|
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) {
|
this.rconConnection = new Rcon("localhost", "25575", process.env.RCON_PASSWORD);
|
||||||
if (!attachments) {
|
this.rconConnection.connect();
|
||||||
attachments = [];
|
this.rconConnection.on("error", (e) => {
|
||||||
}
|
if (e.code === "ECONNREFUSED" && e.syscall === "connect") {
|
||||||
|
console.warn("rcon: ECONNREFUSED, reconnecting in 5s...");
|
||||||
const tellrawPayload = [
|
setTimeout(() => {
|
||||||
{ text: "[" },
|
console.log("rcon: reconnecting...");
|
||||||
{ text: `${username}`, color: "gray" },
|
this.rconConnect();
|
||||||
{ text: "]" },
|
}, 5000);
|
||||||
{ text: " " },
|
} else {
|
||||||
];
|
console.error("rcon: got error", e);
|
||||||
|
console.error("rcon: don't know what to do, disconnecting rcon due to previous error");
|
||||||
if (referencedMessage) {
|
this.rconConnection.disconnect();
|
||||||
let trimmedContent = referencedMessage.content.substring(0, 50);
|
this.rconConnection = null;
|
||||||
if (trimmedContent !== referencedMessage.content) {
|
}
|
||||||
trimmedContent += "...";
|
|
||||||
}
|
|
||||||
tellrawPayload.push({
|
|
||||||
text: `<replying to ${referencedMessage.author.username}: ${trimmedContent}>`
|
|
||||||
});
|
});
|
||||||
tellrawPayload.push({
|
this.rconConnection.on("connect", () => {
|
||||||
text: " "
|
this.serverStartedAt = Date.now();
|
||||||
|
this.sendBridgeMessageAs(TARGET_GUILD_ID, TARGET_CHANNEL_ID, ":zap: Server started!", null, null);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
attachments.forEach((e) => {
|
start() {
|
||||||
tellrawPayload.push({
|
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);
|
||||||
text: `<open attachment: ${e.filename || "[unknown]"}>`,
|
this.gatewayConnection.connect(TOKEN);
|
||||||
color: "gray",
|
}
|
||||||
clickEvent: {
|
|
||||||
action: "open_url",
|
|
||||||
value: e.proxy_url
|
|
||||||
}
|
|
||||||
});
|
|
||||||
tellrawPayload.push({
|
|
||||||
text: " "
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
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)}`);
|
const joinResult = joinNotificationRegex.exec(stringData);
|
||||||
}
|
if (joinResult) {
|
||||||
|
this.onPlayerCountUpdate(1);
|
||||||
async function startHandoffForUser(username) {
|
await this.sendBridgeMessageAs(TARGET_GUILD_ID, TARGET_CHANNEL_ID, `:door: **${joinResult.groups.username}** joined the game. Player count is ${this.playerCount}.`, null, null);
|
||||||
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)
|
|
||||||
return;
|
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);
|
this.process.on("error", (e) => {
|
||||||
} else if (e.eventType === "MESSAGE_CREATE" && e.message.channel_id === TARGET_CHANNEL_ID && e.message.guild_id === TARGET_GUILD_ID && !e.message.webhook_id) {
|
console.error("server process: error", e);
|
||||||
sendMinecraftMessageAs(rconConnection, e.message.author.username, e.message.content, e.message.attachments, e.message.referenced_message);
|
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: `<replying to ${referencedMessage.author.username}: ${trimmedContent}>`,
|
||||||
|
color: "gray"
|
||||||
|
});
|
||||||
|
tellrawPayload.push({
|
||||||
|
text: " "
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
|
||||||
rconConnection.connect();
|
attachments.forEach((e) => {
|
||||||
gateway.connect(TOKEN);
|
tellrawPayload.push({
|
||||||
|
text: `<open attachment: ${e.filename || "[unknown]"}>`,
|
||||||
process.stdin.resume();
|
color: "gray",
|
||||||
process.stdin.on("data", async (rawDataBuffer) => {
|
clickEvent: {
|
||||||
const stringData = rawDataBuffer.toString().trim();
|
action: "open_url",
|
||||||
console.log(stringData);
|
value: e.proxy_url
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
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 {
|
} 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();
|
||||||
|
|
Loading…
Reference in a new issue