MosswartOverlord/go-services/discord-go/main.go
Erik 8d4c6ff68f 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>
2026-06-24 10:06:59 +02:00

90 lines
2.7 KiB
Go

// 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
}