package miner import ( "context" "encoding/json" "errors" "fmt" "net/http" "slices" "strings" "sync" "time" "github.com/gorilla/websocket" ) const ( defaultPubSubURL = "wss://pubsub-edge.twitch.tv/v1" pingInterval = 25 * time.Second stalePongDeadline = 55 * time.Second reconnectDelay = 5 * time.Second ) // Event is one parsed Twitch PubSub message relevant to the miner. type Event struct { Topic string MessageType string ChannelID string Timestamp string Balance int ClaimID string ReasonCode string } func (e Event) key() string { return fmt.Sprintf("%s|%s|%s|%s", e.MessageType, e.Topic, e.ChannelID, e.Timestamp) } // Client maintains Twitch PubSub subscriptions and dispatches decoded miner events. type Client struct { ctx context.Context cancel context.CancelFunc onEvent func(Event) url string dialer *websocket.Dialer mu sync.Mutex conn *websocket.Conn token string topics []string updateCh chan struct{} lastPong time.Time lastSeen string } // NewPubSubClient constructs a PubSub client with the default Twitch endpoint. func NewPubSubClient(ctx context.Context, onEvent func(Event)) *Client { runCtx, cancel := context.WithCancel(ctx) client := &Client{ ctx: runCtx, cancel: cancel, onEvent: onEvent, url: defaultPubSubURL, dialer: websocket.DefaultDialer, updateCh: make(chan struct{}, 1), lastPong: time.Now(), } go client.run() return client } // Sync updates the desired user and channel topic set. func (c *Client) Sync(_ context.Context, viewerID, accessToken string, channelIDs []string) error { topics := make([]string, 0, len(channelIDs)+1) if viewerID != "" { topics = append(topics, "community-points-user-v1."+viewerID) } for _, channelID := range channelIDs { if channelID == "" { continue } topics = append(topics, "video-playback-by-id."+channelID) } slices.Sort(topics) c.mu.Lock() c.token = accessToken changed := !slices.Equal(c.topics, topics) c.topics = topics conn := c.conn c.mu.Unlock() if changed && conn != nil { _ = conn.Close() } select { case c.updateCh <- struct{}{}: default: } return nil } // Close stops the PubSub run loop and closes any active socket. func (c *Client) Close() error { c.cancel() c.mu.Lock() conn := c.conn c.conn = nil c.mu.Unlock() if conn != nil { return conn.Close() } return nil } func (c *Client) run() { for { topics, token := c.snapshot() if len(topics) == 0 || token == "" { select { case <-c.ctx.Done(): return case <-c.updateCh: continue } } if err := c.runConnection(topics, token); err != nil && errors.Is(err, context.Canceled) { return } if err := sleepContext(c.ctx, reconnectDelay); err != nil { return } } } func (c *Client) runConnection(topics []string, token string) error { conn, _, err := c.dialer.DialContext(c.ctx, c.url, http.Header{ "User-Agent": []string{"parasocial"}, }) if err != nil { return err } defer conn.Close() c.mu.Lock() c.conn = conn c.lastPong = time.Now() c.lastSeen = "" c.mu.Unlock() defer func() { c.mu.Lock() if c.conn == conn { c.conn = nil } c.mu.Unlock() }() if err := c.listenTopics(conn, topics, token); err != nil { return err } pingErr := make(chan error, 1) go c.pingLoop(conn, pingErr) for { select { case <-c.ctx.Done(): return c.ctx.Err() case err := <-pingErr: return err default: } _, message, err := conn.ReadMessage() if err != nil { return err } frame, err := parseFrame(message) if err != nil { continue } switch frame.Type { case "PONG": c.mu.Lock() c.lastPong = time.Now() c.mu.Unlock() case "RECONNECT": return nil case "MESSAGE": if frame.Event.key() == "" { continue } c.mu.Lock() duplicate := c.lastSeen == frame.Event.key() if !duplicate { c.lastSeen = frame.Event.key() } c.mu.Unlock() if !duplicate && c.onEvent != nil { c.onEvent(frame.Event) } } } } func (c *Client) pingLoop(conn *websocket.Conn, errCh chan<- error) { ticker := time.NewTicker(pingInterval) defer ticker.Stop() for { select { case <-c.ctx.Done(): return case <-ticker.C: if err := conn.WriteJSON(map[string]any{"type": "PING"}); err != nil { select { case errCh <- err: default: } return } c.mu.Lock() stale := time.Since(c.lastPong) > stalePongDeadline c.mu.Unlock() if stale { select { case errCh <- errors.New("stale pong from twitch pubsub"): default: } return } } } } func (c *Client) listenTopics(conn *websocket.Conn, topics []string, token string) error { payload := map[string]any{ "type": "LISTEN", "nonce": fmt.Sprintf("%d", time.Now().UnixNano()), "data": map[string]any{ "topics": topics, }, } if token != "" { payload["data"].(map[string]any)["auth_token"] = token } return conn.WriteJSON(payload) } func (c *Client) snapshot() ([]string, string) { c.mu.Lock() defer c.mu.Unlock() topics := append([]string(nil), c.topics...) return topics, c.token } type frame struct { Type string Event Event } func parseFrame(message []byte) (*frame, error) { var envelope struct { Type string `json:"type"` Data *struct { Topic string `json:"topic"` Message string `json:"message"` } `json:"data"` } if err := json.Unmarshal(message, &envelope); err != nil { return nil, err } result := &frame{Type: envelope.Type} if envelope.Type != "MESSAGE" || envelope.Data == nil { return result, nil } var payload struct { Type string `json:"type"` Data struct { Timestamp string `json:"timestamp"` ChannelID string `json:"channel_id"` Claim *struct { ID string `json:"id"` ChannelID string `json:"channel_id"` } `json:"claim"` Balance *struct { Balance int `json:"balance"` ChannelID string `json:"channel_id"` } `json:"balance"` PointGain *struct { ReasonCode string `json:"reason_code"` } `json:"point_gain"` } `json:"data"` } if err := json.Unmarshal([]byte(envelope.Data.Message), &payload); err != nil { return nil, err } topic := envelope.Data.Topic topicName, topicChannelID := topic, "" if i := strings.LastIndexByte(topic, '.'); i >= 0 { topicName, topicChannelID = topic[:i], topic[i+1:] } channelID := payload.Data.ChannelID if payload.Data.Claim != nil && payload.Data.Claim.ChannelID != "" { channelID = payload.Data.Claim.ChannelID } if payload.Data.Balance != nil && payload.Data.Balance.ChannelID != "" { channelID = payload.Data.Balance.ChannelID } if channelID == "" { channelID = topicChannelID } result.Event = Event{ Topic: topicName, MessageType: payload.Type, ChannelID: channelID, Timestamp: payload.Data.Timestamp, } if payload.Data.Balance != nil { result.Event.Balance = payload.Data.Balance.Balance } if payload.Data.Claim != nil { result.Event.ClaimID = payload.Data.Claim.ID } if payload.Data.PointGain != nil { result.Event.ReasonCode = payload.Data.PointGain.ReasonCode } return result, nil }