Files
discord-minecraft-status/main.go
T

664 lines
17 KiB
Go
Raw Normal View History

2026-05-24 00:59:02 -07:00
package main
import (
"bytes"
"context"
_ "embed"
"encoding/base64"
"encoding/binary"
"encoding/json"
"fmt"
"image"
"image/png"
"io"
"log"
"net"
"net/http"
"os"
"sort"
"strconv"
"strings"
"time"
"golang.org/x/image/draw"
)
var (
//go:embed headshot_default.png
DEFAULT_HEADSHOT []byte //
OPTIONS_FILENAME = "config.json" //
OPTIONS_FILEMODE = os.FileMode(0600) // rw-------
OPTIONS GlobalOptions // Global Options
PROFILE DiscordUser // Global Profiles
EMOTES []DiscordEmote // Global Emotes
EMOTE_HEADSHOT_NAME = "util_head"
EMOTE_HEADSHOT_ID string
EMOTE_ICON_NAME = "util_icon"
EMOTE_ICON_ID string
)
// ----------------------------------------------------------------------------
// HELPER FUNCTIONS
// ----------------------------------------------------------------------------
func writeVarInt(b *bytes.Buffer, value int) {
for {
part := byte(value & 0x7F)
value >>= 7
if value != 0 {
part |= 0x80
}
b.WriteByte(part)
if value == 0 {
return
}
}
}
func readVarInt(r io.Reader) (int, error) {
var result int
for shift := 0; shift < 35; shift += 7 {
var single [1]byte
if _, err := io.ReadFull(r, single[:]); err != nil {
return 0, err
}
result |= int(single[0]&0x7F) << shift
if single[0]&0x80 == 0 {
return result, nil
}
}
return 0, fmt.Errorf("varint too big")
}
// ----------------------------------------------------------------------------
// GLOBAL FUNCTIONS
// ----------------------------------------------------------------------------
type GlobalOptions struct {
BotToken string `json:"bot_token"`
BotChannelID string `json:"bot_channel"`
BotMessageID string `json:"bot_message"`
ServerAddress string `json:"server_address"`
UpdateInterval int `json:"update_interval"`
EmbedTitle string `json:"embed_title"`
EmbedColor string `json:"embed_color"`
EmbedDecimal int `json:"-"`
}
func (o *GlobalOptions) Load() error {
// Parse configuration file
b, err := os.ReadFile(OPTIONS_FILENAME)
if err != nil {
return err
}
if err := json.Unmarshal(b, &o); err != nil {
return err
}
// Calculate embed color
hex := strings.TrimPrefix(o.EmbedColor, "#")
dec, err := strconv.ParseInt(hex, 16, 32)
o.EmbedDecimal = int(dec)
// Default values
if o.UpdateInterval == 0 {
o.UpdateInterval = 60
}
return nil
}
func (o *GlobalOptions) Save() error {
b, err := json.MarshalIndent(o, "", " ")
if err != nil {
return err
}
if err := os.WriteFile(OPTIONS_FILENAME, b, OPTIONS_FILEMODE); err != nil {
return err
}
return nil
}
func (o *GlobalOptions) Address() (string, error) {
resultHost, resultPort := "127.0.0.1", 25565
rawHost, rawPort, hasPort := strings.Cut(o.ServerAddress, ":")
// Use port if explicity stated
if hasPort {
port, err := strconv.Atoi(rawPort)
if err == nil {
resultHost = rawHost
resultPort = port
return fmt.Sprintf("%s:%d", resultHost, resultPort), nil
}
}
// Determine port using SRV records (if any)
resultHost = rawHost
if net.ParseIP(rawHost) == nil {
_, addrs, err := net.LookupSRV("minecraft", "tcp", rawHost)
if err == nil && len(addrs) > 0 {
resultHost = strings.TrimSuffix(addrs[0].Target, ".")
resultPort = int(addrs[0].Port)
}
}
return fmt.Sprintf("%s:%d", resultHost, resultPort), nil
}
// ----------------------------------------------------------------------------
// MINECRAFT FUNCTIONS
// ----------------------------------------------------------------------------
type MinecraftStatusRoot struct {
Favicon string `json:"favicon"`
Description MinecraftStatusDescription `json:"description"`
Players MinecraftStatusPlayers `json:"players"`
}
type MinecraftStatusPlayers struct {
Max int `json:"max"`
Online int `json:"online"`
Sample []MinecraftStatusPlayerInfo `json:"sample"`
}
type MinecraftStatusPlayerInfo struct {
ID string `json:"id"`
Name string `json:"name"`
}
type MinecraftStatusDescription struct {
Text string
}
func (d *MinecraftStatusDescription) UnmarshalJSON(data []byte) error {
// Attempt to parse Chat Components (modern) otherwise use plaintext (legacy)
var builder strings.Builder
var plain string
if err := json.Unmarshal(data, &plain); err != nil {
var component ChatComponent
if err := json.Unmarshal(data, &component); err != nil {
return err
}
plain = component.String()
}
// Cleanup Special Characters
builder.Grow(len(plain))
skipNext := false
for _, r := range plain {
if skipNext {
skipNext = false
continue
}
if r == '§' || r == '' {
skipNext = true
continue
}
builder.WriteRune(r)
}
// Cleanup Text Padding
lines := strings.Split(builder.String(), "\n")
maxes := make([]string, 0, len(lines))
for _, l := range lines {
maxes = append(maxes, strings.TrimSpace(l))
}
d.Text = strings.Join(maxes, "\n")
return nil
}
type ChatComponent struct {
Text string `json:"text"`
Extra []ChatComponent `json:"extra"`
Translate string `json:"translate"`
With []ChatComponent `json:"with"`
}
func (c ChatComponent) String() string {
var b strings.Builder
b.WriteString(c.Text)
if c.Text == "" && c.Translate != "" {
b.WriteString(c.Translate)
}
for _, child := range c.With {
b.WriteString(child.String())
}
for _, child := range c.Extra {
b.WriteString(child.String())
}
return b.String()
}
func MinecraftActivity() (MinecraftStatusRoot, error) {
var status MinecraftStatusRoot
// Prepare for TCP Connection
serverAddress, err := OPTIONS.Address()
if err != nil {
return status, err
}
s := strings.SplitN(serverAddress, ":", 2)
v, _ := strconv.Atoi(s[1])
serverHost := s[0]
serverPort := uint16(v)
// Create TCP Socket
var b, p bytes.Buffer
socket, err := net.DialTimeout("tcp", serverAddress, 30*time.Minute)
if err != nil {
return status, err
}
defer socket.Close()
// Send Handshake
// https://minecraft.wiki/w/Java_Edition_protocol/Packets#Handshake
writeVarInt(&b, 0x00) // Packet ID
writeVarInt(&b, 770) // Protocol Version
writeVarInt(&b, len(serverHost)) // Server Address Length
b.WriteString(serverHost) // Server Address
binary.Write(&b, binary.BigEndian, serverPort) // Server Port
writeVarInt(&b, 1) // Intent (Status)
writeVarInt(&p, b.Len())
p.Write(b.Bytes())
if _, err := socket.Write(p.Bytes()); err != nil {
return status, err
}
p.Reset()
b.Reset()
// Send Status Request
// https://minecraft.wiki/w/Java_Edition_protocol/Packets#Status_Request
writeVarInt(&b, 0x00) // Status Request
writeVarInt(&p, b.Len())
p.Write(b.Bytes())
if _, err := socket.Write(p.Bytes()); err != nil {
return status, err
}
// Parse Status
// https://minecraft.wiki/w/Java_Edition_protocol/Server_List_Ping#Status_Response
if _, err := readVarInt(socket); err != nil {
return status, err
}
packetID, err := readVarInt(socket)
if err != nil {
return status, err
}
if packetID != 0x00 {
return status, fmt.Errorf("unexpected status packet id (%d)", packetID)
}
payloadLength, err := readVarInt(socket)
if err != nil {
return status, err
}
payload := make([]byte, payloadLength)
if _, err := io.ReadFull(socket, payload); err != nil {
return status, err
}
if err := json.Unmarshal(payload, &status); err != nil {
return status, err
}
sort.Slice(status.Players.Sample, func(i, j int) bool {
return len(status.Players.Sample[i].Name) > len(status.Players.Sample[j].Name)
})
return status, nil
}
func MinecraftHeadshot(uuid string) ([]byte, error) {
var skinURL string
// Fetch Profile
uri := "https://sessionserver.mojang.com/session/minecraft/profile/" + uuid
res, err := http.Get(uri)
if err != nil {
return nil, err
}
defer res.Body.Close()
raw, _ := io.ReadAll(res.Body)
if res.StatusCode != 200 {
return nil, fmt.Errorf("request failed with status code %d: %s", res.StatusCode, string(raw))
}
// Decode Profile
var Profile struct {
ID string `json:"id"`
Name string `json:"name"`
Properties []struct {
Name string `json:"textures"`
Value string `json:"value"`
} `json:"properties"`
}
if err := json.Unmarshal(raw, &Profile); err != nil {
return nil, err
}
if len(Profile.Properties) < 1 {
return nil, fmt.Errorf("missing required property")
}
// Decode Profile Properties
rawProps, err := base64.URLEncoding.DecodeString(Profile.Properties[0].Value)
if err != nil {
return nil, fmt.Errorf("invalid or malformed profile properties")
}
var Properties struct {
Timestamp int `json:"timestamp"`
ProfileID string `json:"profileId"`
ProfileName string `json:"profileName"`
Textures struct {
Skin struct {
URL string `json:"url"`
} `json:"SKIN"`
} `json:"textures"`
}
if err := json.Unmarshal(rawProps, &Properties); err != nil {
return nil, err
}
skinURL = Properties.Textures.Skin.URL
if skinURL == "" {
return nil, fmt.Errorf("this player has no skin")
}
// Download Minecraft Skin
res, err = http.Get(skinURL)
if err != nil {
return nil, err
}
defer res.Body.Close()
raw, _ = io.ReadAll(res.Body)
if res.StatusCode != 200 {
return nil, fmt.Errorf("request failed with status code %d: %s", res.StatusCode, string(raw))
}
// Generate Headshot
skin, err := png.Decode(bytes.NewReader(raw))
if err != nil {
return nil, err
}
crop := image.NewRGBA(image.Rect(0, 0, 8, 8))
draw.Draw(crop, skin.Bounds(), skin, image.Pt(8, 8), draw.Over) // First Layer
draw.Draw(crop, skin.Bounds(), skin, image.Pt(40, 8), draw.Over) // Second Layer
resize := image.NewRGBA(image.Rect(0, 0, 64, 64))
draw.NearestNeighbor.Scale(resize, resize.Rect, crop, crop.Rect, draw.Over, nil)
var b bytes.Buffer
if err := png.Encode(&b, resize); err != nil {
return nil, err
}
return b.Bytes(), nil
}
// ----------------------------------------------------------------------------
// DISCORD FUNCTIONS
// ----------------------------------------------------------------------------
type DiscordEmote struct {
ID string
Name string
}
type DiscordUser struct {
ID string
Username string
}
func DiscordRequest(ctx context.Context, method string, pathname string, body any, v any) error {
var req *http.Request
var err error
// Generate Request
uri := "https://discord.com/api/v10" + pathname
if body != nil {
// Use Body as JSON Payload
payload, err := json.Marshal(body)
if err != nil {
return err
}
req, err = http.NewRequestWithContext(ctx, method, uri, bytes.NewReader(payload))
} else {
// Generic Request
req, err = http.NewRequestWithContext(ctx, method, uri, http.NoBody)
}
if err != nil {
return err
}
req.Header.Add("Content-Type", "application/json")
req.Header.Add("Authorization", "Bot "+OPTIONS.BotToken)
// Check Response
res, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
b, _ := io.ReadAll(res.Body)
if res.StatusCode < 200 || res.StatusCode > 299 {
return fmt.Errorf("server responded with status code %d: %s", res.StatusCode, string(b))
}
// Decode Response (if applicable)
if v != nil {
if err := json.Unmarshal(b, &v); err != nil {
return err
}
}
return nil
}
func DiscordCreateEmote(ctx context.Context, name string, data []byte) (DiscordEmote, error) {
var e DiscordEmote
var d = map[string]any{
// Just use a 'multipart/form-data' discord pls...
"image": ("data:image/png;base64," + base64.StdEncoding.EncodeToString(data)),
"name": name,
}
err := DiscordRequest(ctx, "POST", "/applications/"+PROFILE.ID+"/emojis", d, &e)
return e, err
}
// ----------------------------------------------------------------------------
// MAIN LOOP
// ----------------------------------------------------------------------------
func main() {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
// Load Configuration
if err := OPTIONS.Load(); err != nil {
log.Fatalf("Unable to load options: %s\n", err)
}
// Load Discord Profile
var RemoteEmotes struct {
Items []DiscordEmote `json:"items"`
}
if err := DiscordRequest(ctx, "GET", "/users/@me", nil, &PROFILE); err != nil {
log.Fatalf("Cannot fetch profile: %s\n", err)
return
}
if err := DiscordRequest(ctx, "GET", "/applications/"+PROFILE.ID+"/emojis", nil, &RemoteEmotes); err != nil {
log.Fatalf("Cannot fetch emotes: %s\n", err)
return
}
EMOTES = RemoteEmotes.Items
// Find Fallback Emote
for _, emote := range RemoteEmotes.Items {
if emote.Name == EMOTE_HEADSHOT_NAME {
EMOTE_HEADSHOT_ID = emote.ID
}
if emote.Name == EMOTE_ICON_NAME {
EMOTE_ICON_ID = emote.ID
DiscordRequest(ctx, "DELETE", "/applications/"+PROFILE.ID+"/emojis/"+emote.ID, nil, nil)
}
}
if EMOTE_HEADSHOT_ID == "" {
// Create Fallback Emote
newEmoji, err := DiscordCreateEmote(ctx, EMOTE_HEADSHOT_NAME, DEFAULT_HEADSHOT)
if err != nil {
log.Fatalf("Failed to upload default emote: %s\n", err)
return
}
EMOTE_HEADSHOT_ID = newEmoji.ID
EMOTES = append(EMOTES, newEmoji)
}
cancel()
// Begin Ping Loop
log.Printf("Logged in as '%s' with %d emojis\n", PROFILE.Username, len(EMOTES))
for {
if err := loop(); err != nil {
log.Printf("Failed to ping server: %s\n", err)
}
if err := OPTIONS.Save(); err != nil {
log.Fatalf("Failed to save options: %s\n", err)
}
time.Sleep(time.Second * time.Duration(OPTIONS.UpdateInterval))
}
}
func loop() error {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
// Fetch Server Status
payload := map[string]any{}
status, err := MinecraftActivity()
if err != nil {
// Build Error
log.Println("Failed to fetch server status:", err)
payload["content"] = "Server Unavailable"
payload["embeds"] = []any{}
} else {
// Upload Favicon
if status.Favicon != "" && EMOTE_ICON_ID == "" {
fav := strings.TrimPrefix(status.Favicon, "data:image/png;base64,")
raw, err := base64.StdEncoding.DecodeString(fav)
if err != nil {
log.Printf("Failed to decode server favicon: %s\n", err)
goto favicon_finish
}
emote, err := DiscordCreateEmote(ctx, EMOTE_ICON_NAME, raw)
if err != nil {
log.Printf("Failed to upload favicon emote: %s\n", err)
goto favicon_finish
}
EMOTE_ICON_ID = emote.ID
}
favicon_finish:
// Build Embed
embed := map[string]any{
"description": fmt.Sprintf(
"```%s```\n**Players Online:** `%d/%d`\n\n",
status.Description.Text,
status.Players.Online,
status.Players.Max,
),
}
if OPTIONS.EmbedTitle != "" {
embed["title"] = OPTIONS.EmbedTitle
} else {
embed["title"] = fmt.Sprintf("Status for __%s__", OPTIONS.ServerAddress)
}
if OPTIONS.EmbedDecimal != 0 {
embed["color"] = OPTIONS.EmbedDecimal
}
if EMOTE_ICON_ID != "" {
embed["thumbnail"] = map[string]any{
"url": fmt.Sprintf("https://cdn.discordapp.com/emojis/%s.png", EMOTE_ICON_ID),
}
}
// Build Player List
playerList := map[string]string{}
for _, p := range status.Players.Sample {
// Find existing headshot emoji
var emojiID string
emojiName := "player_" + p.Name
for _, e := range EMOTES {
if e.Name == emojiName {
emojiID = e.ID
break
}
}
// Generate headshot emoji if missing
if emojiID == "" {
headshot, err := MinecraftHeadshot(p.ID)
if err != nil {
log.Printf("Failed to generate headshot for '%s' (%s): %s\n", p.Name, p.ID, err)
playerList[p.Name] = fmt.Sprintf("<:%s:%s>", EMOTE_HEADSHOT_NAME, EMOTE_HEADSHOT_ID)
continue
}
newEmoji, err := DiscordCreateEmote(ctx, emojiName, headshot)
if err != nil {
log.Printf("Failed to upload emoji for '%s': %s\n", p.Name, err)
playerList[p.Name] = fmt.Sprintf("<:%s:%s>", EMOTE_HEADSHOT_NAME, EMOTE_HEADSHOT_ID)
continue
}
emojiID = newEmoji.ID
EMOTES = append(EMOTES, newEmoji)
log.Printf("Generated emoji for player '%s' (%s): %s\n", p.Name, p.ID, emojiID)
}
playerList[p.Name] = fmt.Sprintf("<:%s:%s>", emojiName, emojiID)
}
// Append players to embed description
for name, emoji := range playerList {
next := fmt.Sprintf("%s%s **%s**\n", embed["description"], emoji, name)
if len(next) > 4096 {
break
}
embed["description"] = next
}
payload["content"] = ""
payload["embeds"] = []any{embed}
}
// Post or update status message
if OPTIONS.BotMessageID == "" {
var msg struct {
ID string `json:"id"`
}
if err := DiscordRequest(ctx, "POST", "/channels/"+OPTIONS.BotChannelID+"/messages", payload, &msg); err != nil {
log.Println("Failed to send message:", err)
return err
}
OPTIONS.BotMessageID = msg.ID
} else {
if err := DiscordRequest(ctx, "PATCH", "/channels/"+OPTIONS.BotChannelID+"/messages/"+OPTIONS.BotMessageID, payload, nil); err != nil {
log.Println("Failed to update message:", err)
OPTIONS.BotMessageID = ""
return err
}
}
return nil
}