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 }