Initial Release
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
config.json
|
||||||
@@ -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.
|
||||||
@@ -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
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
@@ -0,0 +1,5 @@
|
|||||||
|
module github.com/bakonpancakz/minecraft-discord-status
|
||||||
|
|
||||||
|
go 1.26
|
||||||
|
|
||||||
|
require golang.org/x/image v0.39.0
|
||||||
@@ -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 |
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user