initial: discord bot for mc server status (slp + gs4 query)

This commit is contained in:
Niko Marmeladkov 2026-07-16 14:10:02 +03:00
commit 5f092578ff
Signed by: Niko
GPG key ID: E3B955F9442D44E3
9 changed files with 543 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
mcstatus

43
config.go Normal file
View file

@ -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
}

5
config.yaml Normal file
View file

@ -0,0 +1,5 @@
bot_token: ""
channel_id: ""
server_host: "localhost"
server_port: 25565
update_interval: 15

5
config.yaml.example Normal file
View file

@ -0,0 +1,5 @@
bot_token: ""
channel_id: ""
server_host: "localhost"
server_port: 25565
update_interval: 15

14
go.mod Normal file
View file

@ -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
)

16
go.sum Normal file
View file

@ -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=

48
main.go Normal file
View file

@ -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()
}

130
monitor.go Normal file
View file

@ -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
}
}

281
query.go Normal file
View file

@ -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 <session_id>
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 <session_id> <challenge_token> 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])
}
}