664 lines
13 KiB
Go
664 lines
13 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"os"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
"unicode"
|
|
|
|
matrix "git.anarchists.space/meer/pixitrix"
|
|
mcrcon "github.com/Kelwing/mc-rcon"
|
|
)
|
|
|
|
type EventType int
|
|
|
|
const (
|
|
ChatEvent EventType = iota
|
|
JoinEvent
|
|
LeaveEvent
|
|
DeathEvent
|
|
AdvancementEvent
|
|
PauseEvent
|
|
)
|
|
|
|
type MCEvent struct {
|
|
Type EventType
|
|
Player string
|
|
Message string
|
|
RawLine string
|
|
Time time.Time
|
|
}
|
|
type Minecraft struct {
|
|
mu sync.Mutex
|
|
RCON *mcrcon.MCConn
|
|
addr string
|
|
password string
|
|
}
|
|
type Bridge struct {
|
|
MC *Minecraft
|
|
Matrix *matrix.Client
|
|
TrustedUsers []string
|
|
RoomID string
|
|
Events <-chan *MCEvent
|
|
Players *PlayerSet
|
|
}
|
|
|
|
type Config struct {
|
|
LogFile string `json:"logfile"`
|
|
Minecraft MinecraftConfig `json:"minecraft"`
|
|
Matrix MatrixConfig `json:"matrix"`
|
|
}
|
|
|
|
type MinecraftConfig struct {
|
|
Address string `json:"address"`
|
|
Password string `json:"password"`
|
|
}
|
|
|
|
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"`
|
|
}
|
|
|
|
var (
|
|
chatRE = regexp.MustCompile(
|
|
`^\[[0-9:]+\] \[[^]]+/INFO\]: (?:\[Not Secure\] )?<([^>]+)> (.*)$`,
|
|
)
|
|
|
|
joinRE = regexp.MustCompile(
|
|
`^\[[0-9:]+\] \[[^]]+/INFO\]: ([A-Za-z0-9_]+) joined the game$`,
|
|
)
|
|
|
|
leaveRE = regexp.MustCompile(
|
|
`^\[[0-9:]+\] \[[^]]+/INFO\]: ([A-Za-z0-9_]+) left the game$`,
|
|
)
|
|
|
|
advancementRE = regexp.MustCompile(
|
|
`^\[[0-9:]+\] \[[^]]+/INFO\]: ([A-Za-z0-9_]+) has (?:made the advancement|reached the goal|completed the challenge) \[(.+)\]$`,
|
|
)
|
|
|
|
deathRE = regexp.MustCompile(
|
|
`^\[[0-9:]+\] \[[^]]+/INFO\]: (.+)$`,
|
|
)
|
|
pauseRE = regexp.MustCompile(
|
|
`^\[[0-9:]+\] \[[^]]+/INFO\]: Server empty for ([0-9]+) seconds, pausing$`,
|
|
)
|
|
)
|
|
|
|
func LoadConfig(path string, ChmodVerify bool) (*Config, error) {
|
|
if fi, err := os.Stat(path); err == nil && fi.Mode().Perm()&0o077 != 0 && ChmodVerify {
|
|
return nil, fmt.Errorf("%s is accessible by other users (mode %o); run: chmod 600 %s", path, fi.Mode().Perm(), path)
|
|
}
|
|
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var cfg Config
|
|
|
|
err = json.Unmarshal(b, &cfg)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if v := os.Getenv("MC_RCON_PASSWORD"); v != "" {
|
|
cfg.Minecraft.Password = v
|
|
}
|
|
if v := os.Getenv("MATRIX_ACCESS_TOKEN"); v != "" {
|
|
cfg.Matrix.AccessToken = v
|
|
}
|
|
|
|
return &cfg, nil
|
|
}
|
|
func main() {
|
|
configpath := flag.String("config", "config.json", "path for configuration file")
|
|
ChmodVerify := flag.Bool("chmodcheck", true, "disable or enable exiting on config file not being permission 600")
|
|
flag.Parse()
|
|
cfg, err := LoadConfig(*configpath, *ChmodVerify)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
mc, err := NewMinecraft(
|
|
cfg.Minecraft.Address,
|
|
cfg.Minecraft.Password,
|
|
)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
mx, err := matrix.New(
|
|
cfg.Matrix.Homeserver,
|
|
cfg.Matrix.AccessToken,
|
|
cfg.Matrix.Proxy,
|
|
)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
roomID, err := mx.JoinRoom(cfg.Matrix.Room)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
events := make(chan *MCEvent, 100)
|
|
|
|
bridge := &Bridge{
|
|
MC: mc,
|
|
Matrix: mx,
|
|
RoomID: roomID,
|
|
Events: events,
|
|
TrustedUsers: cfg.Matrix.TrustedUsers,
|
|
Players: NewPlayerSet(),
|
|
}
|
|
if resp, err := mc.SendCommand("list"); err == nil {
|
|
bridge.Players.Seed(resp)
|
|
}
|
|
|
|
go func() {
|
|
log.Fatalf("log watcher stopped: %v", WatchLog(cfg.LogFile, events))
|
|
}()
|
|
|
|
go bridge.RunMCEvents()
|
|
|
|
go RunMatrixBridge(
|
|
mx,
|
|
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") {
|
|
return "", fmt.Errorf("command contains control characters")
|
|
}
|
|
|
|
mc.mu.Lock()
|
|
defer mc.mu.Unlock()
|
|
|
|
resp, err := mc.RCON.SendCommand(command)
|
|
if err == nil {
|
|
return resp, nil
|
|
}
|
|
|
|
log.Printf("rcon error (%v), reconnecting", err)
|
|
|
|
conn, derr := dialRCON(mc.addr, mc.password)
|
|
if derr != nil {
|
|
return "", fmt.Errorf("rcon reconnect failed: %w", derr)
|
|
}
|
|
mc.RCON.Close()
|
|
mc.RCON = conn
|
|
|
|
return mc.RCON.SendCommand(command)
|
|
}
|
|
|
|
const (
|
|
initialReconnectDelay = 2 * time.Second
|
|
maxReconnectDelay = 2 * time.Minute
|
|
)
|
|
|
|
func RunMatrixBridge(
|
|
client *matrix.Client,
|
|
bridge *Bridge,
|
|
) {
|
|
me, err := client.WhoAmI()
|
|
if err != nil {
|
|
log.Printf("whoami failed: %v", err)
|
|
return
|
|
}
|
|
|
|
log.Printf("logged in as %s", me.UserID)
|
|
|
|
for msg := range client.Messages() {
|
|
|
|
if msg.Sender == me.UserID {
|
|
continue
|
|
}
|
|
|
|
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, "!") {
|
|
handleMatrixCommand(
|
|
bridge,
|
|
msg,
|
|
)
|
|
continue
|
|
}
|
|
|
|
name := msg.Sender
|
|
profile, err := bridge.Matrix.GetProfile(msg.Sender)
|
|
if err != nil {
|
|
log.Printf("profile lookup failed: %v", err)
|
|
} else if profile.DisplayName != "" {
|
|
name = profile.DisplayName
|
|
}
|
|
|
|
if err := bridge.MC.SendMessage(name, msg.Body); err != nil {
|
|
log.Printf("minecraft send failed: %v", err)
|
|
}
|
|
}
|
|
}
|
|
func (b *Bridge) SendMatrix(
|
|
format string,
|
|
args ...any,
|
|
) {
|
|
text := fmt.Sprintf(format, args...)
|
|
|
|
_, err := b.Matrix.SendMessage(
|
|
b.RoomID,
|
|
text,
|
|
)
|
|
|
|
if err != nil {
|
|
log.Printf(
|
|
"matrix send failed: %v",
|
|
err,
|
|
)
|
|
}
|
|
}
|
|
func dualMsg(
|
|
bridge *Bridge,
|
|
roomID string,
|
|
format string,
|
|
args ...any,
|
|
) {
|
|
msg := fmt.Sprintf(format, args...)
|
|
|
|
_, err := bridge.Matrix.SendMessage(
|
|
roomID,
|
|
msg,
|
|
)
|
|
if err != nil {
|
|
log.Printf("failed to send matrix notice: %v", err)
|
|
}
|
|
|
|
err = bridge.MC.SendMessage("global.server", msg)
|
|
if err != nil {
|
|
log.Printf("failed to send mc notice: %v", err)
|
|
}
|
|
}
|
|
func handleMatrixCommand(
|
|
bridge *Bridge,
|
|
msg matrix.Message,
|
|
) {
|
|
parts := strings.Fields(strings.TrimPrefix(msg.Body, "!"))
|
|
|
|
if len(parts) == 0 {
|
|
return
|
|
}
|
|
|
|
switch strings.ToLower(parts[0]) {
|
|
|
|
case "mclist":
|
|
resp, err := bridge.MC.SendCommand("list")
|
|
if err != nil {
|
|
_, _ = bridge.Matrix.SendMessage(msg.RoomID, fmt.Sprintf("list failed: %v", err))
|
|
return
|
|
}
|
|
|
|
_, _ = bridge.Matrix.SendMessage(
|
|
msg.RoomID,
|
|
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")
|
|
return
|
|
}
|
|
torun := strings.Join(parts[1:], " ")
|
|
if torun == "" {
|
|
return
|
|
}
|
|
log.Printf("[audit] rcon by %s: %q", msg.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()))
|
|
return
|
|
}
|
|
bridge.Matrix.SendMessage(msg.RoomID, 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) IsTrustedUser(mxid string) bool {
|
|
if b.TrustedUsers == nil {
|
|
return false
|
|
}
|
|
for _, t := range b.TrustedUsers {
|
|
if mxid == t {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|