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>
159 lines
3.8 KiB
Go
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
|
|
}
|