1
0
Fork 0
mirror of https://git.sr.ht/~ashkeel/strimertul synced 2024-09-18 01:50:50 +00:00

refactor: module conundrum phase 1

Refactor Twitch client and bot so it can be closed and reopened with a
different config.
Add a cancellation function to subscription functions so they can be
cancelled.
Use http.Handler instead of HandlerFunc for custom routes
This commit is contained in:
Ash Keel 2022-12-03 16:16:59 +01:00
parent f42869b34e
commit aaffaebe55
No known key found for this signature in database
GPG key ID: BAD8D93E7314ED3E
20 changed files with 444 additions and 290 deletions

14
app.go
View file

@ -26,7 +26,7 @@ type App struct {
ready *containers.RWSync[bool] ready *containers.RWSync[bool]
db *database.LocalDBClient db *database.LocalDBClient
twitchClient *twitch.Client twitchManager *twitch.Manager
httpServer *http.Server httpServer *http.Server
loyaltyManager *loyalty.Manager loyaltyManager *loyalty.Manager
} }
@ -72,11 +72,11 @@ func (a *App) startup(ctx context.Context) {
failOnError(err, "could not initialize http server") failOnError(err, "could not initialize http server")
// Create twitch client // Create twitch client
a.twitchClient, err = twitch.NewClient(a.db, a.httpServer, logger) a.twitchManager, err = twitch.NewManager(a.db, a.httpServer, logger)
failOnError(err, "could not initialize twitch client") failOnError(err, "could not initialize twitch client")
// Initialize loyalty system // Initialize loyalty system
a.loyaltyManager, err = loyalty.NewManager(a.db, a.twitchClient, logger) a.loyaltyManager, err = loyalty.NewManager(a.db, a.twitchManager, logger)
failOnError(err, "could not initialize loyalty manager") failOnError(err, "could not initialize loyalty manager")
a.ready.Set(true) a.ready.Set(true)
@ -98,8 +98,8 @@ func (a *App) stop(context.Context) {
if a.loyaltyManager != nil { if a.loyaltyManager != nil {
warnOnError(a.loyaltyManager.Close(), "could not cleanly close loyalty manager") warnOnError(a.loyaltyManager.Close(), "could not cleanly close loyalty manager")
} }
if a.twitchClient != nil { if a.twitchManager != nil {
warnOnError(a.twitchClient.Close(), "could not cleanly close twitch client") warnOnError(a.twitchManager.Close(), "could not cleanly close twitch client")
} }
if a.httpServer != nil { if a.httpServer != nil {
warnOnError(a.httpServer.Close(), "could not cleanly close HTTP server") warnOnError(a.httpServer.Close(), "could not cleanly close HTTP server")
@ -129,11 +129,11 @@ func (a *App) GetKilovoltBind() string {
} }
func (a *App) GetTwitchAuthURL() string { func (a *App) GetTwitchAuthURL() string {
return a.twitchClient.GetAuthorizationURL() return a.twitchManager.Client().GetAuthorizationURL()
} }
func (a *App) GetTwitchLoggedUser() (helix.User, error) { func (a *App) GetTwitchLoggedUser() (helix.User, error) {
return a.twitchClient.GetLoggedUser() return a.twitchManager.Client().GetLoggedUser()
} }
func (a *App) GetLastLogs() []LogEntry { func (a *App) GetLastLogs() []LogEntry {

View file

@ -9,6 +9,8 @@ import (
"go.uber.org/zap" "go.uber.org/zap"
) )
type CancelFunc func()
var json = jsoniter.ConfigFastest var json = jsoniter.ConfigFastest
var ( var (
@ -74,29 +76,36 @@ func (mod *LocalDBClient) PutKey(key string, data string) error {
return err return err
} }
func (mod *LocalDBClient) SubscribePrefix(fn kv.SubscriptionCallback, prefixes ...string) error { func (mod *LocalDBClient) SubscribePrefix(fn kv.SubscriptionCallback, prefixes ...string) (err error, cancelFn func()) {
var ids []int64
for _, prefix := range prefixes { for _, prefix := range prefixes {
_, err := mod.makeRequest(kv.CmdSubscribePrefix, map[string]interface{}{"prefix": prefix}) _, err = mod.makeRequest(kv.CmdSubscribePrefix, map[string]interface{}{"prefix": prefix})
if err != nil { if err != nil {
return err return err, nil
}
ids = append(ids, mod.client.SetPrefixSubCallback(prefix, fn))
}
return nil, func() {
for _, id := range ids {
mod.client.UnsetCallback(id)
} }
go mod.client.SetPrefixSubCallback(prefix, fn)
} }
return nil
} }
func (mod *LocalDBClient) SubscribeKey(key string, fn func(string)) error { func (mod *LocalDBClient) SubscribeKey(key string, fn func(string)) (err error, cancelFn CancelFunc) {
_, err := mod.makeRequest(kv.CmdSubscribePrefix, map[string]interface{}{"prefix": key}) _, err = mod.makeRequest(kv.CmdSubscribePrefix, map[string]interface{}{"prefix": key})
if err != nil { if err != nil {
return err return err, nil
} }
go mod.client.SetPrefixSubCallback(key, func(changedKey string, value string) { id := mod.client.SetPrefixSubCallback(key, func(changedKey string, value string) {
if key != changedKey { if key != changedKey {
return return
} }
fn(value) fn(value)
}) })
return nil return nil, func() {
mod.client.UnsetCallback(id)
}
} }
func (mod *LocalDBClient) GetJSON(key string, dst interface{}) error { func (mod *LocalDBClient) GetJSON(key string, dst interface{}) error {

View file

@ -6,6 +6,8 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"github.com/strimertul/strimertul/utils"
"go.uber.org/zap" "go.uber.org/zap"
kv "github.com/strimertul/kilovolt/v9" kv "github.com/strimertul/kilovolt/v9"
@ -48,7 +50,7 @@ func getDatabaseDriverName(ctx *cli.Context) string {
func GetDatabaseDriver(ctx *cli.Context) (DatabaseDriver, error) { func GetDatabaseDriver(ctx *cli.Context) (DatabaseDriver, error) {
name := getDatabaseDriverName(ctx) name := getDatabaseDriverName(ctx)
dbDirectory := ctx.String("database-dir") dbDirectory := ctx.String("database-dir")
logger := ctx.Context.Value("logger").(*zap.Logger) logger := ctx.Context.Value(utils.ContextLogger).(*zap.Logger)
switch name { switch name {
case "badger": case "badger":

2
go.mod
View file

@ -3,7 +3,7 @@ module github.com/strimertul/strimertul
go 1.19 go 1.19
require ( require (
git.sr.ht/~hamcha/containers v0.2.0 git.sr.ht/~hamcha/containers v0.2.1
github.com/Masterminds/sprig/v3 v3.2.2 github.com/Masterminds/sprig/v3 v3.2.2
github.com/apenwarr/fixconsole v0.0.0-20191012055117-5a9f6489cc29 github.com/apenwarr/fixconsole v0.0.0-20191012055117-5a9f6489cc29
github.com/cockroachdb/pebble v0.0.0-20221116223310-87eccabb90a3 github.com/cockroachdb/pebble v0.0.0-20221116223310-87eccabb90a3

4
go.sum
View file

@ -31,8 +31,8 @@ cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohl
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
git.sr.ht/~hamcha/containers v0.2.0 h1:fv8HQ6fsJUa1w46sH9KluW6dfJEh3uZN3QNLJvuCIm4= git.sr.ht/~hamcha/containers v0.2.1 h1:mJ8b4fQhDKU73VRK1SjeIzJ5YnZYHeFHLJvHl6yKtNg=
git.sr.ht/~hamcha/containers v0.2.0/go.mod h1:RiZphUpy9t6EnL4Gf6uzByM9QrBoqRCEPo7kz2wzbhE= git.sr.ht/~hamcha/containers v0.2.1/go.mod h1:RiZphUpy9t6EnL4Gf6uzByM9QrBoqRCEPo7kz2wzbhE=
github.com/AndreasBriese/bbloom v0.0.0-20190306092124-e2d15f34fcf9/go.mod h1:bOvUY6CB00SOBii9/FifXqc0awNKxLFCL/+pkDPuyl8= github.com/AndreasBriese/bbloom v0.0.0-20190306092124-e2d15f34fcf9/go.mod h1:bOvUY6CB00SOBii9/FifXqc0awNKxLFCL/+pkDPuyl8=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak= github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak=

View file

@ -27,7 +27,8 @@ type Server struct {
frontend fs.FS frontend fs.FS
hub *kv.Hub hub *kv.Hub
mux *http.ServeMux mux *http.ServeMux
requestedRoutes map[string]http.HandlerFunc requestedRoutes map[string]http.Handler
cancelConfigSub database.CancelFunc
} }
func NewServer(db *database.LocalDBClient, logger *zap.Logger) (*Server, error) { func NewServer(db *database.LocalDBClient, logger *zap.Logger) (*Server, error) {
@ -35,7 +36,7 @@ func NewServer(db *database.LocalDBClient, logger *zap.Logger) (*Server, error)
logger: logger, logger: logger,
db: db, db: db,
server: &http.Server{}, server: &http.Server{},
requestedRoutes: make(map[string]http.HandlerFunc), requestedRoutes: make(map[string]http.Handler),
} }
err := db.GetJSON(ServerConfigKey, &server.Config) err := db.GetJSON(ServerConfigKey, &server.Config)
@ -70,6 +71,10 @@ type StatusData struct {
} }
func (s *Server) Close() error { func (s *Server) Close() error {
if s.cancelConfigSub != nil {
s.cancelConfigSub()
}
return s.server.Close() return s.server.Close()
} }
@ -99,53 +104,55 @@ func (s *Server) makeMux() *http.ServeMux {
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(s.Config.Path)))) mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(s.Config.Path))))
} }
for route, handler := range s.requestedRoutes { for route, handler := range s.requestedRoutes {
mux.HandleFunc(route, handler) mux.Handle(route, handler)
} }
return mux return mux
} }
func (s *Server) SetRoute(route string, handler http.HandlerFunc) { func (s *Server) RegisterRoute(route string, handler http.Handler) {
s.requestedRoutes[route] = handler s.requestedRoutes[route] = handler
if s.mux != nil { s.mux = s.makeMux()
s.mux.HandleFunc(route, handler) }
}
func (s *Server) UnregisterRoute(route string) {
delete(s.requestedRoutes, route)
s.mux = s.makeMux()
} }
func (s *Server) Listen() error { func (s *Server) Listen() error {
// Start HTTP server // Start HTTP server
restart := containers.NewRWSync(false) restart := containers.NewRWSync(false)
exit := make(chan error) exit := make(chan error)
go func() { var err error
err := s.db.SubscribeKey(ServerConfigKey, func(value string) { err, s.cancelConfigSub = s.db.SubscribeKey(ServerConfigKey, func(value string) {
oldBind := s.Config.Bind oldBind := s.Config.Bind
oldPassword := s.Config.KVPassword oldPassword := s.Config.KVPassword
err := json.Unmarshal([]byte(value), &s.Config) err := json.Unmarshal([]byte(value), &s.Config)
if err != nil {
s.logger.Error("Failed to unmarshal config", zap.Error(err))
return
}
s.mux = s.makeMux()
// Restart hub if password changed
if oldPassword != s.Config.KVPassword {
s.hub.SetOptions(kv.HubOptions{
Password: s.Config.KVPassword,
})
}
// Restart server if bind changed
if oldBind != s.Config.Bind {
restart.Set(true)
err = s.server.Shutdown(context.Background())
if err != nil { if err != nil {
s.logger.Error("Failed to unmarshal config", zap.Error(err)) s.logger.Error("Failed to shutdown server", zap.Error(err))
return return
} }
s.mux = s.makeMux()
// Restart hub if password changed
if oldPassword != s.Config.KVPassword {
s.hub.SetOptions(kv.HubOptions{
Password: s.Config.KVPassword,
})
}
// Restart server if bind changed
if oldBind != s.Config.Bind {
restart.Set(true)
err = s.server.Shutdown(context.Background())
if err != nil {
s.logger.Error("Failed to shutdown server", zap.Error(err))
return
}
}
})
if err != nil {
exit <- fmt.Errorf("error while handling subscription to HTTP config changes: %w", err)
} }
}() })
if err != nil {
exit <- fmt.Errorf("error while handling subscription to HTTP config changes: %w", err)
}
go func() { go func() {
for { for {
s.logger.Info("Starting HTTP server", zap.String("bind", s.Config.Bind)) s.logger.Info("Starting HTTP server", zap.String("bind", s.Config.Bind))

View file

@ -28,22 +28,23 @@ var (
) )
type Manager struct { type Manager struct {
points *containers.SyncMap[string, PointsEntry] points *containers.SyncMap[string, PointsEntry]
Config *containers.RWSync[Config] Config *containers.RWSync[Config]
Rewards *containers.Sync[RewardStorage] Rewards *containers.Sync[RewardStorage]
Goals *containers.Sync[GoalStorage] Goals *containers.Sync[GoalStorage]
Queue *containers.Sync[RedeemQueueStorage] Queue *containers.Sync[RedeemQueueStorage]
db *database.LocalDBClient db *database.LocalDBClient
logger *zap.Logger logger *zap.Logger
cooldowns map[string]time.Time cooldowns map[string]time.Time
banlist map[string]bool banlist map[string]bool
activeUsers *containers.SyncMap[string, bool] activeUsers *containers.SyncMap[string, bool]
twitchClient *twitch.Client twitchManager *twitch.Manager
ctx context.Context ctx context.Context
cancelFn context.CancelFunc cancelFn context.CancelFunc
cancelSub database.CancelFunc
} }
func NewManager(db *database.LocalDBClient, twitchClient *twitch.Client, logger *zap.Logger) (*Manager, error) { func NewManager(db *database.LocalDBClient, twitchManager *twitch.Manager, logger *zap.Logger) (*Manager, error) {
ctx, cancelFn := context.WithCancel(context.Background()) ctx, cancelFn := context.WithCancel(context.Background())
loyalty := &Manager{ loyalty := &Manager{
Config: containers.NewRWSync(Config{Enabled: false}), Config: containers.NewRWSync(Config{Enabled: false}),
@ -51,19 +52,19 @@ func NewManager(db *database.LocalDBClient, twitchClient *twitch.Client, logger
Goals: containers.NewSync(GoalStorage{}), Goals: containers.NewSync(GoalStorage{}),
Queue: containers.NewSync(RedeemQueueStorage{}), Queue: containers.NewSync(RedeemQueueStorage{}),
logger: logger, logger: logger,
db: db, db: db,
points: containers.NewSyncMap[string, PointsEntry](), points: containers.NewSyncMap[string, PointsEntry](),
cooldowns: make(map[string]time.Time), cooldowns: make(map[string]time.Time),
banlist: make(map[string]bool), banlist: make(map[string]bool),
activeUsers: containers.NewSyncMap[string, bool](), activeUsers: containers.NewSyncMap[string, bool](),
twitchClient: twitchClient, twitchManager: twitchManager,
ctx: ctx, ctx: ctx,
cancelFn: cancelFn, cancelFn: cancelFn,
} }
// Get data from DB // Get data from DB
var config Config var config Config
if err := db.GetJSON(ConfigKey, config); err == nil { if err := db.GetJSON(ConfigKey, &config); err == nil {
loyalty.Config.Set(config) loyalty.Config.Set(config)
} else { } else {
if !errors.Is(err, database.ErrEmptyKey) { if !errors.Is(err, database.ErrEmptyKey) {
@ -118,7 +119,7 @@ func NewManager(db *database.LocalDBClient, twitchClient *twitch.Client, logger
} }
// SubscribePrefix for changes // SubscribePrefix for changes
err = db.SubscribePrefix(loyalty.update, "loyalty/") err, loyalty.cancelSub = db.SubscribePrefix(loyalty.update, "loyalty/")
if err != nil { if err != nil {
logger.Error("could not setup loyalty reload subscription", zap.Error(err)) logger.Error("could not setup loyalty reload subscription", zap.Error(err))
} }
@ -132,6 +133,11 @@ func NewManager(db *database.LocalDBClient, twitchClient *twitch.Client, logger
} }
func (m *Manager) Close() error { func (m *Manager) Close() error {
// Stop subscription
if m.cancelSub != nil {
m.cancelSub()
}
// Send cancellation // Send cancellation
m.cancelFn() m.cancelFn()

View file

@ -16,7 +16,7 @@ import (
) )
func (m *Manager) SetupTwitch() { func (m *Manager) SetupTwitch() {
bot := m.twitchClient.Bot bot := m.twitchManager.Client().Bot
if bot == nil { if bot == nil {
return return
} }
@ -57,7 +57,7 @@ func (m *Manager) SetupTwitch() {
// Setup handler for adding points over time // Setup handler for adding points over time
go func() { go func() {
config := m.Config.Get() config := m.Config.Get()
if config.Enabled && bot != nil { if config.Enabled {
for { for {
if config.Points.Interval > 0 { if config.Points.Interval > 0 {
// Wait for next poll // Wait for next poll
@ -68,11 +68,18 @@ func (m *Manager) SetupTwitch() {
} }
// If stream is confirmed offline, don't give points away! // If stream is confirmed offline, don't give points away!
isOnline := m.twitchClient.IsLive() isOnline := m.twitchManager.Client().IsLive()
if !isOnline { if !isOnline {
continue continue
} }
// Check that bot is online and working
bot := m.twitchManager.Client().Bot
if bot == nil {
m.logger.Warn("bot is offline or not configured, could not assign points")
continue
}
m.logger.Debug("awarding points") m.logger.Debug("awarding points")
// Get user list // Get user list
@ -116,7 +123,7 @@ func (m *Manager) SetupTwitch() {
} }
func (m *Manager) StopTwitch() { func (m *Manager) StopTwitch() {
bot := m.twitchClient.Bot bot := m.twitchManager.Client().Bot
if bot != nil { if bot != nil {
bot.RemoveCommand("!redeem") bot.RemoveCommand("!redeem")
bot.RemoveCommand("!balance") bot.RemoveCommand("!balance")

View file

@ -1,6 +1,7 @@
package main package main
import ( import (
"context"
"embed" "embed"
"fmt" "fmt"
"log" "log"
@ -8,6 +9,8 @@ import (
"os" "os"
"time" "time"
"github.com/strimertul/strimertul/utils"
"github.com/apenwarr/fixconsole" "github.com/apenwarr/fixconsole"
"go.uber.org/zap/zapcore" "go.uber.org/zap/zapcore"
@ -86,6 +89,7 @@ func main() {
level = zapcore.InfoLevel level = zapcore.InfoLevel
} }
initLogger(level) initLogger(level)
ctx.Context = context.WithValue(ctx.Context, utils.ContextLogger, logger)
return nil return nil
}, },
After: func(ctx *cli.Context) error { After: func(ctx *cli.Context) error {

View file

@ -7,6 +7,8 @@ import (
"text/template" "text/template"
"time" "time"
"github.com/strimertul/strimertul/database"
"go.uber.org/zap" "go.uber.org/zap"
"github.com/Masterminds/sprig/v3" "github.com/Masterminds/sprig/v3"
@ -91,6 +93,9 @@ type BotAlertsModule struct {
bot *Bot bot *Bot
mu sync.Mutex mu sync.Mutex
templates templateCache templates templateCache
cancelAlertSub database.CancelFunc
cancelTwitchEventSub database.CancelFunc
} }
func SetupAlerts(bot *Bot) *BotAlertsModule { func SetupAlerts(bot *Bot) *BotAlertsModule {
@ -114,7 +119,7 @@ func SetupAlerts(bot *Bot) *BotAlertsModule {
mod.compileTemplates() mod.compileTemplates()
err = bot.api.db.SubscribeKey(BotAlertsKey, func(value string) { err, mod.cancelAlertSub = bot.api.db.SubscribeKey(BotAlertsKey, func(value string) {
err := json.UnmarshalFromString(value, &mod.Config) err := json.UnmarshalFromString(value, &mod.Config)
if err != nil { if err != nil {
bot.logger.Debug("error reloading timer config", zap.Error(err)) bot.logger.Debug("error reloading timer config", zap.Error(err))
@ -238,7 +243,7 @@ func SetupAlerts(bot *Bot) *BotAlertsModule {
} }
} }
err = bot.api.db.SubscribeKey(EventSubEventKey, func(value string) { err, mod.cancelTwitchEventSub = bot.api.db.SubscribeKey(EventSubEventKey, func(value string) {
var ev eventSubNotification var ev eventSubNotification
err := json.UnmarshalFromString(value, &ev) err := json.UnmarshalFromString(value, &ev)
if err != nil { if err != nil {
@ -489,6 +494,15 @@ func (m *BotAlertsModule) addTemplate(templateList map[int]*template.Template, i
templateList[id] = tpl templateList[id] = tpl
} }
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 // writeTemplate renders the template and sends the message to the channel
func writeTemplate(bot *Bot, tpl *template.Template, data interface{}) { func writeTemplate(bot *Bot, tpl *template.Template, data interface{}) {
var buf bytes.Buffer var buf bytes.Buffer

View file

@ -2,10 +2,12 @@ package twitch
import ( import (
"strings" "strings"
"sync"
"text/template" "text/template"
"time" "time"
"git.sr.ht/~hamcha/containers"
"github.com/strimertul/strimertul/database"
"github.com/strimertul/strimertul/utils" "github.com/strimertul/strimertul/utils"
"go.uber.org/zap" "go.uber.org/zap"
@ -21,18 +23,19 @@ type Bot struct {
api *Client api *Client
username string username string
logger *zap.Logger logger *zap.Logger
lastMessage time.Time lastMessage *containers.RWSync[time.Time]
chatHistory []irc.PrivateMessage chatHistory *containers.Sync[[]irc.PrivateMessage]
commands map[string]BotCommand commands *containers.SyncMap[string, BotCommand]
customCommands map[string]BotCustomCommand customCommands *containers.SyncMap[string, BotCustomCommand]
customTemplates map[string]*template.Template customTemplates *containers.SyncMap[string, *template.Template]
customFunctions template.FuncMap customFunctions template.FuncMap
OnConnect *utils.PubSub[BotConnectHandler] OnConnect *utils.PubSub[BotConnectHandler]
OnMessage *utils.PubSub[BotMessageHandler] OnMessage *utils.PubSub[BotMessageHandler]
mu sync.Mutex cancelUpdateSub database.CancelFunc
cancelWriteRPCSub database.CancelFunc
// Module specific vars // Module specific vars
Timers *BotTimerModule Timers *BotTimerModule
@ -49,7 +52,14 @@ type BotMessageHandler interface {
HandleBotMessage(message irc.PrivateMessage) HandleBotMessage(message irc.PrivateMessage)
} }
func NewBot(api *Client, config BotConfig) *Bot { 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 // Create client
client := irc.NewClient(config.Username, config.Token) client := irc.NewClient(config.Username, config.Token)
@ -60,11 +70,10 @@ func NewBot(api *Client, config BotConfig) *Bot {
username: strings.ToLower(config.Username), // Normalize username username: strings.ToLower(config.Username), // Normalize username
logger: api.logger, logger: api.logger,
api: api, api: api,
lastMessage: time.Now(), lastMessage: containers.NewRWSync(time.Now()),
mu: sync.Mutex{}, commands: containers.NewSyncMap[string, BotCommand](),
commands: make(map[string]BotCommand), customCommands: containers.NewSyncMap[string, BotCustomCommand](),
customCommands: make(map[string]BotCustomCommand), customTemplates: containers.NewSyncMap[string, *template.Template](),
customTemplates: make(map[string]*template.Template),
OnConnect: utils.NewPubSub[BotConnectHandler](), OnConnect: utils.NewPubSub[BotConnectHandler](),
OnMessage: utils.NewPubSub[BotMessageHandler](), OnMessage: utils.NewPubSub[BotMessageHandler](),
@ -86,18 +95,17 @@ func NewBot(api *Client, config BotConfig) *Bot {
} }
// Ignore messages for a while or twitch will get mad! // Ignore messages for a while or twitch will get mad!
if message.Time.Before(bot.lastMessage.Add(time.Second * 2)) { if message.Time.Before(bot.lastMessage.Get().Add(time.Second * 2)) {
bot.logger.Debug("message received too soon, ignoring") bot.logger.Debug("message received too soon, ignoring")
return return
} }
bot.mu.Lock()
lowercaseMessage := strings.ToLower(message.Message) lowercaseMessage := strings.ToLower(message.Message)
// Check if it's a command // Check if it's a command
if strings.HasPrefix(message.Message, "!") { if strings.HasPrefix(message.Message, "!") {
// Run through supported commands // Run through supported commands
for cmd, data := range bot.commands { for cmd, data := range bot.commands.Copy() {
if !data.Enabled { if !data.Enabled {
continue continue
} }
@ -109,12 +117,12 @@ func NewBot(api *Client, config BotConfig) *Bot {
continue continue
} }
go data.Handler(bot, message) go data.Handler(bot, message)
bot.lastMessage = time.Now() bot.lastMessage.Set(time.Now())
} }
} }
// Run through custom commands // Run through custom commands
for cmd, data := range bot.customCommands { for cmd, data := range bot.customCommands.Get() {
if !data.Enabled { if !data.Enabled {
continue continue
} }
@ -127,19 +135,19 @@ func NewBot(api *Client, config BotConfig) *Bot {
continue continue
} }
go cmdCustom(bot, cmd, data, message) go cmdCustom(bot, cmd, data, message)
bot.lastMessage = time.Now() bot.lastMessage.Set(time.Now())
} }
bot.mu.Unlock()
err := bot.api.db.PutJSON(ChatEventKey, message) err := bot.api.db.PutJSON(ChatEventKey, message)
if err != nil { if err != nil {
bot.logger.Warn("could not save chat message to key", zap.String("key", ChatEventKey), zap.Error(err)) bot.logger.Warn("could not save chat message to key", zap.String("key", ChatEventKey), zap.Error(err))
} }
if bot.Config.ChatHistory > 0 { if bot.Config.ChatHistory > 0 {
if len(bot.chatHistory) >= bot.Config.ChatHistory { history := bot.chatHistory.Get()
bot.chatHistory = bot.chatHistory[len(bot.chatHistory)-bot.Config.ChatHistory+1:] if len(history) >= bot.Config.ChatHistory {
history = history[len(history)-bot.Config.ChatHistory+1:]
} }
bot.chatHistory = append(bot.chatHistory, message) bot.chatHistory.Set(append(history, message))
err = bot.api.db.PutJSON(ChatHistoryKey, bot.chatHistory) err = bot.api.db.PutJSON(ChatHistoryKey, bot.chatHistory)
if err != nil { if err != nil {
bot.logger.Warn("could not save message to chat history", zap.Error(err)) bot.logger.Warn("could not save message to chat history", zap.Error(err))
@ -175,20 +183,22 @@ func NewBot(api *Client, config BotConfig) *Bot {
bot.Alerts = SetupAlerts(bot) bot.Alerts = SetupAlerts(bot)
// Load custom commands // Load custom commands
err := api.db.GetJSON(CustomCommandsKey, &bot.customCommands) var customCommands map[string]BotCustomCommand
err := api.db.GetJSON(CustomCommandsKey, &customCommands)
if err != nil { if err != nil {
bot.logger.Error("failed to load custom commands", zap.Error(err)) bot.logger.Error("failed to load custom commands", zap.Error(err))
} }
bot.customCommands.Set(customCommands)
err = bot.updateTemplates() err = bot.updateTemplates()
if err != nil { if err != nil {
bot.logger.Error("failed to parse custom commands", zap.Error(err)) bot.logger.Error("failed to parse custom commands", zap.Error(err))
} }
err = api.db.SubscribeKey(CustomCommandsKey, bot.updateCommands) err, bot.cancelUpdateSub = api.db.SubscribeKey(CustomCommandsKey, bot.updateCommands)
if err != nil { if err != nil {
bot.logger.Error("could not set-up bot command reload subscription", zap.Error(err)) bot.logger.Error("could not set-up bot command reload subscription", zap.Error(err))
} }
err = api.db.SubscribeKey(WriteMessageRPC, bot.handleWriteMessageRPC) err, bot.cancelWriteRPCSub = api.db.SubscribeKey(WriteMessageRPC, bot.handleWriteMessageRPC)
if err != nil { if err != nil {
bot.logger.Error("could not set-up bot command reload subscription", zap.Error(err)) bot.logger.Error("could not set-up bot command reload subscription", zap.Error(err))
} }
@ -196,12 +206,24 @@ func NewBot(api *Client, config BotConfig) *Bot {
return bot return bot
} }
func (b *Bot) Close() error {
if b.cancelUpdateSub != nil {
b.cancelUpdateSub()
}
if b.cancelWriteRPCSub != nil {
b.cancelWriteRPCSub()
}
if b.Timers != nil {
b.Timers.Close()
}
if b.Alerts != nil {
b.Alerts.Close()
}
return b.Client.Disconnect()
}
func (b *Bot) updateCommands(value string) { func (b *Bot) updateCommands(value string) {
err := func() error { err := utils.LoadJSONToWrapped[map[string]BotCustomCommand](value, b.customCommands)
b.mu.Lock()
defer b.mu.Unlock()
return json.UnmarshalFromString(value, &b.customCommands)
}()
if err != nil { if err != nil {
b.logger.Error("failed to decode new custom commands", zap.Error(err)) b.logger.Error("failed to decode new custom commands", zap.Error(err))
return return
@ -218,18 +240,21 @@ func (b *Bot) handleWriteMessageRPC(value string) {
} }
func (b *Bot) updateTemplates() error { func (b *Bot) updateTemplates() error {
for cmd, tmpl := range b.customCommands { for cmd, tmpl := range b.customCommands.Copy() {
var err error tpl, err := template.New("").Funcs(sprig.TxtFuncMap()).Funcs(b.customFunctions).Parse(tmpl.Response)
b.customTemplates[cmd], err = template.New("").Funcs(sprig.TxtFuncMap()).Funcs(b.customFunctions).Parse(tmpl.Response)
if err != nil { if err != nil {
return err return err
} }
b.customTemplates.SetKey(cmd, tpl)
} }
return nil return nil
} }
func (b *Bot) Connect() error { func (b *Bot) Connect() {
return b.Client.Connect() err := b.Client.Connect()
if err != nil {
b.logger.Error("bot connection ended", zap.Error(err))
}
} }
func (b *Bot) WriteMessage(message string) { func (b *Bot) WriteMessage(message string) {
@ -237,12 +262,11 @@ func (b *Bot) WriteMessage(message string) {
} }
func (b *Bot) RegisterCommand(trigger string, command BotCommand) { func (b *Bot) RegisterCommand(trigger string, command BotCommand) {
// TODO make it goroutine safe? b.commands.SetKey(trigger, command)
b.commands[trigger] = command
} }
func (b *Bot) RemoveCommand(trigger string) { func (b *Bot) RemoveCommand(trigger string) {
delete(b.commands, trigger) b.commands.DeleteKey(trigger)
} }
func getUserAccessLevel(user irc.User) AccessLevelType { func getUserAccessLevel(user irc.User) AccessLevelType {

View file

@ -5,6 +5,8 @@ import (
"sync" "sync"
"time" "time"
"github.com/strimertul/strimertul/database"
"go.uber.org/zap" "go.uber.org/zap"
irc "github.com/gempir/go-twitch-irc/v3" irc "github.com/gempir/go-twitch-irc/v3"
@ -34,6 +36,8 @@ type BotTimerModule struct {
messages [AverageMessageWindow]int messages [AverageMessageWindow]int
mu sync.Mutex mu sync.Mutex
startTime time.Time startTime time.Time
cancelTimerSub database.CancelFunc
} }
func SetupTimers(bot *Bot) *BotTimerModule { func SetupTimers(bot *Bot) *BotTimerModule {
@ -58,7 +62,7 @@ func SetupTimers(bot *Bot) *BotTimerModule {
} }
} }
err = bot.api.db.SubscribeKey(BotTimersKey, func(value string) { err, mod.cancelTimerSub = bot.api.db.SubscribeKey(BotTimersKey, func(value string) {
err := json.UnmarshalFromString(value, &mod.Config) err := json.UnmarshalFromString(value, &mod.Config)
if err != nil { if err != nil {
bot.logger.Debug("error reloading timer config", zap.Error(err)) bot.logger.Debug("error reloading timer config", zap.Error(err))
@ -144,6 +148,12 @@ func (m *BotTimerModule) runTimers() {
} }
} }
func (m *BotTimerModule) Close() {
if m.cancelTimerSub != nil {
m.cancelTimerSub()
}
}
func (m *BotTimerModule) currentChatActivity() int { func (m *BotTimerModule) currentChatActivity() int {
total := 0 total := 0
for _, v := range m.messages { for _, v := range m.messages {

View file

@ -72,7 +72,7 @@ func (c *Client) GetLoggedUser() (helix.User, error) {
return users.Data.Users[0], nil return users.Data.Users[0], nil
} }
func (c *Client) AuthorizeCallback(w http.ResponseWriter, req *http.Request) { func (c *Client) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// Get code from params // Get code from params
code := req.URL.Query().Get("code") code := req.URL.Query().Get("code")
if code == "" { if code == "" {
@ -110,32 +110,6 @@ type RefreshResponse struct {
Scope []string `json:"scope"` Scope []string `json:"scope"`
} }
func (c *Client) refreshAccessToken(refreshToken string) (r RefreshResponse, err error) { func getRedirectURI(baseurl string) string {
// Exchange code for access/refresh tokens return fmt.Sprintf("http://%s/twitch/callback", baseurl)
query := url.Values{
"client_id": {c.Config.APIClientID},
"client_secret": {c.Config.APIClientSecret},
"grant_type": {"refresh_token"},
"refresh_token": {refreshToken},
}
authRequest, err := http.NewRequest("POST", "https://id.twitch.tv/oauth2/token?"+query.Encode(), nil)
if err != nil {
return RefreshResponse{}, err
}
resp, err := http.DefaultClient.Do(authRequest)
if err != nil {
return RefreshResponse{}, err
}
defer resp.Body.Close()
var refreshResp RefreshResponse
err = jsoniter.ConfigFastest.NewDecoder(resp.Body).Decode(&refreshResp)
return refreshResp, err
}
func (c *Client) getRedirectURI() (string, error) {
var severConfig struct {
Bind string `json:"bind"`
}
err := c.db.GetJSON("http/config", &severConfig)
return fmt.Sprintf("http://%s/twitch/callback", severConfig.Bind), err
} }

View file

@ -15,21 +15,41 @@ import (
const websocketEndpoint = "wss://eventsub-beta.wss.twitch.tv/ws" const websocketEndpoint = "wss://eventsub-beta.wss.twitch.tv/ws"
func (c *Client) connectWebsocket() error { func (c *Client) connectWebsocket() {
connection, _, err := websocket.DefaultDialer.Dial(websocketEndpoint, nil) connection, _, err := websocket.DefaultDialer.Dial(websocketEndpoint, nil)
if err != nil { if err != nil {
c.logger.Error("could not connect to eventsub ws", zap.Error(err)) c.logger.Error("could not connect to eventsub ws", zap.Error(err))
return fmt.Errorf("error connecting to websocket server: %w", err) return
} }
defer connection.Close()
received := make(chan []byte)
wsErr := make(chan error)
go func(recv chan<- []byte) {
for {
messageType, messageData, err := connection.ReadMessage()
if err != nil {
c.logger.Warn("eventsub ws read error", zap.Error(err))
wsErr <- err
return
}
if messageType != websocket.TextMessage {
continue
}
recv <- messageData
}
}(received)
for { for {
messageType, messageData, err := connection.ReadMessage() // Wait for next message or closing/error
if err != nil { var messageData []byte
c.logger.Warn("eventsub ws read error", zap.Error(err)) select {
break case <-c.ctx.Done():
} return
if messageType != websocket.TextMessage { case <-wsErr:
continue return
case messageData = <-received:
} }
var wsMessage EventSubWebsocketMessage var wsMessage EventSubWebsocketMessage
@ -76,8 +96,6 @@ func (c *Client) connectWebsocket() error {
// TODO idk what to do here // TODO idk what to do here
} }
} }
return connection.Close()
} }
func (c *Client) processEvent(message EventSubWebsocketMessage) { func (c *Client) processEvent(message EventSubWebsocketMessage) {

View file

@ -1,6 +1,7 @@
package twitch package twitch
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
"time" "time"
@ -16,138 +17,203 @@ import (
var json = jsoniter.ConfigFastest var json = jsoniter.ConfigFastest
type Manager struct {
client *Client
cancelSubs func()
}
func NewManager(db *database.LocalDBClient, server *http.Server, 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
var botConfig BotConfig
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
err, cancelConfigSub := db.SubscribeKey(ConfigKey, func(value string) {
var newConfig Config
if err := json.UnmarshalFromString(value, &newConfig); err != nil {
logger.Error("failed to unmarshal 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 client")
})
if err != nil {
logger.Error("could not setup twitch config reload subscription", zap.Error(err))
}
// Listen for bot config changes
err, cancelBotSub := db.SubscribeKey(BotConfigKey, func(value string) {
var newBotConfig BotConfig
if err := json.UnmarshalFromString(value, &newBotConfig); err != nil {
logger.Error("failed to unmarshal Config", zap.Error(err))
return
}
oldBot := manager.client.Bot
err = oldBot.Close()
if err != nil {
client.logger.Warn("failed to disconnect old bot from Twitch IRC", zap.Error(err))
}
bot := newBot(manager.client, newBotConfig)
if client.Config.Get().EnableBot {
go bot.Connect()
}
client.Bot = bot
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 { type Client struct {
Config Config Config *containers.RWSync[Config]
Bot *Bot Bot *Bot
db *database.LocalDBClient db *database.LocalDBClient
API *helix.Client API *helix.Client
logger *zap.Logger logger *zap.Logger
eventCache *lru.Cache eventCache *lru.Cache
server *http.Server
ctx context.Context
cancel context.CancelFunc
restart chan bool restart chan bool
streamOnline *containers.RWSync[bool] streamOnline *containers.RWSync[bool]
savedSubscriptions map[string]bool savedSubscriptions map[string]bool
} }
func NewClient(db *database.LocalDBClient, server *http.Server, logger *zap.Logger) (*Client, error) { func (c *Client) Merge(old *Client) {
// Copy bot instance and some params
c.streamOnline.Set(old.streamOnline.Get())
c.Bot = old.Bot
}
func newClient(config Config, db *database.LocalDBClient, server *http.Server, logger *zap.Logger) (*Client, error) {
eventCache, err := lru.New(128) eventCache, err := lru.New(128)
if err != nil { if err != nil {
return nil, fmt.Errorf("could not create LRU cache for events: %w", err) return nil, fmt.Errorf("could not create LRU cache for events: %w", err)
} }
// Get Twitch Config
var config Config
err = db.GetJSON(ConfigKey, &config)
if err != nil {
if !errors.Is(err, database.ErrEmptyKey) {
return nil, fmt.Errorf("failed to get twitch Config: %w", err)
}
config.Enabled = false
}
// Create Twitch client // Create Twitch client
ctx, cancel := context.WithCancel(context.Background())
client := &Client{ client := &Client{
Config: config, Config: containers.NewRWSync(config),
db: db, db: db,
logger: logger.With(zap.String("service", "twitch")), logger: logger.With(zap.String("service", "twitch")),
restart: make(chan bool, 128), restart: make(chan bool, 128),
streamOnline: containers.NewRWSync(false), streamOnline: containers.NewRWSync(false),
eventCache: eventCache, eventCache: eventCache,
savedSubscriptions: make(map[string]bool), savedSubscriptions: make(map[string]bool),
ctx: ctx,
cancel: cancel,
server: server,
} }
// Listen for Config changes baseurl, err := client.baseURL()
err = db.SubscribeKey(ConfigKey, func(value string) {
err := json.UnmarshalFromString(value, &config)
if err != nil {
client.logger.Error("failed to unmarshal Config", zap.Error(err))
return
}
api, err := client.getHelixAPI(config)
if err != nil {
client.logger.Warn("failed to create new twitch client, keeping old credentials", zap.Error(err))
return
}
client.API = api
client.Config = config
client.logger.Info("reloaded/updated Twitch API")
})
if err != nil { if err != nil {
client.logger.Error("could not setup twitch Config reload subscription", zap.Error(err)) return nil, err
}
err = db.SubscribeKey(BotConfigKey, func(value string) {
var twitchBotConfig BotConfig
err := json.UnmarshalFromString(value, &twitchBotConfig)
if err != nil {
client.logger.Error("failed to unmarshal Config", zap.Error(err))
return
}
err = client.Bot.Client.Disconnect()
if err != nil {
client.logger.Warn("failed to disconnect from Twitch IRC", zap.Error(err))
}
if client.Config.EnableBot {
if err := client.startBot(); err != nil {
if !errors.Is(err, database.ErrEmptyKey) {
client.logger.Error("failed to re-create bot", zap.Error(err))
}
}
}
client.restart <- true
client.logger.Info("reloaded/restarted Twitch bot")
})
if err != nil {
client.logger.Error("could not setup twitch bot Config reload subscription", zap.Error(err))
} }
if config.Enabled { if config.Enabled {
client.API, err = client.getHelixAPI(config) api, err := getHelixAPI(config, baseurl)
if err != nil { if err != nil {
client.logger.Error("failed to create twitch client", zap.Error(err)) return nil, fmt.Errorf("failed to create twitch client: %w", err)
} else {
server.SetRoute("/twitch/callback", client.AuthorizeCallback)
go client.runStatusPoll()
go client.connectWebsocket()
} }
client.API = api
server.RegisterRoute(CallbackRoute, client)
go client.runStatusPoll()
go client.connectWebsocket()
} }
if client.Config.EnableBot {
if err := client.startBot(); err != nil {
if !errors.Is(err, database.ErrEmptyKey) {
return nil, err
}
}
}
go func() {
for {
if client.Config.EnableBot && client.Bot != nil {
err := client.RunBot()
if err != nil {
client.logger.Error("failed to connect to Twitch IRC", zap.Error(err))
// Wait for Config change before retrying
<-client.restart
}
} else {
<-client.restart
}
}
}()
return client, nil return client, nil
} }
func (c *Client) runStatusPoll() { func (c *Client) runStatusPoll() {
c.logger.Info("status poll started") c.logger.Info("status poll started")
for { for {
// Wait for next poll // Wait for next poll (or cancellation)
time.Sleep(60 * time.Second) select {
case <-c.ctx.Done():
return
case <-time.After(60 * time.Second):
}
// Make sure we're configured and connected properly first // Make sure we're configured and connected properly first
if !c.Config.Enabled || c.Bot == nil || c.Bot.Config.Channel == "" { if !c.Config.Get().Enabled || c.Bot == nil || c.Bot.Config.Channel == "" {
continue continue
} }
@ -170,28 +236,8 @@ func (c *Client) runStatusPoll() {
} }
} }
func (c *Client) startBot() error { func getHelixAPI(config Config, baseurl string) (*helix.Client, error) {
// Get Twitch bot Config redirectURI := getRedirectURI(baseurl)
var twitchBotConfig BotConfig
err := c.db.GetJSON(BotConfigKey, &twitchBotConfig)
if err != nil {
if !errors.Is(err, database.ErrEmptyKey) {
return fmt.Errorf("failed to get bot Config: %w", err)
}
c.Config.EnableBot = false
}
// Create and run IRC bot
c.Bot = NewBot(c, twitchBotConfig)
return nil
}
func (c *Client) getHelixAPI(config Config) (*helix.Client, error) {
redirectURI, err := c.getRedirectURI()
if err != nil {
return nil, err
}
// Create Twitch client // Create Twitch client
api, err := helix.NewClient(&helix.Options{ api, err := helix.NewClient(&helix.Options{
@ -214,17 +260,12 @@ func (c *Client) getHelixAPI(config Config) (*helix.Client, error) {
return api, nil return api, nil
} }
func (c *Client) RunBot() error { func (c *Client) baseURL() (string, error) {
cherr := make(chan error) var severConfig struct {
go func() { Bind string `json:"bind"`
cherr <- c.Bot.Connect()
}()
select {
case <-c.restart:
return nil
case err := <-cherr:
return err
} }
err := c.db.GetJSON("http/config", &severConfig)
return severConfig.Bind, err
} }
func (c *Client) IsLive() bool { func (c *Client) IsLive() bool {
@ -232,5 +273,12 @@ func (c *Client) IsLive() bool {
} }
func (c *Client) Close() error { func (c *Client) Close() error {
return c.Bot.Client.Disconnect() c.server.UnregisterRoute(CallbackRoute)
defer c.cancel()
if err := c.Bot.Close(); err != nil {
return err
}
return nil
} }

View file

@ -52,8 +52,11 @@ func cmdCustom(bot *Bot, cmd string, data BotCustomCommand, message irc.PrivateM
// Add future logic (like counters etc.) here, for now it's just fixed messages // Add future logic (like counters etc.) here, for now it's just fixed messages
var buf bytes.Buffer var buf bytes.Buffer
err := bot.customTemplates[cmd].Execute(&buf, message) tpl, ok := bot.customTemplates.GetKey(cmd)
if err != nil { if !ok {
return
}
if err := tpl.Execute(&buf, message); err != nil {
bot.logger.Error("Failed to execute custom command template", zap.Error(err)) bot.logger.Error("Failed to execute custom command template", zap.Error(err))
return return
} }
@ -87,7 +90,7 @@ func (b *Bot) setupFunctions() {
counterKey := BotCounterPrefix + name counterKey := BotCounterPrefix + name
counter := 0 counter := 0
if byt, err := b.api.db.GetKey(counterKey); err == nil { if byt, err := b.api.db.GetKey(counterKey); err == nil {
counter, _ = strconv.Atoi(string(byt)) counter, _ = strconv.Atoi(byt)
} }
counter += 1 counter += 1
err := b.api.db.PutKey(counterKey, strconv.Itoa(counter)) err := b.api.db.PutKey(counterKey, strconv.Itoa(counter))

View file

@ -1,5 +1,7 @@
package twitch package twitch
const CallbackRoute = "/twitch/callback"
const ConfigKey = "twitch/config" const ConfigKey = "twitch/config"
type Config struct { type Config struct {

7
utils/context.go Normal file
View file

@ -0,0 +1,7 @@
package utils
type ContextKey string
const (
ContextLogger ContextKey = "logger"
)

13
utils/map.go Normal file
View file

@ -0,0 +1,13 @@
package utils
import "git.sr.ht/~hamcha/containers"
func MergeMap[T comparable, V any](a, b map[T]V) {
for key, value := range b {
a[key] = value
}
}
func MergeSyncMap[T comparable, V any](a, b *containers.SyncMap[T, V]) {
b.Set(a.Copy())
}

View file

@ -31,3 +31,9 @@ func (p *PubSub[T]) Unsubscribe(handler T) {
func (p *PubSub[T]) Subscribers() []T { func (p *PubSub[T]) Subscribers() []T {
return p.subscribers.Get() return p.subscribers.Get()
} }
func (p *PubSub[T]) Copy(other *PubSub[T]) {
for _, subscriber := range other.Subscribers() {
p.Subscribe(subscriber)
}
}