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 }