2021-05-14 11:15:38 +00:00
|
|
|
package twitch
|
|
|
|
|
|
|
|
import (
|
2022-12-22 12:35:30 +00:00
|
|
|
"errors"
|
2021-05-14 11:15:38 +00:00
|
|
|
"strings"
|
2021-09-18 20:06:22 +00:00
|
|
|
"text/template"
|
2021-05-14 11:15:38 +00:00
|
|
|
"time"
|
|
|
|
|
2023-06-13 08:50:20 +00:00
|
|
|
"github.com/nicklaw5/helix/v2"
|
|
|
|
|
2023-11-10 20:36:15 +00:00
|
|
|
"git.sr.ht/~ashkeel/containers/sync"
|
2023-02-02 20:24:14 +00:00
|
|
|
irc "github.com/gempir/go-twitch-irc/v4"
|
2022-12-04 13:45:34 +00:00
|
|
|
"go.uber.org/zap"
|
|
|
|
|
2023-11-10 20:36:15 +00:00
|
|
|
"git.sr.ht/~ashkeel/strimertul/database"
|
|
|
|
"git.sr.ht/~ashkeel/strimertul/utils"
|
2021-05-14 11:15:38 +00:00
|
|
|
)
|
|
|
|
|
2023-06-13 08:50:20 +00:00
|
|
|
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))
|
|
|
|
}
|
|
|
|
|
2021-05-14 11:15:38 +00:00
|
|
|
type Bot struct {
|
2023-06-13 08:50:20 +00:00
|
|
|
Client IRCBot
|
2022-11-30 18:15:47 +00:00
|
|
|
Config BotConfig
|
2021-05-14 11:15:38 +00:00
|
|
|
|
|
|
|
api *Client
|
|
|
|
username string
|
2022-01-27 15:49:18 +00:00
|
|
|
logger *zap.Logger
|
2022-12-04 13:45:34 +00:00
|
|
|
lastMessage *sync.RWSync[time.Time]
|
|
|
|
chatHistory *sync.Slice[irc.PrivateMessage]
|
2021-05-14 11:15:38 +00:00
|
|
|
|
2022-12-04 13:45:34 +00:00
|
|
|
commands *sync.Map[string, BotCommand]
|
|
|
|
customCommands *sync.Map[string, BotCustomCommand]
|
|
|
|
customTemplates *sync.Map[string, *template.Template]
|
2021-09-18 20:06:22 +00:00
|
|
|
customFunctions template.FuncMap
|
2021-09-17 08:54:55 +00:00
|
|
|
|
2023-06-01 08:50:46 +00:00
|
|
|
OnConnect *utils.SyncList[BotConnectHandler]
|
|
|
|
OnMessage *utils.SyncList[BotMessageHandler]
|
2022-11-30 18:15:47 +00:00
|
|
|
|
2023-06-13 08:50:20 +00:00
|
|
|
cancelUpdateSub database.CancelFunc
|
|
|
|
cancelWritePlainRPCSub database.CancelFunc
|
|
|
|
cancelWriteRPCSub database.CancelFunc
|
2021-06-05 23:18:31 +00:00
|
|
|
|
2021-05-14 11:15:38 +00:00
|
|
|
// Module specific vars
|
2022-11-30 18:15:47 +00:00
|
|
|
Timers *BotTimerModule
|
|
|
|
Alerts *BotAlertsModule
|
|
|
|
}
|
|
|
|
|
|
|
|
type BotConnectHandler interface {
|
|
|
|
utils.Comparable
|
|
|
|
HandleBotConnect()
|
|
|
|
}
|
|
|
|
|
|
|
|
type BotMessageHandler interface {
|
|
|
|
utils.Comparable
|
|
|
|
HandleBotMessage(message irc.PrivateMessage)
|
2021-05-14 11:15:38 +00:00
|
|
|
}
|
|
|
|
|
2022-12-03 15:16:59 +00:00
|
|
|
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 {
|
2021-05-14 11:15:38 +00:00
|
|
|
// Create client
|
|
|
|
client := irc.NewClient(config.Username, config.Token)
|
|
|
|
|
2023-06-13 08:50:20 +00:00
|
|
|
return newBotWithClient(client, api, config)
|
|
|
|
}
|
|
|
|
|
|
|
|
func newBotWithClient(client IRCBot, api *Client, config BotConfig) *Bot {
|
2021-05-14 11:15:38 +00:00
|
|
|
bot := &Bot{
|
2022-11-30 18:15:47 +00:00
|
|
|
Client: client,
|
|
|
|
Config: config,
|
|
|
|
|
2021-09-18 20:06:22 +00:00
|
|
|
username: strings.ToLower(config.Username), // Normalize username
|
|
|
|
logger: api.logger,
|
|
|
|
api: api,
|
2022-12-04 13:45:34 +00:00
|
|
|
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](),
|
2022-11-30 18:15:47 +00:00
|
|
|
|
2023-06-01 08:50:46 +00:00
|
|
|
OnConnect: utils.NewSyncList[BotConnectHandler](),
|
|
|
|
OnMessage: utils.NewSyncList[BotMessageHandler](),
|
2021-05-14 11:15:38 +00:00
|
|
|
}
|
|
|
|
|
2023-05-04 13:14:21 +00:00
|
|
|
client.OnConnect(bot.onConnectHandler)
|
|
|
|
client.OnPrivateMessage(bot.onMessageHandler)
|
|
|
|
client.OnUserJoinMessage(bot.onJoinHandler)
|
|
|
|
client.OnUserPartMessage(bot.onPartHandler)
|
2021-05-14 11:15:38 +00:00
|
|
|
|
|
|
|
bot.Client.Join(config.Channel)
|
2022-01-14 15:24:27 +00:00
|
|
|
bot.setupFunctions()
|
2021-05-14 11:15:38 +00:00
|
|
|
|
2021-11-03 13:48:30 +00:00
|
|
|
// Load modules
|
2022-12-02 22:52:45 +00:00
|
|
|
bot.Timers = SetupTimers(bot)
|
|
|
|
bot.Alerts = SetupAlerts(bot)
|
2021-11-03 13:48:30 +00:00
|
|
|
|
2021-09-17 08:54:55 +00:00
|
|
|
// Load custom commands
|
2022-12-03 15:16:59 +00:00
|
|
|
var customCommands map[string]BotCustomCommand
|
|
|
|
err := api.db.GetJSON(CustomCommandsKey, &customCommands)
|
2021-09-18 20:06:22 +00:00
|
|
|
if err != nil {
|
2022-12-22 12:35:30 +00:00
|
|
|
if errors.Is(err, database.ErrEmptyKey) {
|
|
|
|
customCommands = make(map[string]BotCustomCommand)
|
|
|
|
} else {
|
2023-04-19 13:27:13 +00:00
|
|
|
bot.logger.Error("Failed to load custom commands", zap.Error(err))
|
2022-12-22 12:35:30 +00:00
|
|
|
}
|
2021-09-18 20:06:22 +00:00
|
|
|
}
|
2022-12-03 15:16:59 +00:00
|
|
|
bot.customCommands.Set(customCommands)
|
2022-01-21 09:08:51 +00:00
|
|
|
|
|
|
|
err = bot.updateTemplates()
|
|
|
|
if err != nil {
|
2023-04-19 13:27:13 +00:00
|
|
|
bot.logger.Error("Failed to parse custom commands", zap.Error(err))
|
2022-01-21 09:08:51 +00:00
|
|
|
}
|
2024-02-25 13:46:59 +00:00
|
|
|
bot.cancelUpdateSub, err = api.db.SubscribeKey(CustomCommandsKey, bot.updateCommands)
|
2022-11-25 13:16:20 +00:00
|
|
|
if err != nil {
|
2023-04-19 13:27:13 +00:00
|
|
|
bot.logger.Error("Could not set-up bot command reload subscription", zap.Error(err))
|
2022-11-25 13:16:20 +00:00
|
|
|
}
|
2024-02-25 13:46:59 +00:00
|
|
|
bot.cancelWritePlainRPCSub, err = api.db.SubscribeKey(WritePlainMessageRPC, bot.handleWritePlainMessageRPC)
|
2023-06-13 08:50:20 +00:00
|
|
|
if err != nil {
|
|
|
|
bot.logger.Error("Could not set-up bot command reload subscription", zap.Error(err))
|
|
|
|
}
|
2024-02-25 13:46:59 +00:00
|
|
|
bot.cancelWriteRPCSub, err = api.db.SubscribeKey(WriteMessageRPC, bot.handleWriteMessageRPC)
|
2022-11-25 13:16:20 +00:00
|
|
|
if err != nil {
|
2023-04-19 13:27:13 +00:00
|
|
|
bot.logger.Error("Could not set-up bot command reload subscription", zap.Error(err))
|
2022-11-25 13:16:20 +00:00
|
|
|
}
|
2021-09-17 08:54:55 +00:00
|
|
|
|
2021-05-14 11:15:38 +00:00
|
|
|
return bot
|
|
|
|
}
|
|
|
|
|
2023-05-04 13:14:21 +00:00
|
|
|
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) {
|
2023-06-01 08:50:46 +00:00
|
|
|
for _, handler := range b.OnMessage.Items() {
|
2023-05-04 13:14:21 +00:00
|
|
|
if handler != nil {
|
|
|
|
handler.HandleBotMessage(message)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Ignore messages for a while or twitch will get mad!
|
2023-05-25 17:48:32 +00:00
|
|
|
if time.Now().Before(b.lastMessage.Get().Add(time.Second * time.Duration(b.Config.CommandCooldown))) {
|
2023-05-04 13:14:21 +00:00
|
|
|
b.logger.Debug("Message received too soon, ignoring")
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2023-05-25 18:36:35 +00:00
|
|
|
lowercaseMessage := strings.TrimSpace(strings.ToLower(message.Message))
|
2023-05-04 13:14:21 +00:00
|
|
|
|
|
|
|
// Check if it's a command
|
2023-05-25 18:36:35 +00:00
|
|
|
if strings.HasPrefix(lowercaseMessage, "!") {
|
2023-05-04 13:14:21 +00:00
|
|
|
// 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() {
|
2023-06-01 08:50:46 +00:00
|
|
|
for _, handler := range b.OnConnect.Items() {
|
2023-05-04 13:14:21 +00:00
|
|
|
if handler != nil {
|
|
|
|
handler.HandleBotConnect()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-12-03 15:16:59 +00:00
|
|
|
func (b *Bot) Close() error {
|
|
|
|
if b.cancelUpdateSub != nil {
|
|
|
|
b.cancelUpdateSub()
|
|
|
|
}
|
|
|
|
if b.cancelWriteRPCSub != nil {
|
|
|
|
b.cancelWriteRPCSub()
|
|
|
|
}
|
2023-06-13 08:50:20 +00:00
|
|
|
if b.cancelWritePlainRPCSub != nil {
|
|
|
|
b.cancelWritePlainRPCSub()
|
|
|
|
}
|
2022-12-03 15:16:59 +00:00
|
|
|
if b.Timers != nil {
|
|
|
|
b.Timers.Close()
|
|
|
|
}
|
|
|
|
if b.Alerts != nil {
|
|
|
|
b.Alerts.Close()
|
|
|
|
}
|
|
|
|
return b.Client.Disconnect()
|
|
|
|
}
|
|
|
|
|
2022-11-25 13:16:20 +00:00
|
|
|
func (b *Bot) updateCommands(value string) {
|
2022-12-03 15:16:59 +00:00
|
|
|
err := utils.LoadJSONToWrapped[map[string]BotCustomCommand](value, b.customCommands)
|
2022-11-25 13:16:20 +00:00
|
|
|
if err != nil {
|
2023-04-19 13:27:13 +00:00
|
|
|
b.logger.Error("Failed to decode new custom commands", zap.Error(err))
|
2022-11-25 13:16:20 +00:00
|
|
|
return
|
|
|
|
}
|
|
|
|
// Recreate templates
|
|
|
|
if err := b.updateTemplates(); err != nil {
|
2023-04-19 13:27:13 +00:00
|
|
|
b.logger.Error("Failed to update custom commands templates", zap.Error(err))
|
2022-11-25 13:16:20 +00:00
|
|
|
return
|
2021-09-18 20:06:22 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-06-13 08:50:20 +00:00
|
|
|
func (b *Bot) handleWritePlainMessageRPC(value string) {
|
2022-11-30 18:15:47 +00:00
|
|
|
b.Client.Say(b.Config.Channel, value)
|
2021-11-12 22:23:30 +00:00
|
|
|
}
|
|
|
|
|
2023-06-13 08:50:20 +00:00
|
|
|
func (b *Bot) handleWriteMessageRPC(value string) {
|
|
|
|
var request WriteMessageRequest
|
2024-02-25 13:46:59 +00:00
|
|
|
if err := json.Unmarshal([]byte(value), &request); err != nil {
|
2023-06-13 08:50:20 +00:00
|
|
|
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)
|
2024-02-25 13:46:59 +00:00
|
|
|
if err != nil {
|
|
|
|
b.logger.Error("Failed to retrieve client", zap.Error(err))
|
|
|
|
return
|
|
|
|
}
|
2023-06-13 08:50:20 +00:00
|
|
|
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)
|
2024-02-25 13:46:59 +00:00
|
|
|
if err != nil {
|
|
|
|
b.logger.Error("Failed to retrieve client", zap.Error(err))
|
|
|
|
return
|
|
|
|
}
|
2023-06-13 08:50:20 +00:00
|
|
|
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)
|
|
|
|
}
|
|
|
|
|
2021-09-18 20:06:22 +00:00
|
|
|
func (b *Bot) updateTemplates() error {
|
2022-12-24 12:48:58 +00:00
|
|
|
b.customTemplates.Set(make(map[string]*template.Template))
|
2022-12-03 15:16:59 +00:00
|
|
|
for cmd, tmpl := range b.customCommands.Copy() {
|
2023-05-19 13:07:32 +00:00
|
|
|
tpl, err := b.MakeTemplate(tmpl.Response)
|
2021-09-18 20:06:22 +00:00
|
|
|
if err != nil {
|
2021-09-17 08:54:55 +00:00
|
|
|
return err
|
|
|
|
}
|
2022-12-03 15:16:59 +00:00
|
|
|
b.customTemplates.SetKey(cmd, tpl)
|
2021-09-17 08:54:55 +00:00
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2022-12-03 15:16:59 +00:00
|
|
|
func (b *Bot) Connect() {
|
|
|
|
err := b.Client.Connect()
|
|
|
|
if err != nil {
|
2023-04-19 13:27:13 +00:00
|
|
|
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))
|
|
|
|
}
|
2022-12-03 15:16:59 +00:00
|
|
|
}
|
2021-05-14 11:15:38 +00:00
|
|
|
}
|
2021-11-03 13:48:30 +00:00
|
|
|
|
|
|
|
func (b *Bot) WriteMessage(message string) {
|
2022-11-30 18:15:47 +00:00
|
|
|
b.Client.Say(b.Config.Channel, message)
|
|
|
|
}
|
|
|
|
|
|
|
|
func (b *Bot) RegisterCommand(trigger string, command BotCommand) {
|
2022-12-03 15:16:59 +00:00
|
|
|
b.commands.SetKey(trigger, command)
|
2022-11-30 18:15:47 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
func (b *Bot) RemoveCommand(trigger string) {
|
2022-12-03 15:16:59 +00:00
|
|
|
b.commands.DeleteKey(trigger)
|
2021-11-03 13:48:30 +00:00
|
|
|
}
|
2021-11-30 16:37:33 +00:00
|
|
|
|
|
|
|
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
|
|
|
|
}
|
2023-05-25 17:48:32 +00:00
|
|
|
|
|
|
|
func defaultBotConfig() BotConfig {
|
|
|
|
return BotConfig{
|
|
|
|
CommandCooldown: 2,
|
|
|
|
}
|
|
|
|
}
|