update fork to be up-to-date with matrixmc

This commit is contained in:
outlet360 2026-11-06 15:03:43 -05:00
commit c00b33eb1e
4 changed files with 503 additions and 386 deletions

18
go.mod
View file

@ -7,4 +7,20 @@ require (
github.com/Kelwing/mc-rcon v0.0.0-20221109043427-a481cb41818e 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
)

33
go.sum
View file

@ -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 h1:S+2YsfopfcX4j4sw5YbGezYmUGyxDSeiemIDwZKbCsc=
git.anarchists.space/meer/pixitrix v0.0.0-20261001184040-0baca98fc381/go.mod h1:bifrDiGNzaP6BJnlo8ZnYIPRw6zY36v0FKgaai0BGrE= 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 h1:btzl16nXWBeHhlqnAttNBLxs+hSkyRN1xTbRy4kUbSg=
github.com/Kelwing/mc-rcon v0.0.0-20221109043427-a481cb41818e/go.mod h1:wlXY/EO9CBjMViEh95JOQhp//RMnHs4ZUBhYhx43LdM= 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 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= 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=

491
main.go
View file

@ -1,21 +1,21 @@
package main package main
import ( import (
"bufio" "context"
"encoding/json" "encoding/json"
"flag" "flag"
"fmt" "fmt"
"io"
"log" "log"
"os" "os"
"regexp" "regexp"
"strings" "strings"
"sync" "sync"
"time" "time"
"unicode"
matrix "git.anarchists.space/meer/pixitrix"
mcrcon "github.com/Kelwing/mc-rcon" mcrcon "github.com/Kelwing/mc-rcon"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
) )
type EventType int type EventType int
@ -44,11 +44,13 @@ type Minecraft struct {
} }
type Bridge struct { type Bridge struct {
MC *Minecraft MC *Minecraft
Matrix *matrix.Client Matrix *mautrix.Client
TrustedUsers []string ctx context.Context
RoomID string TrustedUsers []id.UserID
RoomID id.RoomID
Events <-chan *MCEvent Events <-chan *MCEvent
Players *PlayerSet Players *PlayerSet
Usepmp bool
} }
type Config struct { type Config struct {
@ -65,8 +67,9 @@ type MinecraftConfig struct {
type MatrixConfig struct { type MatrixConfig struct {
Homeserver string `json:"homeserver"` Homeserver string `json:"homeserver"`
AccessToken string `json:"access_token"` AccessToken string `json:"access_token"`
Room string `json:"room"` Room id.RoomID `json:"room"`
TrustedUsers []string `json:"trusted_users,omitempty"` TrustedUsers []id.UserID `json:"trusted_users,omitempty"`
Usepmp bool `json:"use_pmp"`
Proxy string `json:"proxy,omitempty"` Proxy string `json:"proxy,omitempty"`
} }
@ -137,15 +140,12 @@ func main() {
log.Fatal(err) log.Fatal(err)
} }
mx, err := matrix.New( cli, err := mautrix.NewClient(cfg.Matrix.Homeserver, "", cfg.Matrix.AccessToken)
cfg.Matrix.Homeserver,
cfg.Matrix.AccessToken,
cfg.Matrix.Proxy,
)
if err != nil { if err != nil {
log.Fatal(err) 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 { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
@ -153,12 +153,14 @@ func main() {
events := make(chan *MCEvent, 100) events := make(chan *MCEvent, 100)
bridge := &Bridge{ bridge := &Bridge{
ctx: ctx,
MC: mc, MC: mc,
Matrix: mx, Matrix: cli,
RoomID: roomID, RoomID: joinresp.RoomID,
Events: events, Events: events,
TrustedUsers: cfg.Matrix.TrustedUsers, TrustedUsers: cfg.Matrix.TrustedUsers,
Players: NewPlayerSet(), Players: NewPlayerSet(),
Usepmp: cfg.Matrix.Usepmp,
} }
if resp, err := mc.SendCommand("list"); err == nil { if resp, err := mc.SendCommand("list"); err == nil {
bridge.Players.Seed(resp) bridge.Players.Seed(resp)
@ -170,275 +172,10 @@ func main() {
go bridge.RunMCEvents() go bridge.RunMCEvents()
go RunMatrixBridge( RunMatrixBridge(ctx,
mx, cli,
bridge, 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) { func (mc *Minecraft) SendCommand(command string) (string, error) {
if strings.ContainsAny(command, "\r\n\x00") { if strings.ContainsAny(command, "\r\n\x00") {
@ -470,44 +207,41 @@ const (
maxReconnectDelay = 2 * time.Minute maxReconnectDelay = 2 * time.Minute
) )
func RunMatrixBridge( type failFastSyncer struct {
client *matrix.Client, *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, bridge *Bridge,
) { ) {
me, err := client.WhoAmI() me, err := client.Whoami(ctx)
if err != nil { if err != nil {
log.Printf("whoami failed: %v", err) log.Printf("whoami failed: %v", err)
return return
} }
client.UserID = me.UserID
log.Printf("logged in as %s", me.UserID) log.Printf("logged in as %s", me.UserID)
syncer := &failFastSyncer{mautrix.NewDefaultSyncer()}
for msg := range client.Messages() { client.Syncer = syncer
client.SyncPresence = event.PresenceOffline
if msg.Sender == me.UserID { //syncer.FilterJSON = createSyncFilter()
continue 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
} }
msg := ev.Content.AsMessage()
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))
if strings.HasPrefix(msg.Body, "!") { if strings.HasPrefix(msg.Body, "!") {
handleMatrixCommand( handleMatrixCommand(bridge, ev.RoomID, ev.Sender, *msg)
bridge, return
msg,
)
continue
} }
name := msg.Sender name := ev.Sender.String()
profile, err := bridge.Matrix.GetProfile(msg.Sender) profile, err := bridge.Matrix.GetDisplayName(bridge.ctx, ev.Sender)
if err != nil { if err != nil {
log.Printf("profile lookup failed: %v", err) log.Printf("profile lookup failed: %v", err)
} else if profile.DisplayName != "" { } else if profile.DisplayName != "" {
@ -517,19 +251,54 @@ func RunMatrixBridge(
if err := bridge.MC.SendMessage(name, msg.Body); err != nil { if err := bridge.MC.SendMessage(name, msg.Body); err != nil {
log.Printf("minecraft send failed: %v", err) 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, format string,
args ...any, args ...any,
) { ) {
text := fmt.Sprintf(format, args...) text := fmt.Sprintf(format, args...)
// b.SendMessageWithpmp(b.RoomID, sender, text)
_, err := b.Matrix.SendMessage( _, err := b.Matrix.SendText(b.ctx, id.RoomID(b.RoomID), text)
b.RoomID, if err != nil {
text, 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 { if err != nil {
log.Printf( log.Printf(
"matrix send failed: %v", "matrix send failed: %v",
@ -545,10 +314,7 @@ func dualMsg(
) { ) {
msg := fmt.Sprintf(format, args...) msg := fmt.Sprintf(format, args...)
_, err := bridge.Matrix.SendMessage( _, err := bridge.Matrix.SendText(bridge.ctx, id.RoomID(roomID), msg)
roomID,
msg,
)
if err != nil { if err != nil {
log.Printf("failed to send matrix notice: %v", err) log.Printf("failed to send matrix notice: %v", err)
} }
@ -559,8 +325,8 @@ func dualMsg(
} }
} }
func handleMatrixCommand( func handleMatrixCommand(
bridge *Bridge, bridge *Bridge, id id.RoomID, sender id.UserID,
msg matrix.Message, msg event.MessageEventContent,
) { ) {
parts := strings.Fields(strings.TrimPrefix(msg.Body, "!")) parts := strings.Fields(strings.TrimPrefix(msg.Body, "!"))
@ -573,84 +339,51 @@ func handleMatrixCommand(
case "mclist": case "mclist":
resp, err := bridge.MC.SendCommand("list") resp, err := bridge.MC.SendCommand("list")
if err != nil { if err != nil {
_, _ = bridge.Matrix.SendMessage(msg.RoomID, fmt.Sprintf("list failed: %v", err)) bridge.SendMessage(id, fmt.Sprintf("list failed: %v", err))
return return
} }
_, _ = bridge.Matrix.SendMessage( bridge.SendMessage(
msg.RoomID, id,
resp, resp,
) )
case "rcon": case "rcon":
if !bridge.IsTrustedUser(msg.Sender) { if !bridge.IsTrustedUser(sender) {
bridge.Matrix.SendMessage(msg.RoomID, "You are not a whitelisted user to run an RCON command >:E") bridge.SendMessage(id, "You are not a whitelisted user to run an RCON command >:E")
return return
} }
torun := strings.Join(parts[1:], " ") torun := strings.Join(parts[1:], " ")
if torun == "" { if torun == "" {
return 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) resp, err := bridge.MC.SendCommand(torun)
if err != nil { 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 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() { func (b *Bridge) SendMessage(roomID id.RoomID, text string) {
for ev := range b.Events { b.Matrix.SendText(b.ctx, b.RoomID, text)
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( func (b *Bridge) SendMessageWithpmp(roomID id.RoomID, displayname, text string) {
"%s", if b.Usepmp {
sanitize(ev.Message, 512), json := map[string]any{
) "msgtype": "m.text",
"body": text,
case AdvancementEvent: "com.beeper.per_message_profile": map[string]string{
b.SendMatrix( "displayname": displayname,
"%s has made the advancement [%s] :3", "id": displayname,
ev.Player, },
sanitize(ev.Message, 128),
)
case PauseEvent:
b.SendMatrix(
"Server paused after %s seconds of inactivity :3",
ev.Message,
)
} }
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 { if b.TrustedUsers == nil {
return false return false
} }

335
minecraft.go Normal file
View file

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