feat(go-services): discord-go — Go port of discord-rare-monitor

Consumes the Python tracker's /ws/live firehose (subscribed to rare+chat),
classifies rares common/great, posts embeds + relays allegiance chat.

- classify.go: the 74-name common-rares set, extracted verbatim from the Python
  COMMON_RARES_PATTERN (not hand-transcribed). go test runs at build time; a
  server-side dump-rares vs the Python regex confirms the sets are IDENTICAL.
- poster.go: a `poster` interface with a real discordgo impl (REST sends by
  channel id; gold/blue embeds, location/time fields, icon attachment) and a
  dry-run log impl.
- ws.go: coder/websocket client to /ws/live with subscribe, ping keepalive,
  exponential-backoff reconnect; rare/chat dispatch incl. vortex-warning + the
  MONITOR_CHARACTER filter.
- SAFE BY DEFAULT: dry-run unless a token AND DRY_RUN=0 are set, so it can never
  double-post to production. Deployed via the compose override
  (discord-rare-monitor-go), running dry-run against the same live firehose.

Validated on the server: connects, subscribes, relays a real chat in dry-run;
classifier parity 74/74 vs the Python regex.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-24 10:06:59 +02:00
parent 426fe025d3
commit 8d4c6ff68f
8 changed files with 593 additions and 0 deletions

View file

@ -0,0 +1,14 @@
# Multi-stage build for the Go discord-rare-monitor port. The unit test (rare
# classification) runs at build time, so a classifier regression fails the build.
FROM golang:1.25-bookworm AS build
WORKDIR /src
COPY . .
RUN go mod tidy
RUN go test ./...
ARG BUILD_VERSION=dev
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags "-s -w" -o /out/discord-go .
# distroless/static includes CA certificates (needed for Discord's HTTPS REST API).
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/discord-go /discord-go
ENTRYPOINT ["/discord-go"]

View file

@ -0,0 +1,93 @@
package main
// Rare classification — a faithful port of discord_rare_monitor.py's
// COMMON_RARES_PATTERN (an anchored exact-match regex of common-rare names).
// Because the regex is fully anchored with no wildcards, an exact-match set is
// equivalent. This list was extracted verbatim from the Python source, not
// hand-transcribed. classify_test.go asserts every entry maps to "common".
//
// classify returns "common" for an exact match, "great" otherwise — identical
// to classify_rare().
func classify(rareName string) string {
if commonRares[rareName] {
return "common"
}
return "great"
}
var commonRares = map[string]bool{
"Alchemist's Crystal": true,
"Scholar's Crystal": true,
"Smithy's Crystal": true,
"Hunter's Crystal": true,
"Observer's Crystal": true,
"Thorsten's Crystal": true,
"Elysa's Crystal": true,
"Chef's Crystal": true,
"Enchanter's Crystal": true,
"Oswald's Crystal": true,
"Deceiver's Crystal": true,
"Fletcher's Crystal": true,
"Physician's Crystal": true,
"Artificer's Crystal": true,
"Tinker's Crystal": true,
"Vaulter's Crystal": true,
"Monarch's Crystal": true,
"Life Giver's Crystal": true,
"Thief's Crystal": true,
"Adherent's Crystal": true,
"Resister's Crystal": true,
"Imbuer's Crystal": true,
"Converter's Crystal": true,
"Evader's Crystal": true,
"Dodger's Crystal": true,
"Zefir's Crystal": true,
"Ben Ten's Crystal": true,
"Corruptor's Crystal": true,
"Artist's Crystal": true,
"T'ing's Crystal": true,
"Warrior's Crystal": true,
"Brawler's Crystal": true,
"Hieromancer's Crystal": true,
"Rogue's Crystal": true,
"Berzerker's Crystal": true,
"Knight's Crystal": true,
"Lugian's Pearl": true,
"Ursuin's Pearl": true,
"Wayfarer's Pearl": true,
"Sprinter's Pearl": true,
"Magus's Pearl": true,
"Lich's Pearl": true,
"Warrior's Jewel": true,
"Melee's Jewel": true,
"Mage's Jewel": true,
"Duelist's Jewel": true,
"Archer's Jewel": true,
"Tusker's Jewel": true,
"Olthoi's Jewel": true,
"Inferno's Jewel": true,
"Gelid's Jewel": true,
"Astyrrian's Jewel": true,
"Executor's Jewel": true,
"Pearl of Blood Drinking": true,
"Pearl of Heart Seeking": true,
"Pearl of Defending": true,
"Pearl of Swift Killing": true,
"Pearl of Spirit Drinking": true,
"Pearl of Hermetic Linking": true,
"Pearl of Blade Baning": true,
"Pearl of Pierce Baning": true,
"Pearl of Bludgeon Baning": true,
"Pearl of Acid Baning": true,
"Pearl of Flame Baning": true,
"Pearl of Frost Baning": true,
"Pearl of Lightning Baning": true,
"Pearl of Impenetrability": true,
"Refreshing Elixir": true,
"Invigorating Elixir": true,
"Miraculous Elixir": true,
"Medicated Health Kit": true,
"Medicated Stamina Kit": true,
"Medicated Mana Kit": true,
"Casino Exquisite Keyring": true,
}

View file

@ -0,0 +1,38 @@
package main
import "testing"
// Every name in the common-rares set must classify as "common".
func TestClassifyCommon(t *testing.T) {
if len(commonRares) != 74 {
t.Fatalf("expected 74 common rares, got %d", len(commonRares))
}
for name := range commonRares {
if got := classify(name); got != "common" {
t.Errorf("classify(%q) = %q, want common", name, got)
}
}
}
// Names not in the set (including near-misses) must classify as "great".
func TestClassifyGreat(t *testing.T) {
greats := []string{
"Shimmering Skeleton Key",
"Star of Tukal",
"Hieroglyph of the Bludgeoner",
"Infinite Phial of Pyreal Flux",
"Foolproof Hooks",
"Staff of All Aphus",
"Count Renari's Equctioneer",
"Gelidite Long Sword",
"Pearl of Blade Baning ", // trailing space — not an exact match
"alchemist's crystal", // wrong case — not an exact match
"Alchemist's Crystals", // plural — not an exact match
"",
}
for _, name := range greats {
if got := classify(name); got != "great" {
t.Errorf("classify(%q) = %q, want great", name, got)
}
}
}

View file

@ -0,0 +1,3 @@
module git.snakedesert.se/SawatoMosswartsEnjoyersClub/MosswartOverlord/go-services/discord-go
go 1.25

View file

@ -0,0 +1,90 @@
// Command discord-go is a Go port of discord-rare-monitor: it consumes the
// tracker's /ws/live firehose (subscribed to rare+chat), classifies rares
// common/great, posts embeds to Discord, and relays allegiance chat.
//
// SAFETY: it runs in dry-run (log only, no Discord posts) by default. Going live
// requires BOTH a bot token AND DRY_RUN=0 — so it can never accidentally
// double-post to the production channels during the parallel run. For a parallel
// test, set a TEST token + TEST channel IDs + DRY_RUN=0.
package main
import (
"context"
"fmt"
"log/slog"
"os"
"os/signal"
"sort"
"syscall"
"github.com/bwmarrin/discordgo"
)
func main() {
// `discord-go dump-rares` prints the common-rares set (for parity diffing
// against the Python regex). No network, no token.
if len(os.Args) > 1 && os.Args[1] == "dump-rares" {
names := make([]string, 0, len(commonRares))
for n := range commonRares {
names = append(names, n)
}
sort.Strings(names)
for _, n := range names {
fmt.Println(n)
}
return
}
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
slog.SetDefault(logger)
token := os.Getenv("DISCORD_RARE_BOT_TOKEN")
// Dry-run unless a token is present AND DRY_RUN is explicitly "0".
dryRun := token == "" || os.Getenv("DRY_RUN") != "0"
wsURL := envOr("DERETH_TRACKER_WS_URL", "ws://dereth-tracker:8765/ws/live")
monitorChar := envOr("MONITOR_CHARACTER", "Dunking Rares")
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
var out poster
if dryRun {
reason := "no DISCORD_RARE_BOT_TOKEN"
if token != "" {
reason = "DRY_RUN != 0"
}
logger.Info("starting in DRY-RUN — classifying but NOT posting to Discord", "reason", reason, "ws", wsURL, "monitor", monitorChar)
out = &logPoster{log: logger}
} else {
dg, err := discordgo.New("Bot " + token)
if err != nil {
logger.Error("discord session init failed", "err", err)
os.Exit(1)
}
// REST-only: we send by channel ID, so no gateway Open()/intents needed.
logger.Info("starting LIVE — posting to Discord", "ws", wsURL, "monitor", monitorChar)
out = &discordPoster{
dg: dg,
common: envOr("COMMON_RARE_CHANNEL_ID", "1355328792184226014"),
great: envOr("GREAT_RARE_CHANNEL_ID", "1353676584334131211"),
aclog: envOr("ACLOG_CHANNEL_ID", "1349649482786275328"),
sawato: envOr("SAWATOLIFE_CHANNEL_ID", "1387323032271327423"),
iconsDir: envOr("ICONS_DIR", "icons"),
log: logger,
}
}
b := &bot{wsURL: wsURL, monitorChar: monitorChar, out: out, log: logger}
go b.run(ctx)
<-ctx.Done()
logger.Info("shutdown signal received")
}
func envOr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}

View file

@ -0,0 +1,166 @@
package main
import (
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"time"
"github.com/bwmarrin/discordgo"
)
// Discord embed colors, matching discord.py's Color.gold()/Color.blue().
const (
colorGold = 0xf1c40f
colorBlue = 0x3498db
colorRed = 0xe74c3c
)
type rareEvent struct {
Name string
CharacterName string
Timestamp string
EW, NS, Z *float64
}
// poster abstracts where messages go: a real Discord session, or a dry-run
// logger used for parallel validation without a bot token / live channels.
type poster interface {
postRare(ev rareEvent, tier string)
postChat(charName, text, ts string)
postVortex(speaker, text, ts string)
postStatus(text string)
}
// ---- dry-run (log-only) ----
type logPoster struct{ log *slog.Logger }
func (p *logPoster) postRare(ev rareEvent, tier string) {
p.log.Info("DRY-RUN would post rare", "tier", tier, "channel", tier, "name", ev.Name, "character", ev.CharacterName)
}
func (p *logPoster) postChat(charName, text, ts string) {
p.log.Info("DRY-RUN would relay chat", "character", charName, "text", text)
}
func (p *logPoster) postVortex(speaker, text, ts string) {
p.log.Warn("DRY-RUN would post vortex warning", "speaker", speaker, "text", text)
}
func (p *logPoster) postStatus(text string) {
p.log.Info("DRY-RUN would post status", "text", text)
}
// ---- real Discord ----
type discordPoster struct {
dg *discordgo.Session
common string
great string
aclog string
sawato string
iconsDir string
log *slog.Logger
}
func (p *discordPoster) postRare(ev rareEvent, tier string) {
embed := buildRareEmbed(ev, tier)
channel := p.common
if tier == "great" {
channel = p.great
}
if iconPath := p.iconPath(ev.Name); iconPath != "" {
if f, err := os.Open(iconPath); err == nil {
defer f.Close()
fn := filepath.Base(iconPath)
embed.Image = &discordgo.MessageEmbedImage{URL: "attachment://" + fn}
if _, err := p.dg.ChannelMessageSendComplex(channel, &discordgo.MessageSend{
Embed: embed,
Files: []*discordgo.File{{Name: fn, Reader: f}},
}); err != nil {
p.log.Error("send rare embed (with icon) failed", "err", err, "channel", channel)
}
return
}
}
if _, err := p.dg.ChannelMessageSendEmbed(channel, embed); err != nil {
p.log.Error("send rare embed failed", "err", err, "channel", channel)
}
}
func (p *discordPoster) postChat(charName, text, ts string) {
t := parseTime(ts)
cleaned := strings.TrimPrefix(text, "Dunking Rares: ")
msg := fmt.Sprintf("`%s` %s", t.Format("15:04:05"), cleaned)
if _, err := p.dg.ChannelMessageSend(p.sawato, msg); err != nil {
p.log.Error("send chat failed", "err", err)
}
}
func (p *discordPoster) postVortex(speaker, text, ts string) {
embed := &discordgo.MessageEmbed{
Title: "🌪️ VORTEX WARNING",
Description: fmt.Sprintf("**%s**: %s", speaker, text),
Color: colorRed,
Timestamp: parseTime(ts).Format(time.RFC3339),
}
if _, err := p.dg.ChannelMessageSendEmbed(p.aclog, embed); err != nil {
p.log.Error("send vortex failed", "err", err)
}
}
func (p *discordPoster) postStatus(text string) {
if _, err := p.dg.ChannelMessageSend(p.aclog, text); err != nil {
p.log.Error("send status failed", "err", err)
}
}
func (p *discordPoster) iconPath(rareName string) string {
if p.iconsDir == "" {
return ""
}
fn := strings.NewReplacer("'", "", " ", "_", "-", "_").Replace(rareName) + "_Icon.png"
path := filepath.Join(p.iconsDir, fn)
if _, err := os.Stat(path); err == nil {
return path
}
return ""
}
// buildRareEmbed mirrors post_rare_to_discord's embed.
func buildRareEmbed(ev rareEvent, tier string) *discordgo.MessageEmbed {
title, color := "🔸 Common Rare Discovery", colorBlue
if tier == "great" {
title, color = "💎 Great Rare Discovery!", colorGold
}
t := parseTime(ev.Timestamp)
embed := &discordgo.MessageEmbed{
Title: title,
Description: fmt.Sprintf("**%s** has discovered the **%s**!", ev.CharacterName, ev.Name),
Color: color,
Timestamp: t.Format(time.RFC3339),
}
if ev.EW != nil && ev.NS != nil {
loc := fmt.Sprintf("%.1fE, %.1fN", *ev.EW, *ev.NS)
if ev.Z != nil {
loc += fmt.Sprintf(", %.1fZ", *ev.Z)
}
embed.Fields = append(embed.Fields, &discordgo.MessageEmbedField{Name: "📍 Location", Value: loc, Inline: true})
}
embed.Fields = append(embed.Fields, &discordgo.MessageEmbedField{
Name: "⏰ Time", Value: t.UTC().Format("15:04:05") + " UTC", Inline: true,
})
return embed
}
// parseTime accepts the plugin's ISO8601 (with or without 'Z'); falls back to now.
func parseTime(ts string) time.Time {
if ts != "" {
for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02T15:04:05.999999", "2006-01-02T15:04:05"} {
if t, err := time.Parse(layout, strings.Replace(ts, "Z", "+00:00", 1)); err == nil {
return t
}
}
}
return time.Now().UTC()
}

View file

@ -0,0 +1,159 @@
package main
import (
"context"
"encoding/json"
"log/slog"
"strings"
"time"
"github.com/coder/websocket"
)
// bot consumes the tracker's /ws/live firehose (subscribed to rare+chat) and
// routes events to a poster. It reconnects with exponential backoff, mirroring
// monitor_websocket().
type bot struct {
wsURL string
monitorChar string
out poster
log *slog.Logger
}
func (b *bot) run(ctx context.Context) {
backoff := time.Second
const maxBackoff = 60 * time.Second
for ctx.Err() == nil {
err := b.connectAndConsume(ctx)
if ctx.Err() != nil {
return
}
if err != nil {
b.log.Warn("ws disconnected; reconnecting", "err", err, "backoff", backoff.String())
}
select {
case <-ctx.Done():
return
case <-time.After(backoff):
}
backoff *= 2
if backoff > maxBackoff {
backoff = maxBackoff
}
}
}
func (b *bot) connectAndConsume(ctx context.Context) error {
b.log.Info("connecting to /ws/live", "url", b.wsURL)
c, _, err := websocket.Dial(ctx, b.wsURL, nil)
if err != nil {
return err
}
defer c.CloseNow()
c.SetReadLimit(8 << 20) // payloads (nearby_objects etc.) can be large; we only read rare/chat but the socket carries all
// Subscribe to just rare + chat (server-side filtering), like the Python bot.
sub, _ := json.Marshal(map[string]any{"type": "subscribe", "message_types": []string{"rare", "chat"}})
if err := c.Write(ctx, websocket.MessageText, sub); err != nil {
return err
}
b.log.Info("subscribed", "message_types", []string{"rare", "chat"})
b.out.postStatus("🔗 (go) WebSocket connection established")
// Keepalive pings, like ping_interval=20.
pingCtx, cancel := context.WithCancel(ctx)
defer cancel()
go func() {
t := time.NewTicker(20 * time.Second)
defer t.Stop()
for {
select {
case <-pingCtx.Done():
return
case <-t.C:
pc, cc := context.WithTimeout(pingCtx, 10*time.Second)
_ = c.Ping(pc)
cc()
}
}
}()
for {
_, data, err := c.Read(ctx)
if err != nil {
return err
}
b.handleMessage(data)
}
}
func (b *bot) handleMessage(raw []byte) {
var data map[string]any
if err := json.Unmarshal(raw, &data); err != nil {
return // ignore invalid JSON, like the Python bot
}
switch asString(data["type"]) {
case "rare":
b.handleRare(data)
case "chat":
b.handleChat(data)
}
}
func (b *bot) handleRare(data map[string]any) {
ev := rareEvent{
Name: asStringOr(data["name"], "Unknown Rare"),
CharacterName: asStringOr(data["character_name"], "Unknown Character"),
Timestamp: asString(data["timestamp"]),
EW: asFloatPtr(data["ew"]),
NS: asFloatPtr(data["ns"]),
Z: asFloatPtr(data["z"]),
}
tier := classify(ev.Name)
b.log.Info("rare", "name", ev.Name, "character", ev.CharacterName, "tier", tier)
b.out.postRare(ev, tier)
}
func (b *bot) handleChat(data map[string]any) {
charName := asString(data["character_name"])
text := asString(data["text"])
if charName != b.monitorChar {
return
}
if strings.Contains(text, "m in whirlwind of vortexes") {
b.out.postVortex(parseAllegianceSpeaker(text), text, asString(data["timestamp"]))
return
}
b.out.postChat(charName, text, asString(data["timestamp"]))
}
// parseAllegianceSpeaker extracts <name> from "[Allegiance] <name> says, ...".
func parseAllegianceSpeaker(text string) string {
const prefix = "[Allegiance] "
if i := strings.Index(text, prefix); i >= 0 {
rest := text[i+len(prefix):]
if j := strings.Index(rest, " says,"); j >= 0 {
return rest[:j]
}
}
return "Unknown"
}
func asString(v any) string {
s, _ := v.(string)
return s
}
func asStringOr(v any, def string) string {
if s, ok := v.(string); ok && s != "" {
return s
}
return def
}
func asFloatPtr(v any) *float64 {
if f, ok := v.(float64); ok {
return &f
}
return nil
}