initial commit

This commit is contained in:
outlet360 2026-11-03 16:40:13 -05:00
commit 0786056752

664
main.go Normal file
View file

@ -0,0 +1,664 @@
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]",
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
}