initial commit
This commit is contained in:
commit
0786056752
1 changed files with 753 additions and 0 deletions
664
main.go
Normal file
664
main.go
Normal 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
|
||||
}
|
||||
Loading…
Reference in a new issue