2022-02-11 15:20:59 +02:00
// 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.
2022-02-11 15:22:10 +02:00
// Running example - working directory: project root:
// (cd ~/minecraft_server/ ; exec ~/minecraft_server/start.sh) | RCON_PASSWORD="your rcon password" node scripts/minecraft.js
2022-02-11 15:20:59 +02:00
import fetch from "node-fetch" ;
import { WebSocket } from "ws" ;
2022-03-06 01:37:59 +02:00
import { spawn } from "node:child_process" ;
2022-02-11 15:20:59 +02:00
import Rcon from "rcon" ;
2022-03-06 01:37:59 +02:00
2022-02-11 15:20:59 +02:00
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" ;
2022-03-06 01:37:59 +02:00
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/ ;
2022-02-11 15:20:59 +02:00
const messageSchema = { t : "number" , d : "object" } ;
const messageTypes = {
HELLO : 0 ,
YOO : 1 ,
READY : 2 ,
EVENT : 3
} ;
2022-03-06 01:37:59 +02:00
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" ) ;
}
2022-02-11 15:20:59 +02:00
2022-03-06 01:37:59 +02:00
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
} ) ;
} ;
2022-02-25 19:56:10 +02:00
2022-03-06 01:37:59 +02:00
class GatewayClient {
2022-02-11 15:20:59 +02:00
constructor ( gatewayPath ) {
this . gatewayPath = gatewayPath ;
this . ws = null ;
this . token = null ;
this . user = null ;
this . onEvent = ( e ) => { } ;
}
connect ( token ) {
if ( ! token )
token = this . token ;
2022-02-15 14:25:26 +02:00
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" ) ;
}
}
2022-02-11 15:20:59 +02:00
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 ;
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" , ( ) => {
2022-02-15 13:41:30 +02:00
console . log ( "gateway: closed, reconnecting in 4000ms" ) ;
2022-02-11 15:20:59 +02:00
setTimeout ( ( ) => {
console . log ( "gateway: reconnecting" ) ;
this . connect ( token ) ;
} , 4000 ) ;
} ) ;
2022-02-15 14:25:26 +02:00
this . ws . on ( "error" , ( e ) => {
console . error ( "gateway: error" , e ) ;
console . log ( "gateway: reconnecting in 4000ms due to previous error" ) ;
setTimeout ( ( ) => {
console . log ( "gateway: reconnecting" ) ;
this . connect ( token ) ;
} , 4000 ) ;
} ) ;
2022-02-11 15:20:59 +02:00
}
_checkMessageSchema ( message ) {
for ( const [ key , value ] of Object . entries ( message ) ) {
if ( ! messageSchema [ key ] )
return false ;
if ( typeof value !== messageSchema [ key ] )
return false ;
}
return true ;
}
}
2022-03-06 01:37:59 +02:00
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" ) {
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 ;
}
2022-02-11 15:20:59 +02:00
2022-03-06 01:37:59 +02:00
this . sendMinecraftMessageAs (
e . message . author . username ,
e . message . content ,
e . message . attachments ,
e . message . referenced _message
) ;
}
} ;
}
2022-02-11 15:20:59 +02:00
2022-03-06 01:37:59 +02:00
rconConnect ( ) {
if ( this . rconConnection ) {
this . rconConnection . disconnect ( ) ;
this . rconConnection = null ;
2022-02-16 14:02:37 +02:00
}
2022-03-06 01:37:59 +02:00
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 ;
2022-02-16 14:02:37 +02:00
}
} ) ;
2022-03-06 01:37:59 +02:00
this . rconConnection . on ( "connect" , ( ) => {
this . serverStartedAt = Date . now ( ) ;
this . sendBridgeMessageAs ( TARGET _GUILD _ID , TARGET _CHANNEL _ID , ":zap: Server started!" , null , null ) ;
2022-02-16 14:02:37 +02:00
} ) ;
2022-03-06 01:37:59 +02:00
}
2022-02-16 14:02:37 +02:00
2022-03-06 01:37:59 +02:00
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 ) ;
}
2022-02-11 15:20:59 +02:00
2022-03-06 01:37:59 +02:00
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 ;
2022-02-25 19:56:10 +02:00
}
2022-03-06 01:37:59 +02:00
console . log ( "server job: spawning" ) ;
this . sendBridgeMessageAs ( TARGET _GUILD _ID , TARGET _CHANNEL _ID , ":clock5: Starting server..." , null , null ) ;
2022-02-25 19:56:10 +02:00
2022-03-06 01:37:59 +02:00
this . process = createServerChildProcess ( ) ;
2022-02-25 19:56:10 +02:00
2022-03-12 15:26:35 +02:00
process . stdin . pipe ( this . process . stdin ) ;
2022-03-12 14:09:04 +02:00
2022-03-06 01:37:59 +02:00
this . process . stderr . on ( "data" , async ( rawDataBuffer ) => {
const stringData = rawDataBuffer . toString ( ) . trim ( ) ;
console . error ( ` [server process stderr]: ${ stringData } ` ) ;
} ) ;
2022-02-25 19:56:10 +02:00
2022-03-06 01:37:59 +02:00
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 ) ;
2022-02-25 19:56:10 +02:00
return ;
2022-03-06 01:37:59 +02:00
}
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 ) ;
}
} ) ;
2022-02-25 19:56:10 +02:00
2022-03-06 01:37:59 +02:00
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 ;
} ) ;
2022-02-25 19:56:10 +02:00
2022-03-06 01:37:59 +02:00
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 ;
} ) ;
}
2022-02-11 15:20:59 +02:00
2022-03-06 01:37:59 +02:00
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
}
} ) ;
}
2022-02-15 13:41:30 +02:00
2022-03-06 01:37:59 +02:00
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
2022-02-25 19:56:10 +02:00
}
2022-03-06 01:37:59 +02:00
} ) ;
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" ) ;
2022-02-25 19:56:10 +02:00
} else {
2022-03-06 01:37:59 +02:00
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 ) ;
2022-02-25 19:56:10 +02:00
}
2022-03-06 01:37:59 +02:00
} , 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" ) ;
2022-02-15 13:41:30 +02:00
}
2022-02-11 15:20:59 +02:00
} ) ;
}
2022-03-06 01:37:59 +02:00
main ( ) ;