2022-08-11 23:22:33 +03:00
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 , {
2022-08-12 04:26:49 +03:00
intents : 0 | ( 1 << 0 ) | ( 1 << 9 ) | ( 1 << 15 ) , // GUILDS | GUILD_MESSAGES | MESSAGE_CONTENT
2022-08-11 23:22:33 +03:00
} ) ;
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 ` +
2022-08-12 04:26:49 +03:00
( this . players . length > 0 ? this . players . map ( p => ` ${ p } \n ` ) : "[no players]\n" ) +
"\n" +
2022-08-11 23:22:33 +03:00
` :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" ) ;
2022-08-12 04:26:49 +03:00
this . serverStartedAt = Date . now ( ) ;
2022-08-11 23:22:33 +03:00
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 ( ) ;
2022-08-12 04:26:49 +03:00
bridge . gatewayConnection . close ( ) ;
2022-08-11 23:22:33 +03:00
process . exit ( ) ;
} ) ;
} ) ;
}
main ( ) ;