strimertul/twitch/bot.go

387 lines
10 KiB
Go

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))
}
err, bot.cancelUpdateSub = api.db.SubscribeKey(CustomCommandsKey, bot.updateCommands)
if err != nil {
bot.logger.Error("Could not set-up bot command reload subscription", zap.Error(err))
}
err, bot.cancelWritePlainRPCSub = api.db.SubscribeKey(WritePlainMessageRPC, bot.handleWritePlainMessageRPC)
if err != nil {
bot.logger.Error("Could not set-up bot command reload subscription", zap.Error(err))
}
err, bot.cancelWriteRPCSub = 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
err := json.Unmarshal([]byte(value), &request)
if 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)
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)
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,
}
}