From 5f092578ff1971cd23a1551ef68c2795d9495b76 Mon Sep 17 00:00:00 2001 From: Niko Marmeladkov Date: Thu, 16 Jul 2026 14:10:02 +0300 Subject: [PATCH] initial: discord bot for mc server status (slp + gs4 query) --- .gitignore | 1 + config.go | 43 +++++++ config.yaml | 5 + config.yaml.example | 5 + go.mod | 14 +++ go.sum | 16 +++ main.go | 48 ++++++++ monitor.go | 130 ++++++++++++++++++++ query.go | 281 ++++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 543 insertions(+) create mode 100644 .gitignore create mode 100644 config.go create mode 100644 config.yaml create mode 100644 config.yaml.example create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 monitor.go create mode 100644 query.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d063b90 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +mcstatus diff --git a/config.go b/config.go new file mode 100644 index 0000000..14c3105 --- /dev/null +++ b/config.go @@ -0,0 +1,43 @@ +package main + +import ( + "fmt" + "os" + + "gopkg.in/yaml.v3" +) + +type Config struct { + BotToken string `yaml:"bot_token"` + ChannelID string `yaml:"channel_id"` + ServerHost string `yaml:"server_host"` + ServerPort int `yaml:"server_port"` + UpdateInterval int `yaml:"update_interval"` +} + +func LoadConfig(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var cfg Config + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, err + } + if cfg.BotToken == "" { + return nil, fmt.Errorf("bot_token is required") + } + if cfg.ChannelID == "" { + return nil, fmt.Errorf("channel_id is required") + } + if cfg.ServerHost == "" { + return nil, fmt.Errorf("server_host is required") + } + if cfg.ServerPort == 0 { + cfg.ServerPort = 25565 + } + if cfg.UpdateInterval == 0 { + cfg.UpdateInterval = 15 + } + return &cfg, nil +} diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..6aaf015 --- /dev/null +++ b/config.yaml @@ -0,0 +1,5 @@ +bot_token: "" +channel_id: "" +server_host: "localhost" +server_port: 25565 +update_interval: 15 diff --git a/config.yaml.example b/config.yaml.example new file mode 100644 index 0000000..6aaf015 --- /dev/null +++ b/config.yaml.example @@ -0,0 +1,5 @@ +bot_token: "" +channel_id: "" +server_host: "localhost" +server_port: 25565 +update_interval: 15 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d3e3632 --- /dev/null +++ b/go.mod @@ -0,0 +1,14 @@ +module mcstatus + +go 1.26 + +require ( + github.com/bwmarrin/discordgo v0.29.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/gorilla/websocket v1.4.2 // indirect + golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b // indirect + golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..345dd27 --- /dev/null +++ b/go.sum @@ -0,0 +1,16 @@ +github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno= +github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= +github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b h1:7mWr3k41Qtv8XlltBkDkl8LoP3mpSgBW8BUoxtEdbXg= +golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..2883f44 --- /dev/null +++ b/main.go @@ -0,0 +1,48 @@ +package main + +import ( + "log" + "os" + "os/signal" + "syscall" + + "github.com/bwmarrin/discordgo" +) + +var ( + cfg *Config + session *discordgo.Session +) + +func main() { + var err error + cfg, err = LoadConfig("config.yaml") + if err != nil { + log.Fatalf("Error loading config: %v", err) + } + + session, err = discordgo.New("Bot " + cfg.BotToken) + if err != nil { + log.Fatalf("Error creating session: %v", err) + } + + session.AddHandler(ready) + session.Identify.Intents = discordgo.IntentsGuilds + + if err := session.Open(); err != nil { + log.Fatalf("Error connecting: %v", err) + } + defer session.Close() + + log.Println("Bot started") + sc := make(chan os.Signal, 1) + signal.Notify(sc, syscall.SIGINT, syscall.SIGTERM, os.Interrupt) + <-sc + log.Println("Shutting down...") +} + +func ready(s *discordgo.Session, r *discordgo.Ready) { + log.Printf("Logged in as %s", r.User.String()) + monitor := NewStatusMonitor(s, cfg) + monitor.Start() +} diff --git a/monitor.go b/monitor.go new file mode 100644 index 0000000..3e47930 --- /dev/null +++ b/monitor.go @@ -0,0 +1,130 @@ +package main + +import ( + "fmt" + "log" + "strings" + "sync" + "time" + + "github.com/bwmarrin/discordgo" +) + +type StatusMonitor struct { + mu sync.Mutex + session *discordgo.Session + channelID string + host string + port int + interval time.Duration + messageID string + onlineSince time.Time +} + +func NewStatusMonitor(s *discordgo.Session, cfg *Config) *StatusMonitor { + return &StatusMonitor{ + session: s, + channelID: cfg.ChannelID, + host: cfg.ServerHost, + port: cfg.ServerPort, + interval: time.Duration(cfg.UpdateInterval) * time.Second, + } +} + +func (m *StatusMonitor) Start() { + m.updateStatus() + + m.findExistingMessage() + + ticker := time.NewTicker(m.interval) + go func() { + for range ticker.C { + m.updateStatus() + } + }() +} + +func (m *StatusMonitor) findExistingMessage() { + msgs, err := m.session.ChannelMessages(m.channelID, 50, "", "", "") + if err != nil { + log.Printf("Error fetching messages: %v", err) + return + } + for _, msg := range msgs { + if msg.Author.ID == m.session.State.User.ID { + m.mu.Lock() + m.messageID = msg.ID + m.mu.Unlock() + return + } + } +} + +func (m *StatusMonitor) formatMessage(status *ServerStatus) string { + if !status.Online { + return "**Сервер оффлайн** 🔴" + } + + var b strings.Builder + + b.WriteString("**Игроки:**\n```\n") + if len(status.Players) == 0 { + b.WriteString("Никого нет") + } else { + for i, name := range status.Players { + if i > 0 { + b.WriteString("\n") + } + b.WriteString(name) + } + } + b.WriteString("```\n") + + fmt.Fprintf(&b, ":busts_in_silhouette: Кол-во игроков: %d\n", status.OnlineCount) + + uptime := time.Since(m.onlineSince) + hours := uptime.Hours() + if hours < 1 { + fmt.Fprintf(&b, ":repeat: Аптайм сервера: %.0f мин.", uptime.Minutes()) + } else { + fmt.Fprintf(&b, ":repeat: Аптайм сервера: %.1f ч.", hours) + } + + return b.String() +} + +func (m *StatusMonitor) updateStatus() { + status, err := queryServer(m.host, m.port) + + m.mu.Lock() + defer m.mu.Unlock() + + if err != nil { + log.Printf("Server query failed: %v", err) + m.onlineSince = time.Time{} + status = &ServerStatus{Online: false} + } else { + if m.onlineSince.IsZero() { + m.onlineSince = time.Now() + } + } + + content := m.formatMessage(status) + + if m.messageID != "" { + _, err := m.session.ChannelMessageEdit(m.channelID, m.messageID, content) + if err != nil { + log.Printf("Error editing message (will create new): %v", err) + m.messageID = "" + } + } + + if m.messageID == "" { + msg, err := m.session.ChannelMessageSend(m.channelID, content) + if err != nil { + log.Printf("Error sending message: %v", err) + return + } + m.messageID = msg.ID + } +} diff --git a/query.go b/query.go new file mode 100644 index 0000000..a5f567d --- /dev/null +++ b/query.go @@ -0,0 +1,281 @@ +package main + +import ( + "bufio" + "bytes" + "encoding/binary" + "encoding/json" + "fmt" + "io" + "math/rand" + "net" + "strconv" + "strings" + "time" +) + +type ServerStatus struct { + Online bool + Players []string + OnlineCount int + MaxPlayers int +} + +type slpVersion struct { + Name string `json:"name"` + Protocol int `json:"protocol"` +} + +type slpSample struct { + Name string `json:"name"` + ID string `json:"id"` +} + +type slpPlayers struct { + Max int `json:"max"` + Online int `json:"online"` + Sample []slpSample `json:"sample"` +} + +type slpResponse struct { + Version slpVersion `json:"version"` + Players slpPlayers `json:"players"` + Description any `json:"description"` +} + +func queryServer(host string, port int) (*ServerStatus, error) { + status, err := querySLP(host, port) + if err != nil { + return nil, err + } + + // If SLP returned no player names, try GS4 query + if len(status.Players) == 0 && status.OnlineCount > 0 { + players, gs4Err := queryGS4(host, port) + if gs4Err == nil && len(players) > 0 { + status.Players = players + } + } + + return status, nil +} + +func querySLP(host string, port int) (*ServerStatus, error) { + addr := fmt.Sprintf("%s:%d", host, port) + conn, err := net.DialTimeout("tcp", addr, 10*time.Second) + if err != nil { + return nil, fmt.Errorf("connect: %w", err) + } + defer conn.Close() + + if err := conn.SetDeadline(time.Now().Add(10 * time.Second)); err != nil { + return nil, fmt.Errorf("set deadline: %w", err) + } + + // Handshake + { + var payload strings.Builder + writeVarInt(&payload, 767) // protocol version + writeString(&payload, host) // server address + _ = binary.Write(&payload, binary.BigEndian, uint16(port)) // server port + writeVarInt(&payload, 1) // next state: status + + packet := buildPacket(0, payload.String()) + if _, err := conn.Write([]byte(packet)); err != nil { + return nil, fmt.Errorf("handshake write: %w", err) + } + } + + // Status request + { + packet := buildPacket(0, "") + if _, err := conn.Write([]byte(packet)); err != nil { + return nil, fmt.Errorf("request write: %w", err) + } + } + + // Read response + reader := bufio.NewReader(conn) + data, err := readPacket(reader) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + + r := strings.NewReader(string(data)) + packetID, err := readVarInt(r) + if err != nil { + return nil, fmt.Errorf("read packet id: %w", err) + } + if packetID != 0 { + return nil, fmt.Errorf("unexpected packet id: %d", packetID) + } + + jsonStr, err := readString(r) + if err != nil { + return nil, fmt.Errorf("read json: %w", err) + } + + var resp slpResponse + if err := json.Unmarshal([]byte(jsonStr), &resp); err != nil { + return nil, fmt.Errorf("parse json: %w", err) + } + + status := &ServerStatus{ + Online: true, + OnlineCount: resp.Players.Online, + MaxPlayers: resp.Players.Max, + } + + for _, s := range resp.Players.Sample { + status.Players = append(status.Players, s.Name) + } + + return status, nil +} + +func queryGS4(host string, port int) ([]string, error) { + addr, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", host, port)) + if err != nil { + return nil, err + } + + conn, err := net.DialUDP("udp", nil, addr) + if err != nil { + return nil, err + } + defer conn.Close() + + sessionID := int32(rand.Int31n(0x7FFFFFFF)) + + // Handshake: FE FD 09 + conn.SetDeadline(time.Now().Add(10 * time.Second)) + hs := []byte{0xFE, 0xFD, 0x09} + hs = binary.BigEndian.AppendUint32(hs, uint32(sessionID)) + conn.Write(hs) + + resp := make([]byte, 64) + n, err := conn.Read(resp) + if err != nil { + return nil, fmt.Errorf("gs4 handshake: %w", err) + } + data := resp[:n] + + tokenStr := strings.TrimRight(string(data[5:]), "\x00") + challengeToken, err := strconv.Atoi(tokenStr) + if err != nil { + return nil, fmt.Errorf("parse challenge: %w", err) + } + + // Full stat query: FE FD 00 00 00 00 00 + conn.SetDeadline(time.Now().Add(10 * time.Second)) + q := []byte{0xFE, 0xFD, 0x00} + q = binary.BigEndian.AppendUint32(q, uint32(sessionID)) + q = binary.BigEndian.AppendUint32(q, uint32(challengeToken)) + q = append(q, 0x00, 0x00, 0x00, 0x00) + conn.Write(q) + + resp = make([]byte, 65536) + n, err = conn.Read(resp) + if err != nil { + return nil, fmt.Errorf("gs4 query: %w", err) + } + data = resp[:n] + + // Skip: type(1) + sessionID(4) + padding(11) = 16 bytes + content := data[16:] + + sepIdx := bytes.Index(content, []byte{0x00, 0x00, 0x01}) + if sepIdx < 0 { + return nil, fmt.Errorf("gs4: player separator not found") + } + + playerData := content[sepIdx+3:] + playerData = bytes.TrimPrefix(playerData, []byte("player_\x00\x00")) + playerData = bytes.TrimRight(playerData, "\x00") + players := bytes.Split(playerData, []byte{0x00}) + + var result []string + for _, p := range players { + if len(p) > 0 { + result = append(result, string(p)) + } + } + + return result, nil +} + +func buildPacket(id int, payload string) string { + var inner strings.Builder + writeVarInt(&inner, id) + inner.WriteString(payload) + + var buf strings.Builder + writeVarInt(&buf, inner.Len()) + buf.WriteString(inner.String()) + return buf.String() +} + +func readPacket(r *bufio.Reader) ([]byte, error) { + length, err := readVarInt(r) + if err != nil { + return nil, fmt.Errorf("read length: %w", err) + } + if length < 0 || length > 65536 { + return nil, fmt.Errorf("invalid packet length: %d", length) + } + buf := make([]byte, length) + if _, err := io.ReadFull(r, buf); err != nil { + return nil, fmt.Errorf("read payload: %w", err) + } + return buf, nil +} + +func readVarInt(r interface{ ReadByte() (byte, error) }) (int, error) { + result := 0 + shift := 0 + for { + b, err := r.ReadByte() + if err != nil { + return 0, err + } + result |= int(b&0x7F) << shift + shift += 7 + if shift > 35 { + return 0, fmt.Errorf("varint too long") + } + if b&0x80 == 0 { + break + } + } + return result, nil +} + +func writeVarInt(w interface{ WriteByte(byte) error }, value int) { + for value > 0x7F { + w.WriteByte(byte(value&0x7F) | 0x80) + value >>= 7 + } + w.WriteByte(byte(value & 0x7F)) +} + +func readString(r interface{ ReadByte() (byte, error) }) (string, error) { + length, err := readVarInt(r) + if err != nil { + return "", err + } + if length < 0 || length > 65536 { + return "", fmt.Errorf("invalid string length: %d", length) + } + buf := make([]byte, length) + if _, err := io.ReadFull(r.(io.Reader), buf); err != nil { + return "", fmt.Errorf("read string: %w", err) + } + return string(buf), nil +} + +func writeString(w interface{ WriteByte(byte) error }, s string) { + writeVarInt(w, len(s)) + for i := 0; i < len(s); i++ { + w.WriteByte(s[i]) + } +}