commit 5f092578ff1971cd23a1551ef68c2795d9495b76 Author: Niko Marmeladkov Date: Thu Jul 16 14:10:02 2026 +0300 initial: discord bot for mc server status (slp + gs4 query) 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]) + } +}