mirror of
https://git.sr.ht/~ashkeel/strimertul
synced 2024-09-30 02:40:33 +00:00
282 lines
9.6 KiB
Go
282 lines
9.6 KiB
Go
package twitch
|
|
|
|
import (
|
|
"fmt"
|
|
"time"
|
|
|
|
"git.sr.ht/~ashkeel/strimertul/utils"
|
|
|
|
"github.com/gorilla/websocket"
|
|
jsoniter "github.com/json-iterator/go"
|
|
"github.com/nicklaw5/helix/v2"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
const websocketEndpoint = "wss://eventsub.wss.twitch.tv/ws"
|
|
|
|
func (c *Client) eventSubLoop(userClient *helix.Client) {
|
|
endpoint := websocketEndpoint
|
|
var err error
|
|
var connection *websocket.Conn
|
|
for endpoint != "" {
|
|
endpoint, connection, err = c.connectWebsocket(endpoint, connection, userClient)
|
|
if err != nil {
|
|
c.logger.Error("EventSub websocket read error", zap.Error(err))
|
|
}
|
|
}
|
|
if connection != nil {
|
|
utils.Close(connection, c.logger)
|
|
}
|
|
}
|
|
|
|
func readLoop(connection *websocket.Conn, recv chan<- []byte, wsErr chan<- error) {
|
|
for {
|
|
messageType, messageData, err := connection.ReadMessage()
|
|
if err != nil {
|
|
wsErr <- err
|
|
close(recv)
|
|
close(wsErr)
|
|
return
|
|
}
|
|
if messageType != websocket.TextMessage {
|
|
continue
|
|
}
|
|
|
|
recv <- messageData
|
|
}
|
|
}
|
|
|
|
func (c *Client) connectWebsocket(url string, oldConnection *websocket.Conn, userClient *helix.Client) (string, *websocket.Conn, error) {
|
|
connection, _, err := websocket.DefaultDialer.Dial(url, nil)
|
|
if err != nil {
|
|
c.logger.Error("Could not establish a connection to the EventSub websocket", zap.Error(err))
|
|
return "", nil, err
|
|
}
|
|
|
|
received := make(chan []byte, 10)
|
|
wsErr := make(chan error, 1)
|
|
|
|
go readLoop(connection, received, wsErr)
|
|
|
|
for {
|
|
// Wait for next message or closing/error
|
|
var messageData []byte
|
|
select {
|
|
case <-c.ctx.Done():
|
|
return "", nil, nil
|
|
case err = <-wsErr:
|
|
return url, nil, err // Return the endpoint so we can reconnect
|
|
case messageData = <-received:
|
|
}
|
|
|
|
var wsMessage EventSubWebsocketMessage
|
|
err = json.Unmarshal(messageData, &wsMessage)
|
|
if err != nil {
|
|
c.logger.Error("Error decoding EventSub message", zap.Error(err))
|
|
continue
|
|
}
|
|
|
|
reconnectURL, done, err := c.processMessage(wsMessage, oldConnection, userClient)
|
|
if done {
|
|
return reconnectURL, connection, err
|
|
}
|
|
}
|
|
}
|
|
|
|
func (c *Client) processMessage(wsMessage EventSubWebsocketMessage, oldConnection *websocket.Conn, userClient *helix.Client) (string, bool, error) {
|
|
switch wsMessage.Metadata.MessageType {
|
|
case "session_keepalive":
|
|
// Nothing to do
|
|
case "session_welcome":
|
|
var welcomeData WelcomeMessagePayload
|
|
err := json.Unmarshal(wsMessage.Payload, &welcomeData)
|
|
if err != nil {
|
|
c.logger.Error("Error decoding EventSub welcome message", zap.String("message-type", wsMessage.Metadata.MessageType), zap.Error(err))
|
|
break
|
|
}
|
|
c.logger.Info("Connection to EventSub websocket established", zap.String("session-id", welcomeData.Session.ID))
|
|
|
|
// We can only close the old connection once the new one has been established
|
|
if oldConnection != nil {
|
|
utils.Close(oldConnection, c.logger)
|
|
}
|
|
|
|
// Add subscription to websocket session
|
|
err = c.addSubscriptionsForSession(userClient, welcomeData.Session.ID)
|
|
if err != nil {
|
|
c.logger.Error("Could not add subscriptions", zap.Error(err))
|
|
break
|
|
}
|
|
case "session_reconnect":
|
|
var reconnectData WelcomeMessagePayload
|
|
err := json.Unmarshal(wsMessage.Payload, &reconnectData)
|
|
if err != nil {
|
|
c.logger.Error("Error decoding EventSub session reconnect parameters", zap.String("message-type", wsMessage.Metadata.MessageType), zap.Error(err))
|
|
break
|
|
}
|
|
c.logger.Info("EventSub websocket requested a reconnection", zap.String("session-id", reconnectData.Session.ID), zap.String("reconnect-url", reconnectData.Session.ReconnectURL))
|
|
|
|
return reconnectData.Session.ReconnectURL, true, nil
|
|
case "notification":
|
|
go c.processEvent(wsMessage)
|
|
case "revocation":
|
|
// TODO idk what to do here
|
|
}
|
|
return "", false, nil
|
|
}
|
|
|
|
func (c *Client) processEvent(message EventSubWebsocketMessage) {
|
|
// Check if we processed this already
|
|
if message.Metadata.MessageID != "" {
|
|
if c.eventCache.Contains(message.Metadata.MessageID) {
|
|
c.logger.Debug("Received duplicate event, ignoring", zap.String("message-id", message.Metadata.MessageID))
|
|
return
|
|
}
|
|
}
|
|
defer c.eventCache.Add(message.Metadata.MessageID, message.Metadata.MessageTimestamp)
|
|
|
|
// Decode data
|
|
var notificationData NotificationMessagePayload
|
|
err := json.Unmarshal(message.Payload, ¬ificationData)
|
|
if err != nil {
|
|
c.logger.Error("Error decoding EventSub notification payload", zap.String("message-type", message.Metadata.MessageType), zap.Error(err))
|
|
}
|
|
notificationData.Date = time.Now()
|
|
|
|
eventKey := fmt.Sprintf("%s%s", EventSubEventKeyPrefix, notificationData.Subscription.Type)
|
|
historyKey := fmt.Sprintf("%s%s", EventSubHistoryKeyPrefix, notificationData.Subscription.Type)
|
|
err = c.db.PutJSON(eventKey, notificationData)
|
|
c.logger.Info("Stored event", zap.String("key", eventKey), zap.String("notification-type", notificationData.Subscription.Type))
|
|
if err != nil {
|
|
c.logger.Error("Error storing event to database", zap.String("key", eventKey), zap.Error(err))
|
|
}
|
|
|
|
var archive []NotificationMessagePayload
|
|
err = c.db.GetJSON(historyKey, &archive)
|
|
if err != nil {
|
|
archive = []NotificationMessagePayload{}
|
|
}
|
|
archive = append(archive, notificationData)
|
|
if len(archive) > EventSubHistorySize {
|
|
archive = archive[len(archive)-EventSubHistorySize:]
|
|
}
|
|
err = c.db.PutJSON(historyKey, archive)
|
|
if err != nil {
|
|
c.logger.Error("Error storing event to database", zap.String("key", historyKey), zap.Error(err))
|
|
}
|
|
}
|
|
|
|
func (c *Client) addSubscriptionsForSession(userClient *helix.Client, session string) error {
|
|
if c.savedSubscriptions[session] {
|
|
// Already subscribed
|
|
return nil
|
|
}
|
|
|
|
transport := helix.EventSubTransport{
|
|
Method: "websocket",
|
|
SessionID: session,
|
|
}
|
|
for topic, version := range subscriptionVersions {
|
|
sub, err := userClient.CreateEventSubSubscription(&helix.EventSubSubscription{
|
|
Type: topic,
|
|
Version: version,
|
|
Status: "enabled",
|
|
Transport: transport,
|
|
Condition: topicCondition(topic, c.User.ID),
|
|
})
|
|
if sub.Error != "" || sub.ErrorMessage != "" {
|
|
c.logger.Error("EventSub Subscription error", zap.String("topic", topic), zap.String("topic-version", version), zap.String("err", sub.Error), zap.String("message", sub.ErrorMessage))
|
|
return fmt.Errorf("%s: %s", sub.Error, sub.ErrorMessage)
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("error subscribing to %s: %w", topic, err)
|
|
}
|
|
}
|
|
c.savedSubscriptions[session] = true
|
|
return nil
|
|
}
|
|
|
|
func topicCondition(topic string, id string) helix.EventSubCondition {
|
|
switch topic {
|
|
case helix.EventSubTypeChannelRaid:
|
|
return helix.EventSubCondition{
|
|
ToBroadcasterUserID: id,
|
|
}
|
|
case helix.EventSubTypeChannelFollow:
|
|
return helix.EventSubCondition{
|
|
BroadcasterUserID: id,
|
|
ModeratorUserID: id,
|
|
}
|
|
case
|
|
helix.EventSubTypeChannelChatMessage,
|
|
helix.EventSubTypeChannelChatNotification:
|
|
{
|
|
return helix.EventSubCondition{
|
|
BroadcasterUserID: id,
|
|
UserID: id,
|
|
}
|
|
}
|
|
default:
|
|
return helix.EventSubCondition{
|
|
BroadcasterUserID: id,
|
|
}
|
|
}
|
|
}
|
|
|
|
type EventSubWebsocketMessage struct {
|
|
Metadata EventSubMetadata `json:"metadata"`
|
|
Payload jsoniter.RawMessage `json:"payload"`
|
|
}
|
|
|
|
type WelcomeMessagePayload struct {
|
|
Session struct {
|
|
ID string `json:"id"`
|
|
Status string `json:"status"`
|
|
ConnectedAt time.Time `json:"connected_at"`
|
|
KeepaliveTimeoutSeconds int `json:"keepalive_timeout_seconds"`
|
|
ReconnectURL string `json:"reconnect_url,omitempty"`
|
|
} `json:"session"`
|
|
}
|
|
|
|
type NotificationMessagePayload struct {
|
|
Subscription helix.EventSubSubscription `json:"subscription"`
|
|
Event jsoniter.RawMessage `json:"event"`
|
|
Date time.Time `json:"date,omitempty"`
|
|
}
|
|
|
|
type EventSubMetadata struct {
|
|
MessageID string `json:"message_id"`
|
|
MessageType string `json:"message_type"`
|
|
MessageTimestamp time.Time `json:"message_timestamp"`
|
|
SubscriptionType string `json:"subscription_type"`
|
|
SubscriptionVersion string `json:"subscription_version"`
|
|
}
|
|
|
|
var subscriptionVersions = map[string]string{
|
|
helix.EventSubTypeChannelUpdate: "1",
|
|
helix.EventSubTypeChannelFollow: "2",
|
|
helix.EventSubTypeChannelSubscription: "1",
|
|
helix.EventSubTypeChannelSubscriptionGift: "1",
|
|
helix.EventSubTypeChannelSubscriptionMessage: "1",
|
|
helix.EventSubTypeChannelCheer: "1",
|
|
helix.EventSubTypeChannelRaid: "1",
|
|
helix.EventSubTypeChannelChatMessage: "1",
|
|
helix.EventSubTypeChannelChatNotification: "1",
|
|
helix.EventSubTypeChannelPollBegin: "1",
|
|
helix.EventSubTypeChannelPollProgress: "1",
|
|
helix.EventSubTypeChannelPollEnd: "1",
|
|
helix.EventSubTypeChannelPredictionBegin: "1",
|
|
helix.EventSubTypeChannelPredictionProgress: "1",
|
|
helix.EventSubTypeChannelPredictionLock: "1",
|
|
helix.EventSubTypeChannelPredictionEnd: "1",
|
|
helix.EventSubTypeHypeTrainBegin: "1",
|
|
helix.EventSubTypeHypeTrainProgress: "1",
|
|
helix.EventSubTypeHypeTrainEnd: "1",
|
|
helix.EventSubTypeChannelPointsCustomRewardAdd: "1",
|
|
helix.EventSubTypeChannelPointsCustomRewardUpdate: "1",
|
|
helix.EventSubTypeChannelPointsCustomRewardRemove: "1",
|
|
helix.EventSubTypeChannelPointsCustomRewardRedemptionAdd: "1",
|
|
helix.EventSubTypeChannelPointsCustomRewardRedemptionUpdate: "1",
|
|
helix.EventSubTypeStreamOnline: "1",
|
|
helix.EventSubTypeStreamOffline: "1",
|
|
}
|