feat: WIP twitch rework

This commit is contained in:
Ash Keel 2024-03-10 17:38:18 +01:00
parent bcdecf50c0
commit 0d1c60451b
No known key found for this signature in database
GPG Key ID: 53A9E9A6035DD109
40 changed files with 2304 additions and 2243 deletions

12
app.go
View File

@ -12,6 +12,8 @@ import (
"runtime/debug"
"strconv"
"git.sr.ht/~ashkeel/strimertul/twitch/client"
"github.com/wailsapp/wails/v2/pkg/options"
kv "github.com/strimertul/kilovolt/v11"
@ -41,7 +43,7 @@ type App struct {
cancelLogs database.CancelFunc
db *database.LocalDBClient
twitchManager *twitch.Manager
twitchManager *client.Manager
httpServer *webserver.WebServer
loyaltyManager *loyalty.Manager
}
@ -146,13 +148,13 @@ func (a *App) initializeComponents() error {
}
// Create twitch client
a.twitchManager, err = twitch.NewManager(a.db, a.httpServer, logger)
a.twitchManager, err = client.NewManager(a.db, a.httpServer, logger)
if err != nil {
return fmt.Errorf("could not initialize twitch client: %w", err)
}
// Initialize loyalty system
a.loyaltyManager, err = loyalty.NewManager(a.db, a.twitchManager, logger)
a.loyaltyManager, err = loyalty.NewManager(a.db, logger)
if err != nil {
return fmt.Errorf("could not initialize loyalty manager: %w", err)
}
@ -225,7 +227,7 @@ func (a *App) GetKilovoltBind() string {
}
func (a *App) GetTwitchAuthURL() string {
return a.twitchManager.Client().GetAuthorizationURL()
return twitch.GetAuthorizationURL(a.twitchManager.Client().API)
}
func (a *App) GetTwitchLoggedUser() (helix.User, error) {
@ -296,7 +298,7 @@ func (a *App) GetAppVersion() VersionInfo {
}
func (a *App) TestTemplate(message string, data any) error {
tpl, err := a.twitchManager.Client().Bot.MakeTemplate(message)
tpl, err := a.twitchManager.Client().GetTemplateEngine().MakeTemplate(message)
if err != nil {
return err
}

View File

@ -21,6 +21,19 @@ var (
ErrEmptyKey = errors.New("empty key")
)
type Database interface {
GetKey(key string) (string, error)
PutKey(key string, data string) error
SubscribePrefix(fn kv.SubscriptionCallback, prefixes ...string) (cancelFn CancelFunc, err error)
SubscribeKey(key string, fn func(string)) (cancelFn CancelFunc, err error)
GetJSON(key string, dst any) error
GetAll(prefix string) (map[string]string, error)
PutJSON(key string, data any) error
PutJSONBulk(kvs map[string]any) error
RemoveKey(key string) error
Hub() *kv.Hub
}
type LocalDBClient struct {
client *kv.LocalClient
hub *kv.Hub

View File

@ -3,7 +3,7 @@ package docs
import (
"git.sr.ht/~ashkeel/strimertul/docs/interfaces"
"git.sr.ht/~ashkeel/strimertul/loyalty"
"git.sr.ht/~ashkeel/strimertul/twitch"
"git.sr.ht/~ashkeel/strimertul/twitch/doc"
"git.sr.ht/~ashkeel/strimertul/utils"
"git.sr.ht/~ashkeel/strimertul/webserver"
)
@ -25,12 +25,12 @@ func addKeys(keyMap interfaces.KeyMap) {
func init() {
// Put all enums here
utils.MergeMap(Enums, twitch.Enums)
utils.MergeMap(Enums, doc.Enums)
utils.MergeMap(Enums, enums)
// Put all keys here
addKeys(strimertulKeys)
addKeys(twitch.Keys)
addKeys(doc.Keys)
addKeys(loyalty.Keys)
addKeys(webserver.Keys)
}

5
go.mod
View File

@ -10,11 +10,10 @@ require (
github.com/Masterminds/sprig/v3 v3.2.3
github.com/apenwarr/fixconsole v0.0.0-20191012055117-5a9f6489cc29
github.com/cockroachdb/pebble v1.1.0
github.com/gempir/go-twitch-irc/v4 v4.0.0
github.com/gorilla/websocket v1.5.1
github.com/hashicorp/golang-lru/v2 v2.0.7
github.com/json-iterator/go v1.1.12
github.com/nicklaw5/helix/v2 v2.26.0
github.com/nicklaw5/helix/v2 v2.28.0
github.com/strimertul/kilovolt/v11 v11.0.1
github.com/urfave/cli/v2 v2.27.1
github.com/wailsapp/wails/v2 v2.8.0
@ -22,8 +21,6 @@ require (
gopkg.in/natefinch/lumberjack.v2 v2.2.1
)
replace github.com/nicklaw5/helix/v2 => github.com/ashkeel/helix/v2 v2.26.0-chat.2
require (
github.com/DataDog/zstd v1.4.5 // indirect
github.com/Masterminds/goutils v1.1.1 // indirect

6
go.sum
View File

@ -54,8 +54,6 @@ github.com/apenwarr/fixconsole v0.0.0-20191012055117-5a9f6489cc29 h1:muXWUcay7DD
github.com/apenwarr/fixconsole v0.0.0-20191012055117-5a9f6489cc29/go.mod h1:JYWahgHer+Z2xbsgHPtaDYVWzeHDminu+YIBWkxpCAY=
github.com/apenwarr/w32 v0.0.0-20190407065021-aa00fece76ab h1:CMGzRRCjnD50RjUFSArBLuCxiDvdp7b8YPAcikBEQ+k=
github.com/apenwarr/w32 v0.0.0-20190407065021-aa00fece76ab/go.mod h1:nfFtvHn2Hgs9G1u0/J6LHQv//EksNC+7G8vXmd1VTJ8=
github.com/ashkeel/helix/v2 v2.26.0-chat.2 h1:dedyfwwLEAegbeBuyMhvs4X608bN7YBYjuZ6rT5IOTA=
github.com/ashkeel/helix/v2 v2.26.0-chat.2/go.mod h1:zZcKsyyBWDli34x3QleYsVMiiNGMXPAEU5NjsiZDtvY=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
@ -94,8 +92,6 @@ github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymF
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/gempir/go-twitch-irc/v4 v4.0.0 h1:sHVIvbWOv9nHXGEErilclxASv0AaQEr/r/f9C0B9aO8=
github.com/gempir/go-twitch-irc/v4 v4.0.0/go.mod h1:QsOMMAk470uxQ7EYD9GJBGAVqM/jDrXBNbuePfTauzg=
github.com/getsentry/sentry-go v0.18.0 h1:MtBW5H9QgdcJabtZcuJG80BMOwaBpkRDZkxRkNC1sN0=
github.com/getsentry/sentry-go v0.18.0/go.mod h1:Kgon4Mby+FJ7ZWHFUAZgVaIa8sxHtnRJRLTXZr51aKQ=
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
@ -255,6 +251,8 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/nicklaw5/helix/v2 v2.28.0 h1:BCpIh9gf/7dsTNyxzgY18VHpt9W6/t0zUioyuDhH6tA=
github.com/nicklaw5/helix/v2 v2.28.0/go.mod h1:zZcKsyyBWDli34x3QleYsVMiiNGMXPAEU5NjsiZDtvY=
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU=

View File

@ -7,13 +7,12 @@ import (
"strings"
"time"
"git.sr.ht/~ashkeel/strimertul/database"
"git.sr.ht/~ashkeel/strimertul/twitch"
"git.sr.ht/~ashkeel/strimertul/utils"
"git.sr.ht/~ashkeel/containers/sync"
jsoniter "github.com/json-iterator/go"
"go.uber.org/zap"
"git.sr.ht/~ashkeel/strimertul/database"
"git.sr.ht/~ashkeel/strimertul/utils"
)
var json = jsoniter.ConfigFastest
@ -31,19 +30,17 @@ type Manager struct {
Rewards *sync.Slice[Reward]
Goals *sync.Slice[Goal]
Queue *sync.Slice[Redeem]
db *database.LocalDBClient
db database.Database
logger *zap.Logger
cooldowns map[string]time.Time
banlist map[string]bool
activeUsers *sync.Map[string, bool]
twitchManager *twitch.Manager
ctx context.Context
cancelFn context.CancelFunc
cancelSub database.CancelFunc
restartTwitchHandler chan struct{}
}
func NewManager(db *database.LocalDBClient, twitchManager *twitch.Manager, logger *zap.Logger) (*Manager, error) {
func NewManager(db database.Database, logger *zap.Logger) (*Manager, error) {
ctx, cancelFn := context.WithCancel(context.Background())
loyalty := &Manager{
Config: sync.NewRWSync(Config{Enabled: false}),
@ -56,8 +53,6 @@ func NewManager(db *database.LocalDBClient, twitchManager *twitch.Manager, logge
points: sync.NewMap[string, PointsEntry](),
cooldowns: make(map[string]time.Time),
banlist: make(map[string]bool),
activeUsers: sync.NewMap[string, bool](),
twitchManager: twitchManager,
ctx: ctx,
cancelFn: cancelFn,
restartTwitchHandler: make(chan struct{}),
@ -127,9 +122,6 @@ func NewManager(db *database.LocalDBClient, twitchManager *twitch.Manager, logge
loyalty.SetBanList(config.BanList)
// Setup twitch integration
loyalty.SetupTwitch()
return loyalty, nil
}
@ -142,9 +134,6 @@ func (m *Manager) Close() error {
// Send cancellation
m.cancelFn()
// Teardown twitch integration
m.StopTwitch()
return nil
}
@ -157,8 +146,6 @@ func (m *Manager) update(key, value string) {
if err == nil {
m.SetBanList(m.Config.Get().BanList)
m.restartTwitchHandler <- struct{}{}
m.StopTwitch()
m.SetupTwitch()
}
case GoalsKey:
err = utils.LoadJSONToWrapped[[]Goal](value, m.Goals)
@ -368,3 +355,15 @@ func (m *Manager) Equals(c utils.Comparable) bool {
}
return false
}
func (m *Manager) SetBanList(banned []string) {
m.banlist = make(map[string]bool)
for _, usr := range banned {
m.banlist[usr] = true
}
}
func (m *Manager) IsBanned(user string) bool {
banned, ok := m.banlist[user]
return ok && banned
}

View File

@ -1,360 +0,0 @@
package loyalty
import (
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/nicklaw5/helix/v2"
"git.sr.ht/~ashkeel/strimertul/twitch"
"git.sr.ht/~ashkeel/containers/sync"
irc "github.com/gempir/go-twitch-irc/v4"
"go.uber.org/zap"
)
const (
commandRedeem = "!redeem"
commandGoals = "!goals"
commandBalance = "!balance"
commandContribute = "!contribute"
)
func (m *Manager) SetupTwitch() {
bot := m.twitchManager.Client().Bot
if bot == nil {
m.logger.Warn("Twitch bot is offline or not configured, could not setup commands")
return
}
// Add loyalty-based commands
bot.RegisterCommand(commandRedeem, twitch.BotCommand{
Description: "Redeem a reward with loyalty points",
Usage: fmt.Sprintf("%s <reward-id> [request text]", commandRedeem),
AccessLevel: twitch.ALTEveryone,
Handler: m.cmdRedeemReward,
Enabled: true,
})
bot.RegisterCommand(commandBalance, twitch.BotCommand{
Description: "See your current point balance",
Usage: commandBalance,
AccessLevel: twitch.ALTEveryone,
Handler: m.cmdBalance,
Enabled: true,
})
bot.RegisterCommand(commandGoals, twitch.BotCommand{
Description: "Check currently active community goals",
Usage: commandGoals,
AccessLevel: twitch.ALTEveryone,
Handler: m.cmdGoalList,
Enabled: true,
})
bot.RegisterCommand(commandContribute, twitch.BotCommand{
Description: "Contribute points to a community goal",
Usage: fmt.Sprintf("%s <points> [<goal-id>]", commandContribute),
AccessLevel: twitch.ALTEveryone,
Handler: m.cmdContributeGoal,
Enabled: true,
})
// Setup message handler for tracking user activity
bot.OnMessage.Add(m)
// Setup handler for adding points over time
go func() {
config := m.Config.Get()
// Stop handler if loyalty system is disabled or there is no valid point interval
if !config.Enabled || config.Points.Interval <= 0 {
return
}
for {
// Wait for next poll
select {
case <-m.ctx.Done():
return
case <-m.restartTwitchHandler:
return
case <-time.After(time.Duration(config.Points.Interval) * time.Second):
}
client := m.twitchManager.Client()
// If stream is confirmed offline, don't give points away!
isOnline := client.IsLive()
if !isOnline {
continue
}
// Get user list
cursor := ""
var users []string
for {
userClient, err := client.GetUserClient(false)
if err != nil {
m.logger.Error("Could not get user api client for list of chatters", zap.Error(err))
return
}
res, err := userClient.GetChannelChatChatters(&helix.GetChatChattersParams{
BroadcasterID: client.User.ID,
ModeratorID: client.User.ID,
First: "1000",
After: cursor,
})
if err != nil {
m.logger.Error("Could not retrieve list of chatters", zap.Error(err))
return
}
for _, user := range res.Data.Chatters {
users = append(users, user.UserLogin)
}
cursor = res.Data.Pagination.Cursor
if cursor == "" {
break
}
}
// Iterate for each user in the list
pointsToGive := make(map[string]int64)
for _, user := range users {
// Check if user is blocked
if m.IsBanned(user) {
continue
}
// Check if user was active (chatting) for the bonus dingus
award := config.Points.Amount
if m.IsActive(user) {
award += config.Points.ActivityBonus
}
// Add to point pool if already on it, otherwise initialize
pointsToGive[user] = award
}
m.ResetActivity()
// If changes were made, save the pool!
if len(users) > 0 {
err := m.GivePoints(pointsToGive)
if err != nil {
m.logger.Error("Error awarding loyalty points to user", zap.Error(err))
}
}
}
}()
m.logger.Info("Loyalty system integration with Twitch is ready")
}
func (m *Manager) StopTwitch() {
bot := m.twitchManager.Client().Bot
if bot != nil {
bot.RemoveCommand(commandRedeem)
bot.RemoveCommand(commandBalance)
bot.RemoveCommand(commandGoals)
bot.RemoveCommand(commandContribute)
// Remove message handler
bot.OnMessage.Remove(m)
}
}
func (m *Manager) HandleBotMessage(message irc.PrivateMessage) {
m.activeUsers.SetKey(message.User.Name, true)
}
func (m *Manager) SetBanList(banned []string) {
m.banlist = make(map[string]bool)
for _, usr := range banned {
m.banlist[usr] = true
}
}
func (m *Manager) IsBanned(user string) bool {
banned, ok := m.banlist[user]
return ok && banned
}
func (m *Manager) IsActive(user string) bool {
active, ok := m.activeUsers.GetKey(user)
return ok && active
}
func (m *Manager) ResetActivity() {
m.activeUsers = sync.NewMap[string, bool]()
}
func (m *Manager) cmdBalance(bot *twitch.Bot, message irc.PrivateMessage) {
// Get user balance
balance := m.GetPoints(message.User.Name)
bot.Client.Say(message.Channel, fmt.Sprintf("%s: You have %d %s!", message.User.DisplayName, balance, m.Config.Get().Currency))
}
func (m *Manager) cmdRedeemReward(bot *twitch.Bot, message irc.PrivateMessage) {
parts := strings.Fields(message.Message)
if len(parts) < 2 {
return
}
redeemID := parts[1]
// Find reward
reward := m.GetReward(redeemID)
if reward.ID == "" {
return
}
// Reward not active, return early
if !reward.Enabled {
return
}
// Get user balance
balance := m.GetPoints(message.User.Name)
config := m.Config.Get()
// Check if user can afford the reward
if balance-reward.Price < 0 {
bot.Client.Say(message.Channel, fmt.Sprintf("I'm sorry %s but you cannot afford this (have %d %s, need %d)", message.User.DisplayName, balance, config.Currency, reward.Price))
return
}
text := ""
if len(parts) > 2 {
text = strings.Join(parts[2:], " ")
}
// Perform redeem
if err := m.PerformRedeem(Redeem{
Username: message.User.Name,
DisplayName: message.User.DisplayName,
When: time.Now(),
Reward: reward,
RequestText: text,
}); err != nil {
if errors.Is(err, ErrRedeemInCooldown) {
nextAvailable := m.GetRewardCooldown(reward.ID)
bot.Client.Say(message.Channel, fmt.Sprintf("%s: That reward is in cooldown (available in %s)", message.User.DisplayName,
time.Until(nextAvailable).Truncate(time.Second)))
return
}
m.logger.Error("Error while performing redeem", zap.Error(err))
return
}
bot.Client.Say(message.Channel, fmt.Sprintf("HolidayPresent %s has redeemed %s! (new balance: %d %s)", message.User.DisplayName, reward.Name, m.GetPoints(message.User.Name), config.Currency))
}
func (m *Manager) cmdGoalList(bot *twitch.Bot, message irc.PrivateMessage) {
goals := m.Goals.Get()
if len(goals) < 1 {
bot.Client.Say(message.Channel, fmt.Sprintf("%s: There are no active community goals right now :(!", message.User.DisplayName))
return
}
msg := "Current goals: "
for _, goal := range goals {
if !goal.Enabled {
continue
}
msg += fmt.Sprintf("%s (%d/%d %s) [id: %s] | ", goal.Name, goal.Contributed, goal.TotalGoal, m.Config.Get().Currency, goal.ID)
}
msg += " Contribute with <!contribute POINTS GOALID>"
bot.Client.Say(message.Channel, msg)
}
func (m *Manager) cmdContributeGoal(bot *twitch.Bot, message irc.PrivateMessage) {
goals := m.Goals.Get()
// Set defaults if user doesn't provide them
points := int64(100)
goalIndex := -1
hasGoals := false
// Get first unreached goal for default
for index, goal := range goals {
if !goal.Enabled {
continue
}
hasGoals = true
if goal.Contributed < goal.TotalGoal {
goalIndex = index
break
}
}
// Do we not have any goal we can contribute to? Hooray I guess?
if goalIndex < 0 {
if hasGoals {
bot.Client.Say(message.Channel, fmt.Sprintf("%s: All active community goals have been reached already! NewRecord", message.User.DisplayName))
} else {
bot.Client.Say(message.Channel, fmt.Sprintf("%s: There are no active community goals right now :(!", message.User.DisplayName))
}
return
}
// Parse parameters if provided
parts := strings.Fields(message.Message)
if len(parts) > 1 {
newPoints, err := strconv.ParseInt(parts[1], 10, 64)
if err == nil {
if newPoints <= 0 {
bot.Client.Say(message.Channel, fmt.Sprintf("Nice try %s SoBayed", message.User.DisplayName))
return
}
points = newPoints
}
if len(parts) > 2 {
found := false
goalID := parts[2]
// Find Goal index
for index, goal := range goals {
if !goal.Enabled {
continue
}
if goal.ID == goalID {
goalIndex = index
found = true
break
}
}
// Invalid goal ID provided
if !found {
bot.Client.Say(message.Channel, fmt.Sprintf("%s: I couldn't find that goal ID :(", message.User.DisplayName))
return
}
}
}
// Get goal
selectedGoal := goals[goalIndex]
// Check if goal was reached already
if selectedGoal.Contributed >= selectedGoal.TotalGoal {
bot.Client.Say(message.Channel, fmt.Sprintf("%s: This goal was already reached! ヾ(•ω•`)o", message.User.DisplayName))
return
}
// Add points to goal
points, err := m.PerformContribution(selectedGoal, message.User.Name, points)
if err != nil {
m.logger.Error("Error while contributing to goal", zap.Error(err))
return
}
if points == 0 {
bot.Client.Say(message.Channel, fmt.Sprintf("%s: Sorry but you're broke", message.User.DisplayName))
return
}
selectedGoal = m.Goals.Get()[goalIndex]
config := m.Config.Get()
newRemaining := selectedGoal.TotalGoal - selectedGoal.Contributed
bot.Client.Say(message.Channel, fmt.Sprintf("NewRecord %s contributed %d %s to \"%s\"!! Only %d %s left!", message.User.DisplayName, points, config.Currency, selectedGoal.Name, newRemaining, config.Currency))
// Check if goal was reached!
// TODO Replace this with sub from loyalty system or something?
if newRemaining <= 0 {
bot.Client.Say(message.Channel, fmt.Sprintf("FallWinning The community goal \"%s\" was reached! FallWinning", selectedGoal.Name))
}
}

View File

@ -1,6 +1,9 @@
package main
import "go.uber.org/zap"
import (
"git.sr.ht/~ashkeel/strimertul/twitch"
"go.uber.org/zap"
)
type ProblemID string
@ -21,7 +24,7 @@ func (a *App) GetProblems() (problems []Problem) {
client := a.twitchManager.Client()
if client != nil {
// Check if the app needs to be authorized again
scopesMatch, err := client.CheckScopes()
scopesMatch, err := twitch.CheckScopes(client.DB)
if err != nil {
logger.Warn("Could not check scopes for problems", zap.Error(err))
} else {

68
twitch/alerts/config.go Normal file
View File

@ -0,0 +1,68 @@
package alerts
import (
jsoniter "github.com/json-iterator/go"
"github.com/nicklaw5/helix/v2"
)
const ConfigKey = "twitch/alerts/config"
type eventSubNotification struct {
Subscription helix.EventSubSubscription `json:"subscription"`
Challenge string `json:"challenge"`
Event jsoniter.RawMessage `json:"event" desc:"Event payload, as JSON object"`
}
type subscriptionVariation struct {
MinStreak *int `json:"min_streak,omitempty" desc:"Minimum streak to get this message"`
IsGifted *bool `json:"is_gifted,omitempty" desc:"If true, only gifted subscriptions will get these messages"`
Messages []string `json:"messages" desc:"List of message to write on subscription, one at random will be picked"`
}
type giftSubVariation struct {
MinCumulative *int `json:"min_cumulative,omitempty" desc:"Minimum cumulative amount to get this message"`
IsAnonymous *bool `json:"is_anonymous,omitempty" desc:"If true, only anonymous gifts will get these messages"`
Messages []string `json:"messages" desc:"List of message to write on gifted subscription, one at random will be picked"`
}
type raidVariation struct {
MinViewers *int `json:"min_viewers,omitempty" desc:"Minimum number of viewers to get this message"`
Messages []string `json:"messages" desc:"List of message to write on raid, one at random will be picked"`
}
type cheerVariation struct {
MinAmount *int `json:"min_amount,omitempty" desc:"Minimum amount to get this message"`
Messages []string `json:"messages" desc:"List of message to write on cheer, one at random will be picked"`
}
type Config struct {
Follow struct {
Enabled bool `json:"enabled" desc:"Enable chat message alert on follow"`
Messages []string `json:"messages" desc:"List of message to write on follow, one at random will be picked"`
Announce bool `json:"announce" desc:"If true, send as announcement"`
} `json:"follow"`
Subscription struct {
Enabled bool `json:"enabled" desc:"Enable chat message alert on subscription"`
Messages []string `json:"messages" desc:"List of message to write on subscription, one at random will be picked"`
Announce bool `json:"announce" desc:"If true, send as announcement"`
Variations []subscriptionVariation `json:"variations"`
} `json:"subscription"`
GiftSub struct {
Enabled bool `json:"enabled" desc:"Enable chat message alert on gifted subscription"`
Messages []string `json:"messages" desc:"List of message to write on gifted subscription, one at random will be picked"`
Announce bool `json:"announce" desc:"If true, send as announcement"`
Variations []giftSubVariation `json:"variations"`
} `json:"gift_sub"`
Raid struct {
Enabled bool `json:"enabled" desc:"Enable chat message alert on raid"`
Messages []string `json:"messages" desc:"List of message to write on raid, one at random will be picked"`
Announce bool `json:"announce" desc:"If true, send as announcement"`
Variations []raidVariation `json:"variations"`
} `json:"raid"`
Cheer struct {
Enabled bool `json:"enabled" desc:"Enable chat message alert on cheer"`
Messages []string `json:"messages" desc:"List of message to write on cheer, one at random will be picked"`
Announce bool `json:"announce" desc:"If true, send as announcement"`
Variations []cheerVariation `json:"variations"`
} `json:"cheer"`
}

210
twitch/alerts/events.go Normal file
View File

@ -0,0 +1,210 @@
package alerts
import (
"math/rand"
"text/template"
"github.com/nicklaw5/helix/v2"
"go.uber.org/zap"
"git.sr.ht/~ashkeel/strimertul/twitch/chat"
)
func (m *Module) onEventSubEvent(_ string, value string) {
var ev eventSubNotification
if err := json.UnmarshalFromString(value, &ev); err != nil {
m.logger.Warn("Error parsing webhook payload", zap.Error(err))
return
}
switch ev.Subscription.Type {
case helix.EventSubTypeChannelFollow:
// Only process if we care about follows
if !m.Config.Follow.Enabled {
return
}
// Parse as a follow event
var followEv helix.EventSubChannelFollowEvent
if err := json.Unmarshal(ev.Event, &followEv); err != nil {
m.logger.Warn("Error parsing follow event", zap.Error(err))
return
}
// Pick a random message
messageID := rand.Intn(len(m.Config.Follow.Messages))
// Pick compiled template or fallback to plain text
tpl, ok := m.templates[templateTypeFollow][m.Config.Follow.Messages[messageID]]
if !ok {
chat.WriteMessage(m.db, m.logger, chat.WriteMessageRequest{
Message: m.Config.Follow.Messages[messageID],
Announce: m.Config.Follow.Announce,
})
return
}
m.writeTemplate(tpl, &followEv, m.Config.Follow.Announce)
// Compile template and send
case helix.EventSubTypeChannelRaid:
// Only process if we care about raids
if !m.Config.Raid.Enabled {
return
}
// Parse as raid event
var raidEv helix.EventSubChannelRaidEvent
if err := json.Unmarshal(ev.Event, &raidEv); err != nil {
m.logger.Warn("Error parsing raid event", zap.Error(err))
return
}
// Pick a random message from base set
messageID := rand.Intn(len(m.Config.Raid.Messages))
tpl, ok := m.templates[templateTypeRaid][m.Config.Raid.Messages[messageID]]
if !ok {
// Broken template!
chat.WriteMessage(m.db, m.logger, chat.WriteMessageRequest{
Message: m.Config.Raid.Messages[messageID],
Announce: m.Config.Raid.Announce,
})
return
}
// If we have variations, get the available variations and pick the one with the highest minimum viewers that are met
if len(m.Config.Raid.Variations) > 0 {
variation := getBestValidVariation(m.Config.Raid.Variations, func(variation raidVariation) int {
if variation.MinViewers != nil && raidEv.Viewers >= *variation.MinViewers {
return *variation.MinViewers
}
return 0
})
tpl = m.replaceWithVariation(tpl, templateTypeRaid, variation.Messages)
}
// Compile template and send
m.writeTemplate(tpl, &raidEv, m.Config.Raid.Announce)
case helix.EventSubTypeChannelCheer:
// Only process if we care about bits
if !m.Config.Cheer.Enabled {
return
}
// Parse as cheer event
var cheerEv helix.EventSubChannelCheerEvent
if err := json.Unmarshal(ev.Event, &cheerEv); err != nil {
m.logger.Warn("Error parsing cheer event", zap.Error(err))
return
}
// Pick a random message from base set
messageID := rand.Intn(len(m.Config.Cheer.Messages))
tpl, ok := m.templates[templateTypeCheer][m.Config.Cheer.Messages[messageID]]
if !ok {
// Broken template!
chat.WriteMessage(m.db, m.logger, chat.WriteMessageRequest{
Message: m.Config.Cheer.Messages[messageID],
Announce: m.Config.Cheer.Announce,
})
return
}
// If we have variations, get the available variations and pick the one with the highest minimum amount that is met
if len(m.Config.Cheer.Variations) > 0 {
variation := getBestValidVariation(m.Config.Cheer.Variations, func(variation cheerVariation) int {
if variation.MinAmount != nil && cheerEv.Bits >= *variation.MinAmount {
return *variation.MinAmount
}
return 0
})
tpl = m.replaceWithVariation(tpl, templateTypeCheer, variation.Messages)
}
// Compile template and send
m.writeTemplate(tpl, &cheerEv, m.Config.Cheer.Announce)
case helix.EventSubTypeChannelSubscription:
// Only process if we care about subscriptions
if !m.Config.Subscription.Enabled {
return
}
// Parse as subscription event
var subEv helix.EventSubChannelSubscribeEvent
if err := json.Unmarshal(ev.Event, &subEv); err != nil {
m.logger.Warn("Error parsing new subscription event", zap.Error(err))
return
}
m.addMixedEvent(subEv)
case helix.EventSubTypeChannelSubscriptionMessage:
// Only process if we care about subscriptions
if !m.Config.Subscription.Enabled {
return
}
// Parse as subscription event
var subEv helix.EventSubChannelSubscriptionMessageEvent
err := json.Unmarshal(ev.Event, &subEv)
if err != nil {
m.logger.Warn("Error parsing returning subscription event", zap.Error(err))
return
}
m.addMixedEvent(subEv)
case helix.EventSubTypeChannelSubscriptionGift:
// Only process if we care about gifted subs
if !m.Config.GiftSub.Enabled {
return
}
// Parse as gift event
var giftEv helix.EventSubChannelSubscriptionGiftEvent
if err := json.Unmarshal(ev.Event, &giftEv); err != nil {
m.logger.Warn("Error parsing subscription gifted event", zap.Error(err))
return
}
// Pick a random message from base set
messageID := rand.Intn(len(m.Config.GiftSub.Messages))
tpl, ok := m.templates[templateTypeGift][m.Config.GiftSub.Messages[messageID]]
if !ok {
// Broken template!
chat.WriteMessage(m.db, m.logger, chat.WriteMessageRequest{
Message: m.Config.GiftSub.Messages[messageID],
Announce: m.Config.GiftSub.Announce,
})
return
}
// 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(m.Config.GiftSub.Variations) > 0 {
if giftEv.IsAnonymous {
variation := getBestValidVariation(m.Config.GiftSub.Variations, func(variation giftSubVariation) int {
if variation.IsAnonymous != nil && *variation.IsAnonymous {
return 1
}
return 0
})
tpl = m.replaceWithVariation(tpl, templateTypeGift, variation.Messages)
} else if giftEv.CumulativeTotal > 0 {
variation := getBestValidVariation(m.Config.GiftSub.Variations, func(variation giftSubVariation) int {
if variation.MinCumulative != nil && *variation.MinCumulative > giftEv.CumulativeTotal {
return *variation.MinCumulative
}
return 0
})
tpl = m.replaceWithVariation(tpl, templateTypeGift, variation.Messages)
}
}
// Compile template and send
m.writeTemplate(tpl, &giftEv, m.Config.GiftSub.Announce)
}
}
func (m *Module) replaceWithVariation(tpl *template.Template, templateType templateType, messages []string) *template.Template {
if messages != nil {
messageID := rand.Intn(len(messages))
// Make sure the template is valid
if temp, ok := m.templates[templateType][messages[messageID]]; ok {
return temp
}
}
return tpl
}
// For variations, some variations are better than others, this function returns the best one
// by using a provided score function. The score is 0 or less if the variation is not valid,
// and 1 or more if it is valid. The variation with the highest score is returned.
func getBestValidVariation[T any](variations []T, filterFunc func(T) int) T {
var best T
var bestScore int
for _, variation := range variations {
score := filterFunc(variation)
if score > bestScore {
best = variation
bestScore = score
}
}
return best
}

135
twitch/alerts/mixed.go Normal file
View File

@ -0,0 +1,135 @@
package alerts
import (
"math/rand"
"time"
"github.com/nicklaw5/helix/v2"
"git.sr.ht/~ashkeel/strimertul/twitch/chat"
)
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
}
// Subscriptions are handled with a slight delay as info come from different events and must be aggregated
func (m *Module) addMixedEvent(event any) {
switch sub := event.(type) {
case helix.EventSubChannelSubscribeEvent:
m.pendingMux.Lock()
defer m.pendingMux.Unlock()
if ev, ok := m.pendingSubs[sub.UserID]; ok {
// Already pending, add extra data
ev.IsGift = sub.IsGift
m.pendingSubs[sub.UserID] = ev
return
}
m.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)
m.processPendingSub(sub.UserID)
}()
case helix.EventSubChannelSubscriptionMessageEvent:
m.pendingMux.Lock()
defer m.pendingMux.Unlock()
if ev, ok := m.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
}
m.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)
m.processPendingSub(sub.UserID)
}()
}
}
func (m *Module) processPendingSub(user string) {
m.pendingMux.Lock()
defer m.pendingMux.Unlock()
sub, ok := m.pendingSubs[user]
defer delete(m.pendingSubs, user)
if !ok {
// Somehow it's gone? Return early
return
}
// One last check in case config changed
if !m.Config.Subscription.Enabled {
return
}
// Assign random message
messageID := rand.Intn(len(m.Config.Subscription.Messages))
tpl, ok := m.templates[templateTypeSubscription][m.Config.Subscription.Messages[messageID]]
// If template is broken, write it as is (soft fail, plus we raise attention I guess?)
if !ok {
// Broken template!
chat.WriteMessage(m.db, m.logger, chat.WriteMessageRequest{
Message: m.Config.Subscription.Messages[messageID],
Announce: m.Config.Subscription.Announce,
})
return
}
// Check for variations, either by streak or gifted
if sub.IsGift {
variation := getBestValidVariation(m.Config.Subscription.Variations, func(variation subscriptionVariation) int {
if variation.IsGifted != nil && *variation.IsGifted {
return 1
}
return 0
})
tpl = m.replaceWithVariation(tpl, templateTypeSubscription, variation.Messages)
} else if sub.DurationMonths > 0 {
// Get variation with the highest minimum streak that's met
variation := getBestValidVariation(m.Config.Subscription.Variations, func(variation subscriptionVariation) int {
if variation.MinStreak != nil && sub.DurationMonths >= *variation.MinStreak {
return sub.DurationMonths
}
return 0
})
tpl = m.replaceWithVariation(tpl, templateTypeSubscription, variation.Messages)
}
m.writeTemplate(tpl, sub, m.Config.Subscription.Announce)
}

102
twitch/alerts/module.go Normal file
View File

@ -0,0 +1,102 @@
package alerts
import (
"sync"
"text/template"
"git.sr.ht/~ashkeel/strimertul/twitch/eventsub"
template2 "git.sr.ht/~ashkeel/strimertul/twitch/template"
jsoniter "github.com/json-iterator/go"
"go.uber.org/zap"
"git.sr.ht/~ashkeel/strimertul/database"
)
var json = jsoniter.ConfigFastest
type (
templateCache map[string]*template.Template
templateCacheMap map[templateType]templateCache
)
type templateType string
const (
templateTypeSubscription templateType = "subscription"
templateTypeFollow templateType = "follow"
templateTypeRaid templateType = "raid"
templateTypeCheer templateType = "cheer"
templateTypeGift templateType = "gift"
)
type Module struct {
Config Config
db database.Database
logger *zap.Logger
templater template2.Engine
templates templateCacheMap
cancelAlertSub database.CancelFunc
cancelTwitchEventSub database.CancelFunc
pendingMux sync.Mutex
pendingSubs map[string]subMixedEvent
}
func Setup(db database.Database, logger *zap.Logger, templater template2.Engine) *Module {
mod := &Module{
db: db,
logger: logger,
templater: templater,
pendingMux: sync.Mutex{},
pendingSubs: make(map[string]subMixedEvent),
}
// Load config from database
err := db.GetJSON(ConfigKey, &mod.Config)
if err != nil {
logger.Debug("Config load error", zap.Error(err))
mod.Config = Config{}
// Save empty config
err = db.PutJSON(ConfigKey, mod.Config)
if err != nil {
logger.Warn("Could not save default config for bot alerts", zap.Error(err))
}
}
mod.compileTemplates()
mod.cancelAlertSub, err = db.SubscribeKey(ConfigKey, func(value string) {
err := json.UnmarshalFromString(value, &mod.Config)
if err != nil {
logger.Warn("Error loading alert config", zap.Error(err))
} else {
logger.Info("Reloaded alert config")
}
mod.compileTemplates()
})
if err != nil {
logger.Error("Could not set-up bot alert reload subscription", zap.Error(err))
}
mod.cancelTwitchEventSub, err = db.SubscribePrefix(mod.onEventSubEvent, eventsub.EventKeyPrefix)
if err != nil {
logger.Error("Could not setup twitch alert subscription", zap.Error(err))
}
logger.Debug("Loaded bot alerts")
return mod
}
func (m *Module) Close() {
if m.cancelAlertSub != nil {
m.cancelAlertSub()
}
if m.cancelTwitchEventSub != nil {
m.cancelTwitchEventSub()
}
}

View File

@ -0,0 +1,70 @@
package alerts
import (
"bytes"
"text/template"
"go.uber.org/zap"
"git.sr.ht/~ashkeel/strimertul/twitch/chat"
)
func (m *Module) compileTemplates() {
// Reset caches
m.templates = templateCacheMap{
templateTypeSubscription: make(templateCache),
templateTypeFollow: make(templateCache),
templateTypeRaid: make(templateCache),
templateTypeCheer: make(templateCache),
templateTypeGift: make(templateCache),
}
// Add base templates
m.addTemplatesForType(templateTypeFollow, m.Config.Follow.Messages)
m.addTemplatesForType(templateTypeSubscription, m.Config.Subscription.Messages)
m.addTemplatesForType(templateTypeRaid, m.Config.Raid.Messages)
m.addTemplatesForType(templateTypeCheer, m.Config.Cheer.Messages)
m.addTemplatesForType(templateTypeGift, m.Config.GiftSub.Messages)
// Add variations
for _, variation := range m.Config.Subscription.Variations {
m.addTemplatesForType(templateTypeSubscription, variation.Messages)
}
for _, variation := range m.Config.Raid.Variations {
m.addTemplatesForType(templateTypeRaid, variation.Messages)
}
for _, variation := range m.Config.Cheer.Variations {
m.addTemplatesForType(templateTypeCheer, variation.Messages)
}
for _, variation := range m.Config.GiftSub.Variations {
m.addTemplatesForType(templateTypeGift, variation.Messages)
}
}
func (m *Module) addTemplate(templateList templateCache, message string) {
tpl, err := m.templater.MakeTemplate(message)
if err != nil {
m.logger.Error("Error compiling alert template", zap.Error(err))
return
}
templateList[message] = tpl
}
func (m *Module) addTemplatesForType(templateList templateType, messages []string) {
for _, message := range messages {
m.addTemplate(m.templates[templateList], message)
}
}
// writeTemplate renders the template and sends the message to the channel
func (m *Module) writeTemplate(tpl *template.Template, data interface{}, announce bool) {
var buf bytes.Buffer
if err := tpl.Execute(&buf, data); err != nil {
m.logger.Error("Error executing template for bot alert", zap.Error(err))
return
}
chat.WriteMessage(m.db, m.logger, chat.WriteMessageRequest{
Message: buf.String(),
Announce: announce,
})
}

114
twitch/api.go Normal file
View File

@ -0,0 +1,114 @@
package twitch
import (
"fmt"
"time"
"github.com/nicklaw5/helix/v2"
"git.sr.ht/~ashkeel/strimertul/database"
)
func GetConfig(db database.Database) (Config, error) {
var config Config
if err := db.GetJSON(ConfigKey, &config); err != nil {
return Config{}, fmt.Errorf("failed to get twitch config: %w", err)
}
return config, nil
}
type AuthResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
Scope []string `json:"scope"`
Time time.Time
}
func GetUserClient(db database.Database, forceRefresh bool) (*helix.Client, error) {
var authResp AuthResponse
if err := db.GetJSON(AuthKey, &authResp); err != nil {
return nil, err
}
// Handle token expiration
if forceRefresh || time.Now().After(authResp.Time.Add(time.Duration(authResp.ExpiresIn)*time.Second)) {
// Refresh tokens
api, err := GetHelixAPI(db)
if err != nil {
return nil, err
}
refreshed, err := api.RefreshUserAccessToken(authResp.RefreshToken)
if err != nil {
return nil, err
}
authResp.AccessToken = refreshed.Data.AccessToken
authResp.RefreshToken = refreshed.Data.RefreshToken
authResp.Time = time.Now().Add(time.Duration(refreshed.Data.ExpiresIn) * time.Second)
// Save new token pair
err = db.PutJSON(AuthKey, authResp)
if err != nil {
return nil, err
}
}
config, err := GetConfig(db)
if err != nil {
return nil, err
}
return helix.NewClient(&helix.Options{
ClientID: config.APIClientID,
ClientSecret: config.APIClientSecret,
UserAccessToken: authResp.AccessToken,
})
}
func GetHelixAPI(db database.Database) (*helix.Client, error) {
config, err := GetConfig(db)
if err != nil {
return nil, err
}
baseurl, err := baseURL(db)
if err != nil {
return nil, err
}
redirectURI := getRedirectURI(baseurl)
// Create Twitch client
api, err := helix.NewClient(&helix.Options{
ClientID: config.APIClientID,
ClientSecret: config.APIClientSecret,
RedirectURI: redirectURI,
})
if err != nil {
return nil, err
}
// Get access token
resp, err := api.RequestAppAccessToken([]string{"user:read:email"})
if err != nil {
return nil, err
}
// Set the access token on the client
api.SetAppAccessToken(resp.Data.AccessToken)
return api, nil
}
func baseURL(db database.Database) (string, error) {
var severConfig struct {
Bind string `json:"bind"`
}
err := db.GetJSON("http/config", &severConfig)
return severConfig.Bind, err
}
func getRedirectURI(baseurl string) string {
return fmt.Sprintf("http://%s/twitch/callback", baseurl)
}

View File

@ -1,523 +0,0 @@
package twitch
import (
"bytes"
"math/rand"
"sync"
"text/template"
"time"
jsoniter "github.com/json-iterator/go"
"github.com/nicklaw5/helix/v2"
"go.uber.org/zap"
"git.sr.ht/~ashkeel/strimertul/database"
)
const BotAlertsKey = "twitch/bot-modules/alerts/config"
type eventSubNotification struct {
Subscription helix.EventSubSubscription `json:"subscription"`
Challenge string `json:"challenge"`
Event jsoniter.RawMessage `json:"event" desc:"Event payload, as JSON object"`
}
type subscriptionVariation struct {
MinStreak *int `json:"min_streak,omitempty" desc:"Minimum streak to get this message"`
IsGifted *bool `json:"is_gifted,omitempty" desc:"If true, only gifted subscriptions will get these messages"`
Messages []string `json:"messages" desc:"List of message to write on subscription, one at random will be picked"`
}
type giftSubVariation struct {
MinCumulative *int `json:"min_cumulative,omitempty" desc:"Minimum cumulative amount to get this message"`
IsAnonymous *bool `json:"is_anonymous,omitempty" desc:"If true, only anonymous gifts will get these messages"`
Messages []string `json:"messages" desc:"List of message to write on gifted subscription, one at random will be picked"`
}
type raidVariation struct {
MinViewers *int `json:"min_viewers,omitempty" desc:"Minimum number of viewers to get this message"`
Messages []string `json:"messages" desc:"List of message to write on raid, one at random will be picked"`
}
type cheerVariation struct {
MinAmount *int `json:"min_amount,omitempty" desc:"Minimum amount to get this message"`
Messages []string `json:"messages" desc:"List of message to write on cheer, one at random will be picked"`
}
type BotAlertsConfig struct {
Follow struct {
Enabled bool `json:"enabled" desc:"Enable chat message alert on follow"`
Messages []string `json:"messages" desc:"List of message to write on follow, one at random will be picked"`
} `json:"follow"`
Subscription struct {
Enabled bool `json:"enabled" desc:"Enable chat message alert on subscription"`
Messages []string `json:"messages" desc:"List of message to write on subscription, one at random will be picked"`
Variations []subscriptionVariation `json:"variations"`
} `json:"subscription"`
GiftSub struct {
Enabled bool `json:"enabled" desc:"Enable chat message alert on gifted subscription"`
Messages []string `json:"messages" desc:"List of message to write on gifted subscription, one at random will be picked"`
Variations []giftSubVariation `json:"variations"`
} `json:"gift_sub"`
Raid struct {
Enabled bool `json:"enabled" desc:"Enable chat message alert on raid"`
Messages []string `json:"messages" desc:"List of message to write on raid, one at random will be picked"`
Variations []raidVariation `json:"variations"`
} `json:"raid"`
Cheer struct {
Enabled bool `json:"enabled" desc:"Enable chat message alert on cheer"`
Messages []string `json:"messages" desc:"List of message to write on cheer, one at random will be picked"`
Variations []cheerVariation `json:"variations"`
} `json:"cheer"`
}
type (
templateCache map[string]*template.Template
templateCacheMap map[templateType]templateCache
)
type templateType string
const (
templateTypeSubscription templateType = "subscription"
templateTypeFollow templateType = "follow"
templateTypeRaid templateType = "raid"
templateTypeCheer templateType = "cheer"
templateTypeGift templateType = "gift"
)
type BotAlertsModule struct {
Config BotAlertsConfig
bot *Bot
templates templateCacheMap
cancelAlertSub database.CancelFunc
cancelTwitchEventSub database.CancelFunc
pendingMux sync.Mutex
pendingSubs map[string]subMixedEvent
}
func SetupAlerts(bot *Bot) *BotAlertsModule {
mod := &BotAlertsModule{
bot: bot,
pendingMux: sync.Mutex{},
pendingSubs: make(map[string]subMixedEvent),
}
// Load config from database
err := bot.api.db.GetJSON(BotAlertsKey, &mod.Config)
if err != nil {
bot.logger.Debug("Config load error", zap.Error(err))
mod.Config = BotAlertsConfig{}
// Save empty config
err = bot.api.db.PutJSON(BotAlertsKey, mod.Config)
if err != nil {
bot.logger.Warn("Could not save default config for bot alerts", zap.Error(err))
}
}
mod.compileTemplates()
mod.cancelAlertSub, err = bot.api.db.SubscribeKey(BotAlertsKey, func(value string) {
err := json.UnmarshalFromString(value, &mod.Config)
if err != nil {
bot.logger.Warn("Error loading alert config", zap.Error(err))
} else {
bot.logger.Info("Reloaded alert config")
}
mod.compileTemplates()
})
if err != nil {
bot.logger.Error("Could not set-up bot alert reload subscription", zap.Error(err))
}
mod.cancelTwitchEventSub, err = bot.api.db.SubscribePrefix(mod.onEventSubEvent, EventSubEventKeyPrefix)
if err != nil {
bot.logger.Error("Could not setup twitch alert subscription", zap.Error(err))
}
bot.logger.Debug("Loaded bot alerts")
return mod
}
func (m *BotAlertsModule) onEventSubEvent(_ string, value string) {
var ev eventSubNotification
err := json.UnmarshalFromString(value, &ev)
if err != nil {
m.bot.logger.Warn("Error parsing webhook payload", zap.Error(err))
return
}
switch ev.Subscription.Type {
case helix.EventSubTypeChannelFollow:
// Only process if we care about follows
if !m.Config.Follow.Enabled {
return
}
// Parse as a follow event
var followEv helix.EventSubChannelFollowEvent
err := json.Unmarshal(ev.Event, &followEv)
if err != nil {
m.bot.logger.Warn("Error parsing follow event", zap.Error(err))
return
}
// Pick a random message
messageID := rand.Intn(len(m.Config.Follow.Messages))
// Pick compiled template or fallback to plain text
if tpl, ok := m.templates[templateTypeFollow][m.Config.Follow.Messages[messageID]]; ok {
writeTemplate(m.bot, tpl, &followEv)
} else {
m.bot.WriteMessage(m.Config.Follow.Messages[messageID])
}
// Compile template and send
case helix.EventSubTypeChannelRaid:
// Only process if we care about raids
if !m.Config.Raid.Enabled {
return
}
// Parse as raid event
var raidEv helix.EventSubChannelRaidEvent
err := json.Unmarshal(ev.Event, &raidEv)
if err != nil {
m.bot.logger.Warn("Error parsing raid event", zap.Error(err))
return
}
// Pick a random message from base set
messageID := rand.Intn(len(m.Config.Raid.Messages))
tpl, ok := m.templates[templateTypeRaid][m.Config.Raid.Messages[messageID]]
if !ok {
// Broken template!
m.bot.WriteMessage(m.Config.Raid.Messages[messageID])
return
}
// If we have variations, get the available variations and pick the one with the highest minimum viewers that are met
if len(m.Config.Raid.Variations) > 0 {
variation := getBestValidVariation(m.Config.Raid.Variations, func(variation raidVariation) int {
if variation.MinViewers != nil && raidEv.Viewers >= *variation.MinViewers {
return *variation.MinViewers
}
return 0
})
tpl = m.replaceWithVariation(tpl, templateTypeRaid, variation.Messages)
}
// Compile template and send
writeTemplate(m.bot, tpl, &raidEv)
case helix.EventSubTypeChannelCheer:
// Only process if we care about bits
if !m.Config.Cheer.Enabled {
return
}
// Parse as cheer event
var cheerEv helix.EventSubChannelCheerEvent
err := json.Unmarshal(ev.Event, &cheerEv)
if err != nil {
m.bot.logger.Warn("Error parsing cheer event", zap.Error(err))
return
}
// Pick a random message from base set
messageID := rand.Intn(len(m.Config.Cheer.Messages))
tpl, ok := m.templates[templateTypeCheer][m.Config.Cheer.Messages[messageID]]
if !ok {
// Broken template!
m.bot.WriteMessage(m.Config.Raid.Messages[messageID])
return
}
// If we have variations, get the available variations and pick the one with the highest minimum amount that is met
if len(m.Config.Cheer.Variations) > 0 {
variation := getBestValidVariation(m.Config.Cheer.Variations, func(variation cheerVariation) int {
if variation.MinAmount != nil && cheerEv.Bits >= *variation.MinAmount {
return *variation.MinAmount
}
return 0
})
tpl = m.replaceWithVariation(tpl, templateTypeCheer, variation.Messages)
}
// Compile template and send
writeTemplate(m.bot, tpl, &cheerEv)
case helix.EventSubTypeChannelSubscription:
// Only process if we care about subscriptions
if !m.Config.Subscription.Enabled {
return
}
// Parse as subscription event
var subEv helix.EventSubChannelSubscribeEvent
err := json.Unmarshal(ev.Event, &subEv)
if err != nil {
m.bot.logger.Warn("Error parsing new subscription event", zap.Error(err))
return
}
m.addMixedEvent(subEv)
case helix.EventSubTypeChannelSubscriptionMessage:
// Only process if we care about subscriptions
if !m.Config.Subscription.Enabled {
return
}
// Parse as subscription event
var subEv helix.EventSubChannelSubscriptionMessageEvent
err := json.Unmarshal(ev.Event, &subEv)
if err != nil {
m.bot.logger.Warn("Error parsing returning subscription event", zap.Error(err))
return
}
m.addMixedEvent(subEv)
case helix.EventSubTypeChannelSubscriptionGift:
// Only process if we care about gifted subs
if !m.Config.GiftSub.Enabled {
return
}
// Parse as gift event
var giftEv helix.EventSubChannelSubscriptionGiftEvent
err := json.Unmarshal(ev.Event, &giftEv)
if err != nil {
m.bot.logger.Warn("Error parsing subscription gifted event", zap.Error(err))
return
}
// Pick a random message from base set
messageID := rand.Intn(len(m.Config.GiftSub.Messages))
tpl, ok := m.templates[templateTypeGift][m.Config.GiftSub.Messages[messageID]]
if !ok {
// Broken template!
m.bot.WriteMessage(m.Config.GiftSub.Messages[messageID])
return
}
// 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(m.Config.GiftSub.Variations) > 0 {
if giftEv.IsAnonymous {
variation := getBestValidVariation(m.Config.GiftSub.Variations, func(variation giftSubVariation) int {
if variation.IsAnonymous != nil && *variation.IsAnonymous {
return 1
}
return 0
})
tpl = m.replaceWithVariation(tpl, templateTypeGift, variation.Messages)
} else if giftEv.CumulativeTotal > 0 {
variation := getBestValidVariation(m.Config.GiftSub.Variations, func(variation giftSubVariation) int {
if variation.MinCumulative != nil && *variation.MinCumulative > giftEv.CumulativeTotal {
return *variation.MinCumulative
}
return 0
})
tpl = m.replaceWithVariation(tpl, templateTypeGift, variation.Messages)
}
}
// Compile template and send
writeTemplate(m.bot, tpl, &giftEv)
}
}
func (m *BotAlertsModule) replaceWithVariation(tpl *template.Template, templateType templateType, messages []string) *template.Template {
if messages != nil {
messageID := rand.Intn(len(messages))
// Make sure the template is valid
if temp, ok := m.templates[templateType][messages[messageID]]; ok {
return temp
}
}
return tpl
}
// Subscriptions are handled with a slight delay as info come from different events and must be aggregated
func (m *BotAlertsModule) addMixedEvent(event any) {
switch sub := event.(type) {
case helix.EventSubChannelSubscribeEvent:
m.pendingMux.Lock()
defer m.pendingMux.Unlock()
if ev, ok := m.pendingSubs[sub.UserID]; ok {
// Already pending, add extra data
ev.IsGift = sub.IsGift
m.pendingSubs[sub.UserID] = ev
return
}
m.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)
m.processPendingSub(sub.UserID)
}()
case helix.EventSubChannelSubscriptionMessageEvent:
m.pendingMux.Lock()
defer m.pendingMux.Unlock()
if ev, ok := m.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
}
m.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)
m.processPendingSub(sub.UserID)
}()
}
}
func (m *BotAlertsModule) processPendingSub(user string) {
m.pendingMux.Lock()
defer m.pendingMux.Unlock()
sub, ok := m.pendingSubs[user]
defer delete(m.pendingSubs, user)
if !ok {
// Somehow it's gone? Return early
return
}
// One last check in case config changed
if !m.Config.Subscription.Enabled {
return
}
// Assign random message
messageID := rand.Intn(len(m.Config.Subscription.Messages))
tpl, ok := m.templates[templateTypeSubscription][m.Config.Subscription.Messages[messageID]]
// If template is broken, write it as is (soft fail, plus we raise attention I guess?)
if !ok {
m.bot.WriteMessage(m.Config.Subscription.Messages[messageID])
return
}
// Check for variations, either by streak or gifted
if sub.IsGift {
variation := getBestValidVariation(m.Config.Subscription.Variations, func(variation subscriptionVariation) int {
if variation.IsGifted != nil && *variation.IsGifted {
return 1
}
return 0
})
tpl = m.replaceWithVariation(tpl, templateTypeSubscription, variation.Messages)
} else if sub.DurationMonths > 0 {
// Get variation with the highest minimum streak that's met
variation := getBestValidVariation(m.Config.Subscription.Variations, func(variation subscriptionVariation) int {
if variation.MinStreak != nil && sub.DurationMonths >= *variation.MinStreak {
return sub.DurationMonths
}
return 0
})
tpl = m.replaceWithVariation(tpl, templateTypeSubscription, variation.Messages)
}
writeTemplate(m.bot, tpl, sub)
}
// For variations, some variations are better than others, this function returns the best one
// by using a provided score function. The score is 0 or less if the variation is not valid,
// and 1 or more if it is valid. The variation with the highest score is returned.
func getBestValidVariation[T any](variations []T, filterFunc func(T) int) T {
var best T
var bestScore int
for _, variation := range variations {
score := filterFunc(variation)
if score > bestScore {
best = variation
bestScore = score
}
}
return best
}
func (m *BotAlertsModule) compileTemplates() {
// Reset caches
m.templates = templateCacheMap{
templateTypeSubscription: make(templateCache),
templateTypeFollow: make(templateCache),
templateTypeRaid: make(templateCache),
templateTypeCheer: make(templateCache),
templateTypeGift: make(templateCache),
}
// Add base templates
m.addTemplatesForType(templateTypeFollow, m.Config.Follow.Messages)
m.addTemplatesForType(templateTypeSubscription, m.Config.Subscription.Messages)
m.addTemplatesForType(templateTypeRaid, m.Config.Raid.Messages)
m.addTemplatesForType(templateTypeCheer, m.Config.Cheer.Messages)
m.addTemplatesForType(templateTypeGift, m.Config.GiftSub.Messages)
// Add variations
for _, variation := range m.Config.Subscription.Variations {
m.addTemplatesForType(templateTypeSubscription, variation.Messages)
}
for _, variation := range m.Config.Raid.Variations {
m.addTemplatesForType(templateTypeRaid, variation.Messages)
}
for _, variation := range m.Config.Cheer.Variations {
m.addTemplatesForType(templateTypeCheer, variation.Messages)
}
for _, variation := range m.Config.GiftSub.Variations {
m.addTemplatesForType(templateTypeGift, variation.Messages)
}
}
func (m *BotAlertsModule) addTemplate(templateList templateCache, message string) {
tpl, err := m.bot.MakeTemplate(message)
if err != nil {
m.bot.logger.Error("Error compiling alert template", zap.Error(err))
return
}
templateList[message] = tpl
}
func (m *BotAlertsModule) addTemplatesForType(templateList templateType, messages []string) {
for _, message := range messages {
m.addTemplate(m.templates[templateList], message)
}
}
func (m *BotAlertsModule) Close() {
if m.cancelAlertSub != nil {
m.cancelAlertSub()
}
if m.cancelTwitchEventSub != nil {
m.cancelTwitchEventSub()
}
}
// writeTemplate renders the template and sends the message to the channel
func writeTemplate(bot *Bot, tpl *template.Template, data interface{}) {
var buf bytes.Buffer
err := tpl.Execute(&buf, data)
if err != nil {
bot.logger.Error("Error executing template for bot alert", zap.Error(err))
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
}

View File

@ -1,393 +0,0 @@
package twitch
import (
"errors"
"strings"
"text/template"
"time"
"github.com/nicklaw5/helix/v2"
"git.sr.ht/~ashkeel/containers/sync"
irc "github.com/gempir/go-twitch-irc/v4"
"go.uber.org/zap"
"git.sr.ht/~ashkeel/strimertul/database"
"git.sr.ht/~ashkeel/strimertul/utils"
)
type IRCBot interface {
Join(channel ...string)
Connect() error
Disconnect() error
Say(channel, message string)
Reply(channel, messageID, message string)
OnConnect(handler func())
OnPrivateMessage(handler func(irc.PrivateMessage))
OnUserJoinMessage(handler func(message irc.UserJoinMessage))
OnUserPartMessage(handler func(message irc.UserPartMessage))
}
type Bot struct {
Client IRCBot
Config BotConfig
api *Client
username string
logger *zap.Logger
lastMessage *sync.RWSync[time.Time]
chatHistory *sync.Slice[irc.PrivateMessage]
commands *sync.Map[string, BotCommand]
customCommands *sync.Map[string, BotCustomCommand]
customTemplates *sync.Map[string, *template.Template]
customFunctions template.FuncMap
OnConnect *utils.SyncList[BotConnectHandler]
OnMessage *utils.SyncList[BotMessageHandler]
cancelUpdateSub database.CancelFunc
cancelWritePlainRPCSub database.CancelFunc
cancelWriteRPCSub database.CancelFunc
// Module specific vars
Timers *BotTimerModule
Alerts *BotAlertsModule
}
type BotConnectHandler interface {
utils.Comparable
HandleBotConnect()
}
type BotMessageHandler interface {
utils.Comparable
HandleBotMessage(message irc.PrivateMessage)
}
func (b *Bot) Migrate(old *Bot) {
utils.MergeSyncMap(b.commands, old.commands)
// Get registered commands and handlers from old bot
b.OnConnect.Copy(old.OnConnect)
b.OnMessage.Copy(old.OnMessage)
}
func newBot(api *Client, config BotConfig) *Bot {
// Create client
client := irc.NewClient(config.Username, config.Token)
return newBotWithClient(client, api, config)
}
func newBotWithClient(client IRCBot, api *Client, config BotConfig) *Bot {
bot := &Bot{
Client: client,
Config: config,
username: strings.ToLower(config.Username), // Normalize username
logger: api.logger,
api: api,
lastMessage: sync.NewRWSync(time.Now()),
commands: sync.NewMap[string, BotCommand](),
customCommands: sync.NewMap[string, BotCustomCommand](),
customTemplates: sync.NewMap[string, *template.Template](),
chatHistory: sync.NewSlice[irc.PrivateMessage](),
OnConnect: utils.NewSyncList[BotConnectHandler](),
OnMessage: utils.NewSyncList[BotMessageHandler](),
}
client.OnConnect(bot.onConnectHandler)
client.OnPrivateMessage(bot.onMessageHandler)
client.OnUserJoinMessage(bot.onJoinHandler)
client.OnUserPartMessage(bot.onPartHandler)
bot.Client.Join(config.Channel)
bot.setupFunctions()
// Load modules
bot.Timers = SetupTimers(bot)
bot.Alerts = SetupAlerts(bot)
// Load custom commands
var customCommands map[string]BotCustomCommand
err := api.db.GetJSON(CustomCommandsKey, &customCommands)
if err != nil {
if errors.Is(err, database.ErrEmptyKey) {
customCommands = make(map[string]BotCustomCommand)
} else {
bot.logger.Error("Failed to load custom commands", zap.Error(err))
}
}
bot.customCommands.Set(customCommands)
err = bot.updateTemplates()
if err != nil {
bot.logger.Error("Failed to parse custom commands", zap.Error(err))
}
bot.cancelUpdateSub, err = api.db.SubscribeKey(CustomCommandsKey, bot.updateCommands)
if err != nil {
bot.logger.Error("Could not set-up bot command reload subscription", zap.Error(err))
}
bot.cancelWritePlainRPCSub, err = api.db.SubscribeKey(WritePlainMessageRPC, bot.handleWritePlainMessageRPC)
if err != nil {
bot.logger.Error("Could not set-up bot command reload subscription", zap.Error(err))
}
bot.cancelWriteRPCSub, err = api.db.SubscribeKey(WriteMessageRPC, bot.handleWriteMessageRPC)
if err != nil {
bot.logger.Error("Could not set-up bot command reload subscription", zap.Error(err))
}
return bot
}
func (b *Bot) onJoinHandler(message irc.UserJoinMessage) {
if strings.ToLower(message.User) == b.username {
b.logger.Info("Twitch bot joined channel", zap.String("channel", message.Channel))
} else {
b.logger.Debug("User joined channel", zap.String("channel", message.Channel), zap.String("username", message.User))
}
}
func (b *Bot) onPartHandler(message irc.UserPartMessage) {
if strings.ToLower(message.User) == b.username {
b.logger.Info("Twitch bot left channel", zap.String("channel", message.Channel))
} else {
b.logger.Debug("User left channel", zap.String("channel", message.Channel), zap.String("username", message.User))
}
}
func (b *Bot) onMessageHandler(message irc.PrivateMessage) {
for _, handler := range b.OnMessage.Items() {
if handler != nil {
handler.HandleBotMessage(message)
}
}
// Ignore messages for a while or twitch will get mad!
if time.Now().Before(b.lastMessage.Get().Add(time.Second * time.Duration(b.Config.CommandCooldown))) {
b.logger.Debug("Message received too soon, ignoring")
return
}
lowercaseMessage := strings.TrimSpace(strings.ToLower(message.Message))
// Check if it's a command
if strings.HasPrefix(lowercaseMessage, "!") {
// Run through supported commands
for cmd, data := range b.commands.Copy() {
if !data.Enabled {
continue
}
if !strings.HasPrefix(lowercaseMessage, cmd) {
continue
}
parts := strings.SplitN(lowercaseMessage, " ", 2)
if parts[0] != cmd {
continue
}
go data.Handler(b, message)
b.lastMessage.Set(time.Now())
}
}
// Run through custom commands
for cmd, data := range b.customCommands.Copy() {
if !data.Enabled {
continue
}
lc := strings.ToLower(cmd)
if !strings.HasPrefix(lowercaseMessage, lc) {
continue
}
parts := strings.SplitN(lowercaseMessage, " ", 2)
if parts[0] != lc {
continue
}
go cmdCustom(b, cmd, data, message)
b.lastMessage.Set(time.Now())
}
err := b.api.db.PutJSON(ChatEventKey, message)
if err != nil {
b.logger.Warn("Could not save chat message to key", zap.String("key", ChatEventKey), zap.Error(err))
}
if b.Config.ChatHistory > 0 {
history := b.chatHistory.Get()
if len(history) >= b.Config.ChatHistory {
history = history[len(history)-b.Config.ChatHistory+1:]
}
b.chatHistory.Set(append(history, message))
err = b.api.db.PutJSON(ChatHistoryKey, b.chatHistory.Get())
if err != nil {
b.logger.Warn("Could not save message to chat history", zap.Error(err))
}
}
if b.Timers != nil {
go b.Timers.OnMessage(message)
}
}
func (b *Bot) onConnectHandler() {
for _, handler := range b.OnConnect.Items() {
if handler != nil {
handler.HandleBotConnect()
}
}
}
func (b *Bot) Close() error {
if b.cancelUpdateSub != nil {
b.cancelUpdateSub()
}
if b.cancelWriteRPCSub != nil {
b.cancelWriteRPCSub()
}
if b.cancelWritePlainRPCSub != nil {
b.cancelWritePlainRPCSub()
}
if b.Timers != nil {
b.Timers.Close()
}
if b.Alerts != nil {
b.Alerts.Close()
}
return b.Client.Disconnect()
}
func (b *Bot) updateCommands(value string) {
err := utils.LoadJSONToWrapped[map[string]BotCustomCommand](value, b.customCommands)
if err != nil {
b.logger.Error("Failed to decode new custom commands", zap.Error(err))
return
}
// Recreate templates
if err := b.updateTemplates(); err != nil {
b.logger.Error("Failed to update custom commands templates", zap.Error(err))
return
}
}
func (b *Bot) handleWritePlainMessageRPC(value string) {
b.Client.Say(b.Config.Channel, value)
}
func (b *Bot) handleWriteMessageRPC(value string) {
var request WriteMessageRequest
if err := json.Unmarshal([]byte(value), &request); err != nil {
b.logger.Warn("Failed to decode write message request", zap.Error(err))
return
}
if request.ReplyTo != nil && *request.ReplyTo != "" {
b.Client.Reply(b.Config.Channel, *request.ReplyTo, request.Message)
return
}
if request.WhisperTo != nil && *request.WhisperTo != "" {
client, err := b.api.GetUserClient(false)
if err != nil {
b.logger.Error("Failed to retrieve client", zap.Error(err))
return
}
reply, err := client.SendUserWhisper(&helix.SendUserWhisperParams{
FromUserID: b.api.User.ID,
ToUserID: *request.WhisperTo,
Message: request.Message,
})
if reply.Error != "" {
b.logger.Error("Failed to send whisper", zap.String("code", reply.Error), zap.String("message", reply.ErrorMessage))
}
if err != nil {
b.logger.Error("Failed to send whisper", zap.Error(err))
}
return
}
if request.Announce {
client, err := b.api.GetUserClient(false)
if err != nil {
b.logger.Error("Failed to retrieve client", zap.Error(err))
return
}
reply, err := client.SendChatAnnouncement(&helix.SendChatAnnouncementParams{
BroadcasterID: b.api.User.ID,
ModeratorID: b.api.User.ID,
Message: request.Message,
})
if reply.Error != "" {
b.logger.Error("Failed to send announcement", zap.String("code", reply.Error), zap.String("message", reply.ErrorMessage))
}
if err != nil {
b.logger.Error("Failed to send announcement", zap.Error(err))
}
return
}
b.Client.Say(b.Config.Channel, request.Message)
}
func (b *Bot) updateTemplates() error {
b.customTemplates.Set(make(map[string]*template.Template))
for cmd, tmpl := range b.customCommands.Copy() {
tpl, err := b.MakeTemplate(tmpl.Response)
if err != nil {
return err
}
b.customTemplates.SetKey(cmd, tpl)
}
return nil
}
func (b *Bot) Connect() {
err := b.Client.Connect()
if err != nil {
if errors.Is(err, irc.ErrClientDisconnected) {
b.logger.Info("Twitch bot connection terminated", zap.Error(err))
} else {
b.logger.Error("Twitch bot connection terminated unexpectedly", zap.Error(err))
}
}
}
func (b *Bot) WriteMessage(message string) {
b.Client.Say(b.Config.Channel, message)
}
func (b *Bot) RegisterCommand(trigger string, command BotCommand) {
b.commands.SetKey(trigger, command)
}
func (b *Bot) RemoveCommand(trigger string) {
b.commands.DeleteKey(trigger)
}
func getUserAccessLevel(user irc.User) AccessLevelType {
// Check broadcaster
if _, ok := user.Badges["broadcaster"]; ok {
return ALTStreamer
}
// Check mods
if _, ok := user.Badges["moderator"]; ok {
return ALTModerators
}
// Check VIP
if _, ok := user.Badges["vip"]; ok {
return ALTVIP
}
// Check subscribers
if _, ok := user.Badges["subscriber"]; ok {
return ALTSubscribers
}
return ALTEveryone
}
func defaultBotConfig() BotConfig {
return BotConfig{
CommandCooldown: 2,
}
}

104
twitch/chat/commands.go Normal file
View File

@ -0,0 +1,104 @@
package chat
import (
"bytes"
"github.com/nicklaw5/helix/v2"
"go.uber.org/zap"
)
var accessLevels = map[AccessLevelType]int{
ALTEveryone: 0,
ALTSubscribers: 1,
ALTVIP: 2,
ALTModerators: 3,
ALTStreamer: 999,
}
type CommandHandler func(message helix.EventSubChannelChatMessageEvent)
type Command struct {
Description string
Usage string
AccessLevel AccessLevelType
Handler CommandHandler
Enabled bool
}
func getUserAccessLevel(badges []helix.EventSubChatBadge) AccessLevelType {
// Read badges
var broadcaster, moderator, vip, subscriber bool
for _, badge := range badges {
switch badge.SetID {
case "broadcaster":
broadcaster = true
case "moderator":
moderator = true
case "vip":
vip = true
case "subscriber":
subscriber = true
}
}
switch {
case broadcaster:
return ALTStreamer
case moderator:
return ALTModerators
case vip:
return ALTVIP
case subscriber:
return ALTSubscribers
default:
return ALTEveryone
}
}
func cmdCustom(mod *Module, cmd string, data CustomCommand, message helix.EventSubChannelChatMessageEvent) {
// Check access level
accessLevel := getUserAccessLevel(message.Badges)
// Ensure that access level is high enough
if accessLevels[accessLevel] < accessLevels[data.AccessLevel] {
return
}
var buf bytes.Buffer
tpl, ok := mod.customTemplates.GetKey(cmd)
if !ok {
return
}
if err := tpl.Execute(&buf, message); err != nil {
mod.logger.Error("Failed to execute custom command template", zap.Error(err))
return
}
var request WriteMessageRequest
switch data.ResponseType {
case ResponseTypeDefault, ResponseTypeChat:
request = WriteMessageRequest{
Message: buf.String(),
}
case ResponseTypeReply:
request = WriteMessageRequest{
Message: buf.String(),
ReplyTo: message.MessageID,
}
case ResponseTypeWhisper:
request = WriteMessageRequest{
Message: buf.String(),
WhisperTo: message.ChatterUserID,
}
case ResponseTypeAnnounce:
request = WriteMessageRequest{
Message: buf.String(),
Announce: true,
}
default:
mod.logger.Error("Unknown response type", zap.String("type", string(data.ResponseType)))
}
WriteMessage(mod.db, mod.logger, request)
}

11
twitch/chat/config.go Normal file
View File

@ -0,0 +1,11 @@
package chat
const ConfigKey = "twitch/chat/config"
type Config struct {
// How many messages to keep in twitch/chat-history
ChatHistory int `json:"chat_history" desc:"How many messages to keep in the chat history key"`
// Global command cooldown in seconds
CommandCooldown int `json:"command_cooldown" desc:"Global command cooldown in seconds"`
}

47
twitch/chat/data.go Normal file
View File

@ -0,0 +1,47 @@
package chat
const (
EventKey = "twitch/chat/ev/message"
HistoryKey = "twitch/chat/history"
ActivityKey = "twitch/chat/activity"
CustomCommandsKey = "twitch/chat/custom-commands"
WriteMessageRPC = "twitch/chat/@send-message"
)
type ResponseType string
const (
ResponseTypeDefault ResponseType = ""
ResponseTypeChat ResponseType = "chat"
ResponseTypeWhisper ResponseType = "whisper"
ResponseTypeReply ResponseType = "reply"
ResponseTypeAnnounce ResponseType = "announce"
)
type AccessLevelType string
const (
ALTEveryone AccessLevelType = "everyone"
ALTSubscribers AccessLevelType = "subscriber"
ALTVIP AccessLevelType = "vip"
ALTModerators AccessLevelType = "moderators"
ALTStreamer AccessLevelType = "streamer"
)
// CustomCommand is a definition of a custom command of the chatbot
type CustomCommand struct {
// Command description
Description string `json:"description" desc:"Command description"`
// Minimum access level needed to use the command
AccessLevel AccessLevelType `json:"access_level" desc:"Minimum access level needed to use the command"`
// Response template (in Go templating format)
Response string `json:"response" desc:"Response template (in Go templating format)"`
// Is the command enabled?
Enabled bool `json:"enabled" desc:"Is the command enabled?"`
// How to respond to the user
ResponseType ResponseType `json:"response_type" desc:"How to respond to the user"`
}

394
twitch/chat/loyalty.go Normal file
View File

@ -0,0 +1,394 @@
package chat
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
"git.sr.ht/~ashkeel/strimertul/twitch"
"git.sr.ht/~ashkeel/containers/sync"
"github.com/nicklaw5/helix/v2"
"go.uber.org/zap"
"git.sr.ht/~ashkeel/strimertul/loyalty"
)
const (
commandRedeem = "!redeem"
commandGoals = "!goals"
commandBalance = "!balance"
commandContribute = "!contribute"
)
type loyaltyIntegration struct {
ctx context.Context
manager *loyalty.Manager
module *Module
activeUsers *sync.Map[string, bool]
}
func setupLoyaltyIntegration(ctx context.Context, mod *Module, manager *loyalty.Manager) *loyaltyIntegration {
li := &loyaltyIntegration{
ctx: ctx,
manager: manager,
module: mod,
activeUsers: sync.NewMap[string, bool](),
}
// Add loyalty-based commands
mod.commands.SetKey(commandRedeem, Command{
Description: "Redeem a reward with loyalty points",
Usage: fmt.Sprintf("%s <reward-id> [request text]", commandRedeem),
AccessLevel: ALTEveryone,
Handler: li.cmdRedeemReward,
Enabled: true,
})
mod.commands.SetKey(commandBalance, Command{
Description: "See your current point balance",
Usage: commandBalance,
AccessLevel: ALTEveryone,
Handler: li.cmdBalance,
Enabled: true,
})
mod.commands.SetKey(commandGoals, Command{
Description: "Check currently active community goals",
Usage: commandGoals,
AccessLevel: ALTEveryone,
Handler: li.cmdGoalList,
Enabled: true,
})
mod.commands.SetKey(commandContribute, Command{
Description: "Contribute points to a community goal",
Usage: fmt.Sprintf("%s <points> [<goal-id>]", commandContribute),
AccessLevel: ALTEveryone,
Handler: li.cmdContributeGoal,
Enabled: true,
})
// Setup handler for adding points over time
go func() {
config := li.manager.Config.Get()
// Stop handler if loyalty system is disabled or there is no valid point interval
if !config.Enabled || config.Points.Interval <= 0 {
return
}
for {
// Wait for next poll
select {
case <-li.ctx.Done():
return
case <-time.After(time.Duration(config.Points.Interval) * time.Second):
}
// If stream is confirmed offline, don't give points away!
var streamInfos []helix.Stream
err := mod.db.GetJSON(twitch.StreamInfoKey, &streamInfos)
if err != nil {
mod.logger.Error("Error retrieving stream info", zap.Error(err))
continue
}
if len(streamInfos) < 1 {
continue
}
// Get user list
cursor := ""
var users []string
for {
userClient, err := twitch.GetUserClient(mod.db, false)
if err != nil {
mod.logger.Error("Could not get user api client for list of chatters", zap.Error(err))
return
}
res, err := userClient.GetChannelChatChatters(&helix.GetChatChattersParams{
BroadcasterID: mod.user.ID,
ModeratorID: mod.user.ID,
First: "1000",
After: cursor,
})
if err != nil {
mod.logger.Error("Could not retrieve list of chatters", zap.Error(err))
return
}
for _, user := range res.Data.Chatters {
users = append(users, user.UserLogin)
}
cursor = res.Data.Pagination.Cursor
if cursor == "" {
break
}
}
// Iterate for each user in the list
pointsToGive := make(map[string]int64)
for _, user := range users {
// Check if user is blocked
if li.manager.IsBanned(user) {
continue
}
// Check if user was active (chatting) for the bonus dingus
award := config.Points.Amount
if li.IsActive(user) {
award += config.Points.ActivityBonus
}
// Add to point pool if already on it, otherwise initialize
pointsToGive[user] = award
}
li.ResetActivity()
// If changes were made, save the pool!
if len(users) > 0 {
err := li.manager.GivePoints(pointsToGive)
if err != nil {
mod.logger.Error("Error awarding loyalty points to user", zap.Error(err))
}
}
}
}()
mod.logger.Info("Loyalty system integration with Twitch is ready")
return li
}
func (li *loyaltyIntegration) Close() {
li.module.commands.DeleteKey(commandRedeem)
li.module.commands.DeleteKey(commandBalance)
li.module.commands.DeleteKey(commandGoals)
li.module.commands.DeleteKey(commandContribute)
}
func (li *loyaltyIntegration) HandleMessage(message helix.EventSubChannelChatMessageEvent) {
li.activeUsers.SetKey(message.ChatterUserLogin, true)
}
func (li *loyaltyIntegration) IsActive(user string) bool {
active, ok := li.activeUsers.GetKey(user)
return ok && active
}
func (li *loyaltyIntegration) ResetActivity() {
li.activeUsers = sync.NewMap[string, bool]()
}
func (li *loyaltyIntegration) cmdBalance(message helix.EventSubChannelChatMessageEvent) {
// Get user balance
balance := li.manager.GetPoints(message.ChatterUserLogin)
li.module.WriteMessage(WriteMessageRequest{
Message: fmt.Sprintf("You have %d %s!", balance, li.manager.Config.Get().Currency),
ReplyTo: message.MessageID,
})
}
func (li *loyaltyIntegration) cmdRedeemReward(message helix.EventSubChannelChatMessageEvent) {
parts := strings.Fields(message.Message.Text)
if len(parts) < 2 {
return
}
redeemID := parts[1]
// Find reward
reward := li.manager.GetReward(redeemID)
if reward.ID == "" {
return
}
// Reward not active, return early
if !reward.Enabled {
return
}
// Get user balance
balance := li.manager.GetPoints(message.ChatterUserLogin)
config := li.manager.Config.Get()
// Check if user can afford the reward
if balance-reward.Price < 0 {
li.module.WriteMessage(WriteMessageRequest{
Message: fmt.Sprintf("I'm sorry but you cannot afford this (have %d %s, need %d)", balance, config.Currency, reward.Price),
ReplyTo: message.MessageID,
})
return
}
text := ""
if len(parts) > 2 {
text = strings.Join(parts[2:], " ")
}
// Perform redeem
if err := li.manager.PerformRedeem(loyalty.Redeem{
Username: message.ChatterUserLogin,
DisplayName: message.ChatterUserName,
When: time.Now(),
Reward: reward,
RequestText: text,
}); err != nil {
if errors.Is(err, loyalty.ErrRedeemInCooldown) {
nextAvailable := li.manager.GetRewardCooldown(reward.ID)
li.module.WriteMessage(WriteMessageRequest{
Message: fmt.Sprintf("That reward is in cooldown (available in %s)",
time.Until(nextAvailable).Truncate(time.Second)),
ReplyTo: message.MessageID,
})
return
}
li.module.logger.Error("Error while performing redeem", zap.Error(err))
return
}
li.module.WriteMessage(WriteMessageRequest{
Message: fmt.Sprintf("HolidayPresent %s has redeemed %s! (new balance: %d %s)",
message.ChatterUserName, reward.Name, li.manager.GetPoints(message.ChatterUserLogin), config.Currency),
})
}
func (li *loyaltyIntegration) cmdGoalList(message helix.EventSubChannelChatMessageEvent) {
goals := li.manager.Goals.Get()
if len(goals) < 1 {
li.module.WriteMessage(WriteMessageRequest{
Message: fmt.Sprintf("There are no active community goals right now :(!"),
ReplyTo: message.MessageID,
})
return
}
msg := "Current goals: "
for _, goal := range goals {
if !goal.Enabled {
continue
}
msg += fmt.Sprintf("%s (%d/%d %s) [id: %s] | ", goal.Name, goal.Contributed, goal.TotalGoal, li.manager.Config.Get().Currency, goal.ID)
}
msg += " Contribute with <!contribute POINTS GOALID>"
li.module.WriteMessage(WriteMessageRequest{
Message: msg,
})
}
func (li *loyaltyIntegration) cmdContributeGoal(message helix.EventSubChannelChatMessageEvent) {
goals := li.manager.Goals.Get()
// Set defaults if user doesn't provide them
points := int64(100)
goalIndex := -1
hasGoals := false
// Get first unreached goal for default
for index, goal := range goals {
if !goal.Enabled {
continue
}
hasGoals = true
if goal.Contributed < goal.TotalGoal {
goalIndex = index
break
}
}
// Do we not have any goal we can contribute to? Hooray I guess?
if goalIndex < 0 {
if hasGoals {
li.module.WriteMessage(WriteMessageRequest{
Message: fmt.Sprintf("All active community goals have been reached already! NewRecord"),
ReplyTo: message.MessageID,
})
} else {
li.module.WriteMessage(WriteMessageRequest{
Message: fmt.Sprintf("There are no active community goals right now :(!"),
ReplyTo: message.MessageID,
})
}
return
}
// Parse parameters if provided
parts := strings.Fields(message.Message.Text)
if len(parts) > 1 {
newPoints, err := strconv.ParseInt(parts[1], 10, 64)
if err == nil {
if newPoints <= 0 {
li.module.WriteMessage(WriteMessageRequest{
Message: fmt.Sprintf("Nice try SoBayed"),
ReplyTo: message.MessageID,
})
return
}
points = newPoints
}
if len(parts) > 2 {
found := false
goalID := parts[2]
// Find Goal index
for index, goal := range goals {
if !goal.Enabled {
continue
}
if goal.ID == goalID {
goalIndex = index
found = true
break
}
}
// Invalid goal ID provided
if !found {
li.module.WriteMessage(WriteMessageRequest{
Message: fmt.Sprintf("I couldn't find that goal ID :("),
ReplyTo: message.MessageID,
})
return
}
}
}
// Get goal
selectedGoal := goals[goalIndex]
// Check if goal was reached already
if selectedGoal.Contributed >= selectedGoal.TotalGoal {
li.module.WriteMessage(WriteMessageRequest{
Message: fmt.Sprintf("This goal was already reached! ヾ(•ω•`)o"),
ReplyTo: message.MessageID,
})
return
}
// Add points to goal
points, err := li.manager.PerformContribution(selectedGoal, message.ChatterUserLogin, points)
if err != nil {
li.module.logger.Error("Error while contributing to goal", zap.Error(err))
return
}
if points == 0 {
li.module.WriteMessage(WriteMessageRequest{
Message: fmt.Sprintf("Sorry but you're broke"),
ReplyTo: message.MessageID,
})
return
}
selectedGoal = li.manager.Goals.Get()[goalIndex]
config := li.manager.Config.Get()
newRemaining := selectedGoal.TotalGoal - selectedGoal.Contributed
li.module.WriteMessage(WriteMessageRequest{
Message: fmt.Sprintf("NewRecord %s contributed %d %s to \"%s\"!! Only %d %s left!", message.ChatterUserName, points, config.Currency, selectedGoal.Name, newRemaining, config.Currency),
})
// Check if goal was reached!
if newRemaining <= 0 {
li.module.WriteMessage(WriteMessageRequest{
Message: fmt.Sprintf("FallWinning The community goal \"%s\" was reached! FallWinning", selectedGoal.Name),
Announce: true,
})
}
}

270
twitch/chat/module.go Normal file
View File

@ -0,0 +1,270 @@
package chat
import (
"context"
"errors"
"strings"
textTemplate "text/template"
"time"
"git.sr.ht/~ashkeel/containers/sync"
jsoniter "github.com/json-iterator/go"
"github.com/nicklaw5/helix/v2"
"go.uber.org/zap"
"git.sr.ht/~ashkeel/strimertul/database"
"git.sr.ht/~ashkeel/strimertul/twitch/eventsub"
"git.sr.ht/~ashkeel/strimertul/twitch/template"
"git.sr.ht/~ashkeel/strimertul/utils"
)
var json = jsoniter.ConfigFastest
type Module struct {
Config Config
ctx context.Context
db *database.LocalDBClient
api *helix.Client
user *helix.User
logger *zap.Logger
templater template.Engine
lastMessage *sync.RWSync[time.Time]
chatHistory *sync.Slice[helix.EventSubChannelChatMessageEvent]
commands *sync.Map[string, Command]
customCommands *sync.Map[string, CustomCommand]
customTemplates *sync.Map[string, *textTemplate.Template]
customFunctions textTemplate.FuncMap
cancelContext context.CancelFunc
cancelUpdateSub database.CancelFunc
cancelWriteRPCSub database.CancelFunc
cancelChatMessageSub database.CancelFunc
}
func Setup(ctx context.Context, db *database.LocalDBClient, api *helix.Client, user *helix.User, logger *zap.Logger, templater template.Engine) *Module {
newContext, cancel := context.WithCancel(ctx)
mod := &Module{
ctx: newContext,
db: db,
api: api,
user: user,
logger: logger,
templater: templater,
lastMessage: sync.NewRWSync(time.Now()),
commands: sync.NewMap[string, Command](),
customCommands: sync.NewMap[string, CustomCommand](),
customTemplates: sync.NewMap[string, *textTemplate.Template](),
customFunctions: make(textTemplate.FuncMap),
cancelContext: cancel,
}
// Get config
if err := db.GetJSON(ConfigKey, &mod.Config); err != nil {
if errors.Is(err, database.ErrEmptyKey) {
mod.Config = Config{
ChatHistory: 0,
CommandCooldown: 2,
}
} else {
logger.Error("Failed to load chat module config", zap.Error(err))
}
}
var err error
mod.cancelChatMessageSub, err = db.SubscribeKey(eventsub.EventKeyPrefix+helix.EventSubTypeChannelChatMessage, mod.onChatMessage)
if err != nil {
logger.Error("Could not subscribe to chat messages", zap.Error(err))
}
// Load custom commands
var customCommands map[string]CustomCommand
err = db.GetJSON(CustomCommandsKey, &customCommands)
if err != nil {
if errors.Is(err, database.ErrEmptyKey) {
customCommands = make(map[string]CustomCommand)
} else {
logger.Error("Failed to load custom commands", zap.Error(err))
}
}
mod.customCommands.Set(customCommands)
err = mod.updateTemplates()
if err != nil {
logger.Error("Failed to parse custom commands", zap.Error(err))
}
mod.cancelUpdateSub, err = db.SubscribeKey(CustomCommandsKey, mod.updateCommands)
if err != nil {
logger.Error("Could not set-up chat command reload subscription", zap.Error(err))
}
mod.cancelWriteRPCSub, err = db.SubscribeKey(WriteMessageRPC, mod.handleWriteMessageRPC)
if err != nil {
logger.Error("Could not set-up chat command reload subscription", zap.Error(err))
}
return mod
}
func (mod *Module) Close() {
if mod.cancelChatMessageSub != nil {
mod.cancelChatMessageSub()
}
if mod.cancelUpdateSub != nil {
mod.cancelUpdateSub()
}
if mod.cancelWriteRPCSub != nil {
mod.cancelWriteRPCSub()
}
mod.cancelContext()
}
func (mod *Module) onChatMessage(newValue string) {
var chatMessage helix.EventSubChannelChatMessageEvent
if err := json.Unmarshal([]byte(newValue), &chatMessage); err != nil {
mod.logger.Error("Failed to decode incoming chat message", zap.Error(err))
return
}
// TODO Command cooldown logic here!
lowercaseMessage := strings.TrimSpace(strings.ToLower(chatMessage.Message.Text))
// Check if it's a command
if strings.HasPrefix(lowercaseMessage, "!") {
// Run through supported commands
for cmd, data := range mod.commands.Copy() {
if !data.Enabled {
continue
}
if !strings.HasPrefix(lowercaseMessage, cmd) {
continue
}
parts := strings.SplitN(lowercaseMessage, " ", 2)
if parts[0] != cmd {
continue
}
go data.Handler(chatMessage)
mod.lastMessage.Set(time.Now())
}
}
// Run through custom commands
for cmd, data := range mod.customCommands.Copy() {
if !data.Enabled {
continue
}
lc := strings.ToLower(cmd)
if !strings.HasPrefix(lowercaseMessage, lc) {
continue
}
parts := strings.SplitN(lowercaseMessage, " ", 2)
if parts[0] != lc {
continue
}
go cmdCustom(mod, cmd, data, chatMessage)
mod.lastMessage.Set(time.Now())
}
err := mod.db.PutJSON(EventKey, chatMessage)
if err != nil {
mod.logger.Warn("Could not save chat message to key", zap.Error(err))
}
if mod.Config.ChatHistory > 0 {
history := mod.chatHistory.Get()
if len(history) >= mod.Config.ChatHistory {
history = history[len(history)-mod.Config.ChatHistory+1:]
}
mod.chatHistory.Set(append(history, chatMessage))
err = mod.db.PutJSON(HistoryKey, mod.chatHistory.Get())
if err != nil {
mod.logger.Warn("Could not save message to chat history", zap.Error(err))
}
}
}
func (mod *Module) handleWriteMessageRPC(value string) {
var request WriteMessageRequest
if err := json.Unmarshal([]byte(value), &request); err != nil {
mod.logger.Warn("Failed to decode write message request", zap.Error(err))
return
}
if request.Announce {
resp, err := mod.api.SendChatAnnouncement(&helix.SendChatAnnouncementParams{
BroadcasterID: mod.user.ID,
ModeratorID: mod.user.ID,
Message: request.Message,
})
if err != nil {
mod.logger.Error("Failed to send announcement", zap.Error(err))
}
if resp.Error != "" {
mod.logger.Error("Failed to send announcement", zap.String("code", resp.Error), zap.String("message", resp.ErrorMessage))
}
return
}
if request.WhisperTo != "" {
resp, err := mod.api.SendUserWhisper(&helix.SendUserWhisperParams{
FromUserID: mod.user.ID,
ToUserID: request.WhisperTo,
Message: request.Message,
})
if err != nil {
mod.logger.Error("Failed to send whisper", zap.Error(err))
}
if resp.Error != "" {
mod.logger.Error("Failed to send whisper", zap.String("code", resp.Error), zap.String("message", resp.ErrorMessage))
}
return
}
resp, err := mod.api.SendChatMessage(&helix.SendChatMessageParams{
BroadcasterID: mod.user.ID,
SenderID: mod.user.ID,
Message: request.Message,
ReplyParentMessageID: request.ReplyTo,
})
if err != nil {
mod.logger.Error("Failed to send chat message", zap.Error(err))
}
if resp.Error != "" {
mod.logger.Error("Failed to send chat message", zap.String("code", resp.Error), zap.String("message", resp.ErrorMessage))
}
}
func (mod *Module) updateCommands(value string) {
err := utils.LoadJSONToWrapped[map[string]CustomCommand](value, mod.customCommands)
if err != nil {
mod.logger.Error("Failed to decode new custom commands", zap.Error(err))
return
}
// Recreate templates
if err := mod.updateTemplates(); err != nil {
mod.logger.Error("Failed to update custom commands templates", zap.Error(err))
return
}
}
func (mod *Module) updateTemplates() error {
mod.customTemplates.Set(make(map[string]*textTemplate.Template))
for cmd, tmpl := range mod.customCommands.Copy() {
tpl, err := mod.templater.MakeTemplate(tmpl.Response)
if err != nil {
return err
}
mod.customTemplates.SetKey(cmd, tpl)
}
return nil
}
func (mod *Module) WriteMessage(request WriteMessageRequest) {
WriteMessage(mod.db, mod.logger, request)
}

21
twitch/chat/write.go Normal file
View File

@ -0,0 +1,21 @@
package chat
import (
"git.sr.ht/~ashkeel/strimertul/database"
"go.uber.org/zap"
)
// WriteMessageRequest is an RPC to send a chat message with extra options
type WriteMessageRequest struct {
Message string `json:"message" desc:"Chat message to send"`
ReplyTo string `json:"reply_to,omitempty" desc:"If specified, send as reply to a message ID"`
WhisperTo string `json:"whisper_to,omitempty" desc:"If specified, send as whisper to user ID"`
Announce bool `json:"announce" desc:"If true, send as announcement"`
}
func WriteMessage(db database.Database, logger *zap.Logger, m WriteMessageRequest) {
err := db.PutJSON(WriteMessageRPC, m)
if err != nil {
logger.Error("Failed to write chat message", zap.Error(err))
}
}

View File

@ -1,163 +0,0 @@
package twitch
import (
"fmt"
"net/http"
"slices"
"time"
"github.com/nicklaw5/helix/v2"
)
type AuthResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
Scope []string `json:"scope"`
Time time.Time
}
var scopes = []string{
"bits:read",
"channel:bot",
"channel:moderate",
"channel:read:hype_train",
"channel:read:polls",
"channel:read:predictions",
"channel:read:redemptions",
"channel:read:subscriptions",
"chat:edit",
"chat:read",
"moderator:manage:announcements",
"moderator:read:chatters",
"moderator:read:followers",
"user:bot",
"user:manage:whispers",
"user:read:chat",
"user_read",
"whispers:edit",
"whispers:read",
}
func (c *Client) GetAuthorizationURL() string {
if c.API == nil {
return "twitch-not-configured"
}
return c.API.GetAuthorizationURL(&helix.AuthorizationURLParams{
ResponseType: "code",
Scopes: scopes,
})
}
// CheckScopes checks if the user has authorized all required scopes
// Normally this would be the case but between versions strimertul has changed
// the required scopes, and it's possible that some users have not re-authorized
// the application with the new scopes.
func (c *Client) CheckScopes() (bool, error) {
var authResp AuthResponse
if err := c.db.GetJSON(AuthKey, &authResp); err != nil {
return false, err
}
// Sort scopes for comparison
slices.Sort(authResp.Scope)
slices.Sort(scopes)
return slices.Equal(scopes, authResp.Scope), nil
}
func (c *Client) GetUserClient(forceRefresh bool) (*helix.Client, error) {
var authResp AuthResponse
if err := c.db.GetJSON(AuthKey, &authResp); err != nil {
return nil, err
}
// Handle token expiration
if forceRefresh || time.Now().After(authResp.Time.Add(time.Duration(authResp.ExpiresIn)*time.Second)) {
// Refresh tokens
refreshed, err := c.API.RefreshUserAccessToken(authResp.RefreshToken)
if err != nil {
return nil, err
}
authResp.AccessToken = refreshed.Data.AccessToken
authResp.RefreshToken = refreshed.Data.RefreshToken
authResp.Time = time.Now().Add(time.Duration(refreshed.Data.ExpiresIn) * time.Second)
// Save new token pair
err = c.db.PutJSON(AuthKey, authResp)
if err != nil {
return nil, err
}
}
config := c.Config.Get()
return helix.NewClient(&helix.Options{
ClientID: config.APIClientID,
ClientSecret: config.APIClientSecret,
UserAccessToken: authResp.AccessToken,
})
}
func (c *Client) GetLoggedUser() (helix.User, error) {
if c.User.ID != "" {
return c.User, nil
}
client, err := c.GetUserClient(false)
if err != nil {
return helix.User{}, fmt.Errorf("failed getting API client for user: %w", err)
}
users, err := client.GetUsers(&helix.UsersParams{})
if err != nil {
return helix.User{}, fmt.Errorf("failed looking up user: %w", err)
}
if len(users.Data.Users) < 1 {
return helix.User{}, fmt.Errorf("no users found")
}
c.User = users.Data.Users[0]
return c.User, nil
}
func (c *Client) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// Get code from params
code := req.URL.Query().Get("code")
if code == "" {
// TODO Nice error page
http.Error(w, "missing code", http.StatusBadRequest)
return
}
// Exchange code for access/refresh tokens
userTokenResponse, err := c.API.RequestUserAccessToken(code)
if err != nil {
http.Error(w, "failed auth token request: "+err.Error(), http.StatusInternalServerError)
return
}
err = c.db.PutJSON(AuthKey, AuthResponse{
AccessToken: userTokenResponse.Data.AccessToken,
RefreshToken: userTokenResponse.Data.RefreshToken,
ExpiresIn: userTokenResponse.Data.ExpiresIn,
Scope: userTokenResponse.Data.Scopes,
Time: time.Now(),
})
if err != nil {
http.Error(w, "error saving auth data for user: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Add("Content-Type", "text/html")
_, _ = fmt.Fprintf(w, `<html><body><h2>All done, you can close me now!</h2><script>window.close();</script></body></html>`)
}
type RefreshResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
TokenType string `json:"token_type"`
Scope []string `json:"scope"`
}
func getRedirectURI(baseurl string) string {
return fmt.Sprintf("http://%s/twitch/callback", baseurl)
}

View File

@ -1,312 +0,0 @@
package twitch
import (
"context"
"errors"
"fmt"
"time"
"git.sr.ht/~ashkeel/containers/sync"
lru "github.com/hashicorp/golang-lru/v2"
jsoniter "github.com/json-iterator/go"
"github.com/nicklaw5/helix/v2"
"go.uber.org/zap"
"git.sr.ht/~ashkeel/strimertul/database"
"git.sr.ht/~ashkeel/strimertul/webserver"
)
var json = jsoniter.ConfigFastest
type Manager struct {
client *Client
cancelSubs func()
}
func NewManager(db *database.LocalDBClient, server *webserver.WebServer, logger *zap.Logger) (*Manager, error) {
// Get Twitch config
var config Config
if err := db.GetJSON(ConfigKey, &config); err != nil {
if !errors.Is(err, database.ErrEmptyKey) {
return nil, fmt.Errorf("failed to get twitch config: %w", err)
}
config.Enabled = false
}
// Get Twitch bot config
botConfig := defaultBotConfig()
if err := db.GetJSON(BotConfigKey, &botConfig); err != nil {
if !errors.Is(err, database.ErrEmptyKey) {
return nil, fmt.Errorf("failed to get bot config: %w", err)
}
config.EnableBot = false
}
// Create new client
client, err := newClient(config, db, server, logger)
if err != nil {
return nil, fmt.Errorf("failed to create twitch client: %w", err)
}
if config.EnableBot {
client.Bot = newBot(client, botConfig)
go client.Bot.Connect()
}
manager := &Manager{
client: client,
}
// Listen for client config changes
cancelConfigSub, err := db.SubscribeKey(ConfigKey, func(value string) {
var newConfig Config
if err := json.UnmarshalFromString(value, &newConfig); err != nil {
logger.Error("Failed to decode Twitch integration config", zap.Error(err))
return
}
var updatedClient *Client
updatedClient, err = newClient(newConfig, db, server, logger)
if err != nil {
logger.Error("Could not create twitch client with new config, keeping old", zap.Error(err))
return
}
err = manager.client.Close()
if err != nil {
logger.Warn("Twitch client could not close cleanly", zap.Error(err))
}
// New client works, replace old
updatedClient.Merge(manager.client)
manager.client = updatedClient
logger.Info("Reloaded/updated Twitch integration")
})
if err != nil {
logger.Error("Could not setup twitch config reload subscription", zap.Error(err))
}
// Listen for bot config changes
cancelBotSub, err := db.SubscribeKey(BotConfigKey, func(value string) {
newBotConfig := defaultBotConfig()
if err := json.UnmarshalFromString(value, &newBotConfig); err != nil {
logger.Error("Failed to decode bot config", zap.Error(err))
return
}
if manager.client.Bot != nil {
err = manager.client.Bot.Close()
if err != nil {
manager.client.logger.Warn("Failed to disconnect old bot from Twitch IRC", zap.Error(err))
}
}
if manager.client.Config.Get().EnableBot {
bot := newBot(manager.client, newBotConfig)
go bot.Connect()
manager.client.Bot = bot
} else {
manager.client.Bot = nil
}
manager.client.logger.Info("Reloaded/restarted Twitch bot")
})
if err != nil {
client.logger.Error("Could not setup twitch bot config reload subscription", zap.Error(err))
}
manager.cancelSubs = func() {
if cancelConfigSub != nil {
cancelConfigSub()
}
if cancelBotSub != nil {
cancelBotSub()
}
}
return manager, nil
}
func (m *Manager) Client() *Client {
return m.client
}
func (m *Manager) Close() error {
m.cancelSubs()
if err := m.client.Close(); err != nil {
return err
}
return nil
}
type Client struct {
Config *sync.RWSync[Config]
Bot *Bot
db *database.LocalDBClient
API *helix.Client
User helix.User
logger *zap.Logger
eventCache *lru.Cache[string, time.Time]
server *webserver.WebServer
ctx context.Context
cancel context.CancelFunc
restart chan bool
streamOnline *sync.RWSync[bool]
savedSubscriptions map[string]bool
}
func (c *Client) Merge(old *Client) {
// Copy bot instance and some params
c.streamOnline.Set(old.streamOnline.Get())
c.Bot = old.Bot
c.ensureRoute()
}
// Hacky function to deal with sync issues when restarting client
func (c *Client) ensureRoute() {
if c.Config.Get().Enabled {
c.server.RegisterRoute(CallbackRoute, c)
}
}
func newClient(config Config, db *database.LocalDBClient, server *webserver.WebServer, logger *zap.Logger) (*Client, error) {
eventCache, err := lru.New[string, time.Time](128)
if err != nil {
return nil, fmt.Errorf("could not create LRU cache for events: %w", err)
}
// Create Twitch client
ctx, cancel := context.WithCancel(context.Background())
client := &Client{
Config: sync.NewRWSync(config),
db: db,
logger: logger.With(zap.String("service", "twitch")),
restart: make(chan bool, 128),
streamOnline: sync.NewRWSync(false),
eventCache: eventCache,
savedSubscriptions: make(map[string]bool),
ctx: ctx,
cancel: cancel,
server: server,
}
baseurl, err := client.baseURL()
if err != nil {
return nil, err
}
if config.Enabled {
api, err := getHelixAPI(config, baseurl)
if err != nil {
return nil, fmt.Errorf("failed to create twitch client: %w", err)
}
client.API = api
server.RegisterRoute(CallbackRoute, client)
if userClient, err := client.GetUserClient(true); err == nil {
users, err := userClient.GetUsers(&helix.UsersParams{})
if err != nil {
client.logger.Error("Failed looking up user", zap.Error(err))
} else if len(users.Data.Users) < 1 {
client.logger.Error("No users found, please authenticate in Twitch configuration -> Events")
} else {
client.User = users.Data.Users[0]
go client.eventSubLoop(userClient)
}
} else {
client.logger.Warn("Twitch user not identified, this will break most features")
}
go client.runStatusPoll()
}
return client, nil
}
func (c *Client) runStatusPoll() {
c.logger.Info("Started polling for stream status")
for {
// Make sure we're configured and connected properly first
if !c.Config.Get().Enabled || c.Bot == nil || c.Bot.Config.Channel == "" {
continue
}
// Check if streamer is online, if possible
func() {
status, err := c.API.GetStreams(&helix.StreamsParams{
UserLogins: []string{c.Bot.Config.Channel}, // TODO Replace with something non bot dependant
})
if err != nil {
c.logger.Error("Error checking stream status", zap.Error(err))
return
}
c.streamOnline.Set(len(status.Data.Streams) > 0)
err = c.db.PutJSON(StreamInfoKey, status.Data.Streams)
if err != nil {
c.logger.Warn("Error saving stream info", zap.Error(err))
}
}()
// Wait for next poll (or cancellation)
select {
case <-c.ctx.Done():
return
case <-time.After(60 * time.Second):
}
}
}
func getHelixAPI(config Config, baseurl string) (*helix.Client, error) {
redirectURI := getRedirectURI(baseurl)
// Create Twitch client
api, err := helix.NewClient(&helix.Options{
ClientID: config.APIClientID,
ClientSecret: config.APIClientSecret,
RedirectURI: redirectURI,
})
if err != nil {
return nil, err
}
// Get access token
resp, err := api.RequestAppAccessToken([]string{"user:read:email"})
if err != nil {
return nil, err
}
// Set the access token on the client
api.SetAppAccessToken(resp.Data.AccessToken)
return api, nil
}
func (c *Client) baseURL() (string, error) {
var severConfig struct {
Bind string `json:"bind"`
}
err := c.db.GetJSON("http/config", &severConfig)
return severConfig.Bind, err
}
func (c *Client) IsLive() bool {
return c.streamOnline.Get()
}
func (c *Client) Close() error {
c.server.UnregisterRoute(CallbackRoute)
defer c.cancel()
if c.Bot != nil {
if err := c.Bot.Close(); err != nil {
return err
}
}
return nil
}

71
twitch/client/auth.go Normal file
View File

@ -0,0 +1,71 @@
package client
import (
"fmt"
"net/http"
"time"
"git.sr.ht/~ashkeel/strimertul/twitch"
"github.com/nicklaw5/helix/v2"
)
func (c *Client) GetLoggedUser() (helix.User, error) {
if c.User.ID != "" {
return c.User, nil
}
client, err := twitch.GetUserClient(c.DB, false)
if err != nil {
return helix.User{}, fmt.Errorf("failed getting API client for user: %w", err)
}
users, err := client.GetUsers(&helix.UsersParams{})
if err != nil {
return helix.User{}, fmt.Errorf("failed looking up user: %w", err)
}
if len(users.Data.Users) < 1 {
return helix.User{}, fmt.Errorf("no users found")
}
c.User = users.Data.Users[0]
return c.User, nil
}
func (c *Client) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// Get code from params
code := req.URL.Query().Get("code")
if code == "" {
// TODO Nice error page
http.Error(w, "missing code", http.StatusBadRequest)
return
}
// Exchange code for access/refresh tokens
userTokenResponse, err := c.API.RequestUserAccessToken(code)
if err != nil {
http.Error(w, "failed auth token request: "+err.Error(), http.StatusInternalServerError)
return
}
err = c.DB.PutJSON(twitch.AuthKey, twitch.AuthResponse{
AccessToken: userTokenResponse.Data.AccessToken,
RefreshToken: userTokenResponse.Data.RefreshToken,
ExpiresIn: userTokenResponse.Data.ExpiresIn,
Scope: userTokenResponse.Data.Scopes,
Time: time.Now(),
})
if err != nil {
http.Error(w, "error saving auth data for user: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Add("Content-Type", "text/html")
_, _ = fmt.Fprintf(w, `<html><body><h2>All done, you can close me now!</h2><script>window.close();</script></body></html>`)
}
type RefreshResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
TokenType string `json:"token_type"`
Scope []string `json:"scope"`
}

224
twitch/client/client.go Normal file
View File

@ -0,0 +1,224 @@
package client
import (
"context"
"errors"
"fmt"
"time"
"git.sr.ht/~ashkeel/strimertul/twitch"
"git.sr.ht/~ashkeel/strimertul/twitch/eventsub"
"git.sr.ht/~ashkeel/containers/sync"
jsoniter "github.com/json-iterator/go"
"github.com/nicklaw5/helix/v2"
"go.uber.org/zap"
"git.sr.ht/~ashkeel/strimertul/database"
"git.sr.ht/~ashkeel/strimertul/webserver"
)
var json = jsoniter.ConfigFastest
type Manager struct {
client *Client
cancelSubs func()
}
func NewManager(db database.Database, server *webserver.WebServer, logger *zap.Logger) (*Manager, error) {
// Get Twitch config
var config twitch.Config
if err := db.GetJSON(twitch.ConfigKey, &config); err != nil {
if !errors.Is(err, database.ErrEmptyKey) {
return nil, fmt.Errorf("failed to get twitch config: %w", err)
}
config.Enabled = false
}
// Create new client
client, err := newClient(config, db, server, logger)
if err != nil {
return nil, fmt.Errorf("failed to create twitch client: %w", err)
}
manager := &Manager{
client: client,
}
// Listen for client config changes
cancelConfigSub, err := db.SubscribeKey(twitch.ConfigKey, func(value string) {
var newConfig twitch.Config
if err := json.UnmarshalFromString(value, &newConfig); err != nil {
logger.Error("Failed to decode Twitch integration config", zap.Error(err))
return
}
var updatedClient *Client
updatedClient, err = newClient(newConfig, db, server, logger)
if err != nil {
logger.Error("Could not create twitch client with new config, keeping old", zap.Error(err))
return
}
err = manager.client.Close()
if err != nil {
logger.Warn("Twitch client could not close cleanly", zap.Error(err))
}
// New client works, replace old
updatedClient.Merge(manager.client)
manager.client = updatedClient
logger.Info("Reloaded/updated Twitch integration")
})
if err != nil {
logger.Error("Could not setup twitch config reload subscription", zap.Error(err))
}
manager.cancelSubs = func() {
if cancelConfigSub != nil {
cancelConfigSub()
}
}
return manager, nil
}
func (m *Manager) Client() *Client {
return m.client
}
func (m *Manager) Close() error {
m.cancelSubs()
if err := m.client.Close(); err != nil {
return err
}
return nil
}
type Client struct {
Config *sync.RWSync[twitch.Config]
DB database.Database
API *helix.Client
User helix.User
Logger *zap.Logger
eventSub *eventsub.Client
server *webserver.WebServer
ctx context.Context
cancel context.CancelFunc
restart chan bool
streamOnline *sync.RWSync[bool]
}
func (c *Client) Merge(old *Client) {
// Copy bot instance and some params
c.streamOnline.Set(old.streamOnline.Get())
c.ensureRoute()
}
// Hacky function to deal with sync issues when restarting client
func (c *Client) ensureRoute() {
if c.Config.Get().Enabled {
c.server.RegisterRoute(twitch.CallbackRoute, c)
}
}
func newClient(config twitch.Config, db database.Database, server *webserver.WebServer, logger *zap.Logger) (*Client, error) {
// Create Twitch client
ctx, cancel := context.WithCancel(context.Background())
client := &Client{
Config: sync.NewRWSync(config),
DB: db,
Logger: logger.With(zap.String("service", "twitch")),
restart: make(chan bool, 128),
streamOnline: sync.NewRWSync(false),
ctx: ctx,
cancel: cancel,
server: server,
}
if config.Enabled {
var err error
client.API, err = twitch.GetHelixAPI(db)
if err != nil {
return nil, fmt.Errorf("failed to create twitch client: %w", err)
}
server.RegisterRoute(twitch.CallbackRoute, client)
if userClient, err := twitch.GetUserClient(db, true); err == nil {
users, err := userClient.GetUsers(&helix.UsersParams{})
if err != nil {
client.Logger.Error("Failed looking up user", zap.Error(err))
} else if len(users.Data.Users) < 1 {
client.Logger.Error("No users found, please authenticate in Twitch configuration -> Events")
} else {
client.User = users.Data.Users[0]
client.eventSub, err = eventsub.Setup(ctx, userClient, client.User, db, logger)
if err != nil {
client.Logger.Error("Failed to setup EventSub", zap.Error(err))
}
}
} else {
client.Logger.Warn("Twitch user not identified, this will break most features")
}
go client.runStatusPoll()
}
return client, nil
}
func (c *Client) runStatusPoll() {
c.Logger.Info("Started polling for stream status")
for {
// Make sure we're configured and connected properly first
if !c.Config.Get().Enabled {
continue
}
// Check if streamer is online, if possible
func() {
status, err := c.API.GetStreams(&helix.StreamsParams{
UserLogins: []string{c.Config.Get().Channel}, // TODO Replace with something non bot dependant
})
if err != nil {
c.Logger.Error("Error checking stream status", zap.Error(err))
return
}
c.streamOnline.Set(len(status.Data.Streams) > 0)
err = c.DB.PutJSON(twitch.StreamInfoKey, status.Data.Streams)
if err != nil {
c.Logger.Warn("Error saving stream info", zap.Error(err))
}
}()
// Wait for next poll (or cancellation)
select {
case <-c.ctx.Done():
return
case <-time.After(60 * time.Second):
}
}
}
func (c *Client) baseURL() (string, error) {
var severConfig struct {
Bind string `json:"bind"`
}
err := c.DB.GetJSON("http/config", &severConfig)
return severConfig.Bind, err
}
func (c *Client) Close() error {
c.server.UnregisterRoute(twitch.CallbackRoute)
defer c.cancel()
return nil
}

View File

@ -1,8 +1,10 @@
package twitch
package client
import (
"testing"
"git.sr.ht/~ashkeel/strimertul/twitch"
"go.uber.org/zap/zaptest"
"git.sr.ht/~ashkeel/strimertul/database"
@ -19,7 +21,7 @@ func TestNewClient(t *testing.T) {
t.Fatal(err)
}
config := Config{}
config := twitch.Config{}
_, err = newClient(config, client, server, logger)
if err != nil {
t.Fatal(err)

66
twitch/client/template.go Normal file
View File

@ -0,0 +1,66 @@
package client
import (
"math/rand"
"strconv"
"strings"
textTemplate "text/template"
"github.com/Masterminds/sprig/v3"
"git.sr.ht/~ashkeel/strimertul/twitch/template"
"github.com/nicklaw5/helix/v2"
"go.uber.org/zap"
)
const ChatCounterPrefix = "twitch/chat/counters/"
type templateEngineImpl struct {
customFunctions textTemplate.FuncMap
}
func (b *templateEngineImpl) MakeTemplate(message string) (*textTemplate.Template, error) {
return textTemplate.New("").Funcs(sprig.TxtFuncMap()).Funcs(b.customFunctions).Parse(message)
}
func (c *Client) GetTemplateEngine() template.Engine {
return &templateEngineImpl{
customFunctions: textTemplate.FuncMap{
"user": func(message helix.EventSubChannelChatMessageEvent) string {
return message.ChatterUserLogin
},
"param": func(num int, message helix.EventSubChannelChatMessageEvent) string {
parts := strings.Split(message.Message.Text, " ")
if num >= len(parts) {
return parts[len(parts)-1]
}
return parts[num]
},
"randomInt": func(min int, max int) int {
return rand.Intn(max-min) + min
},
"game": func(channel string) string {
channel = strings.TrimLeft(channel, "@")
info, err := c.API.SearchChannels(&helix.SearchChannelsParams{Channel: channel, First: 1, LiveOnly: false})
if err != nil {
return "unknown"
}
return info.Data.Channels[0].GameName
},
"count": func(name string) int {
counterKey := ChatCounterPrefix + name
counter := 0
if byt, err := c.DB.GetKey(counterKey); err == nil {
counter, _ = strconv.Atoi(byt)
}
counter++
err := c.DB.PutKey(counterKey, strconv.Itoa(counter))
if err != nil {
c.Logger.Error("Error saving key", zap.Error(err), zap.String("key", counterKey))
}
return counter
},
},
}
}

View File

@ -1,189 +0,0 @@
package twitch
import (
"bytes"
"math/rand"
"strconv"
"strings"
"text/template"
"time"
"github.com/Masterminds/sprig/v3"
irc "github.com/gempir/go-twitch-irc/v4"
"github.com/nicklaw5/helix/v2"
"go.uber.org/zap"
)
type AccessLevelType string
const (
ALTEveryone AccessLevelType = "everyone"
ALTSubscribers AccessLevelType = "subscriber"
ALTVIP AccessLevelType = "vip"
ALTModerators AccessLevelType = "moderators"
ALTStreamer AccessLevelType = "streamer"
)
var accessLevels = map[AccessLevelType]int{
ALTEveryone: 0,
ALTSubscribers: 1,
ALTVIP: 2,
ALTModerators: 3,
ALTStreamer: 999,
}
type BotCommandHandler func(bot *Bot, message irc.PrivateMessage)
type BotCommand struct {
Description string
Usage string
AccessLevel AccessLevelType
Handler BotCommandHandler
Enabled bool
}
func cmdCustom(bot *Bot, cmd string, data BotCustomCommand, message irc.PrivateMessage) {
// Check access level
accessLevel := getUserAccessLevel(message.User)
// Ensure that access level is high enough
if accessLevels[accessLevel] < accessLevels[data.AccessLevel] {
return
}
var buf bytes.Buffer
tpl, ok := bot.customTemplates.GetKey(cmd)
if !ok {
return
}
if err := tpl.Execute(&buf, message); err != nil {
bot.logger.Error("Failed to execute custom command template", zap.Error(err))
return
}
switch data.ResponseType {
case ResponseTypeDefault, ResponseTypeChat:
bot.Client.Say(message.Channel, buf.String())
case ResponseTypeReply:
bot.Client.Reply(message.Channel, message.ID, buf.String())
case ResponseTypeWhisper:
client, err := bot.api.GetUserClient(false)
if err != nil {
bot.logger.Error("Failed to retrieve client", zap.Error(err))
return
}
reply, err := client.SendUserWhisper(&helix.SendUserWhisperParams{
FromUserID: bot.api.User.ID,
ToUserID: message.User.ID,
Message: buf.String(),
})
if reply.Error != "" {
bot.logger.Error("Failed to send whisper", zap.String("code", reply.Error), zap.String("message", reply.ErrorMessage))
}
if err != nil {
bot.logger.Error("Failed to send whisper", zap.Error(err))
}
case ResponseTypeAnnounce:
client, err := bot.api.GetUserClient(false)
if err != nil {
bot.logger.Error("Failed to retrieve client", zap.Error(err))
return
}
reply, err := client.SendChatAnnouncement(&helix.SendChatAnnouncementParams{
BroadcasterID: bot.api.User.ID,
ModeratorID: bot.api.User.ID,
Message: buf.String(),
})
if reply.Error != "" {
bot.logger.Error("Failed to send announcement", zap.String("code", reply.Error), zap.String("message", reply.ErrorMessage))
}
if err != nil {
bot.logger.Error("Failed to send announcement", zap.Error(err))
}
}
}
func (b *Bot) MakeTemplate(message string) (*template.Template, error) {
return template.New("").Funcs(sprig.TxtFuncMap()).Funcs(b.customFunctions).Parse(message)
}
var TestMessageData = irc.PrivateMessage{
User: irc.User{
ID: "603448316",
Name: "ashkeelvt",
DisplayName: "AshKeelVT",
Color: "#EC2B87",
Badges: map[string]int{
"subscriber": 0,
"moments": 1,
"broadcaster": 1,
},
},
Type: 1,
Tags: map[string]string{
"emotes": "",
"first-msg": "0",
"id": "e6b80ab3-d068-4226-83b2-a991da9c0cc3",
"turbo": "0",
"user-id": "603448316",
"badges": "broadcaster/1,subscriber/0,moments/1",
"color": "#EC2B87",
"user-type": "",
"room-id": "603448316",
"tmi-sent-ts": "1684345559394",
"flags": "",
"mod": "0",
"returning-chatter": "0",
"badge-info": "subscriber/21",
"display-name": "AshKeelVT",
"subscriber": "1",
},
Message: "!test",
Channel: "ashkeelvt",
RoomID: "603448316",
ID: "e6b80ab3-d068-4226-83b2-a991da9c0cc3",
Time: time.Now(),
Emotes: nil,
Bits: 0,
Action: false,
}
func (b *Bot) setupFunctions() {
b.customFunctions = template.FuncMap{
"user": func(message irc.PrivateMessage) string {
return message.User.DisplayName
},
"param": func(num int, message irc.PrivateMessage) string {
parts := strings.Split(message.Message, " ")
if num >= len(parts) {
return parts[len(parts)-1]
}
return parts[num]
},
"randomInt": func(min int, max int) int {
return rand.Intn(max-min) + min
},
"game": func(channel string) string {
channel = strings.TrimLeft(channel, "@")
info, err := b.api.API.SearchChannels(&helix.SearchChannelsParams{Channel: channel, First: 1, LiveOnly: false})
if err != nil {
return "unknown"
}
return info.Data.Channels[0].GameName
},
"count": func(name string) int {
counterKey := BotCounterPrefix + name
counter := 0
if byt, err := b.api.db.GetKey(counterKey); err == nil {
counter, _ = strconv.Atoi(byt)
}
counter++
err := b.api.db.PutKey(counterKey, strconv.Itoa(counter))
if err != nil {
b.logger.Error("Error saving key", zap.Error(err), zap.String("key", counterKey))
}
return counter
},
}
}

21
twitch/config.go Normal file
View File

@ -0,0 +1,21 @@
package twitch
const ConfigKey = "twitch/config"
// Config is the general configuration for the Twitch subsystem
type Config struct {
// Enable subsystem
Enabled bool `json:"enabled" desc:"Enable subsystem"`
// Enable the chatbot
EnableBot bool `json:"enable_bot" desc:"Enable the chatbot"`
// Twitch API App Client ID
APIClientID string `json:"api_client_id" desc:"Twitch API App Client ID"`
// Twitch API App Client Secret
APIClientSecret string `json:"api_client_secret" desc:"Twitch API App Client Secret"`
// Twitch channel to use
Channel string `json:"channel" desc:"Twitch channel to join and use"`
}

View File

@ -1,104 +1,39 @@
package twitch
import (
"github.com/nicklaw5/helix/v2"
)
const CallbackRoute = "/twitch/callback"
const ConfigKey = "twitch/config"
// Config is the general configuration for the Twitch subsystem
type Config struct {
// Enable subsystem
Enabled bool `json:"enabled" desc:"Enable subsystem"`
// Enable the chatbot
EnableBot bool `json:"enable_bot" desc:"Enable the chatbot"`
// Twitch API App Client ID
APIClientID string `json:"api_client_id" desc:"Twitch API App Client ID"`
// Twitch API App Client Secret
APIClientSecret string `json:"api_client_secret" desc:"Twitch API App Client Secret"`
}
const StreamInfoKey = "twitch/stream-info"
const BotConfigKey = "twitch/bot-config"
// BotConfig is the general configuration for the Twitch chatbot
type BotConfig struct {
// Chatbot username (for internal use, ignored by Twitch)
Username string `json:"username" desc:"Chatbot username (for internal use, ignored by Twitch)"`
// OAuth key for IRC authentication
Token string `json:"oauth" desc:"OAuth key for IRC authentication"`
// Twitch channel to join and use
Channel string `json:"channel" desc:"Twitch channel to join and use"`
// How many messages to keep in twitch/chat-history
ChatHistory int `json:"chat_history" desc:"How many messages to keep in twitch/chat-history"`
// Global command cooldown in seconds
CommandCooldown int `json:"command_cooldown" desc:"Global command cooldown in seconds"`
}
const (
ChatEventKey = "twitch/ev/chat-message"
ChatHistoryKey = "twitch/chat-history"
ChatActivityKey = "twitch/chat-activity"
)
type ResponseType string
const (
ResponseTypeDefault ResponseType = ""
ResponseTypeChat ResponseType = "chat"
ResponseTypeWhisper ResponseType = "whisper"
ResponseTypeReply ResponseType = "reply"
ResponseTypeAnnounce ResponseType = "announce"
)
// BotCustomCommand is a definition of a custom command of the chatbot
type BotCustomCommand struct {
// Command description
Description string `json:"description" desc:"Command description"`
// Minimum access level needed to use the command
AccessLevel AccessLevelType `json:"access_level" desc:"Minimum access level needed to use the command"`
// Response template (in Go templating format)
Response string `json:"response" desc:"Response template (in Go templating format)"`
// Is the command enabled?
Enabled bool `json:"enabled" desc:"Is the command enabled?"`
// How to respond to the user
ResponseType ResponseType `json:"response_type" desc:"How to respond to the user"`
}
const CustomCommandsKey = "twitch/bot-custom-commands"
const (
// WritePlainMessageRPC is the old send command, will be renamed someday
WritePlainMessageRPC = "twitch/@send-chat-message"
WriteMessageRPC = "twitch/bot/@send-message"
)
// WriteMessageRequest is an RPC to send a chat message with extra options
type WriteMessageRequest struct {
Message string `json:"message" desc:"Chat message to send"`
ReplyTo *string `json:"reply_to" desc:"If specified, send as reply to a message ID"`
WhisperTo *string `json:"whisper_to" desc:"If specified, send as whisper to user ID"`
Announce bool `json:"announce" desc:"If true, send as announcement"`
}
const BotCounterPrefix = "twitch/bot-counters/"
const AuthKey = "twitch/auth-keys"
const (
EventSubEventKeyPrefix = "twitch/ev/eventsub-event/"
EventSubHistoryKeyPrefix = "twitch/eventsub-history/"
)
const EventSubHistorySize = 100
var TestMessageData = helix.EventSubChannelChatMessageEvent{
ChatterUserLogin: "ashkeelvt",
ChatterUserID: "603448316",
ChatterUserName: "AshKeelVT",
Color: "#EC2B87",
Badges: []helix.EventSubChatBadge{
{
SetID: "broadcaster",
ID: "1",
},
{
SetID: "subscriber",
ID: "21",
},
},
MessageID: "e6b80ab3-d068-4226-83b2-a991da9c0cc3",
Message: helix.EventSubChatMessage{
Text: "!test param1 param2 param3 param4",
Fragments: []helix.EventSubChatMessageFragment{
{
Text: "!test param1 param2 param3 param4",
Type: "text",
},
},
},
MessageType: "chat",
}

View File

@ -1,96 +0,0 @@
package twitch
import (
"reflect"
"git.sr.ht/~ashkeel/strimertul/docs/interfaces"
irc "github.com/gempir/go-twitch-irc/v4"
"github.com/nicklaw5/helix/v2"
)
// Documentation stuff, keep updated at all times
var Keys = interfaces.KeyMap{
ConfigKey: interfaces.KeyDef{
Description: "General configuration for the Twitch subsystem",
Type: reflect.TypeOf(Config{}),
},
StreamInfoKey: interfaces.KeyDef{
Description: "List of active twitch streams (1 element if live, 0 otherwise)",
Type: reflect.TypeOf([]helix.Stream{}),
},
BotConfigKey: interfaces.KeyDef{
Description: "General configuration for the Twitch chatbot",
Type: reflect.TypeOf(BotConfig{}),
},
ChatEventKey: interfaces.KeyDef{
Description: "On chat message received",
Type: reflect.TypeOf(irc.PrivateMessage{}),
Tags: []interfaces.KeyTag{interfaces.TagEvent},
},
ChatHistoryKey: interfaces.KeyDef{
Description: "Last chat messages received",
Type: reflect.TypeOf([]irc.PrivateMessage{}),
Tags: []interfaces.KeyTag{interfaces.TagHistory},
},
ChatActivityKey: interfaces.KeyDef{
Description: "Number of chat messages in the last minute",
Type: reflect.TypeOf(0),
},
CustomCommandsKey: interfaces.KeyDef{
Description: "Chatbot custom commands",
Type: reflect.TypeOf(map[string]BotCustomCommand{}),
},
AuthKey: interfaces.KeyDef{
Description: "User access token for the twitch subsystem",
Type: reflect.TypeOf(AuthResponse{}),
},
EventSubEventKeyPrefix + "[event-name]": interfaces.KeyDef{
Description: "On Eventsub event [event-name] received",
Type: reflect.TypeOf(NotificationMessagePayload{}),
Tags: []interfaces.KeyTag{interfaces.TagEvent},
},
EventSubHistoryKeyPrefix + "[event-name]": interfaces.KeyDef{
Description: "Last eventsub notifications received for [event-name]",
Type: reflect.TypeOf([]NotificationMessagePayload{}),
Tags: []interfaces.KeyTag{interfaces.TagHistory},
},
BotAlertsKey: interfaces.KeyDef{
Description: "Configuration of chat bot alerts",
Type: reflect.TypeOf(BotAlertsConfig{}),
},
BotTimersKey: interfaces.KeyDef{
Description: "Configuration of chat bot timers",
Type: reflect.TypeOf(BotTimersConfig{}),
},
WritePlainMessageRPC: interfaces.KeyDef{
Description: "Send plain text chat message (this will be deprecated or renamed someday, please use the other one!)",
Type: reflect.TypeOf(""),
Tags: []interfaces.KeyTag{interfaces.TagRPC},
},
WriteMessageRPC: interfaces.KeyDef{
Description: "Send chat message with extra options (as reply, whisper, etc)",
Type: reflect.TypeOf(WriteMessageRequest{}),
Tags: []interfaces.KeyTag{interfaces.TagRPC},
},
}
var Enums = interfaces.EnumMap{
"AccessLevelType": interfaces.Enum{
Values: []any{
ALTEveryone,
ALTSubscribers,
ALTVIP,
ALTModerators,
ALTStreamer,
},
},
"ResponseType": interfaces.Enum{
Values: []any{
ResponseTypeChat,
ResponseTypeReply,
ResponseTypeWhisper,
ResponseTypeAnnounce,
},
},
}

96
twitch/doc/doc.go Normal file
View File

@ -0,0 +1,96 @@
package doc
import (
"reflect"
"github.com/nicklaw5/helix/v2"
"git.sr.ht/~ashkeel/strimertul/docs/interfaces"
"git.sr.ht/~ashkeel/strimertul/twitch"
"git.sr.ht/~ashkeel/strimertul/twitch/alerts"
"git.sr.ht/~ashkeel/strimertul/twitch/chat"
"git.sr.ht/~ashkeel/strimertul/twitch/eventsub"
"git.sr.ht/~ashkeel/strimertul/twitch/timers"
)
// Documentation stuff, keep updated at all times
var Keys = interfaces.KeyMap{
twitch.ConfigKey: interfaces.KeyDef{
Description: "General configuration for the Twitch subsystem",
Type: reflect.TypeOf(twitch.Config{}),
},
twitch.StreamInfoKey: interfaces.KeyDef{
Description: "List of active twitch streams (1 element if live, 0 otherwise)",
Type: reflect.TypeOf([]helix.Stream{}),
},
twitch.AuthKey: interfaces.KeyDef{
Description: "User access token for the twitch subsystem",
Type: reflect.TypeOf(twitch.AuthResponse{}),
},
chat.ConfigKey: interfaces.KeyDef{
Description: "Configuration for chat-related features",
Type: reflect.TypeOf(chat.Config{}),
},
chat.EventKey: interfaces.KeyDef{
Description: "On chat message received",
Type: reflect.TypeOf(helix.EventSubChannelChatMessageEvent{}),
Tags: []interfaces.KeyTag{interfaces.TagEvent},
},
chat.HistoryKey: interfaces.KeyDef{
Description: "Last chat messages received",
Type: reflect.TypeOf([]helix.EventSubChannelChatMessageEvent{}),
Tags: []interfaces.KeyTag{interfaces.TagHistory},
},
chat.ActivityKey: interfaces.KeyDef{
Description: "Number of chat messages in the last minute",
Type: reflect.TypeOf(0),
},
chat.CustomCommandsKey: interfaces.KeyDef{
Description: "Chat custom commands",
Type: reflect.TypeOf(map[string]chat.CustomCommand{}),
},
chat.WriteMessageRPC: interfaces.KeyDef{
Description: "Send chat message with extra options (as reply, whisper, etc)",
Type: reflect.TypeOf(chat.WriteMessageRequest{}),
Tags: []interfaces.KeyTag{interfaces.TagRPC},
},
eventsub.EventKeyPrefix + "[event-name]": interfaces.KeyDef{
Description: "On Eventsub event [event-name] received",
Type: reflect.TypeOf(eventsub.NotificationMessagePayload{}),
Tags: []interfaces.KeyTag{interfaces.TagEvent},
},
eventsub.HistoryKeyPrefix + "[event-name]": interfaces.KeyDef{
Description: "Last eventsub notifications received for [event-name]",
Type: reflect.TypeOf([]eventsub.NotificationMessagePayload{}),
Tags: []interfaces.KeyTag{interfaces.TagHistory},
},
alerts.ConfigKey: interfaces.KeyDef{
Description: "Configuration of chat alerts",
Type: reflect.TypeOf(alerts.Config{}),
},
timers.ConfigKey: interfaces.KeyDef{
Description: "Configuration of chat timers",
Type: reflect.TypeOf(timers.Config{}),
},
}
var Enums = interfaces.EnumMap{
"AccessLevelType": interfaces.Enum{
Values: []any{
chat.ALTEveryone,
chat.ALTSubscribers,
chat.ALTVIP,
chat.ALTModerators,
chat.ALTStreamer,
},
},
"ResponseType": interfaces.Enum{
Values: []any{
chat.ResponseTypeChat,
chat.ResponseTypeReply,
chat.ResponseTypeWhisper,
chat.ResponseTypeAnnounce,
},
},
}

View File

@ -1,25 +1,62 @@
package twitch
package eventsub
import (
"context"
"fmt"
"time"
"git.sr.ht/~ashkeel/strimertul/utils"
"github.com/gorilla/websocket"
lru "github.com/hashicorp/golang-lru/v2"
jsoniter "github.com/json-iterator/go"
"github.com/nicklaw5/helix/v2"
"go.uber.org/zap"
"git.sr.ht/~ashkeel/strimertul/database"
"git.sr.ht/~ashkeel/strimertul/utils"
)
var json = jsoniter.ConfigFastest
const websocketEndpoint = "wss://eventsub.wss.twitch.tv/ws"
func (c *Client) eventSubLoop(userClient *helix.Client) {
type Client struct {
ctx context.Context
twitchAPI *helix.Client
db database.Database
logger *zap.Logger
user helix.User
eventCache *lru.Cache[string, time.Time]
savedSubscriptions map[string]bool
}
func Setup(ctx context.Context, twitchAPI *helix.Client, user helix.User, db database.Database, logger *zap.Logger) (*Client, error) {
eventCache, err := lru.New[string, time.Time](128)
if err != nil {
return nil, fmt.Errorf("could not create LRU cache for events: %w", err)
}
client := &Client{
ctx: ctx,
twitchAPI: twitchAPI,
db: db,
logger: logger,
user: user,
eventCache: eventCache,
savedSubscriptions: make(map[string]bool),
}
go client.eventSubLoop()
return client, nil
}
func (c *Client) eventSubLoop() {
endpoint := websocketEndpoint
var err error
var connection *websocket.Conn
for endpoint != "" {
endpoint, connection, err = c.connectWebsocket(endpoint, connection, userClient)
endpoint, connection, err = c.connectWebsocket(endpoint, connection)
if err != nil {
c.logger.Error("EventSub websocket read error", zap.Error(err))
}
@ -46,7 +83,7 @@ func readLoop(connection *websocket.Conn, recv chan<- []byte, wsErr chan<- error
}
}
func (c *Client) connectWebsocket(url string, oldConnection *websocket.Conn, userClient *helix.Client) (string, *websocket.Conn, error) {
func (c *Client) connectWebsocket(url string, oldConnection *websocket.Conn) (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))
@ -69,21 +106,21 @@ func (c *Client) connectWebsocket(url string, oldConnection *websocket.Conn, use
case messageData = <-received:
}
var wsMessage EventSubWebsocketMessage
var wsMessage WebsocketMessage
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)
reconnectURL, done, err := c.processMessage(wsMessage, oldConnection)
if done {
return reconnectURL, connection, err
}
}
}
func (c *Client) processMessage(wsMessage EventSubWebsocketMessage, oldConnection *websocket.Conn, userClient *helix.Client) (string, bool, error) {
func (c *Client) processMessage(wsMessage WebsocketMessage, oldConnection *websocket.Conn) (string, bool, error) {
switch wsMessage.Metadata.MessageType {
case "session_keepalive":
// Nothing to do
@ -102,7 +139,7 @@ func (c *Client) processMessage(wsMessage EventSubWebsocketMessage, oldConnectio
}
// Add subscription to websocket session
err = c.addSubscriptionsForSession(userClient, welcomeData.Session.ID)
err = c.addSubscriptionsForSession(welcomeData.Session.ID)
if err != nil {
c.logger.Error("Could not add subscriptions", zap.Error(err))
break
@ -125,7 +162,7 @@ func (c *Client) processMessage(wsMessage EventSubWebsocketMessage, oldConnectio
return "", false, nil
}
func (c *Client) processEvent(message EventSubWebsocketMessage) {
func (c *Client) processEvent(message WebsocketMessage) {
// Check if we processed this already
if message.Metadata.MessageID != "" {
if c.eventCache.Contains(message.Metadata.MessageID) {
@ -143,8 +180,8 @@ func (c *Client) processEvent(message EventSubWebsocketMessage) {
}
notificationData.Date = time.Now()
eventKey := fmt.Sprintf("%s%s", EventSubEventKeyPrefix, notificationData.Subscription.Type)
historyKey := fmt.Sprintf("%s%s", EventSubHistoryKeyPrefix, notificationData.Subscription.Type)
eventKey := fmt.Sprintf("%s%s", EventKeyPrefix, notificationData.Subscription.Type)
historyKey := fmt.Sprintf("%s%s", HistoryKeyPrefix, 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 {
@ -157,8 +194,8 @@ func (c *Client) processEvent(message EventSubWebsocketMessage) {
archive = []NotificationMessagePayload{}
}
archive = append(archive, notificationData)
if len(archive) > EventSubHistorySize {
archive = archive[len(archive)-EventSubHistorySize:]
if len(archive) > HistorySize {
archive = archive[len(archive)-HistorySize:]
}
err = c.db.PutJSON(historyKey, archive)
if err != nil {
@ -166,7 +203,7 @@ func (c *Client) processEvent(message EventSubWebsocketMessage) {
}
}
func (c *Client) addSubscriptionsForSession(userClient *helix.Client, session string) error {
func (c *Client) addSubscriptionsForSession(session string) error {
if c.savedSubscriptions[session] {
// Already subscribed
return nil
@ -177,12 +214,12 @@ func (c *Client) addSubscriptionsForSession(userClient *helix.Client, session st
SessionID: session,
}
for topic, version := range subscriptionVersions {
sub, err := userClient.CreateEventSubSubscription(&helix.EventSubSubscription{
sub, err := c.twitchAPI.CreateEventSubSubscription(&helix.EventSubSubscription{
Type: topic,
Version: version,
Status: "enabled",
Transport: transport,
Condition: topicCondition(topic, c.User.ID),
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))
@ -223,8 +260,8 @@ func topicCondition(topic string, id string) helix.EventSubCondition {
}
}
type EventSubWebsocketMessage struct {
Metadata EventSubMetadata `json:"metadata"`
type WebsocketMessage struct {
Metadata Metadata `json:"metadata"`
Payload jsoniter.RawMessage `json:"payload"`
}
@ -244,7 +281,7 @@ type NotificationMessagePayload struct {
Date time.Time `json:"date,omitempty"`
}
type EventSubMetadata struct {
type Metadata struct {
MessageID string `json:"message_id"`
MessageType string `json:"message_type"`
MessageTimestamp time.Time `json:"message_timestamp"`

8
twitch/eventsub/data.go Normal file
View File

@ -0,0 +1,8 @@
package eventsub
const (
EventKeyPrefix = "twitch/ev/eventsub-event/"
HistoryKeyPrefix = "twitch/eventsub-history/"
)
const HistorySize = 100

58
twitch/scopes.go Normal file
View File

@ -0,0 +1,58 @@
package twitch
import (
"slices"
"github.com/nicklaw5/helix/v2"
"git.sr.ht/~ashkeel/strimertul/database"
)
var scopes = []string{
"bits:read",
"channel:bot",
"channel:moderate",
"channel:read:hype_train",
"channel:read:polls",
"channel:read:predictions",
"channel:read:redemptions",
"channel:read:subscriptions",
"chat:edit",
"chat:read",
"moderator:manage:announcements",
"moderator:read:chatters",
"moderator:read:followers",
"user:bot",
"user:manage:whispers",
"user:read:chat",
"user_read",
"whispers:edit",
"whispers:read",
}
// CheckScopes checks if the user has authorized all required scopes
// Normally this would be the case but between versions strimertul has changed
// the required scopes, and it's possible that some users have not re-authorized
// the application with the new scopes.
func CheckScopes(db database.Database) (bool, error) {
var authResp AuthResponse
if err := db.GetJSON(AuthKey, &authResp); err != nil {
return false, err
}
// Sort scopes for comparison
slices.Sort(authResp.Scope)
slices.Sort(scopes)
return slices.Equal(scopes, authResp.Scope), nil
}
func GetAuthorizationURL(api *helix.Client) string {
if api == nil {
return "twitch-not-configured"
}
return api.GetAuthorizationURL(&helix.AuthorizationURLParams{
ResponseType: "code",
Scopes: scopes,
})
}

View File

@ -0,0 +1,9 @@
package template
import (
textTemplate "text/template"
)
type Engine interface {
MakeTemplate(message string) (*textTemplate.Template, error)
}

27
twitch/timers/config.go Normal file
View File

@ -0,0 +1,27 @@
package timers
const ConfigKey = "twitch/timers/config"
type Config struct {
Timers map[string]ChatTimer `json:"timers" desc:"List of timers as a dictionary"`
}
type ChatTimer struct {
// Whether the timer is enabled
Enabled bool `json:"enabled" desc:"Enable the timer"`
// Timer name (must be unique)
Name string `json:"name" desc:"Timer name (must be unique)"`
// Minimum chat messages in the last 5 minutes for timer to trigger
MinimumChatActivity int `json:"minimum_chat_activity" desc:"Minimum chat messages in the last 5 minutes for timer to trigger"`
// Minimum amount of time (in seconds) that needs to pass before it triggers again
MinimumDelay int `json:"minimum_delay" desc:"Minimum amount of time (in seconds) that needs to pass before it triggers again"`
// Messages to write (randomly chosen)
Messages []string `json:"messages" desc:"Messages to write (randomly chosen)"`
// Announce the message to the chat
Announce bool `json:"announce" desc:"If true, send as announcement"`
}

View File

@ -1,56 +1,38 @@
package twitch
package timers
import (
"math/rand"
"time"
"git.sr.ht/~ashkeel/containers/sync"
irc "github.com/gempir/go-twitch-irc/v4"
jsoniter "github.com/json-iterator/go"
"go.uber.org/zap"
"git.sr.ht/~ashkeel/strimertul/database"
"git.sr.ht/~ashkeel/strimertul/twitch/chat"
)
const BotTimersKey = "twitch/bot-modules/timers/config"
type BotTimersConfig struct {
Timers map[string]BotTimer `json:"timers" desc:"List of timers as a dictionary"`
}
type BotTimer struct {
// Whether the timer is enabled
Enabled bool `json:"enabled" desc:"Enable the timer"`
// Timer name (must be unique)
Name string `json:"name" desc:"Timer name (must be unique)"`
// Minimum chat messages in the last 5 minutes for timer to trigger
MinimumChatActivity int `json:"minimum_chat_activity" desc:"Minimum chat messages in the last 5 minutes for timer to trigger"`
// Minimum amount of time (in seconds) that needs to pass before it triggers again
MinimumDelay int `json:"minimum_delay" desc:"Minimum amount of time (in seconds) that needs to pass before it triggers again"`
// Messages to write (randomly chosen)
Messages []string `json:"messages" desc:"Messages to write (randomly chosen)"`
}
var json = jsoniter.ConfigFastest
const AverageMessageWindow = 5
type BotTimerModule struct {
Config BotTimersConfig
type Module struct {
Config Config
bot *Bot
lastTrigger *sync.Map[string, time.Time]
messages *sync.Slice[int]
logger *zap.Logger
db database.Database
cancelTimerSub database.CancelFunc
}
func SetupTimers(bot *Bot) *BotTimerModule {
mod := &BotTimerModule{
bot: bot,
func Setup(db database.Database, logger *zap.Logger) *Module {
mod := &Module{
lastTrigger: sync.NewMap[string, time.Time](),
messages: sync.NewSlice[int](),
db: db,
logger: logger,
}
// Fill messages with zero values
@ -60,32 +42,32 @@ func SetupTimers(bot *Bot) *BotTimerModule {
}
// Load config from database
err := bot.api.db.GetJSON(BotTimersKey, &mod.Config)
err := db.GetJSON(ConfigKey, &mod.Config)
if err != nil {
bot.logger.Debug("Config load error", zap.Error(err))
mod.Config = BotTimersConfig{
Timers: make(map[string]BotTimer),
logger.Debug("Config load error", zap.Error(err))
mod.Config = Config{
Timers: make(map[string]ChatTimer),
}
// Save empty config
err = bot.api.db.PutJSON(BotTimersKey, mod.Config)
err = db.PutJSON(ConfigKey, mod.Config)
if err != nil {
bot.logger.Warn("Could not save default config for bot timers", zap.Error(err))
logger.Warn("Could not save default config for bot timers", zap.Error(err))
}
}
mod.cancelTimerSub, err = bot.api.db.SubscribeKey(BotTimersKey, func(value string) {
mod.cancelTimerSub, err = db.SubscribeKey(ConfigKey, func(value string) {
err := json.UnmarshalFromString(value, &mod.Config)
if err != nil {
bot.logger.Debug("Error reloading timer config", zap.Error(err))
logger.Debug("Error reloading timer config", zap.Error(err))
} else {
bot.logger.Info("Reloaded timer config")
logger.Info("Reloaded timer config")
}
})
if err != nil {
bot.logger.Error("Could not set-up timer reload subscription", zap.Error(err))
logger.Error("Could not set-up timer reload subscription", zap.Error(err))
}
bot.logger.Debug("Loaded timers", zap.Int("timers", len(mod.Config.Timers)))
logger.Debug("Loaded timers", zap.Int("timers", len(mod.Config.Timers)))
// Start goroutine for clearing message counters and running timers
go mod.runTimers()
@ -93,7 +75,7 @@ func SetupTimers(bot *Bot) *BotTimerModule {
return mod
}
func (m *BotTimerModule) runTimers() {
func (m *Module) runTimers() {
for {
// Wait until next tick (remainder until next minute, as close to 0 seconds as possible)
currentTime := time.Now()
@ -101,9 +83,9 @@ func (m *BotTimerModule) runTimers() {
timeUntilNextTick := nextTick.Sub(currentTime)
time.Sleep(timeUntilNextTick)
err := m.bot.api.db.PutJSON(ChatActivityKey, m.messages.Get())
err := m.db.PutJSON(chat.ActivityKey, m.messages.Get())
if err != nil {
m.bot.logger.Warn("Error saving chat activity", zap.Error(err))
m.logger.Warn("Error saving chat activity", zap.Error(err))
}
// Calculate activity
@ -122,7 +104,7 @@ func (m *BotTimerModule) runTimers() {
}
}
func (m *BotTimerModule) ProcessTimer(name string, timer BotTimer, activity int) {
func (m *Module) ProcessTimer(name string, timer ChatTimer, activity int) {
// Must be enabled
if !timer.Enabled {
return
@ -155,19 +137,22 @@ func (m *BotTimerModule) ProcessTimer(name string, timer BotTimer, activity int)
message := timer.Messages[rand.Intn(len(timer.Messages))]
// Write message to chat
m.bot.WriteMessage(message)
chat.WriteMessage(m.db, m.logger, chat.WriteMessageRequest{
Message: message,
Announce: timer.Announce,
})
// Update last trigger
m.lastTrigger.SetKey(name, now)
}
func (m *BotTimerModule) Close() {
func (m *Module) Close() {
if m.cancelTimerSub != nil {
m.cancelTimerSub()
}
}
func (m *BotTimerModule) currentChatActivity() int {
func (m *Module) currentChatActivity() int {
total := 0
for _, v := range m.messages.Get() {
total += v
@ -175,7 +160,7 @@ func (m *BotTimerModule) currentChatActivity() int {
return total
}
func (m *BotTimerModule) OnMessage(message irc.PrivateMessage) {
index := message.Time.Minute() % AverageMessageWindow
func (m *Module) OnMessage() {
index := time.Now().Minute() % AverageMessageWindow
m.messages.SetIndex(index, 1)
}

View File

@ -24,7 +24,7 @@ var json = jsoniter.ConfigFastest
type WebServer struct {
Config *sync.RWSync[ServerConfig]
db *database.LocalDBClient
db database.Database
logger *zap.Logger
server Server
frontend fs.FS
@ -36,7 +36,7 @@ type WebServer struct {
factory ServerFactory
}
func NewServer(db *database.LocalDBClient, logger *zap.Logger, serverFactory ServerFactory) (*WebServer, error) {
func NewServer(db database.Database, logger *zap.Logger, serverFactory ServerFactory) (*WebServer, error) {
server := &WebServer{
logger: logger,
db: db,