strimertul/twitch/chat/module.go

255 lines
6.8 KiB
Go

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.Database
api *helix.Client
user helix.User
logger *zap.Logger
templater template.Engine
lastMessage *sync.RWSync[time.Time]
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.Database, 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{
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 struct {
Event helix.EventSubChannelChatMessageEvent `json:"event"`
}
if err := json.UnmarshalFromString(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.Event.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.Event)
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.Event)
mod.lastMessage.Set(time.Now())
}
}
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)
}