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
)
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/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=

503
main.go
View file

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

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