strimertul/modules/twitch/modules.alerts.go

476 lines
16 KiB
Go

package twitch
import (
"bytes"
"context"
"encoding/json"
"fmt"
"math/rand"
"sync"
"text/template"
"time"
"github.com/Masterminds/sprig/v3"
jsoniter "github.com/json-iterator/go"
"github.com/nicklaw5/helix/v2"
"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:"messages"`
Variations []struct {
MinStreak *int `json:"min_streak,omitempty"`
IsGifted *bool `json:"is_gifted,omitempty"`
Messages []string `json:"messages"`
} `json:"variations"`
} `json:"subscription"`
GiftSub struct {
Enabled bool `json:"enabled"`
Messages []string `json:"messages"`
Variations []struct {
MinCumulative *int `json:"min_cumulative,omitempty"`
IsAnonymous *bool `json:"is_anonymous,omitempty"`
Messages []string `json:"messages"`
} `json:"variations"`
} `json:"gift_sub"`
Raid struct {
Enabled bool `json:"enabled"`
Messages []string `json:"messages"`
Variations []struct {
MinViewers *int `json:"min_viewers,omitempty"`
Messages []string `json:"messages"`
} `json:"variations"`
} `json:"raid"`
Cheer struct {
Enabled bool `json:"enabled"`
Messages []string `json:"messages"`
Variations []struct {
MinAmount *int `json:"min_amount,omitempty"`
Messages []string `json:"messages"`
} `json:"variations"`
} `json:"cheer"`
}
type BotAlertsModule struct {
Config BotAlertsConfig
bot *Bot
mu sync.Mutex
templates map[string]*template.Template
}
func SetupAlerts(bot *Bot) *BotAlertsModule {
mod := &BotAlertsModule{
bot: bot,
mu: sync.Mutex{},
templates: make(map[string]*template.Template),
}
// 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{}
// Save empty config
bot.api.db.PutJSON(BotAlertsKey, mod.Config)
}
mod.compileTemplates()
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")
}
mod.compileTemplates()
}
}
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
messageID := rand.Intn(len(mod.Config.Subscription.Messages))
tpl, ok := mod.templates[fmt.Sprintf("sub-%d", messageID)]
// If template is broken, write it as is (soft fail, plus we raise attention I guess?)
if !ok {
mod.bot.WriteMessage(mod.Config.Subscription.Messages[messageID])
return
}
// Check for variations, either by streak or gifted
if sub.IsGift {
for variationIndex, variation := range mod.Config.Subscription.Variations {
if variation.IsGifted != nil && *variation.IsGifted {
// Make sure template is valid
if temp, ok := mod.templates[fmt.Sprintf("sub-v%d-%d", variationIndex, messageID)]; ok {
tpl = temp
break
}
}
}
} else if sub.DurationMonths > 0 {
minMonths := -1
for variationIndex, variation := range mod.Config.Subscription.Variations {
if variation.MinStreak != nil && sub.DurationMonths >= *variation.MinStreak && sub.DurationMonths >= minMonths {
// Make sure template is valid
if temp, ok := mod.templates[fmt.Sprintf("sub-v%d-%d", variationIndex, messageID)]; ok {
tpl = temp
minMonths = *variation.MinStreak
}
}
}
}
writeTemplate(bot, tpl, 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.CumulativeMonths = sub.CumulativeMonths
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,
CumulativeMonths: sub.CumulativeMonths,
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
messageID := rand.Intn(len(mod.Config.Follow.Messages))
// Pick compiled template or fallback to plain text
if tpl, ok := mod.templates[fmt.Sprintf("follow-%d", messageID)]; ok {
writeTemplate(bot, tpl, &followEv)
} else {
bot.WriteMessage(mod.Config.Follow.Messages[messageID])
}
// Compile template and send
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
messageID := rand.Intn(len(mod.Config.Raid.Messages))
tpl, ok := mod.templates[fmt.Sprintf("raid-%d", messageID)]
if !ok {
// Broken template!
mod.bot.WriteMessage(mod.Config.Raid.Messages[messageID])
continue
}
// 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 variationIndex, variation := range mod.Config.Raid.Variations {
if variation.MinViewers != nil && *variation.MinViewers > minViewers && raidEv.Viewers >= *variation.MinViewers {
// Make sure the template is valid
if varTemplate, ok := mod.templates[fmt.Sprintf("raid-v%d-%d", variationIndex, messageID)]; ok {
tpl = varTemplate
minViewers = *variation.MinViewers
}
}
}
}
// Compile template and send
writeTemplate(bot, tpl, &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
messageID := rand.Intn(len(mod.Config.Cheer.Messages))
tpl, ok := mod.templates[fmt.Sprintf("cheer-%d", messageID)]
if !ok {
// Broken template!
mod.bot.WriteMessage(mod.Config.Raid.Messages[messageID])
continue
}
// 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 variationIndex, variation := range mod.Config.Cheer.Variations {
if variation.MinAmount != nil && *variation.MinAmount > minAmount && cheerEv.Bits >= *variation.MinAmount {
// Make sure the template is valid
if varTemplate, ok := mod.templates[fmt.Sprintf("cheer-v%d-%d", variationIndex, messageID)]; ok {
tpl = varTemplate
minAmount = *variation.MinAmount
}
}
}
}
// Compile template and send
writeTemplate(bot, tpl, &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)
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)
case helix.EventSubTypeChannelSubscriptionGift:
// Only process if we care about gifted subs
if !mod.Config.GiftSub.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
messageID := rand.Intn(len(mod.Config.GiftSub.Messages))
tpl, ok := mod.templates[fmt.Sprintf("gift-%d", messageID)]
if !ok {
// Broken template!
mod.bot.WriteMessage(mod.Config.GiftSub.Messages[messageID])
continue
}
// 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 {
if giftEv.IsAnonymous {
for variationIndex, variation := range mod.Config.GiftSub.Variations {
if variation.IsAnonymous != nil && *variation.IsAnonymous {
// Make sure template is valid
if temp, ok := mod.templates[fmt.Sprintf("gift-v%d-%d", variationIndex, messageID)]; ok {
tpl = temp
break
}
}
}
} else if giftEv.CumulativeTotal > 0 {
minCumulative := -1
for variationIndex, variation := range mod.Config.GiftSub.Variations {
if variation.MinCumulative != nil && *variation.MinCumulative > minCumulative && giftEv.CumulativeTotal >= *variation.MinCumulative {
// Make sure template is valid
if temp, ok := mod.templates[fmt.Sprintf("gift-v%d-%d", variationIndex, messageID)]; ok {
tpl = temp
minCumulative = *variation.MinCumulative
}
}
}
}
}
// Compile template and send
writeTemplate(bot, tpl, &giftEv)
}
}
}
return nil
}, "stulbe/ev/webhook")
bot.logger.Debug("loaded bot alerts")
return mod
}
func (m *BotAlertsModule) compileTemplates() {
m.templates = make(map[string]*template.Template)
for index, msg := range m.Config.Follow.Messages {
m.addTemplate(fmt.Sprintf("follow-%d", index), msg)
}
for index, msg := range m.Config.Subscription.Messages {
m.addTemplate(fmt.Sprintf("sub-%d", index), msg)
}
for varIndex, variation := range m.Config.Subscription.Variations {
for index, msg := range variation.Messages {
m.addTemplate(fmt.Sprintf("sub-v%d-%d", varIndex, index), msg)
}
}
for index, msg := range m.Config.Raid.Messages {
m.addTemplate(fmt.Sprintf("raid-%d", index), msg)
}
for varIndex, variation := range m.Config.Raid.Variations {
for index, msg := range variation.Messages {
m.addTemplate(fmt.Sprintf("raid-v%d-%d", varIndex, index), msg)
}
}
for index, msg := range m.Config.Cheer.Messages {
m.addTemplate(fmt.Sprintf("cheer-%d", index), msg)
}
for varIndex, variation := range m.Config.Cheer.Variations {
for index, msg := range variation.Messages {
m.addTemplate(fmt.Sprintf("cheer-v%d-%d", varIndex, index), msg)
}
}
for index, msg := range m.Config.GiftSub.Messages {
m.addTemplate(fmt.Sprintf("gift-%d", index), msg)
}
for varIndex, variation := range m.Config.GiftSub.Variations {
for index, msg := range variation.Messages {
m.addTemplate(fmt.Sprintf("gift-v%d-%d", varIndex, index), msg)
}
}
}
func (m *BotAlertsModule) addTemplate(id string, msg string) {
tpl, err := template.New("").Funcs(sprig.TxtFuncMap()).Parse(msg)
if err != nil {
m.bot.logger.WithError(err).Error("error compiling template")
return
}
m.templates[id] = tpl
}
func writeTemplate(bot *Bot, tpl *template.Template, data interface{}) {
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
CumulativeMonths int
StreakMonths int
DurationMonths int
Message helix.EventSubMessage
}