Browse Source

Major updates/patches, functionality, api

master
Tyler 1 year ago
parent
commit
b52b38179a
  1. 16
      .drone.yml
  2. 3
      .gitignore
  3. 9
      Dockerfile
  4. 76
      commands.go
  5. 88
      commands/arguments.go
  6. 15
      commands/context.go
  7. 69
      commands/definition.go
  8. 35
      commands/dispatcher.go
  9. 71
      commands/parser.go
  10. 69
      commands/parser_test.go
  11. 54
      events/events.go
  12. 29
      events/names.go
  13. 9
      go.mod
  14. 18
      go.sum
  15. 272
      main.go
  16. 110
      parser.go
  17. 48
      parser_test.go
  18. 81
      rcon/players.go
  19. 115
      rcon/rcon.go
  20. 47
      rcon/time.go
  21. 55
      scripting.go
  22. 37
      scripting/commands/commands.go
  23. 96
      scripting/config/config.go
  24. 37
      scripting/event/event.go
  25. 33
      scripting/minecraft/nbt.go
  26. 69
      scripting/minecraft/ops.go
  27. 125
      scripting/minecraft/players.go
  28. 30
      scripting/regexp/regexp.go
  29. 98
      scripts/residentsleeper.lua
  30. 124
      scripts/timetracker.lua
  31. 137
      scripts/warps.lua
  32. 5
      scripts/welcome.lua

16
.drone.yml

@ -0,0 +1,16 @@
kind: pipeline
type: docker
name: default
steps:
- name: docker
image: plugins/docker
settings:
username:
from_secret: docker_username
password:
from_secret: docker_password
repo: registry.meow.tf/tyler/residentsleeper
registry: registry.meow.tf
tags:
- latest

3
.gitignore

@ -1,2 +1,3 @@
.idea/
*.iml
*.iml
*.log

9
Dockerfile

@ -0,0 +1,9 @@
FROM golang:alpine AS builder
RUN go build -o sleeper
FROM alpine
COPY --from=builder sleeper /sleeper
CMD ["/sleeper"]

76
commands.go

@ -1,76 +0,0 @@
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])
}

88
commands/arguments.go

@ -0,0 +1,88 @@
package commands
import "strings"
const (
ArgumentTypeBasic = iota
ArgumentTypeUserMention
ArgumentTypeChannelMention
)
func parseCommandUsage(prefix string) *CommandHolder {
holder := &CommandHolder{}
holder.Name = prefix
holder.Usage = prefix
if idx := strings.Index(prefix, " "); idx != -1 {
holder.Name = prefix[0:idx]
prefix = prefix[idx+1:]
// Parse out command arguments, example:
// test <arg1> [optional arg2]
// Walk through string, match < and >, [ and ]
holder.Arguments = make(map[string]*CommandArgument)
str := prefix
var name string
var index int
for {
if len(str) == 0 {
break
}
ch := str[0]
if ch == '<' || ch == '[' {
// Scan until closing arrow or end of string
for i := 1; i < len(str); i++ {
if (str[i] == '>' || str[i] == ']') && str[i-1] != '\\' {
name = str[1:i]
if i+2 < len(str) {
str = str[i+2:]
} else {
str = ""
}
required := false
if ch == '<' {
required = true
holder.RequiredArgumentCount++
}
t := ArgumentTypeBasic
if name[0] == '@' {
t = ArgumentTypeUserMention
name = name[1:]
} else if name[0] == '#' {
t = ArgumentTypeChannelMention
name = name[1:]
}
holder.Arguments[name] = &CommandArgument{
Index: index,
Name: name,
Required: required,
Type: t,
}
index++
break
}
}
}
}
holder.ArgumentCount = len(holder.Arguments)
}
return holder
}

15
commands/context.go

@ -0,0 +1,15 @@
package commands
type CommandContext struct {
holder *CommandHolder
User string
Prefix string
Command string
ArgumentString string
Arguments []string
ArgumentCount int
Reply func(text string)
}

69
commands/definition.go

@ -0,0 +1,69 @@
package commands
import "errors"
type CommandHolder struct {
Name string
Usage string
Handler CommandHandler
Permission int
Arguments map[string]*CommandArgument
ArgumentCount int
RequiredArgumentCount int
}
type CommandArgument struct {
Index int
Name string
Required bool
Type int
}
func (c *CommandHolder) Call(ctx *CommandContext) {
ctx.holder = c
if c.ArgumentCount > 0 {
// Arguments are cached, construct usage
if err := c.Validate(ctx); err != nil {
if err == UsageError {
ctx.Reply("Usage: " + ctx.Prefix + c.Usage)
} else {
ctx.Reply(err.Error())
}
return
}
}
// Use a goroutine to avoid any blocking operations
c.Handler(ctx)
}
var (
UsageError = errors.New("usage")
)
func (c *CommandHolder) Validate(ctx *CommandContext) error {
if ctx.ArgumentCount < c.RequiredArgumentCount {
return UsageError
}
var argValue string
for _, arg := range c.Arguments {
if ctx.ArgumentCount < arg.Index+1 {
break
}
if !arg.Required {
continue
}
argValue = ctx.Arguments[arg.Index]
if argValue == "" {
return errors.New("The " + arg.Name + " argument is required.")
}
}
return nil
}

35
commands/dispatcher.go

@ -0,0 +1,35 @@
package commands
import "strings"
type CommandHandler func(ctx *CommandContext)
var (
commandMap = make(map[string]*CommandHolder)
)
func Register(prefix string, f CommandHandler) {
h := parseCommandUsage(prefix)
h.Handler = f
commandMap[h.Name] = h
}
func Find(prefix, name, full string) *CommandHolder {
if strings.Index(name, prefix) == 0 {
name = name[len(prefix):]
if holder := Handler(name); holder != nil {
return holder
}
}
return nil
}
func Handler(name string) *CommandHolder {
if holder, ok := commandMap[name]; ok {
return holder
}
return nil
}

71
commands/parser.go

@ -0,0 +1,71 @@
package commands
type SpaceTokenizer struct {
Input string
}
func (t *SpaceTokenizer) NextToken() string {
if len(t.Input) == 0 {
return ""
}
ch := t.Input[0]
if ch == '"' {
// Scan until closing quote or end of string
for i := 1; i < len(t.Input); i++ {
if t.Input[i] == '"' && t.Input[i-1] != '\\' {
ret := t.Input[1:i]
if i+2 < len(t.Input) {
t.Input = t.Input[i+2:]
} else {
t.Input = ""
}
return ret
}
}
} else {
for i := 0; i < len(t.Input); i++ {
if t.Input[i] == ' ' {
ret := t.Input[0:i]
if i+1 < len(t.Input) {
t.Input = t.Input[i+1:]
} else {
t.Input = ""
}
return ret
}
}
}
ret := t.Input
t.Input = ""
return ret
}
func (t *SpaceTokenizer) Empty() bool {
return t.Input == ""
}
func NewSpaceTokenizer(input string) *SpaceTokenizer {
return &SpaceTokenizer{Input: input}
}
func ParseCommandArguments(command string) []string {
tokenizer := NewSpaceTokenizer(command)
arguments := make([]string, 0)
for {
if tokenizer.Empty() {
break
}
arguments = append(arguments, tokenizer.NextToken())
}
return arguments
}

69
commands/parser_test.go

@ -0,0 +1,69 @@
package commands
import (
"regexp"
"testing"
)
func TestParseCommandArguments(t *testing.T) {
args := ParseCommandArguments("normal \"testing quoted\" and normal \"end closed\"")
expected := []string{"normal", "testing quoted", "and", "normal", "end closed"}
for i := 0; i < len(expected); i++ {
if args[i] != expected[i] {
t.Errorf("Expected %s, got %s at index %d", expected[i], args[i], i)
}
}
}
func TestDispatching(t *testing.T) {
Register("testing", func(ctx *CommandContext) {
// Something
})
if handler := Find("!", "!testing", "testing"); handler == nil {
t.Error("Expected handler for testing")
}
}
func TestRegexpDispatching(t *testing.T) {
RegisterMatch(regexp.MustCompile("^compliment"), func(ctx *CommandContext) {
// Something
})
if handler := Find("", "compliment", "compliment me"); handler == nil {
t.Error("Expected handler for compliment")
}
}
func TestArgumentParsing(t *testing.T) {
holder := parseCommandUsage("test <arg1> <arg2> [arg3]")
if holder.Name != "test" {
t.Error("Unexpected command name")
}
if len(holder.Arguments) != 3 {
t.Error("Invalid number of command arguments:", len(holder.Arguments))
}
if holder.RequiredArgumentCount != 2 {
t.Error("Invalid required argument count")
}
}
func TestArgumentDispatching(t *testing.T) {
holder := parseCommandUsage("test <arg1> <arg2> [arg3]")
if holder.Name != "test" {
t.Error("Unexpected command name")
}
if len(holder.Arguments) != 3 {
t.Error("Invalid number of command arguments:", len(holder.Arguments))
}
if holder.RequiredArgumentCount != 2 {
t.Error("Invalid required argument count")
}
}

54
events/events.go

@ -0,0 +1,54 @@
package events
import (
"sync"
)
type eventHandler func(args ...interface{})
var (
eventMap = make(map[string][]eventHandler)
eventMapLock sync.RWMutex
allListeners = make([]eventHandler, 0)
initialized = false
)
func On(name string, f eventHandler) {
eventMapLock.Lock()
defer eventMapLock.Unlock()
handlers, exists := eventMap[name]
if !exists {
handlers = make([]eventHandler, 0)
}
handlers = append(handlers, f)
eventMap[name] = handlers
}
func OnALl(f eventHandler) {
allListeners = append(allListeners, f)
}
func Call(name string, args ...interface{}) {
if !initialized && name != Init {
Call(Init)
initialized = true
}
eventMapLock.RLock()
handlers, exists := eventMap[name]
eventMapLock.RUnlock()
if !exists {
return
}
for _, handler := range handlers {
handler(args...)
}
}

29
events/names.go

@ -0,0 +1,29 @@
package events
const (
Init = "init"
ServerStarted = "server_started"
ServerClosing = "server_closing"
// LoggedIn, Args: User, Address, X, Y, Z
LoggedIn = "logged_in"
// Authenticated, Args: User, UUID
Authenticated = "authenticated"
// Join, Args: User
Join = "join"
// Leave, Args: User
Leave = "leave"
// Op, Args: User, Source?
Op = "op"
// Deop, Args: User, Source?
Deop = "deop"
// Message, Args: User, Message
Message = "message"
)

9
go.mod

@ -1,12 +1,17 @@
module sleepvote
module meow.tf/residentsleeper
go 1.12
require (
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d
github.com/fsnotify/fsnotify v1.4.7 // indirect
github.com/hpcloud/tail v1.0.0
github.com/tystuyfzand/mcgorcon v0.0.0-20190416171454-d0d528ef5548
github.com/ppacher/nbt v0.0.0-20181201174858-0cad976cf07c
github.com/tystuyfzand/mcgorcon v0.0.0-20190418232414-4bb1402707f4
github.com/yuin/gopher-lua v0.0.0-20190206043414-8bfc7677f583
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
layeh.com/gopher-json v0.0.0-20190114024228-97fed8db8427
layeh.com/gopher-luar v1.0.5
)

18
go.sum

@ -1,12 +1,26 @@
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8=
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
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=
github.com/ppacher/nbt v0.0.0-20181201174858-0cad976cf07c h1:d9Pm+C0vGwMRxn04D6MjHbqkEvG+qlpHjlARadAAmpQ=
github.com/ppacher/nbt v0.0.0-20181201174858-0cad976cf07c/go.mod h1:vvnpyLNjExupwOjP8Dvqprqwtt3BxXKoDSvWkHvCODs=
github.com/tystuyfzand/mcgorcon v0.0.0-20190418232414-4bb1402707f4 h1:E2wndDHuZBEqCUijjvLhfFu8wInU9ciBZuXXkn/lGHk=
github.com/tystuyfzand/mcgorcon v0.0.0-20190418232414-4bb1402707f4/go.mod h1:MpDGxcw1VVpnQrSbEjy5ZRc+RVgO3j68N8RuI8snkF4=
github.com/yuin/gopher-lua v0.0.0-20190206043414-8bfc7677f583 h1:SZPG5w7Qxq7bMcMVl6e3Ht2X7f+AAGQdzjkbyOnNNZ8=
github.com/yuin/gopher-lua v0.0.0-20190206043414-8bfc7677f583/go.mod h1:gqRgreBUhTSL0GeU64rtZ3Uq3wtjOa/TB2YfrtkCbVQ=
golang.org/x/sys v0.0.0-20190204203706-41f3e6584952/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
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=
layeh.com/gopher-json v0.0.0-20190114024228-97fed8db8427 h1:RZkKxMR3jbQxdCEcglq3j7wY3PRJIopAwBlx1RE71X0=
layeh.com/gopher-json v0.0.0-20190114024228-97fed8db8427/go.mod h1:ivKkcY8Zxw5ba0jldhZCYYQfGdb2K6u9tbYK1AwMIBc=
layeh.com/gopher-luar v1.0.5 h1:fBuMh/xVN7bZxOsFzY6mxL2I+0ePJIWfyGc0dBdpqs4=
layeh.com/gopher-luar v1.0.5/go.mod h1:N3rev/ttQd8yVluXaYsa0M/eknzRYWe+pxZ35ZFmaaI=

272
main.go

@ -2,43 +2,30 @@ package main
import (
"flag"
"fmt"
"github.com/hpcloud/tail"
"github.com/tystuyfzand/mcgorcon"
"log"
"math"
"meow.tf/residentsleeper/commands"
"meow.tf/residentsleeper/events"
"meow.tf/residentsleeper/rcon"
"meow.tf/residentsleeper/scripting/config"
"meow.tf/residentsleeper/scripting/minecraft"
"os"
"path"
"regexp"
"strconv"
"sync"
"time"
)
const (
timeThreadRegexp = "^\\[.*?\\]\\s\\[.*?\\/INFO\\]:\\s"
tickDuration = time.Millisecond * 50
"strings"
)
var (
serverPath string
rconPort int
serverPath string
rconPort int
rconPassword string
debug bool
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: (.*?)$")
client *rcon.Session
configRegexp = regexp.MustCompile("^(.*?)=(.*)$")
flagDebug = flag.Bool("debug", false, "debug messages")
)
func init() {
@ -48,6 +35,8 @@ func init() {
func main() {
flag.Parse()
debug = *flagDebug
// Load properties
configPath := path.Join(serverPath, "server.properties")
@ -74,225 +63,86 @@ func main() {
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)
}
events.On(events.Message, handleCommands)
var m []string
client = rcon.NewSession("localhost", rconPort, rconPassword)
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()
}
}
}
client.Debug = debug
var (
votes = make(map[string]bool)
voteLock sync.RWMutex
scriptPath := path.Join(serverPath, "scripts")
onlinePlayers int
expireTime time.Time
)
if _, err := os.Stat(scriptPath); !os.IsNotExist(err) {
config.BasePath = path.Join(scriptPath, "config")
func queryPlayersLoop() {
t := time.NewTicker(30 * time.Second)
minecraft.BasePath = serverPath
minecraft.WorldPath = path.Join(serverPath, cfg["level-name"])
for {
if client == nil {
<- t.C
continue
if _, err = os.Stat(config.BasePath); os.IsNotExist(err) {
os.Mkdir(config.BasePath, 0755)
}
online, _, _, err := onlineUsers()
log.Println("Loading scripts from", scriptPath)
err = LoadScripts(scriptPath)
if err != nil {
continue
log.Println("Warning: Unable to load scripts -", err)
}
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
logPath := path.Join(serverPath, "logs/latest.log")
if expireTime.IsZero() || time.Now().After(expireTime) {
expDuration := time.Duration(difference) * tickDuration
logParser(logPath)
}
expireTime = time.Now().Add(expDuration)
func handleCommands(eventArgs ...interface{}) {
user := eventArgs[0].(string)
str := eventArgs[1].(string)
// Reset the time after
time.AfterFunc(expDuration, func() {
voteLock.Lock()
votes = make(map[string]bool)
voteLock.Unlock()
})
if debug {
log.Println("Message from " + user + ": " + str)
}
voteLock.RLock()
if _, exists := votes[user]; exists {
voteLock.RUnlock()
return
}
voteLock.RUnlock()
args := commands.ParseCommandArguments(str)
voteLock.Lock()
votes[user] = true
voteLock.Unlock()
idx := strings.Index(str, " ")
requiredVotes := int(math.Ceil(float64(onlinePlayers) * 0.30))
var argString string
if len(votes) >= requiredVotes {
mimicSleeping(t)
if idx == -1 {
argString = ""
} else {
serverMessage(fmt.Sprintf("%s wants to sleep (%d of %d votes)", user, len(votes), requiredVotes))
argString = strings.TrimSpace(str[idx+1:])
}
}
func mimicSleeping(t int) {
var err error
if t == -1 {
t, err = queryTime()
var command string
if err != nil {
return
}
if len(args) > 1 {
command, args = args[0], args[1:]
} else {
command = str
args = []string{}
}
voteLock.Lock()
votes = make(map[string]bool)
voteLock.Unlock()
difference := 24000 - t
log.Println("Adding time", difference)
// Find the channel that the message came from. Override and use State if enabled.
t, err = addTime(difference)
match := commands.Find("!", command, str)
if err != nil || t > 12000 {
serverMessage("Could not set the time, sorry")
if match == nil {
return
}
serverMessage("Good morning everyone!")
ctx := &commands.CommandContext{
User: user,
Prefix: "!",
Command: command,
ArgumentString: argString,
Arguments: args,
ArgumentCount: len(args),
// 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)
Reply: func(text string) {
client.SendMessage(user, text)
},
}
requiredVotes := int(math.Ceil(float64(onlinePlayers) * 0.30))
if len(votes) >= requiredVotes {
mimicSleeping(-1)
}
match.Call(ctx)
}
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
}

110
parser.go

@ -0,0 +1,110 @@
package main
import (
"github.com/acarl005/stripansi"
"github.com/hpcloud/tail"
"log"
"meow.tf/residentsleeper/events"
"meow.tf/residentsleeper/rcon"
"os"
"regexp"
"strings"
)
const (
timeThreadRegexp = "^\\[.*?\\]\\s\\[.*?\\/INFO\\]:\\s"
)
var (
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")
loggedInRegexp = regexp.MustCompile(timeThreadRegexp + "(.*?)\\[(.*?)\\] logged in with entity id \\d+ at \\((.*?)\\)")
startedRegexp = regexp.MustCompile(timeThreadRegexp + "Done \\(.*?\\)! For help, type \"help\"")
authenticatedRegexp = regexp.MustCompile(timeThreadRegexp + "UUID of player (.*?) is (.*?)$")
opRegexp = regexp.MustCompile("Made (.*?) a server operator")
deopRegexp = regexp.MustCompile("Made (.*?) no longer a server operator")
sourceRegexp = regexp.MustCompile(timeThreadRegexp + "\\[(.*?): (.*?)\\]")
sleepRegexp = regexp.MustCompile("^z{3,}$")
timeRegexp = regexp.MustCompile("(\\d+)$")
)
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 {
line.Text = stripansi.Strip(strings.TrimSpace(line.Text))
if debug {
log.Println("Parsing line", line.Text)
log.Println("Bytes:", []byte(line.Text))
}
if m = messageRegexp.FindStringSubmatch(line.Text); m != nil {
events.Call(events.Message, m[1], m[2])
} else if m = loggedInRegexp.FindStringSubmatch(line.Text); m != nil {
position, err := rcon.SliceToFloats(strings.Split(m[3], ", "))
if err != nil {
position = []float64{0, 0, 0}
}
events.Call(events.LoggedIn, m[1], m[2], position[0], position[1], position[2])
} else if m = authenticatedRegexp.FindStringSubmatch(line.Text); m != nil {
events.Call(events.Authenticated, m[1], m[2])
} else if m = joinedRegexp.FindStringSubmatch(line.Text); m != nil {
events.Call(events.Join, m[1])
} else if m = leftRegexp.FindStringSubmatch(line.Text); m != nil {
events.Call(events.Leave, m[1])
} else if m = opRegexp.FindStringSubmatch(line.Text); m != nil {
source := "Server"
subM := sourceRegexp.FindStringSubmatch(line.Text)
if subM != nil {
source = subM[1]
}
events.Call(events.Op, m[1], source)
} else if m = deopRegexp.FindStringSubmatch(line.Text); m != nil {
source := "Server"
subM := sourceRegexp.FindStringSubmatch(line.Text)
if subM != nil {
source = subM[1]
}
events.Call(events.Deop, m[1], source)
} else if stoppingRegexp.MatchString(line.Text) {
events.Call(events.ServerClosing)
client.Disconnect()
} else if startedRegexp.MatchString(line.Text) {
events.Call(events.ServerStarted)
} else if rconRegexp.MatchString(line.Text) {
events.Call(events.Init)
}
}
}

48
parser_test.go

@ -0,0 +1,48 @@
package main
import (
"bufio"
"os"
"regexp"
"testing"
)
type regexpTest struct {
input string
re *regexp.Regexp
}
func Test_Regexp(t *testing.T) {
inputs := map[string]regexpTest{
"join": {"[19:22:13] [Server thread/INFO]: notch joined the game", joinedRegexp},
"leave": {"[19:16:13] [Server thread/INFO]: notch left the game", leftRegexp},
"message": {"[19:37:13] [Server thread/INFO]: <notch> hello", messageRegexp},
"spigotMessage": {"[23:32:42] [Async Chat Thread - #2/INFO]: <ccatss> zzz", messageRegexp},
"rcon": {"[16:46:42] [RCON Listener #1/INFO]: RCON running on 0.0.0.0:25575", rconRegexp},
}
for key, re := range inputs {
if !re.re.MatchString(re.input) {
t.Fatal("Regexp for", key, "did not match")
}
}
}
func Test_EndCharacters(t *testing.T) {
f, err := os.Open("latest.log")
if err != nil {
t.Fatal("Log file not found")
}
defer f.Close()
scanner := bufio.NewScanner(f)
if !scanner.Scan() {
t.Fatal("Log empty")
}
t.Log(scanner.Text())
t.Log(scanner.Bytes())
}

81
rcon/players.go

@ -0,0 +1,81 @@
package rcon
import (
"errors"
"fmt"
"regexp"
"strconv"
"strings"
)
var (
userRegexp = regexp.MustCompile("There are (\\d+) of a max of (\\d+) players online: (.*?)$")
teleportedRegexp = regexp.MustCompile("^Teleported (.*?) to (.*?)$")
)
func (r *Session) OnlinePlayers() (int, int, []string, error) {
res, err := r.connection().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 (r *Session) GetLocation(user string) []float32 {
res, err := r.connection().SendCommand(fmt.Sprintf("execute at %s run tp %s ~ ~ ~", user, user))
if err != nil {
return nil
}
m := teleportedRegexp.FindStringSubmatch(res)
pos, err := SliceToFloats(strings.Split(m[2], ","))
if err != nil {
return nil
}
ret := make([]float32, 3)
for i, v := range pos {
ret[i] = float32(v)
}
return ret
}
func (r *Session) Teleport(user, destination string) (string, error) {
return r.connection().SendCommand(fmt.Sprintf("tp %s %s", user, destination))
}
func SliceToFloats(str []string) ([]float64, error) {
ret := make([]float64, len(str))
var err error
for i, s := range str {
s = strings.TrimSpace(s)
ret[i], err = strconv.ParseFloat(s, 64)
if err != nil {
return nil, err
}
}
return ret, err
}

115
rcon/rcon.go

@ -0,0 +1,115 @@
package rcon
import (
"encoding/json"
"fmt"
"github.com/tystuyfzand/mcgorcon"
"log"
"time"
)
const (
pingDuration = 30 * time.Second
)
type Session struct {
host string
port int
password string
Debug bool
client *mcgorcon.Client
lastUse time.Time
}
func NewSession(host string, port int, password string) *Session {
return &Session{host: host, port: port, password: password}
}
func (r *Session) Disconnect() {
if r.client != nil {
r.client.Close()
}
}
func (r *Session) connection() *mcgorcon.Client {
if r.client != nil {
if time.Since(r.lastUse) > pingDuration {
_, err := r.client.SendCommand("seed")
if err == nil {
r.lastUse = time.Now()
return r.client
}
} else {
return r.client
}
}
// Open new connection
r.client = r.openConnection()
r.lastUse = time.Now()
return r.client
}
func (r *Session) openConnection() *mcgorcon.Client {
var c *mcgorcon.Client
var err error
for c == nil || err != nil {
c, err = mcgorcon.Dial(r.host, r.port, r.password)
if err == nil {
break
}
<-time.After(10 * time.Second)
}
log.Println("Connection opened")
return c
}
func (r *Session) SendCommand(command string) (string, error) {
if r.Debug {
log.Println("Send command: " + command)
}
return r.connection().SendCommand(command)
}
func (r *Session) ServerMessage(message string) error {
_, err := r.SendCommand(fmt.Sprintf("say %s", message))
return err
}
func (r *Session) SendMessage(user, message string) error {
_, err := r.SendCommand(fmt.Sprintf("msg %s %s", user, message))
return err
}
type colorMessage struct {
Text string `json:"text"`
Color string `json:"color"`
}
func (r *Session) SendColorfulMessage(target, color, message string) error {
messages := []colorMessage{
{Text: message, Color: color},
}
b, err := json.Marshal(messages)
if err != nil {
return err
}
_, err = r.SendCommand(fmt.Sprintf("tellraw %s %s", target, string(b)))
return err
}

47
rcon/time.go

@ -0,0 +1,47 @@
package rcon
import (
"fmt"
"regexp"
"strconv"
)
var (
timeRegexp = regexp.MustCompile("(\\d+)$")
)
func (r *Session) AddTime(delta int) int {
res, err := r.connection().SendCommand(fmt.Sprintf("time add %d", delta))
if err != nil {
return -1
}
m := timeRegexp.FindStringSubmatch(res)
if m == nil {
return -1
}
ret, _ := strconv.Atoi(m[1])
return ret
}
func (r *Session) GetTime() int {
res, err := r.connection().SendCommand("time query daytime")
if err != nil {
return -1
}
m := timeRegexp.FindStringSubmatch(res)
if m == nil {
return -1
}
v, _ := strconv.Atoi(m[1])
return v
}

55
scripting.go

@ -0,0 +1,55 @@
package main
import (
"github.com/yuin/gopher-lua"
"io/ioutil"
luajson "layeh.com/gopher-json"
"layeh.com/gopher-luar"
"log"
"meow.tf/residentsleeper/scripting/commands"
"meow.tf/residentsleeper/scripting/config"
"meow.tf/residentsleeper/scripting/event"
"meow.tf/residentsleeper/scripting/minecraft"
"meow.tf/residentsleeper/scripting/regexp"
"path"
)
func LoadScripts(scriptPath string) error {
files, err := ioutil.ReadDir(scriptPath)
if err != nil {
return err
}
for _, file := range files {
if file.IsDir() {
continue
}
log.Println("Loading script", file.Name())
err = loadScript(path.Join(scriptPath, file.Name()))
if err != nil {
return err
}
}
return nil
}
func loadScript(scriptFile string) error {
L := lua.NewState()
luajson.Preload(L)
L.PreloadModule("config", config.Loader)
L.PreloadModule("event", event.Loader)
L.PreloadModule("regexp", regexp.Loader)
L.PreloadModule("minecraft", minecraft.Loader)
L.PreloadModule("commands", commands.Loader)
L.SetGlobal("rcon", luar.New(L, client))
return L.DoFile(scriptFile)
}

37
scripting/commands/commands.go

@ -0,0 +1,37 @@
package commands
import (
lua "github.com/yuin/gopher-lua"
"meow.tf/residentsleeper/commands"
)
func Loader(L *lua.LState) int {
mod := L.SetFuncs(L.NewTable(), exports)
L.Push(mod)
return 1
}
var exports = map[string]lua.LGFunction{
"register": commandRegister,
}
func commandRegister(L *lua.LState) int {
name := L.CheckString(1)
handler := L.CheckFunction(2)
cb := func(ctx *commands.CommandContext) {
L.Push(handler)
L.Push(lua.LString(ctx.User))
for _, v := range ctx.Arguments {
L.Push(lua.LString(v))
}
L.PCall(1+ctx.ArgumentCount, 0, nil)
}
commands.Register(name, cb)
return 0
}

96
scripting/config/config.go

@ -0,0 +1,96 @@
package config
import (
"github.com/yuin/gopher-lua"
"io/ioutil"
luajson "layeh.com/gopher-json"
"os"
"path"
)
var (
BasePath string
)
func Loader(L *lua.LState) int {
// register functions to the table
mod := L.SetFuncs(L.NewTable(), exports)
// returns the module
L.Push(mod)
return 1
}
var exports = map[string]lua.LGFunction{
"load": loadFunc,
"save": saveFunc,
}
func loadFunc(L *lua.LState) int {
name := L.CheckString(1)
f, err := os.Open(path.Join(BasePath, name+".json"))
if err != nil {
L.Push(lua.LNil)
L.Push(lua.LString(err.Error()))
return 2
}
defer f.Close()
b, err := ioutil.ReadAll(f)
if err != nil {
L.Push(lua.LNil)
L.Push(lua.LString(err.Error()))
return 2
}
v, err := luajson.Decode(L, b)
if err != nil {
L.Push(lua.LNil)
L.Push(lua.LString(err.Error()))
return 2
}
L.Push(v)
L.Push(lua.LNil)
return 2
}
func saveFunc(L *lua.LState) int {
name := L.CheckString(1)
value := L.CheckAny(2)
f, err := os.Create(path.Join(BasePath, name+".json"))
if err != nil {
L.Push(lua.LString(err.Error()))
return 1
}
b, err := luajson.Encode(value)
if err != nil {
L.Push(lua.LString(err.Error()))
return 1
}
_, err = f.Write(b)
if err != nil {
L.Push(lua.LString(err.Error()))
return 1
}
err = f.Close()
if err != nil {
L.Push(lua.LString(err.Error()))
return 1
}
return 0
}

37
scripting/event/event.go

@ -0,0 +1,37 @@
package event
import (
lua "github.com/yuin/gopher-lua"
luar "layeh.com/gopher-luar"
"meow.tf/residentsleeper/events"
)
func Loader(L *lua.LState) int {
// register functions to the table
mod := L.SetFuncs(L.NewTable(), exports)
// returns the module
L.Push(mod)
return 1
}
var exports = map[string]lua.LGFunction{
"on": onFunc,
}
func onFunc(L *lua.LState) int {
name := L.CheckString(1)
handler := L.CheckFunction(2)
events.On(name, func(args ...interface{}) {
L.Push(handler)
for _, arg := range args {
L.Push(luar.New(L, arg))
}
L.PCall(len(args), 0, handler)
})
return 0
}

33
scripting/minecraft/nbt.go

@ -0,0 +1,33 @@
package minecraft
import (
"github.com/ppacher/nbt"
lua "github.com/yuin/gopher-lua"
)
func TagToLuaValue(L *lua.LState, tag nbt.Tag) lua.LValue {
switch tag.TagID() {
case nbt.TagDouble:
return lua.LNumber(tag.(*nbt.DoubleTag).Value)
case nbt.TagFloat:
return lua.LNumber(tag.(*nbt.FloatTag).Value)
case nbt.TagInt:
return lua.LNumber(tag.(*nbt.IntTag).Value)
case nbt.TagLong:
return lua.LNumber(tag.(*nbt.LongTag).Value)
case nbt.TagString:
return lua.LString(tag.(*nbt.StringTag).Value)
case nbt.TagCompound:
t := L.NewTable()
c := tag.(*nbt.CompoundTag)
for key, v := range c.Tags {
t.RawSetString(key, TagToLuaValue(L, v))
}
return t
}
return lua.LNil
}

69
scripting/minecraft/ops.go

@ -0,0 +1,69 @@
package minecraft
import (
"encoding/json"
"github.com/yuin/gopher-lua"
"meow.tf/residentsleeper/events"
"os"
"path"
)
var (
ops = make([]*minecraftOp, 0)
)