commit 4083e0ab45d1e20ee8c4e0ad62575b5e9a41164c Author: bakonpancakz Date: Sun May 24 00:59:02 2026 -0700 Initial Release diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0cffcb3 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +config.json \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6a5decc --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2026 bakonpancakz + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..cb0551c --- /dev/null +++ b/README.md @@ -0,0 +1,34 @@ +# ๐Ÿ”Ž `minecraft-discord-status` +See who's online using a fancy Discord Embed! + + + +--- + +## โš™๏ธ Configuration +This application expects a `config.json` in the current working directory with the following fields: + +| Name | Description | +| :-------------- | :-------------------------------------------------------------------- | +| bot_token | Your Discord Bot token, used to send messages and upload emojis | +| bot_channel | The Discord Channel ID to post the server status to | +| server_address | The Address and Port to the Minecraft Server (e.g. `localhost:25565`) | +| update_interval | Update Interval in seconds, default 60 seconds (1 minute) | +| embed_title | The Embed Title, defaults to `Server Status for {minecraft_address}` | +| embed_color | The Embed Color in Hexadecimal Representation (e.g. #FF005B) | + +### Example Configuration +```json +{ + "bot_token": "", + "bot_channel": "954501752722444388", + "server_address": "localhost:25565", + "update_interval": 60, + "embed_color": 16711771, + "embed_title": "Status for __Carthage__" +} +``` + +## ๐Ÿš€ Startup + +Check the Releases tab for a precompiled executable for both Linux and Windows. \ No newline at end of file diff --git a/README.png b/README.png new file mode 100644 index 0000000..c63b975 Binary files /dev/null and b/README.png differ diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c0ff3f8 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module github.com/bakonpancakz/minecraft-discord-status + +go 1.26 + +require golang.org/x/image v0.39.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d215763 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w= +golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g= +golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww= +golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA= diff --git a/headshot_default.png b/headshot_default.png new file mode 100644 index 0000000..fd781ae Binary files /dev/null and b/headshot_default.png differ diff --git a/main.go b/main.go new file mode 100644 index 0000000..644919f --- /dev/null +++ b/main.go @@ -0,0 +1,663 @@ +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 +}