first commit

This commit is contained in:
Tyler 2019-04-16 21:42:46 -04:00
commit afb79eaceb
7 changed files with 467 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
.idea/
*.iml

25
README.md Normal file
View File

@ -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
```

76
commands.go Normal file
View File

@ -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])
}

42
config.go Normal file
View File

@ -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
}

12
go.mod Normal file
View File

@ -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
)

12
go.sum Normal file
View File

@ -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=

298
main.go Normal file
View File

@ -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
}