initial: discord bot for mc server status (slp + gs4 query)
This commit is contained in:
commit
5f092578ff
9 changed files with 543 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
mcstatus
|
||||
43
config.go
Normal file
43
config.go
Normal 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
5
config.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
bot_token: ""
|
||||
channel_id: ""
|
||||
server_host: "localhost"
|
||||
server_port: 25565
|
||||
update_interval: 15
|
||||
5
config.yaml.example
Normal file
5
config.yaml.example
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
bot_token: ""
|
||||
channel_id: ""
|
||||
server_host: "localhost"
|
||||
server_port: 25565
|
||||
update_interval: 15
|
||||
14
go.mod
Normal file
14
go.mod
Normal 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
16
go.sum
Normal 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
48
main.go
Normal 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
130
monitor.go
Normal 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
281
query.go
Normal 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])
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue