286 lines
11 KiB
JavaScript
286 lines
11 KiB
JavaScript
|
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 = /^\[(?:.*?)\]: \<(?<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 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: <t:${Math.floor(this.serverStartedAt/1000)}:R>` : "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: `<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.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();
|