MosswartOverlord/go-services/discord-go/ws.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

159 lines
3.8 KiB
Go

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
}