Ctrl K
ComunidadGuías…

twitchWatcher.go

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package main

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"sync"

	log "github.com/sirupsen/logrus"

	"github.com/Luzifer/twitch-bot/v3/internal/helpers"
	"github.com/Luzifer/twitch-bot/v3/internal/service/access"
	"github.com/Luzifer/twitch-bot/v3/pkg/event"
	"github.com/Luzifer/twitch-bot/v3/pkg/twitch"
)

type (
	topicRegistration struct {
		Topic          string
		Condition      twitch.EventSubCondition
		RequiredScopes []string
		AnyScope       bool
		Hook           func(json.RawMessage) error
		Version        string
		Optional       bool
	}

	twitchChannelState struct {
		Category string
		IsLive   bool
		Title    string

		isInitialized bool
		esc           *twitch.EventSubSocketClient
	}

	twitchWatcher struct {
		ChannelStatus map[string]*twitchChannelState

		lock sync.RWMutex
	}
)

func (t *twitchChannelState) CloseESC() {
	t.esc.Close()
	t.esc = nil
}

func (t twitchChannelState) Equals(c twitchChannelState) bool {
	return t.Category == c.Category &&
		t.IsLive == c.IsLive &&
		t.Title == c.Title
}

func (t *twitchChannelState) Update(c twitchChannelState) {
	t.Category = c.Category
	t.IsLive = c.IsLive
	t.Title = c.Title
}

func newTwitchWatcher() *twitchWatcher {
	return &twitchWatcher{
		ChannelStatus: make(map[string]*twitchChannelState),
	}
}

func (t *twitchWatcher) AddChannel(channel string) error {
	t.lock.RLock()
	_, ok := t.ChannelStatus[channel]
	t.lock.RUnlock()

	if ok {
		return nil
	}

	// Initialize for check loop
	t.lock.Lock()
	t.ChannelStatus[channel] = &twitchChannelState{}
	t.lock.Unlock()

	return t.updateChannelFromAPI(channel)
}

func (t *twitchWatcher) Check() {
	var channels []string
	t.lock.RLock()
	for c := range t.ChannelStatus {
		if t.ChannelStatus[c].esc != nil {
			continue
		}

		channels = append(channels, c)
	}
	t.lock.RUnlock()

	for _, ch := range channels {
		if err := t.updateChannelFromAPI(ch); err != nil {
			log.WithError(err).WithField("channel", ch).Error("Unable to update channel status")
		}
	}
}

func (t *twitchWatcher) RemoveChannel(channel string) error {
	t.lock.Lock()
	defer t.lock.Unlock()

	if t.ChannelStatus[channel].esc != nil {
		t.ChannelStatus[channel].esc.Close()
	}

	delete(t.ChannelStatus, channel)
	return nil
}

//nolint:funlen // Just a collection of topics
func (t *twitchWatcher) getTopicRegistrations(userID string) []topicRegistration {
	return []topicRegistration{
		{
			Topic:          twitch.EventSubEventTypeChannelAdBreakBegin,
			Version:        twitch.EventSubTopicVersion1,
			Condition:      twitch.EventSubCondition{BroadcasterUserID: userID},
			RequiredScopes: []string{twitch.ScopeChannelReadAds},
			Hook:           t.handleEventSubChannelAdBreakBegin,
			Optional:       true,
		},
		{
			Topic:          twitch.EventSubEventTypeChannelFollow,
			Version:        twitch.EventSubTopicVersion2,
			Condition:      twitch.EventSubCondition{BroadcasterUserID: userID, ModeratorUserID: userID},
			RequiredScopes: []string{twitch.ScopeModeratorReadFollowers},
			Hook:           t.handleEventSubChannelFollow,
			Optional:       true,
		},
		{
			Topic:          twitch.EventSubEventTypeChannelHypetrainBegin,
			Version:        twitch.EventSubTopicVersion2,
			Condition:      twitch.EventSubCondition{BroadcasterUserID: userID},
			RequiredScopes: []string{twitch.ScopeChannelReadHypetrain},
			Hook:           t.handleEventSubHypetrainEvent(*event.HypetrainBegin{}.Event()),
			Optional:       true,
		},
		{
			Topic:          twitch.EventSubEventTypeChannelHypetrainEnd,
			Version:        twitch.EventSubTopicVersion2,
			Condition:      twitch.EventSubCondition{BroadcasterUserID: userID},
			RequiredScopes: []string{twitch.ScopeChannelReadHypetrain},
			Hook:           t.handleEventSubHypetrainEvent(*event.HypetrainEnd{}.Event()),
			Optional:       true,
		},
		{
			Topic:          twitch.EventSubEventTypeChannelHypetrainProgress,
			Version:        twitch.EventSubTopicVersion2,
			Condition:      twitch.EventSubCondition{BroadcasterUserID: userID},
			RequiredScopes: []string{twitch.ScopeChannelReadHypetrain},
			Hook:           t.handleEventSubHypetrainEvent(*event.HypetrainProgress{}.Event()),
			Optional:       true,
		},
		{
			Topic:          twitch.EventSubEventTypeChannelModeratorAdd,
			Condition:      twitch.EventSubCondition{BroadcasterUserID: userID},
			RequiredScopes: []string{twitch.ScopeModerationRead},
			Hook:           t.handleEventSubModeratorAdd,
			Optional:       true,
		},
		{
			Topic:          twitch.EventSubEventTypeChannelModeratorRemove,
			Condition:      twitch.EventSubCondition{BroadcasterUserID: userID},
			RequiredScopes: []string{twitch.ScopeModerationRead},
			Hook:           t.handleEventSubModeratorRemove,
			Optional:       true,
		},
		{
			Topic:          twitch.EventSubEventTypeChannelPointCustomRewardRedemptionAdd,
			Condition:      twitch.EventSubCondition{BroadcasterUserID: userID},
			RequiredScopes: []string{twitch.ScopeChannelReadRedemptions, twitch.ScopeChannelManageRedemptions},
			AnyScope:       true,
			Hook:           t.handleEventSubChannelPointCustomRewardRedemptionAdd,
			Optional:       true,
		},
		{
			Topic:          twitch.EventSubEventTypeChannelPollBegin,
			Condition:      twitch.EventSubCondition{BroadcasterUserID: userID},
			RequiredScopes: []string{twitch.ScopeChannelReadPolls, twitch.ScopeChannelManagePolls},
			AnyScope:       true,
			Hook:           t.handleEventSubChannelPollChange(*event.PollBegin{}.Event()),
			Optional:       true,
		},
		{
			Topic:          twitch.EventSubEventTypeChannelPollEnd,
			Condition:      twitch.EventSubCondition{BroadcasterUserID: userID},
			RequiredScopes: []string{twitch.ScopeChannelReadPolls, twitch.ScopeChannelManagePolls},
			AnyScope:       true,
			Hook:           t.handleEventSubChannelPollChange(*event.PollEnd{}.Event()),
			Optional:       true,
		},
		{
			Topic:          twitch.EventSubEventTypeChannelPollProgress,
			Condition:      twitch.EventSubCondition{BroadcasterUserID: userID},
			RequiredScopes: []string{twitch.ScopeChannelReadPolls, twitch.ScopeChannelManagePolls},
			AnyScope:       true,
			Hook:           t.handleEventSubChannelPollChange(*event.PollProgress{}.Event()),
			Optional:       true,
		},
		{
			Topic:          twitch.EventSubEventTypeChannelRaid,
			Condition:      twitch.EventSubCondition{FromBroadcasterUserID: userID},
			RequiredScopes: nil,
			Hook:           t.handleEventSubChannelOutboundRaid,
			Optional:       true,
		},
		{
			Topic:          twitch.EventSubEventTypeChannelShoutoutCreate,
			Condition:      twitch.EventSubCondition{BroadcasterUserID: userID, ModeratorUserID: userID},
			RequiredScopes: []string{twitch.ScopeModeratorManageShoutouts, twitch.ScopeModeratorReadShoutouts},
			AnyScope:       true,
			Hook:           t.handleEventSubShoutoutCreated,
			Optional:       true,
		},
		{
			Topic:          twitch.EventSubEventTypeChannelShoutoutReceive,
			Condition:      twitch.EventSubCondition{BroadcasterUserID: userID, ModeratorUserID: userID},
			RequiredScopes: []string{twitch.ScopeModeratorManageShoutouts, twitch.ScopeModeratorReadShoutouts},
			AnyScope:       true,
			Hook:           t.handleEventSubShoutoutReceived,
			Optional:       true,
		},
		{
			Topic:          twitch.EventSubEventTypeChannelUpdate,
			Version:        twitch.EventSubTopicVersion2,
			Condition:      twitch.EventSubCondition{BroadcasterUserID: userID},
			RequiredScopes: nil,
			Hook:           t.handleEventSubChannelUpdate,
			Optional:       true,
		},
		{
			Topic:          twitch.EventSubEventTypeStreamOffline,
			Condition:      twitch.EventSubCondition{BroadcasterUserID: userID},
			RequiredScopes: nil,
			Hook:           t.handleEventSubStreamOnOff(false),
			Optional:       true,
		},
		{
			Topic:          twitch.EventSubEventTypeStreamOnline,
			Condition:      twitch.EventSubCondition{BroadcasterUserID: userID},
			RequiredScopes: nil,
			Hook:           t.handleEventSubStreamOnOff(true),
			Optional:       true,
		},
		{
			Topic:          twitch.EventSubEventTypeChannelSuspiciousUserMessage,
			Condition:      twitch.EventSubCondition{BroadcasterUserID: userID, ModeratorUserID: userID},
			RequiredScopes: []string{twitch.ScopeModeratorReadSuspiciousUsers},
			Hook:           t.handleEventSubSusUserMessage,
			Optional:       true,
		},
		{
			Topic:          twitch.EventSubEventTypeChannelSuspiciousUserUpdate,
			Condition:      twitch.EventSubCondition{BroadcasterUserID: userID, ModeratorUserID: userID},
			RequiredScopes: []string{twitch.ScopeModeratorReadSuspiciousUsers},
			Hook:           t.handleEventSubSusUserUpdate,
			Optional:       true,
		},
		{
			Topic:          twitch.EventSubEventTypeVIPAdd,
			Condition:      twitch.EventSubCondition{BroadcasterUserID: userID},
			RequiredScopes: []string{twitch.ScopeChannelReadVIPs, twitch.ScopeChannelManageVIPS},
			AnyScope:       true,
			Hook:           t.handleEventSubVIPAdd,
			Optional:       true,
		},
		{
			Topic:          twitch.EventSubEventTypeVIPRemove,
			Condition:      twitch.EventSubCondition{BroadcasterUserID: userID},
			RequiredScopes: []string{twitch.ScopeChannelReadVIPs, twitch.ScopeChannelManageVIPS},
			AnyScope:       true,
			Hook:           t.handleEventSubVIPRemove,
			Optional:       true,
		},
	}
}

func (*twitchWatcher) handleEventSubChannelAdBreakBegin(m json.RawMessage) error {
	var payload twitch.EventSubEventAdBreakBegin
	if err := json.Unmarshal(m, &payload); err != nil {
		return fmt.Errorf("unmarshalling event: %w", err)
	}

	evt := event.AdBreakBegin{
		Channel:     "#" + payload.BroadcasterUserLogin,
		Duration:    payload.Duration,
		IsAutomatic: payload.IsAutomatic,
		StartedAt:   payload.StartedAt,
	}

	log.WithFields(event.ToLogFields(evt)).Info("Ad-Break started")
	go handleTypedMessage(ircHdl.Client(), nil, evt)

	return nil
}

func (*twitchWatcher) handleEventSubChannelFollow(m json.RawMessage) error {
	var payload twitch.EventSubEventFollow
	if err := json.Unmarshal(m, &payload); err != nil {
		return fmt.Errorf("unmarshalling event: %w", err)
	}

	evt := event.Follow{
		Channel:    "#" + payload.BroadcasterUserLogin,
		FollowedAt: payload.FollowedAt,
		User:       payload.UserLogin,
		UserID:     payload.UserID,
	}

	log.WithFields(event.ToLogFields(evt)).Info("User followed")
	go handleTypedMessage(ircHdl.Client(), nil, evt)

	return nil
}

func (*twitchWatcher) handleEventSubChannelOutboundRaid(m json.RawMessage) error {
	var payload twitch.EventSubEventRaid
	if err := json.Unmarshal(m, &payload); err != nil {
		return fmt.Errorf("unmarshalling event: %w", err)
	}

	evt := event.OutboundRaid{
		Channel: "#" + payload.FromBroadcasterUserLogin,
		To:      payload.ToBroadcasterUserLogin,
		ToID:    payload.ToBroadcasterUserID,
		Viewers: payload.Viewers,
	}

	log.WithFields(event.ToLogFields(evt)).Info("Outbound raid detected")
	go handleTypedMessage(ircHdl.Client(), nil, evt)

	return nil
}

func (*twitchWatcher) handleEventSubChannelPointCustomRewardRedemptionAdd(m json.RawMessage) error {
	var payload twitch.EventSubEventChannelPointCustomRewardRedemptionAdd
	if err := json.Unmarshal(m, &payload); err != nil {
		return fmt.Errorf("unmarshalling event: %w", err)
	}

	evt := event.ChannelPointRedeem{
		Channel:     "#" + payload.BroadcasterUserLogin,
		RewardCost:  payload.Reward.Cost,
		RewardID:    payload.Reward.ID,
		RewardTitle: payload.Reward.Title,
		Status:      payload.Status,
		User:        payload.UserLogin,
		UserID:      payload.UserID,
		UserInput:   payload.UserInput,
	}

	log.WithFields(event.ToLogFields(evt)).Info("ChannelPoint reward was redeemed")
	go handleTypedMessage(ircHdl.Client(), nil, evt)

	return nil
}

func (*twitchWatcher) handleEventSubChannelPollChange(eventType string) func(json.RawMessage) error {
	return func(m json.RawMessage) error {
		var payload twitch.EventSubEventPoll
		if err := json.Unmarshal(m, &payload); err != nil {
			return fmt.Errorf("unmarshalling event: %w", err)
		}

		poll := event.Poll{
			Channel:               "#" + payload.BroadcasterUserLogin,
			HasChannelPointVoting: payload.ChannelPointsVoting.IsEnabled,
			Poll:                  payload,
			Title:                 payload.Title,
		}

		var evt event.Event

		switch eventType {
		case *event.PollBegin{}.Event():
			evt = event.PollBegin{
				Poll: poll,
			}

			log.WithFields(event.ToLogFields(evt)).Info("Poll started")

		case *event.PollEnd{}.Event():
			evt = event.PollEnd{
				Poll:   poll,
				Status: payload.Status,
			}

			log.WithFields(event.ToLogFields(evt)).Info("Poll ended")

		case *event.PollProgress{}.Event():
			evt = event.PollProgress{
				Poll: poll,
			}

			// Lets not spam the info-level-log with every single vote but
			// provide them for bots with debug-level-logging
			log.WithFields(event.ToLogFields(evt)).Debug("Poll changed")
		}

		go handleTypedMessage(ircHdl.Client(), nil, evt)
		return nil
	}
}

func (t *twitchWatcher) handleEventSubChannelUpdate(m json.RawMessage) error {
	var payload twitch.EventSubEventChannelUpdate
	if err := json.Unmarshal(m, &payload); err != nil {
		return fmt.Errorf("unmarshalling event: %w", err)
	}

	t.triggerUpdate(payload.BroadcasterUserLogin, &payload.Title, &payload.CategoryName, nil)

	return nil
}

func (*twitchWatcher) handleEventSubHypetrainEvent(eventType string) func(json.RawMessage) error {
	return func(m json.RawMessage) error {
		var payload twitch.EventSubEventHypetrain
		if err := json.Unmarshal(m, &payload); err != nil {
			return fmt.Errorf("unmarshalling event: %w", err)
		}

		hypetrain := event.Hypetrain{
			Channel: "#" + payload.BroadcasterUserLogin,
			Event:   payload,
			Level:   payload.Level,
		}

		var levelProgress float64
		if payload.Goal > 0 {
			levelProgress = float64(payload.Progress) / float64(payload.Goal)
		}

		var evt event.Event
		switch eventType {
		case *event.HypetrainBegin{}.Event():
			evt = event.HypetrainBegin{
				Hypetrain:     hypetrain,
				LevelProgress: levelProgress,
			}

		case *event.HypetrainEnd{}.Event():
			evt = event.HypetrainEnd{
				Hypetrain: hypetrain,
			}

		case *event.HypetrainProgress{}.Event():
			evt = event.HypetrainProgress{
				Hypetrain:     hypetrain,
				LevelProgress: levelProgress,
			}
		}

		log.WithFields(event.ToLogFields(evt)).Info("Hypetrain event")
		go handleTypedMessage(ircHdl.Client(), nil, evt)

		return nil
	}
}

func (*twitchWatcher) handleEventSubModeratorAdd(m json.RawMessage) error {
	var payload twitch.EventSubEventUserRoleChange
	if err := json.Unmarshal(m, &payload); err != nil {
		return fmt.Errorf("unmarshalling event: %w", err)
	}

	evt := event.ModeratorAdd{
		Channel: "#" + payload.BroadcasterUserLogin,
		User:    payload.UserLogin,
		UserID:  payload.UserID,
	}

	log.WithFields(event.ToLogFields(evt)).Info("Moderator added")
	go handleTypedMessage(ircHdl.Client(), nil, evt)

	return nil
}

func (*twitchWatcher) handleEventSubModeratorRemove(m json.RawMessage) error {
	var payload twitch.EventSubEventUserRoleChange
	if err := json.Unmarshal(m, &payload); err != nil {
		return fmt.Errorf("unmarshalling event: %w", err)
	}

	evt := event.ModeratorRemove{
		Channel: "#" + payload.BroadcasterUserLogin,
		User:    payload.UserLogin,
		UserID:  payload.UserID,
	}

	log.WithFields(event.ToLogFields(evt)).Info("Moderator removed")
	go handleTypedMessage(ircHdl.Client(), nil, evt)

	return nil
}

func (*twitchWatcher) handleEventSubShoutoutCreated(m json.RawMessage) error {
	var payload twitch.EventSubEventShoutoutCreated
	if err := json.Unmarshal(m, &payload); err != nil {
		return fmt.Errorf("unmarshalling event: %w", err)
	}

	evt := event.ShoutoutCreated{
		Channel: "#" + payload.BroadcasterUserLogin,
		To:      payload.ToBroadcasterUserLogin,
		ToID:    payload.ToBroadcasterUserID,
		Viewers: payload.ViewerCount,
	}

	log.WithFields(event.ToLogFields(evt)).Info("Shoutout created")
	go handleTypedMessage(ircHdl.Client(), nil, evt)

	return nil
}

func (*twitchWatcher) handleEventSubShoutoutReceived(m json.RawMessage) error {
	var payload twitch.EventSubEventShoutoutReceived
	if err := json.Unmarshal(m, &payload); err != nil {
		return fmt.Errorf("unmarshalling event: %w", err)
	}

	evt := event.ShoutoutReceived{
		Channel: "#" + payload.BroadcasterUserLogin,
		From:    payload.FromBroadcasterUserLogin,
		FromID:  payload.FromBroadcasterUserID,
		Viewers: payload.ViewerCount,
	}

	log.WithFields(event.ToLogFields(evt)).Info("Shoutout received")
	go handleTypedMessage(ircHdl.Client(), nil, evt)

	return nil
}

func (t *twitchWatcher) handleEventSubStreamOnOff(isOnline bool) func(json.RawMessage) error {
	return func(m json.RawMessage) error {
		var payload twitch.EventSubEventFollow
		if err := json.Unmarshal(m, &payload); err != nil {
			return fmt.Errorf("unmarshalling event: %w", err)
		}

		t.triggerUpdate(payload.BroadcasterUserLogin, nil, nil, &isOnline)
		return nil
	}
}

func (*twitchWatcher) handleEventSubSusUserMessage(m json.RawMessage) (err error) {
	var payload twitch.EventSubEventSuspiciousUserMessage
	if err := json.Unmarshal(m, &payload); err != nil {
		return fmt.Errorf("unmarshalling event: %w", err)
	}

	evt := event.SuspiciousUserMessage{
		BanEvasion:        payload.BanEvasionEvaluation,
		Channel:           "#" + payload.BroadcasterUserLogin,
		Message:           payload.Message.Text,
		SharedBanChannels: payload.SharedBanChannelIDs,
		Status:            payload.LowTrustStatus,
		UserID:            payload.UserID,
		Username:          payload.UserLogin,
		UserType:          payload.Types,
	}

	log.WithFields(event.ToLogFields(evt)).Info("restricted user message")
	go handleTypedMessage(ircHdl.Client(), nil, evt)

	return nil
}

func (*twitchWatcher) handleEventSubSusUserUpdate(m json.RawMessage) (err error) {
	var payload twitch.EventSubEventSuspiciousUserUpdated
	if err := json.Unmarshal(m, &payload); err != nil {
		return fmt.Errorf("unmarshalling event: %w", err)
	}

	evt := event.SuspiciousUserUpdate{
		Channel:   "#" + payload.BroadcasterUserLogin,
		Moderator: payload.ModeratorUserLogin,
		Status:    payload.LowTrustStatus,
		UserID:    payload.UserID,
		Username:  payload.UserLogin,
	}

	log.WithFields(event.ToLogFields(evt)).Info("user restriction updated")
	go handleTypedMessage(ircHdl.Client(), nil, evt)

	return nil
}

func (*twitchWatcher) handleEventSubVIPAdd(m json.RawMessage) error {
	var payload twitch.EventSubEventUserRoleChange
	if err := json.Unmarshal(m, &payload); err != nil {
		return fmt.Errorf("unmarshalling event: %w", err)
	}

	evt := event.VIPAdd{
		Channel: "#" + payload.BroadcasterUserLogin,
		User:    payload.UserLogin,
		UserID:  payload.UserID,
	}

	log.WithFields(event.ToLogFields(evt)).Info("VIP added")
	go handleTypedMessage(ircHdl.Client(), nil, evt)

	return nil
}

func (*twitchWatcher) handleEventSubVIPRemove(m json.RawMessage) error {
	var payload twitch.EventSubEventUserRoleChange
	if err := json.Unmarshal(m, &payload); err != nil {
		return fmt.Errorf("unmarshalling event: %w", err)
	}

	evt := event.VIPRemove{
		Channel: "#" + payload.BroadcasterUserLogin,
		User:    payload.UserLogin,
		UserID:  payload.UserID,
	}

	log.WithFields(event.ToLogFields(evt)).Info("VIP removed")
	go handleTypedMessage(ircHdl.Client(), nil, evt)

	return nil
}

func (t *twitchWatcher) registerEventSubCallbacks(channel string) (*twitch.EventSubSocketClient, error) {
	tc, err := accessService.GetTwitchClientForChannel(channel, access.ClientConfig{
		TwitchClient:       cfg.TwitchClient,
		TwitchClientSecret: cfg.TwitchClientSecret,
	})
	if err != nil {
		if errors.Is(err, access.ErrChannelNotAuthorized) {
			return nil, nil //nolint:nilnil // This is fine - not authorized cannot register callbacks
		}

		return nil, fmt.Errorf("getting twitch client for channel: %w", err)
	}

	userID, err := twitchClient.GetIDForUsername(context.Background(), channel)
	if err != nil {
		return nil, fmt.Errorf("resolving channel to user-id: %w", err)
	}

	var (
		topicRegistrations = t.getTopicRegistrations(userID)
		topicOpts          []twitch.EventSubSocketClientOpt
	)

	for _, tr := range topicRegistrations {
		logger := log.WithFields(log.Fields{
			"any":     tr.AnyScope,
			"channel": channel,
			"scopes":  tr.RequiredScopes,
			"topic":   tr.Topic,
		})

		if len(tr.RequiredScopes) > 0 {
			fn := accessService.HasPermissionsForChannel
			if tr.AnyScope {
				fn = accessService.HasAnyPermissionForChannel
			}

			hasScopes, err := fn(channel, tr.RequiredScopes...)
			if err != nil {
				return nil, fmt.Errorf("checking granted scopes: %w", err)
			}

			if !hasScopes {
				logger.Debug("Missing scopes for eventsub topic")
				continue
			}
		}

		var opt twitch.EventSubSocketClientOpt
		if tr.Optional {
			opt = twitch.WithRetryBackgroundSubscribe(tr.Topic, tr.Version, tr.Condition, tr.Hook)
		} else {
			opt = twitch.WithMustSubscribe(tr.Topic, tr.Version, tr.Condition, tr.Hook)
		}

		topicOpts = append(topicOpts, opt)
	}

	esClient, err := twitch.NewEventSubSocketClient(append(
		topicOpts,
		twitch.WithLogger(log.WithField("channel", channel)),
		twitch.WithTwitchClient(tc),
	)...)
	if err != nil {
		return nil, fmt.Errorf("getting eventsub client for channel: %w", err)
	}

	return esClient, nil
}

func (t *twitchWatcher) triggerUpdate(channel string, title, category *string, online *bool) {
	if category != nil && t.ChannelStatus[channel].Category != *category {
		t.ChannelStatus[channel].Category = *category

		evt := event.CategoryUpdate{
			Category: *category,
			Channel:  "#" + channel,
		}

		log.WithFields(event.ToLogFields(evt)).Info("Category updated")
		go handleTypedMessage(ircHdl.Client(), nil, evt)
	}

	if title != nil && t.ChannelStatus[channel].Title != *title {
		t.ChannelStatus[channel].Title = *title

		evt := event.TitleUpdate{
			Channel: "#" + channel,
			Title:   *title,
		}

		log.WithFields(event.ToLogFields(evt)).Info("Title updated")
		go handleTypedMessage(ircHdl.Client(), nil, evt)
	}

	if online != nil && t.ChannelStatus[channel].IsLive != *online {
		t.ChannelStatus[channel].IsLive = *online

		log.WithFields(log.Fields{
			"channel": channel,
			"isLive":  *online,
		}).Info("Live-status updated")

		var evt event.Event = event.StreamOnline{Channel: "#" + channel}
		if !*online {
			evt = event.StreamOffline{Channel: "#" + channel}
		}

		go handleTypedMessage(ircHdl.Client(), nil, evt)
	}
}

func (t *twitchWatcher) updateChannelFromAPI(channel string) error {
	t.lock.Lock()
	defer t.lock.Unlock()

	var (
		err          error
		status       twitchChannelState
		storedStatus = t.ChannelStatus[channel]
	)

	status.IsLive, err = twitchClient.HasLiveStream(context.Background(), channel)
	if err != nil {
		return fmt.Errorf("getting live status: %w", err)
	}

	status.Category, status.Title, err = twitchClient.GetRecentStreamInfo(context.Background(), channel)
	if err != nil {
		return fmt.Errorf("getting stream info: %w", err)
	}

	if storedStatus == nil {
		storedStatus = &twitchChannelState{}
		t.ChannelStatus[channel] = storedStatus
	}

	if storedStatus.isInitialized && !storedStatus.Equals(status) {
		// Send updates only when we do have an update
		t.triggerUpdate(channel, &status.Title, &status.Category, &status.IsLive)
	}

	storedStatus.Update(status)
	storedStatus.isInitialized = true

	if storedStatus.esc != nil {
		// Do not register twice
		return nil
	}

	if storedStatus.esc, err = t.registerEventSubCallbacks(channel); err != nil {
		return fmt.Errorf("registering eventsub callbacks: %w", err)
	}

	if storedStatus.esc != nil {
		log.WithField("channel", channel).Info("watching for eventsub events")
		go func(storedStatus *twitchChannelState) {
			if err := storedStatus.esc.Run(); err != nil {
				log.WithField("channel", channel).WithError(helpers.CleanNetworkAddressFromError(err)).Error("eventsub client caused error")
			}
			storedStatus.CloseESC()
		}(storedStatus)
	}

	return nil
}