diff --git a/go.mod b/go.mod index 76d7dde..5ee4452 100644 --- a/go.mod +++ b/go.mod @@ -7,4 +7,20 @@ require ( github.com/Kelwing/mc-rcon v0.0.0-20221109043427-a481cb41818e ) -require golang.org/x/net v0.59.0 // indirect +require ( + filippo.io/edwards25519 v1.2.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/rs/zerolog v1.35.1 // indirect + github.com/tidwall/gjson v1.20.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect + go.mau.fi/util v0.11.0 // indirect + golang.org/x/crypto v0.58.0 // indirect + golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e // indirect + golang.org/x/net v0.61.0 // indirect + golang.org/x/sys v0.49.0 // indirect + golang.org/x/text v0.43.0 // indirect + maunium.net/go/mautrix v0.32.0 // indirect +) diff --git a/go.sum b/go.sum index da9ebcd..ad884f1 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,39 @@ +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= git.anarchists.space/meer/pixitrix v0.0.0-20261001184040-0baca98fc381 h1:S+2YsfopfcX4j4sw5YbGezYmUGyxDSeiemIDwZKbCsc= git.anarchists.space/meer/pixitrix v0.0.0-20261001184040-0baca98fc381/go.mod h1:bifrDiGNzaP6BJnlo8ZnYIPRw6zY36v0FKgaai0BGrE= github.com/Kelwing/mc-rcon v0.0.0-20221109043427-a481cb41818e h1:btzl16nXWBeHhlqnAttNBLxs+hSkyRN1xTbRy4kUbSg= github.com/Kelwing/mc-rcon v0.0.0-20221109043427-a481cb41818e/go.mod h1:wlXY/EO9CBjMViEh95JOQhp//RMnHs4ZUBhYhx43LdM= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= +github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.20.0 h1:+agJ3rEzKcCXKDKo0ml26UROcpcAlnZyjq+TjbY5Fto= +github.com/tidwall/gjson v1.20.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +go.mau.fi/util v0.11.0 h1:JNEpkVRxfTdJUC0DIGKiRlLKzl4G/qLX/eVzoP7QuwY= +go.mau.fi/util v0.11.0/go.mod h1:04AEFVsc4zNdIkDC1gkdqbg/nZFolHY50pyXh2DSy7s= +golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= +golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= +golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e h1:HgI6W1OolKK6tcV74is+rl9SMT0njsHlp0TbQiGXI/A= +golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e/go.mod h1:DBoxwWhjaW8DJJw/YeM4zpeOKMC1FMXSblsYy+1mFSw= golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw= +golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= +golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= +golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= +maunium.net/go/mautrix v0.32.0 h1:xuAZzzyWce0pFkBhV/MCLFiJdVs2rFkY3Eep7hNng4I= +maunium.net/go/mautrix v0.32.0/go.mod h1:tZSwPzw+/0pa0dxGufkh0uJqM84BNxB6e2I0+S0UyjU= diff --git a/main.go b/main.go index 869310c..b55d885 100644 --- a/main.go +++ b/main.go @@ -1,21 +1,21 @@ package main import ( - "bufio" + "context" "encoding/json" "flag" "fmt" - "io" "log" "os" "regexp" "strings" "sync" "time" - "unicode" - matrix "git.anarchists.space/meer/pixitrix" mcrcon "github.com/Kelwing/mc-rcon" + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" ) type EventType int @@ -44,11 +44,13 @@ type Minecraft struct { } type Bridge struct { MC *Minecraft - Matrix *matrix.Client - TrustedUsers []string - RoomID string + Matrix *mautrix.Client + ctx context.Context + TrustedUsers []id.UserID + RoomID id.RoomID Events <-chan *MCEvent Players *PlayerSet + Usepmp bool } type Config struct { @@ -63,11 +65,12 @@ type MinecraftConfig struct { } type MatrixConfig struct { - Homeserver string `json:"homeserver"` - AccessToken string `json:"access_token"` - Room string `json:"room"` - TrustedUsers []string `json:"trusted_users,omitempty"` - Proxy string `json:"proxy,omitempty"` + Homeserver string `json:"homeserver"` + AccessToken string `json:"access_token"` + Room id.RoomID `json:"room"` + TrustedUsers []id.UserID `json:"trusted_users,omitempty"` + Usepmp bool `json:"use_pmp"` + Proxy string `json:"proxy,omitempty"` } var ( @@ -85,7 +88,7 @@ var ( advancementRE = regexp.MustCompile( `^\[[0-9:]+\] \[[^]]+/INFO\]: (?:System chat: )?([A-Za-z0-9_]+) has (?:made the advancement|reached the goal|completed the challenge) \[(.+)\]$`, - ) + ) deathRE = regexp.MustCompile( `^\[[0-9:]+\] \[[^]]+/INFO\]: (.+)$`, ) @@ -137,15 +140,12 @@ func main() { log.Fatal(err) } - mx, err := matrix.New( - cfg.Matrix.Homeserver, - cfg.Matrix.AccessToken, - cfg.Matrix.Proxy, - ) + cli, err := mautrix.NewClient(cfg.Matrix.Homeserver, "", cfg.Matrix.AccessToken) if err != nil { log.Fatal(err) } - roomID, err := mx.JoinRoom(cfg.Matrix.Room) + ctx := context.Background() + joinresp, err := cli.JoinRoom(ctx, cfg.Matrix.Room.String(), nil) if err != nil { log.Fatal(err) } @@ -153,12 +153,14 @@ func main() { events := make(chan *MCEvent, 100) bridge := &Bridge{ + ctx: ctx, MC: mc, - Matrix: mx, - RoomID: roomID, + Matrix: cli, + RoomID: joinresp.RoomID, Events: events, TrustedUsers: cfg.Matrix.TrustedUsers, Players: NewPlayerSet(), + Usepmp: cfg.Matrix.Usepmp, } if resp, err := mc.SendCommand("list"); err == nil { bridge.Players.Seed(resp) @@ -170,275 +172,10 @@ func main() { go bridge.RunMCEvents() - go RunMatrixBridge( - mx, + RunMatrixBridge(ctx, + cli, bridge, ) - - select {} -} -func dialRCON(addr, password string) (*mcrcon.MCConn, error) { - conn := new(mcrcon.MCConn) - - if err := conn.Open(addr, password); err != nil { - return nil, err - } - if err := conn.Authenticate(); err != nil { - conn.Close() - return nil, err - } - - return conn, nil -} - -func NewMinecraft(addr, password string) (*Minecraft, error) { - conn, err := dialRCON(addr, password) - if err != nil { - return nil, err - } - - return &Minecraft{RCON: conn, addr: addr, password: password}, nil -} - -const ( - maxNickLen = 32 - maxMsgLen = 256 -) - -func sanitize(s string, max int) string { - s = strings.ToValidUTF8(s, "") - var b strings.Builder - n := 0 - for _, r := range s { - if r == '\n' || r == '\r' || r == '\t' { - r = ' ' - } - if r == '\u00a7' || unicode.IsControl(r) || unicode.Is(unicode.Cf, r) { - continue - } - if n >= max { - b.WriteString("…") - break - } - b.WriteRune(r) - n++ - } - return b.String() -} - -func (mc *Minecraft) SendMessage(nick, msg string) error { - components := []map[string]any{ - {"text": "", "color": "dark_gray"}, - {"text": sanitize(nick, maxNickLen), "color": "aqua", "bold": true}, - {"text": ": ", "color": "gray"}, - {"text": sanitize(msg, maxMsgLen), "color": "white"}, - } - j, err := json.Marshal(components) - if err != nil { - return err - } - - _, err = mc.SendCommand("tellraw @a " + string(j)) - - return err -} - -var playerNameRE = regexp.MustCompile(`^[A-Za-z0-9_]{1,16}$`) - -var onlineRE = regexp.MustCompile(`players online: ?(.*)$`) - -type PlayerSet struct { - mu sync.Mutex - m map[string]struct{} -} - -func NewPlayerSet() *PlayerSet { return &PlayerSet{m: map[string]struct{}{}} } - -func (p *PlayerSet) Add(n string) { p.mu.Lock(); p.m[n] = struct{}{}; p.mu.Unlock() } -func (p *PlayerSet) Remove(n string) { p.mu.Lock(); delete(p.m, n); p.mu.Unlock() } -func (p *PlayerSet) Has(n string) bool { - p.mu.Lock() - defer p.mu.Unlock() - _, ok := p.m[n] - return ok -} - -func (p *PlayerSet) Seed(listOutput string) { - m := onlineRE.FindStringSubmatch(listOutput) - if m == nil { - return - } - for _, n := range strings.Split(m[1], ",") { - if n = strings.TrimSpace(n); playerNameRE.MatchString(n) { - p.Add(n) - } - } -} - -func ParseLogLine(line string) (*MCEvent, bool) { - now := time.Now() - - if m := chatRE.FindStringSubmatch(line); m != nil { - return &MCEvent{ - Type: ChatEvent, - Player: m[1], - Message: m[2], - RawLine: line, - Time: now, - }, true - } - - if m := joinRE.FindStringSubmatch(line); m != nil { - return &MCEvent{ - Type: JoinEvent, - Player: m[1], - RawLine: line, - Time: now, - }, true - } - - if m := leaveRE.FindStringSubmatch(line); m != nil { - return &MCEvent{ - Type: LeaveEvent, - Player: m[1], - RawLine: line, - Time: now, - }, true - } - - if m := advancementRE.FindStringSubmatch(line); m != nil { - return &MCEvent{ - Type: AdvancementEvent, - Player: m[1], - Message: m[2], - RawLine: line, - Time: now, - }, true - } - if m := pauseRE.FindStringSubmatch(line); m != nil { - return &MCEvent{ - Type: PauseEvent, - Message: m[1], - RawLine: line, - Time: now, - }, true - } - - if m := deathRE.FindStringSubmatch(line); m != nil { - msg := m[1] - - deaths := []string{ - " was slain by ", - " was shot by ", - " was blown up", - " was doomed to ", - " drowned", - " died", - " blew up", - " burned to death", - " fell from", - " hit the ground too hard", - " was killed by ", - } - - for _, d := range deaths { - if strings.Contains(msg, d) { - - player := strings.Fields(msg)[0] - if !playerNameRE.MatchString(player) { - return nil, false - } - - return &MCEvent{ - Type: DeathEvent, - Player: player, - Message: msg, - RawLine: line, - Time: now, - }, true - } - } - } - - return nil, false -} - -const maxLogLine = 64 << 10 - -func WatchLog(path string, events chan<- *MCEvent) error { - f, err := os.Open(path) - if err != nil { - return err - } - defer func() { f.Close() }() - - info, err := f.Stat() - if err != nil { - return err - } - - if _, err = f.Seek(0, io.SeekEnd); err != nil { - return err - } - - var ( - reader = bufio.NewReaderSize(f, 32<<10) - pending []byte - overflow bool - offset = info.Size() - ) - - for { - chunk, err := reader.ReadSlice('\n') - offset += int64(len(chunk)) - - if !overflow { - pending = append(pending, chunk...) - if len(pending) > maxLogLine { - pending, overflow = nil, true - } - } - - if err == bufio.ErrBufferFull { - continue - } - - if err == nil { - if !overflow { - line := strings.TrimSpace(string(pending)) - if event, ok := ParseLogLine(line); ok { - events <- event - } - } - pending, overflow = pending[:0], false - continue - } - - if err != io.EOF { - return err - } - - if cur, statErr := os.Stat(path); statErr == nil { - if !os.SameFile(info, cur) || cur.Size() < offset { - log.Printf("%s rotated or truncated, reopening", path) - - nf, openErr := os.Open(path) - if openErr != nil { - return openErr - } - f.Close() - f = nf - info, err = f.Stat() - if err != nil { - return err - } - reader.Reset(f) - pending, overflow, offset = nil, false, 0 - } - } - - time.Sleep(250 * time.Millisecond) - } } func (mc *Minecraft) SendCommand(command string) (string, error) { if strings.ContainsAny(command, "\r\n\x00") { @@ -470,44 +207,41 @@ const ( maxReconnectDelay = 2 * time.Minute ) -func RunMatrixBridge( - client *matrix.Client, +type failFastSyncer struct { + *mautrix.DefaultSyncer +} + +func (s *failFastSyncer) OnFailedSync(res *mautrix.RespSync, err error) (time.Duration, error) { + return 0, err +} +func RunMatrixBridge(ctx context.Context, + client *mautrix.Client, bridge *Bridge, ) { - me, err := client.WhoAmI() + me, err := client.Whoami(ctx) if err != nil { log.Printf("whoami failed: %v", err) return } - + client.UserID = me.UserID log.Printf("logged in as %s", me.UserID) - - for msg := range client.Messages() { - - if msg.Sender == me.UserID { - continue + syncer := &failFastSyncer{mautrix.NewDefaultSyncer()} + client.Syncer = syncer + client.SyncPresence = event.PresenceOffline + //syncer.FilterJSON = createSyncFilter() + client.Syncer.(mautrix.ExtensibleSyncer).OnSync(client.DontProcessOldEvents) + syncer.OnEventType(event.EventMessage, func(_ context.Context, ev *event.Event) { + if ev.RoomID != bridge.RoomID || ev.Sender == client.UserID { + return } - - if msg.RoomID != bridge.RoomID { - continue - } - - if msg.MsgType != "m.text" || msg.Body == "" { - continue - } - - //log.Printf("[matrix] %s: (%d bytes)", msg.Sender, len(msg.Body)) - + msg := ev.Content.AsMessage() if strings.HasPrefix(msg.Body, "!") { - handleMatrixCommand( - bridge, - msg, - ) - continue + handleMatrixCommand(bridge, ev.RoomID, ev.Sender, *msg) + return } - name := msg.Sender - profile, err := bridge.Matrix.GetProfile(msg.Sender) + name := ev.Sender.String() + profile, err := bridge.Matrix.GetDisplayName(bridge.ctx, ev.Sender) if err != nil { log.Printf("profile lookup failed: %v", err) } else if profile.DisplayName != "" { @@ -517,19 +251,54 @@ func RunMatrixBridge( if err := bridge.MC.SendMessage(name, msg.Body); err != nil { log.Printf("minecraft send failed: %v", err) } + }) + backoff := initialReconnectDelay + for { + select { + case <-ctx.Done(): + log.Printf("Matrix bridge context cancelled. Stopping sync.") + return + default: + if err := client.SyncWithContext(ctx); err != nil { + log.Printf("Matrix sync loop halted with error: %v", err) + + log.Printf("Reconnecting to homeserver in %v...", backoff) + select { + case <-ctx.Done(): + return + case <-time.After(backoff): + } + backoff *= 2 + if backoff > maxReconnectDelay { + backoff = maxReconnectDelay + } + } else { + backoff = initialReconnectDelay + } + } } } -func (b *Bridge) SendMatrix( +func (b *Bridge) SendMatrix(sender, format string, args ...any, ) { text := fmt.Sprintf(format, args...) - - _, err := b.Matrix.SendMessage( - b.RoomID, - text, - ) - + // b.SendMessageWithpmp(b.RoomID, sender, text) + _, err := b.Matrix.SendText(b.ctx, id.RoomID(b.RoomID), text) + if err != nil { + log.Printf( + "matrix send failed: %v", + err, + ) + } +} +func (b *Bridge) _SendMatrix( + format string, + args ...any, +) { + text := fmt.Sprintf(format, args...) + // b.SendMessageWithpmp(b.RoomID, sender, text) + _, err := b.Matrix.SendText(b.ctx, id.RoomID(b.RoomID), text) if err != nil { log.Printf( "matrix send failed: %v", @@ -545,10 +314,7 @@ func dualMsg( ) { msg := fmt.Sprintf(format, args...) - _, err := bridge.Matrix.SendMessage( - roomID, - msg, - ) + _, err := bridge.Matrix.SendText(bridge.ctx, id.RoomID(roomID), msg) if err != nil { log.Printf("failed to send matrix notice: %v", err) } @@ -559,8 +325,8 @@ func dualMsg( } } func handleMatrixCommand( - bridge *Bridge, - msg matrix.Message, + bridge *Bridge, id id.RoomID, sender id.UserID, + msg event.MessageEventContent, ) { parts := strings.Fields(strings.TrimPrefix(msg.Body, "!")) @@ -573,84 +339,51 @@ func handleMatrixCommand( case "mclist": resp, err := bridge.MC.SendCommand("list") if err != nil { - _, _ = bridge.Matrix.SendMessage(msg.RoomID, fmt.Sprintf("list failed: %v", err)) + bridge.SendMessage(id, fmt.Sprintf("list failed: %v", err)) return } - _, _ = bridge.Matrix.SendMessage( - msg.RoomID, + bridge.SendMessage( + id, resp, ) case "rcon": - if !bridge.IsTrustedUser(msg.Sender) { - bridge.Matrix.SendMessage(msg.RoomID, "You are not a whitelisted user to run an RCON command >:E") + if !bridge.IsTrustedUser(sender) { + bridge.SendMessage(id, "You are not a whitelisted user to run an RCON command >:E") return } torun := strings.Join(parts[1:], " ") if torun == "" { return } - log.Printf("[audit] rcon by %s: %q", msg.Sender, torun) + log.Printf("[audit] rcon by %s: %q", sender, torun) resp, err := bridge.MC.SendCommand(torun) if err != nil { - bridge.Matrix.SendMessage(msg.RoomID, fmt.Sprintf("error running rcon command: %s", err.Error())) + bridge.SendMessage(id, fmt.Sprintf("error running rcon command: %s", err.Error())) return } - bridge.Matrix.SendMessage(msg.RoomID, fmt.Sprintf("command response: %s", resp)) + bridge.SendMessage(id, fmt.Sprintf("command response: %s", resp)) } } -func (b *Bridge) RunMCEvents() { - for ev := range b.Events { - - switch ev.Type { - - case ChatEvent: - b.Players.Add(ev.Player) - b.SendMatrix( - "%s: %s", - sanitize(ev.Player, 64), - sanitize(ev.Message, 1024), - ) - - case JoinEvent: - b.Players.Add(ev.Player) - b.SendMatrix( - "%s joined the game :3", - ev.Player, - ) - - case LeaveEvent: - b.Players.Remove(ev.Player) - b.SendMatrix( - "%s left the game :<", - ev.Player, - ) - - case DeathEvent: - if !b.Players.Has(ev.Player) { - continue - } - b.SendMatrix( - "%s", - sanitize(ev.Message, 512), - ) - - case AdvancementEvent: - b.SendMatrix( - "%s has made the advancement [%s] :3", - ev.Player, - sanitize(ev.Message, 128), - ) - - case PauseEvent: - b.SendMatrix( - "Server paused after %s seconds of inactivity :3", - ev.Message, - ) +func (b *Bridge) SendMessage(roomID id.RoomID, text string) { + b.Matrix.SendText(b.ctx, b.RoomID, text) +} +func (b *Bridge) SendMessageWithpmp(roomID id.RoomID, displayname, text string) { + if b.Usepmp { + json := map[string]any{ + "msgtype": "m.text", + "body": text, + "com.beeper.per_message_profile": map[string]string{ + "displayname": displayname, + "id": displayname, + }, } + b.Matrix.SendMessageEvent(b.ctx, b.RoomID, event.EventMessage, json) + return } + b.Matrix.SendText(b.ctx, b.RoomID, text) } -func (b *Bridge) IsTrustedUser(mxid string) bool { +func (b *Bridge) IsTrustedUser(mxid id.UserID) bool { if b.TrustedUsers == nil { return false } diff --git a/minecraft.go b/minecraft.go new file mode 100644 index 0000000..d86df9f --- /dev/null +++ b/minecraft.go @@ -0,0 +1,335 @@ +package main + +import ( + "bufio" + "encoding/json" + "io" + "log" + "os" + "regexp" + "strings" + "sync" + "time" + "unicode" + + mcrcon "github.com/Kelwing/mc-rcon" +) + +func dialRCON(addr, password string) (*mcrcon.MCConn, error) { + conn := new(mcrcon.MCConn) + + if err := conn.Open(addr, password); err != nil { + return nil, err + } + if err := conn.Authenticate(); err != nil { + conn.Close() + return nil, err + } + + return conn, nil +} + +func NewMinecraft(addr, password string) (*Minecraft, error) { + conn, err := dialRCON(addr, password) + if err != nil { + return nil, err + } + + return &Minecraft{RCON: conn, addr: addr, password: password}, nil +} + +const ( + maxNickLen = 32 + maxMsgLen = 256 +) + +func sanitize(s string, max int) string { + s = strings.ToValidUTF8(s, "") + var b strings.Builder + n := 0 + for _, r := range s { + if r == '\n' || r == '\r' || r == '\t' { + r = ' ' + } + if r == '\u00a7' || unicode.IsControl(r) || unicode.Is(unicode.Cf, r) { + continue + } + if n >= max { + b.WriteString("…") + break + } + b.WriteRune(r) + n++ + } + return b.String() +} + +func (mc *Minecraft) SendMessage(nick, msg string) error { + components := []map[string]any{ + {"text": "", "color": "dark_gray"}, + {"text": sanitize(nick, maxNickLen), "color": "aqua", "bold": true}, + {"text": ": ", "color": "gray"}, + {"text": sanitize(msg, maxMsgLen), "color": "white"}, + } + j, err := json.Marshal(components) + if err != nil { + return err + } + + _, err = mc.SendCommand("tellraw @a " + string(j)) + + return err +} + +var playerNameRE = regexp.MustCompile(`^[A-Za-z0-9_]{1,16}$`) + +var onlineRE = regexp.MustCompile(`players online: ?(.*)$`) + +type PlayerSet struct { + mu sync.Mutex + m map[string]struct{} +} + +func NewPlayerSet() *PlayerSet { return &PlayerSet{m: map[string]struct{}{}} } + +func (p *PlayerSet) Add(n string) { p.mu.Lock(); p.m[n] = struct{}{}; p.mu.Unlock() } +func (p *PlayerSet) Remove(n string) { p.mu.Lock(); delete(p.m, n); p.mu.Unlock() } +func (p *PlayerSet) Has(n string) bool { + p.mu.Lock() + defer p.mu.Unlock() + _, ok := p.m[n] + return ok +} + +func (p *PlayerSet) Seed(listOutput string) { + m := onlineRE.FindStringSubmatch(listOutput) + if m == nil { + return + } + for _, n := range strings.Split(m[1], ",") { + if n = strings.TrimSpace(n); playerNameRE.MatchString(n) { + p.Add(n) + } + } +} + +func ParseLogLine(line string) (*MCEvent, bool) { + now := time.Now() + + if m := chatRE.FindStringSubmatch(line); m != nil { + return &MCEvent{ + Type: ChatEvent, + Player: m[1], + Message: m[2], + RawLine: line, + Time: now, + }, true + } + + if m := joinRE.FindStringSubmatch(line); m != nil { + return &MCEvent{ + Type: JoinEvent, + Player: m[1], + RawLine: line, + Time: now, + }, true + } + + if m := leaveRE.FindStringSubmatch(line); m != nil { + return &MCEvent{ + Type: LeaveEvent, + Player: m[1], + RawLine: line, + Time: now, + }, true + } + + if m := advancementRE.FindStringSubmatch(line); m != nil { + return &MCEvent{ + Type: AdvancementEvent, + Player: m[1], + Message: m[2], + RawLine: line, + Time: now, + }, true + } + if m := pauseRE.FindStringSubmatch(line); m != nil { + return &MCEvent{ + Type: PauseEvent, + Message: m[1], + RawLine: line, + Time: now, + }, true + } + + if m := deathRE.FindStringSubmatch(line); m != nil { + msg := m[1] + + deaths := []string{ + " was slain by ", + " was shot by ", + " was blown up", + " was doomed to ", + " drowned", + " died", + " blew up", + " burned to death", + " fell from", + " hit the ground too hard", + " was killed by ", + } + + for _, d := range deaths { + if strings.Contains(msg, d) { + + player := strings.Fields(msg)[0] + if !playerNameRE.MatchString(player) { + return nil, false + } + + return &MCEvent{ + Type: DeathEvent, + Player: player, + Message: msg, + RawLine: line, + Time: now, + }, true + } + } + } + + return nil, false +} + +const maxLogLine = 64 << 10 + +func WatchLog(path string, events chan<- *MCEvent) error { + f, err := os.Open(path) + if err != nil { + return err + } + defer func() { f.Close() }() + + info, err := f.Stat() + if err != nil { + return err + } + + if _, err = f.Seek(0, io.SeekEnd); err != nil { + return err + } + + var ( + reader = bufio.NewReaderSize(f, 32<<10) + pending []byte + overflow bool + offset = info.Size() + ) + + for { + chunk, err := reader.ReadSlice('\n') + offset += int64(len(chunk)) + + if !overflow { + pending = append(pending, chunk...) + if len(pending) > maxLogLine { + pending, overflow = nil, true + } + } + + if err == bufio.ErrBufferFull { + continue + } + + if err == nil { + if !overflow { + line := strings.TrimSpace(string(pending)) + if event, ok := ParseLogLine(line); ok { + events <- event + } + } + pending, overflow = pending[:0], false + continue + } + + if err != io.EOF { + return err + } + + if cur, statErr := os.Stat(path); statErr == nil { + if !os.SameFile(info, cur) || cur.Size() < offset { + log.Printf("%s rotated or truncated, reopening", path) + + nf, openErr := os.Open(path) + if openErr != nil { + return openErr + } + f.Close() + f = nf + info, err = f.Stat() + if err != nil { + return err + } + reader.Reset(f) + pending, overflow, offset = nil, false, 0 + } + } + + time.Sleep(250 * time.Millisecond) + } +} +func (b *Bridge) RunMCEvents() { + for ev := range b.Events { + + switch ev.Type { + + case ChatEvent: + b.Players.Add(ev.Player) + if b.Usepmp { + b.SendMessageWithpmp(b.RoomID, ev.Player, sanitize(ev.Message, 1024)) + } else { + b._SendMatrix( + "%s: %s", + sanitize(ev.Player, 64), + sanitize(ev.Message, 1024), + ) + } + //b.SendMatrix(sanitize(ev.Player, 64), "%s", sanitize(ev.Message, 1024)) + case JoinEvent: + b.Players.Add(ev.Player) + b._SendMatrix( + "%s joined the game :3", + ev.Player, + ) + + case LeaveEvent: + b.Players.Remove(ev.Player) + b._SendMatrix( + "%s left the game :<", + ev.Player, + ) + + case DeathEvent: + if !b.Players.Has(ev.Player) { + continue + } + b._SendMatrix( + "%s", + sanitize(ev.Message, 512), + ) + + case AdvancementEvent: + b._SendMatrix( + "%s has made the advancement [%s] :3", + ev.Player, + sanitize(ev.Message, 128), + ) + + case PauseEvent: + b._SendMatrix( + "Server paused after %s seconds of inactivity :3", + ev.Message, + ) + } + } +}