Initial Release

This commit is contained in:
2026-05-24 00:59:02 -07:00
commit 4083e0ab45
8 changed files with 728 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
config.json
+21
View File
@@ -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.
+34
View File
@@ -0,0 +1,34 @@
# 🔎 `minecraft-discord-status`
See who's online using a fancy Discord Embed!
<img src="README.png">
---
## ⚙️ 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 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.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

+5
View File
@@ -0,0 +1,5 @@
module github.com/bakonpancakz/minecraft-discord-status
go 1.26
require golang.org/x/image v0.39.0
+4
View File
@@ -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=
Binary file not shown.

After

Width:  |  Height:  |  Size: 540 B

+663
View File
@@ -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
}