From 42538757878d3741fbe3125e2b3048a1734388ed Mon Sep 17 00:00:00 2001 From: Ash Keel Date: Sun, 5 Dec 2021 15:47:53 +0100 Subject: [PATCH] Add alerts, remove disabling timers --- modules/twitch/bot.go | 1 + modules/twitch/modules.alerts.go | 369 +++++++++++++++++++++++++++++++ modules/twitch/modules.go | 30 +-- modules/twitch/modules.timer.go | 3 +- 4 files changed, 375 insertions(+), 28 deletions(-) create mode 100644 modules/twitch/modules.alerts.go diff --git a/modules/twitch/bot.go b/modules/twitch/bot.go index a80694f..171a0e2 100644 --- a/modules/twitch/bot.go +++ b/modules/twitch/bot.go @@ -38,6 +38,7 @@ type Bot struct { // Module specific vars Loyalty *loyalty.Manager Timers *BotTimerModule + Alerts *BotAlertsModule } func NewBot(api *Client, config BotConfig) *Bot { diff --git a/modules/twitch/modules.alerts.go b/modules/twitch/modules.alerts.go new file mode 100644 index 0000000..65493d6 --- /dev/null +++ b/modules/twitch/modules.alerts.go @@ -0,0 +1,369 @@ +package twitch + +import ( + "bytes" + "context" + "encoding/json" + "math/rand" + "sync" + "text/template" + "time" + + "github.com/Masterminds/sprig" + + "github.com/nicklaw5/helix" + + jsoniter "github.com/json-iterator/go" + + "github.com/strimertul/strimertul/modules/database" +) + +const BotAlertsKey = "twitch/bot-modules/alerts/config" + +type eventSubNotification struct { + Subscription helix.EventSubSubscription `json:"subscription"` + Challenge string `json:"challenge"` + Event json.RawMessage `json:"event"` +} + +type BotAlertsConfig struct { + Follow struct { + Enabled bool `json:"enabled"` + Messages []string `json:"messages"` + } `json:"follow"` + Subscription struct { + Enabled bool `json:"enabled"` + Messages []string `json:"message"` + Variations []struct { + MinStreak *int `json:"min_streak,omitempty"` + IsGifted *bool `json:"is_gifted,omitempty"` + Messages []string `json:"message"` + } `json:"variations"` + } `json:"subscription"` + GiftSub struct { + Enabled bool `json:"enabled"` + Messages []string `json:"message"` + Variations []struct { + MinCumulative *int `json:"min_cumulative,omitempty"` + Messages []string `json:"message"` + } `json:"variations"` + } `json:"gift_sub"` + Raid struct { + Enabled bool `json:"enabled"` + Messages []string `json:"message"` + Variations []struct { + MinViewers *int `json:"min_viewers,omitempty"` + Messages []string `json:"message"` + } `json:"variations"` + } `json:"raid"` + Cheer struct { + Enabled bool `json:"enabled"` + Messages []string `json:"message"` + Variations []struct { + MinAmount *int `json:"min_amount,omitempty"` + Messages []string `json:"message"` + } `json:"variations"` + } `json:"cheer"` +} + +type BotAlertsModule struct { + Config BotAlertsConfig + + bot *Bot + mu sync.Mutex +} + +func SetupAlerts(bot *Bot) *BotAlertsModule { + mod := &BotAlertsModule{ + bot: bot, + } + + // Load config from database + err := bot.api.db.GetJSON(BotAlertsKey, &mod.Config) + if err != nil { + bot.logger.WithError(err).Debug("config load error") + mod.Config = BotAlertsConfig{} + } + + go bot.api.db.Subscribe(context.Background(), func(changed []database.ModifiedKV) error { + for _, kv := range changed { + if kv.Key == BotAlertsKey { + err := jsoniter.ConfigFastest.Unmarshal(kv.Data, &mod.Config) + if err != nil { + bot.logger.WithError(err).Debug("error reloading timer config") + } else { + bot.logger.Info("reloaded timer config") + } + } + } + return nil + }, BotAlertsKey) + + // Subscriptions are handled with a slight delay as info come from different events and must be aggregated + pendingSubs := make(map[string]subMixedEvent) + pendingMux := sync.Mutex{} + processPendingSub := func(user string) { + pendingMux.Lock() + defer pendingMux.Unlock() + sub, ok := pendingSubs[user] + defer delete(pendingSubs, user) + if !ok { + // Somehow it's gone? Return early + return + } + // One last check in case config changed + if !mod.Config.Subscription.Enabled { + return + } + // Assign random message + msg := mod.Config.Subscription.Messages[rand.Intn(len(mod.Config.Subscription.Messages))] + // Check for variations, either by streak or gifted + if sub.IsGift { + for _, variation := range mod.Config.Subscription.Variations { + if variation.IsGifted != nil && *variation.IsGifted { + msg = variation.Messages[rand.Intn(len(variation.Messages))] + break + } + } + } else if sub.DurationMonths > 0 { + minMonths := -1 + for _, variation := range mod.Config.Subscription.Variations { + if variation.MinStreak != nil && sub.DurationMonths >= *variation.MinStreak && sub.DurationMonths >= minMonths { + msg = variation.Messages[rand.Intn(len(variation.Messages))] + minMonths = *variation.MinStreak + break + } + } + } + writeTemplate(bot, msg, sub) + } + addPendingSub := func(ev interface{}) { + switch ev.(type) { + case *helix.EventSubChannelSubscribeEvent: + sub := ev.(*helix.EventSubChannelSubscribeEvent) + pendingMux.Lock() + defer pendingMux.Unlock() + if ev, ok := pendingSubs[sub.UserID]; ok { + // Already pending, add extra data + ev.IsGift = sub.IsGift + pendingSubs[sub.UserID] = ev + return + } + pendingSubs[sub.UserID] = subMixedEvent{ + UserID: sub.UserID, + UserLogin: sub.UserLogin, + UserName: sub.UserName, + BroadcasterUserID: sub.BroadcasterUserID, + BroadcasterUserLogin: sub.BroadcasterUserLogin, + BroadcasterUserName: sub.BroadcasterUserName, + Tier: sub.Tier, + IsGift: sub.IsGift, + } + go func() { + // Wait a bit to make sure we aggregate all events + time.Sleep(time.Second * 3) + processPendingSub(sub.UserID) + }() + case *helix.EventSubChannelSubscriptionMessageEvent: + sub := ev.(*helix.EventSubChannelSubscriptionMessageEvent) + pendingMux.Lock() + defer pendingMux.Unlock() + if ev, ok := pendingSubs[sub.UserID]; ok { + // Already pending, add extra data + ev.StreakMonths = sub.StreakMonths + ev.DurationMonths = sub.DurationMonths + ev.CumulativeTotal = sub.CumulativeTotal + ev.Message = sub.Message + return + } + pendingSubs[sub.UserID] = subMixedEvent{ + UserID: sub.UserID, + UserLogin: sub.UserLogin, + UserName: sub.UserName, + BroadcasterUserID: sub.BroadcasterUserID, + BroadcasterUserLogin: sub.BroadcasterUserLogin, + BroadcasterUserName: sub.BroadcasterUserName, + Tier: sub.Tier, + StreakMonths: sub.StreakMonths, + DurationMonths: sub.DurationMonths, + CumulativeTotal: sub.CumulativeTotal, + Message: sub.Message, + } + go func() { + // Wait a bit to make sure we aggregate all events + time.Sleep(time.Second * 3) + processPendingSub(sub.UserID) + }() + } + } + + go bot.api.db.Subscribe(context.Background(), func(changed []database.ModifiedKV) error { + for _, kv := range changed { + if kv.Key == "stulbe/ev/webhook" { + var ev eventSubNotification + err := jsoniter.ConfigFastest.Unmarshal(kv.Data, &ev) + if err != nil { + bot.logger.WithError(err).Debug("error parsing webhook payload") + continue + } + switch ev.Subscription.Type { + case helix.EventSubTypeChannelFollow: + // Only process if we care about follows + if !mod.Config.Follow.Enabled { + continue + } + // Parse as follow event + var followEv helix.EventSubChannelFollowEvent + err := jsoniter.ConfigFastest.Unmarshal(ev.Event, &followEv) + if err != nil { + bot.logger.WithError(err).Debug("error parsing follow event") + continue + } + // Pick a random message + msg := mod.Config.Follow.Messages[rand.Intn(len(mod.Config.Follow.Messages))] + // Compile template and send + writeTemplate(bot, msg, &followEv) + case helix.EventSubTypeChannelRaid: + // Only process if we care about raids + if !mod.Config.Raid.Enabled { + continue + } + // Parse as raid event + var raidEv helix.EventSubChannelRaidEvent + err := jsoniter.ConfigFastest.Unmarshal(ev.Event, &raidEv) + if err != nil { + bot.logger.WithError(err).Debug("error parsing raid event") + continue + } + // Pick a random message from base set + msg := mod.Config.Raid.Messages[rand.Intn(len(mod.Config.Raid.Messages))] + // If we have variations, loop through all the available variations and pick the one with the highest minimum viewers that are met + if len(mod.Config.Raid.Variations) > 0 { + minViewers := -1 + for _, variation := range mod.Config.Raid.Variations { + if variation.MinViewers != nil && *variation.MinViewers > minViewers && raidEv.Viewers >= *variation.MinViewers { + msg = variation.Messages[rand.Intn(len(variation.Messages))] + minViewers = *variation.MinViewers + } + } + } + // Compile template and send + writeTemplate(bot, msg, &raidEv) + case helix.EventSubTypeChannelCheer: + // Only process if we care about bits + if !mod.Config.Cheer.Enabled { + continue + } + // Parse as cheer event + var cheerEv helix.EventSubChannelCheerEvent + err := jsoniter.ConfigFastest.Unmarshal(ev.Event, &cheerEv) + if err != nil { + bot.logger.WithError(err).Debug("error parsing cheer event") + continue + } + // Pick a random message from base set + msg := mod.Config.Cheer.Messages[rand.Intn(len(mod.Config.Cheer.Messages))] + // If we have variations, loop through all the available variations and pick the one with the highest minimum amount that is met + if len(mod.Config.Cheer.Variations) > 0 { + minAmount := -1 + for _, variation := range mod.Config.Cheer.Variations { + if variation.MinAmount != nil && *variation.MinAmount > minAmount && cheerEv.Bits >= *variation.MinAmount { + msg = variation.Messages[rand.Intn(len(variation.Messages))] + minAmount = *variation.MinAmount + } + } + } + // Compile template and send + writeTemplate(bot, msg, &cheerEv) + case helix.EventSubTypeChannelSubscription: + // Only process if we care about subscriptions + if !mod.Config.Subscription.Enabled { + continue + } + // Parse as subscription event + var subEv helix.EventSubChannelSubscribeEvent + err := jsoniter.ConfigFastest.Unmarshal(ev.Event, &subEv) + if err != nil { + bot.logger.WithError(err).Debug("error parsing sub event") + continue + } + addPendingSub(subEv.UserID) + case helix.EventSubTypeChannelSubscriptionMessage: + // Only process if we care about subscriptions + if !mod.Config.Subscription.Enabled { + continue + } + // Parse as subscription event + var subEv helix.EventSubChannelSubscriptionMessageEvent + err := jsoniter.ConfigFastest.Unmarshal(ev.Event, &subEv) + if err != nil { + bot.logger.WithError(err).Debug("error parsing sub event") + continue + } + addPendingSub(subEv.UserID) + case helix.EventSubTypeChannelSubscriptionGift: + // Only process if we care about gifted subs + if !mod.Config.Raid.Enabled { + continue + } + // Parse as gift event + var giftEv helix.EventSubChannelSubscriptionGiftEvent + err := jsoniter.ConfigFastest.Unmarshal(ev.Event, &giftEv) + if err != nil { + bot.logger.WithError(err).Debug("error parsing raid event") + continue + } + // Pick a random message from base set + msg := mod.Config.GiftSub.Messages[rand.Intn(len(mod.Config.GiftSub.Messages))] + // If we have variations, loop through all the available variations and pick the one with the highest minimum cumulative total that are met + if len(mod.Config.GiftSub.Variations) > 0 { + minCumulative := -1 + for _, variation := range mod.Config.GiftSub.Variations { + if variation.MinCumulative != nil && *variation.MinCumulative > minCumulative && giftEv.CumulativeTotal >= *variation.MinCumulative { + msg = variation.Messages[rand.Intn(len(variation.Messages))] + minCumulative = *variation.MinCumulative + } + } + } + // Compile template and send + writeTemplate(bot, msg, &giftEv) + } + } + } + return nil + }, "stulbe/ev/webhook") + + bot.logger.Debug("loaded bot alerts") + + return mod +} + +func writeTemplate(bot *Bot, msg string, data interface{}) { + tpl, err := template.New("").Funcs(sprig.TxtFuncMap()).Parse(msg) + if err != nil { + bot.logger.WithError(err).Error("error parsing template for alert") + return + } + var buf bytes.Buffer + err = tpl.Execute(&buf, data) + if err != nil { + bot.logger.WithError(err).Error("error executing template for alert") + return + } + bot.WriteMessage(buf.String()) +} + +type subMixedEvent struct { + UserID string + UserLogin string + UserName string + BroadcasterUserID string + BroadcasterUserLogin string + BroadcasterUserName string + Tier string + IsGift bool + CumulativeTotal int + StreakMonths int + DurationMonths int + Message helix.EventSubMessage +} diff --git a/modules/twitch/modules.go b/modules/twitch/modules.go index b9ec270..6901e2a 100644 --- a/modules/twitch/modules.go +++ b/modules/twitch/modules.go @@ -1,31 +1,9 @@ package twitch -import ( - "errors" - - "github.com/strimertul/strimertul/modules/database" -) - -const BotModulesConfigKey = "twitch/bot-modules/config" - -type BotModulesConfig struct { - EnableTimers bool `json:"enable_timers"` -} - func (b *Bot) LoadModules() error { - var cfg BotModulesConfig - err := b.api.db.GetJSON(BotModulesConfigKey, &cfg) - if err != nil { - if !errors.Is(err, database.ErrKeyNotFound) { - return err - } - cfg = BotModulesConfig{ - EnableTimers: false, - } - } - if cfg.EnableTimers { - b.logger.Debug("starting timer module") - b.Timers = SetupTimers(b) - } + b.logger.Debug("starting timer module") + b.Timers = SetupTimers(b) + b.logger.Debug("starting alerts module") + b.Alerts = SetupAlerts(b) return nil } diff --git a/modules/twitch/modules.timer.go b/modules/twitch/modules.timer.go index 806e074..661fbe7 100644 --- a/modules/twitch/modules.timer.go +++ b/modules/twitch/modules.timer.go @@ -44,6 +44,7 @@ func SetupTimers(bot *Bot) *BotTimerModule { bot: bot, startTime: time.Now().Round(time.Minute), lastTrigger: make(map[string]time.Time), + mu: sync.Mutex{}, } // Load config from database @@ -79,8 +80,6 @@ func SetupTimers(bot *Bot) *BotTimerModule { func (m *BotTimerModule) runTimers() { for { - //TODO Add stopping condition (channel or something) - // Wait until next tick (remainder until next minute, as close to 0 seconds as possible) currentTime := time.Now() nextTick := currentTime.Round(time.Minute).Add(time.Minute)