commit afb79eaceb54804c6b0907739a2ecf6e5d3e833c Author: Tyler Date: Tue Apr 16 21:42:46 2019 -0400 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..caa32e6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.idea/ +*.iml \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..3bb93f8 --- /dev/null +++ b/README.md @@ -0,0 +1,25 @@ +# ResidentSleeper + +A plugin-less, mod-less sleep voting system using log parsing, only available on Linux. + +## Why + +Plugins exist that let you define a certain percentage of users that need to sleep, but they're plugins. They don't work on snapshots/etc. + +## How + +Log files are generated by the Minecraft server under `logs/latest.log`, which can be read in a fashion similar to `tail -F` to constantly receive updates. Commands are then executed via RCON that's connected using a configuration parser. + +## Usage + +Requirements: Linux, RCON + +Use supervisord or similar to run the program as your Minecraft user, with the flag `-dir /path/to/server`. + +The following configuration values are required in server.properties: + +```ini +rcon.port=25575 +rcon.password=password +enable-rcon=true +``` \ No newline at end of file diff --git a/commands.go b/commands.go new file mode 100644 index 0000000..98cd613 --- /dev/null +++ b/commands.go @@ -0,0 +1,76 @@ +package main + +import ( + "errors" + "fmt" + "strconv" + "strings" +) + +func sendMessage(user, message string) { + _, err := client.SendCommand(fmt.Sprintf("msg %s %s", user, message)) + + if err != nil { + sendMessage(user, message) + } +} + +func serverMessage(message string) { + _, err := client.SendCommand("say " + message) + + if err != nil { + serverMessage(message) + } +} + +func onlineUsers() (int, int, []string, error) { + res, err := client.SendCommand("list") + + if err != nil { + return -1, -1, nil, err + } + + m := userRegexp.FindStringSubmatch(res) + + if m == nil { + return -1, -1, nil, errors.New("unexpected response") + } + + online, _ := strconv.Atoi(m[1]) + max, _ := strconv.Atoi(m[2]) + names := strings.Split(m[3], ", ") + + return online, max, names, nil +} + +func queryTime() (int, error) { + res, err := client.SendCommand("time query daytime") + + if err != nil { + return -1, err + } + + m := timeRegexp.FindStringSubmatch(res) + + if m == nil { + return -1, errors.New("no time found") + } + + return strconv.Atoi(m[1]) +} + +func addTime(ticks int) (int, error) { + res, err := client.SendCommand(fmt.Sprintf("time add %d", ticks)) + + if err != nil { + return -1, err + } + + m := timeRegexp.FindStringSubmatch(res) + + if m == nil { + return -1, err + } + + return strconv.Atoi(m[1]) +} \ No newline at end of file diff --git a/config.go b/config.go new file mode 100644 index 0000000..fab5651 --- /dev/null +++ b/config.go @@ -0,0 +1,42 @@ +package main + +import ( + "bufio" + "os" + "strings" +) + +func loadServerConfig(configPath string) (map[string]string, error) { + f, err := os.Open(configPath) + + if err != nil { + return nil, err + } + + defer f.Close() + + scanner := bufio.NewScanner(f) + + var text string + var m []string + + ret := make(map[string]string) + + for scanner.Scan() { + text = scanner.Text() + + if text[0] == '#' { + continue + } + + m = configRegexp.FindStringSubmatch(text) + + if m == nil { + continue + } + + ret[strings.TrimSpace(m[1])] = strings.TrimSpace(m[2]) + } + + return ret, nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8fef5d1 --- /dev/null +++ b/go.mod @@ -0,0 +1,12 @@ +module sleepvote + +go 1.12 + +require ( + github.com/fsnotify/fsnotify v1.4.7 // indirect + github.com/hpcloud/tail v1.0.0 + github.com/tystuyfzand/mcgorcon v0.0.0-20190416171454-d0d528ef5548 + golang.org/x/sys v0.0.0-20190415145633-3fd5a3612ccd // indirect + gopkg.in/fsnotify.v1 v1.4.7 // indirect + gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5e8dfe6 --- /dev/null +++ b/go.sum @@ -0,0 +1,12 @@ +github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/tystuyfzand/mcgorcon v0.0.0-20190416171454-d0d528ef5548 h1:ho5QHzfPALI0+v7sPzJFtSHDH/5amyuG88+3ycp70Rk= +github.com/tystuyfzand/mcgorcon v0.0.0-20190416171454-d0d528ef5548/go.mod h1:MpDGxcw1VVpnQrSbEjy5ZRc+RVgO3j68N8RuI8snkF4= +golang.org/x/sys v0.0.0-20190415145633-3fd5a3612ccd h1:MNN7PRW7zYXd8upVO5qfKeOnQG74ivRNv7sz4k4cQMs= +golang.org/x/sys v0.0.0-20190415145633-3fd5a3612ccd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= diff --git a/main.go b/main.go new file mode 100644 index 0000000..da2850e --- /dev/null +++ b/main.go @@ -0,0 +1,298 @@ +package main + +import ( + "flag" + "fmt" + "github.com/hpcloud/tail" + "github.com/tystuyfzand/mcgorcon" + "log" + "math" + "os" + "path" + "regexp" + "strconv" + "sync" + "time" +) + +const ( + timeThreadRegexp = "^\\[.*?\\]\\s\\[.*?\\/INFO\\]:\\s" + + tickDuration = time.Millisecond * 50 +) + +var ( + serverPath string + rconPort int + rconPassword string + + client *mcgorcon.Client + + messageRegexp = regexp.MustCompile(timeThreadRegexp + "<(.*?)>\\s(.*)") + joinedRegexp = regexp.MustCompile(timeThreadRegexp + "(.*?) joined the game$") + leftRegexp = regexp.MustCompile(timeThreadRegexp + "(.*?) left the game$") + stoppingRegexp = regexp.MustCompile(timeThreadRegexp + "Stopping server") + rconRegexp = regexp.MustCompile(timeThreadRegexp + "RCON running on") + + sleepRegexp = regexp.MustCompile("^z{3,}$") + timeRegexp = regexp.MustCompile("(\\d+)$") + userRegexp = regexp.MustCompile("There are (\\d+) of a max (\\d+) players online: (.*?)$") + + configRegexp = regexp.MustCompile("^(.*?)=(.*)$") +) + +func init() { + flag.StringVar(&serverPath, "dir", "", "server directory") +} + +func main() { + flag.Parse() + + // Load properties + configPath := path.Join(serverPath, "server.properties") + + cfg, err := loadServerConfig(configPath) + + if err != nil { + log.Fatalln("Unable to load config:", err) + } + + if rconEnabled, ok := cfg["enable-rcon"]; !ok || rconEnabled != "true" { + log.Fatalln("RCON not enabled.") + } + + var rconPortStr string + var ok bool + + if rconPortStr, ok = cfg["rcon.port"]; !ok { + log.Fatalln("RCON is not enabled: No port set") + } + + rconPort, _ = strconv.Atoi(rconPortStr) + + if rconPassword, ok = cfg["rcon.password"]; !ok { + log.Fatalln("RCON is not enabled: No password set") + } + + go queryPlayersLoop() + + logPath := path.Join(serverPath, "logs/latest.log") + + log.Println("Starting rcon connection") + + ensureConnection() + + logParser(logPath) +} + +func logParser(logPath string) { + log.Println("Watching log path ", logPath) + + stat, err := os.Stat(logPath) + + if err != nil { + log.Fatalln("Unable to open log file:", err) + } + + seek := &tail.SeekInfo{ + Offset: stat.Size(), + } + + // Start parsing file + t, err := tail.TailFile(logPath, tail.Config{Location: seek, Follow: true, ReOpen: true}) + + if err != nil { + log.Fatalln("Unable to open file:", err) + } + + var m []string + + for line := range t.Lines { + + if m = messageRegexp.FindStringSubmatch(line.Text); m != nil { + handleMessage(m[1], m[2]) + } else if m = joinedRegexp.FindStringSubmatch(line.Text); m != nil { + userJoined(m[1]) + } else if m = leftRegexp.FindStringSubmatch(line.Text); m != nil { + userLeft(m[1]) + } else if stoppingRegexp.MatchString(line.Text) { + log.Println("Server closing") + + if client != nil { + // Close the server connection to allow it to exit normally + client.Close() + } + } else if rconRegexp.MatchString(line.Text) { + log.Println("Rcon started, connecting") + + if client != nil { + client.Close() + } + + // Reconnect, as we got a new rcon start line + ensureConnection() + } + } +} + +var ( + votes = make(map[string]bool) + voteLock sync.RWMutex + + onlinePlayers int + expireTime time.Time +) + +func queryPlayersLoop() { + t := time.NewTicker(30 * time.Second) + + for { + if client == nil { + <- t.C + continue + } + + online, _, _, err := onlineUsers() + + if err != nil { + continue + } + + onlinePlayers = online + + <- t.C + } +} + +func handleMessage(user, message string) { + if !sleepRegexp.MatchString(message) { + return + } + + // Query time from server + // Add seconds + t, err := queryTime() + + if err != nil { + sendMessage(user, "Something went wrong and the time couldn't be retrieved.") + return + } + + if t < 12000 { + sendMessage(user, "It's not night time, go mine some more.") + return + } + + difference := 24000 - t + + if expireTime.IsZero() || time.Now().After(expireTime) { + expDuration := time.Duration(difference) * tickDuration + + expireTime = time.Now().Add(expDuration) + + // Reset the time after + time.AfterFunc(expDuration, func() { + voteLock.Lock() + votes = make(map[string]bool) + voteLock.Unlock() + }) + } + + voteLock.RLock() + if _, exists := votes[user]; exists { + voteLock.RUnlock() + return + } + voteLock.RUnlock() + + voteLock.Lock() + votes[user] = true + voteLock.Unlock() + + requiredVotes := int(math.Ceil(float64(onlinePlayers) * 0.30)) + + if len(votes) >= requiredVotes { + mimicSleeping(t) + } else { + serverMessage(fmt.Sprintf("%s wants to sleep (%d of %d votes)", user, len(votes), requiredVotes)) + } +} + +func mimicSleeping(t int) { + var err error + + if t == -1 { + t, err = queryTime() + + if err != nil { + return + } + } + + voteLock.Lock() + votes = make(map[string]bool) + voteLock.Unlock() + + difference := 24000 - t + + log.Println("Adding time", difference) + + t, err = addTime(difference) + + if err != nil || t > 12000 { + serverMessage("Could not set the time, sorry") + return + } + + serverMessage("Good morning everyone!") + + // Force the vote to expire + expireTime = time.Now() +} + +func userJoined(user string) { + onlinePlayers++ + + log.Println("Player joined:", user) +} + +func userLeft(user string) { + onlinePlayers-- + + log.Println("Player left:", user) + + voteLock.Lock() + defer voteLock.Unlock() + + if _, exists := votes[user]; exists { + delete(votes, user) + } + + requiredVotes := int(math.Ceil(float64(onlinePlayers) * 0.30)) + + if len(votes) >= requiredVotes { + mimicSleeping(-1) + } +} + +func ensureConnection() *mcgorcon.Client { + if client == nil { + c, err := mcgorcon.Dial("localhost", rconPort, rconPassword) + + tries := 0 + + for err != nil { + c, err = mcgorcon.Dial("localhost", rconPort, rconPassword) + + tries++ + + if tries >= 10 { + log.Fatalln("Unable to connect to rcon, giving up.") + } + } + + client = &c + } + + return client +} \ No newline at end of file