first commit
This commit is contained in:
commit
afb79eaceb
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
.idea/
|
||||
*.iml
|
25
README.md
Normal file
25
README.md
Normal 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
76
commands.go
Normal 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
42
config.go
Normal 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
12
go.mod
Normal 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
12
go.sum
Normal 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
298
main.go
Normal 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
|
||||
}
|
Loading…
Reference in New Issue
Block a user