diff --git a/go-services/discord-go/Dockerfile b/go-services/discord-go/Dockerfile new file mode 100644 index 00000000..b33d96e4 --- /dev/null +++ b/go-services/discord-go/Dockerfile @@ -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"] diff --git a/go-services/discord-go/classify.go b/go-services/discord-go/classify.go new file mode 100644 index 00000000..1893b83d --- /dev/null +++ b/go-services/discord-go/classify.go @@ -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, +} diff --git a/go-services/discord-go/classify_test.go b/go-services/discord-go/classify_test.go new file mode 100644 index 00000000..1e4a3e36 --- /dev/null +++ b/go-services/discord-go/classify_test.go @@ -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) + } + } +} diff --git a/go-services/discord-go/go.mod b/go-services/discord-go/go.mod new file mode 100644 index 00000000..9b5552ab --- /dev/null +++ b/go-services/discord-go/go.mod @@ -0,0 +1,3 @@ +module git.snakedesert.se/SawatoMosswartsEnjoyersClub/MosswartOverlord/go-services/discord-go + +go 1.25 diff --git a/go-services/discord-go/main.go b/go-services/discord-go/main.go new file mode 100644 index 00000000..51cfa9ae --- /dev/null +++ b/go-services/discord-go/main.go @@ -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 +} diff --git a/go-services/discord-go/poster.go b/go-services/discord-go/poster.go new file mode 100644 index 00000000..7510f995 --- /dev/null +++ b/go-services/discord-go/poster.go @@ -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() +} diff --git a/go-services/discord-go/ws.go b/go-services/discord-go/ws.go new file mode 100644 index 00000000..ed60e58a --- /dev/null +++ b/go-services/discord-go/ws.go @@ -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 from "[Allegiance] 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 +} diff --git a/go-services/docker-compose.go.yml b/go-services/docker-compose.go.yml index c80d4938..42de969c 100644 --- a/go-services/docker-compose.go.yml +++ b/go-services/docker-compose.go.yml @@ -42,3 +42,33 @@ services: options: max-size: "10m" max-file: "3" + + # Go port of discord-rare-monitor. Consumes the SAME Python /ws/live firehose + # as the live Python bot. DRY-RUN by default (logs classifications, posts + # nothing) so it can't double-post. To parallel-test for real, set a TEST + # DISCORD_RARE_BOT_TOKEN + TEST channel IDs + DRY_RUN=0 here. + discord-rare-monitor-go: + build: + context: ./go-services/discord-go + args: + BUILD_VERSION: ${BUILD_VERSION:-dev} + container_name: discord-rare-monitor-go + environment: + DERETH_TRACKER_WS_URL: "ws://dereth-tracker:8765/ws/live" + MONITOR_CHARACTER: "Dunking Rares" + ICONS_DIR: "/icons" + LOG_LEVEL: "INFO" + # DISCORD_RARE_BOT_TOKEN: "" # set a TEST token to go live + # DRY_RUN: "0" # required (with a token) to actually post + # COMMON_RARE_CHANNEL_ID / GREAT_RARE_CHANNEL_ID / SAWATOLIFE_CHANNEL_ID / + # ACLOG_CHANNEL_ID: set TEST channels before going live + volumes: + - ./discord-rare-monitor/icons:/icons:ro + depends_on: + - dereth-tracker + restart: unless-stopped + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3"