strimertul/twitch/chat/module.go

304 lines
8.4 KiB
Go

package chat
import (
"context"
"encoding/json"
"errors"
"log/slog"
"strings"
textTemplate "text/template"
"time"
"git.sr.ht/~ashkeel/strimertul/log"
"github.com/nicklaw5/helix/v2"
"git.sr.ht/~ashkeel/containers/sync"
"git.sr.ht/~ashkeel/strimertul/database"
"git.sr.ht/~ashkeel/strimertul/twitch"
"git.sr.ht/~ashkeel/strimertul/twitch/eventsub"
"git.sr.ht/~ashkeel/strimertul/twitch/template"
"git.sr.ht/~ashkeel/strimertul/utils"
)
type Module struct {
Config Config
streamerAPI *helix.Client
ctx context.Context
db database.Database
api *helix.Client
streamer helix.User
user helix.User
logger *slog.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
}
func Setup(ctx context.Context, db database.Database, api *helix.Client, user helix.User, logger *slog.Logger, templater template.Engine) *Module {
mod := &Module{
ctx: ctx,
db: db,
streamerAPI: api,
api: api,
streamer: user,
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),
}
// 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", log.Error(err))
}
}
// Set custom user (and set hook to reload when it changes)
mod.setCustomUser()
if err := db.SubscribeKeyContext(ctx, CustomAccountKey, func(value string) {
mod.setCustomUser()
}); err != nil {
logger.Error("Could not subscribe to custom account changes", log.Error(err))
}
if err := db.SubscribeKeyContext(ctx, eventsub.EventKeyPrefix+helix.EventSubTypeChannelChatMessage, mod.onChatMessage); err != nil {
logger.Error("Could not subscribe to chat messages", log.Error(err))
}
// Load custom commands
var customCommands map[string]CustomCommand
if err := db.GetJSON(CustomCommandsKey, &customCommands); err != nil {
if errors.Is(err, database.ErrEmptyKey) {
customCommands = make(map[string]CustomCommand)
} else {
logger.Error("Failed to load custom commands", log.Error(err))
}
}
mod.customCommands.Set(customCommands)
if err := mod.updateTemplates(); err != nil {
logger.Error("Failed to parse custom commands", log.Error(err))
}
if err := db.SubscribeKeyContext(ctx, CustomCommandsKey, mod.updateCommands); err != nil {
logger.Error("Could not set-up chat command reload subscription", log.Error(err))
}
if err := db.SubscribeKeyContext(ctx, WriteMessageRPC, mod.handleWriteMessageRPC); err != nil {
logger.Error("Could not set-up chat command reload subscription", log.Error(err))
}
return mod
}
func (mod *Module) setCustomUser() {
customUserClient, customUserInfo, err := GetCustomUser(mod.db)
if err != nil {
if !errors.Is(err, database.ErrEmptyKey) {
mod.logger.Error("Failed to get custom user, falling back to streamer account", log.Error(err))
}
customUserClient = mod.streamerAPI
customUserInfo = mod.streamer
}
mod.api = customUserClient
mod.user = customUserInfo
}
func (mod *Module) onChatMessage(newValue string) {
var chatMessage struct {
Event helix.EventSubChannelChatMessageEvent `json:"event"`
}
if err := json.Unmarshal([]byte(newValue), &chatMessage); err != nil {
mod.logger.Error("Failed to decode incoming chat message", log.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", log.Error(err))
return
}
if request.Announce {
resp, err := mod.api.SendChatAnnouncement(&helix.SendChatAnnouncementParams{
BroadcasterID: mod.streamer.ID,
ModeratorID: mod.user.ID,
Message: request.Message,
})
if err != nil {
mod.logger.Error("Failed to send announcement", log.Error(err))
}
if resp.Error != "" {
mod.logger.Error("Failed to send announcement", slog.String("code", resp.Error), slog.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", log.Error(err))
}
if resp.Error != "" {
mod.logger.Error("Failed to send whisper", slog.String("code", resp.Error), slog.String("message", resp.ErrorMessage))
}
return
}
resp, err := mod.api.SendChatMessage(&helix.SendChatMessageParams{
BroadcasterID: mod.streamer.ID,
SenderID: mod.user.ID,
Message: request.Message,
ReplyParentMessageID: request.ReplyTo,
})
if err != nil {
mod.logger.Error("Failed to send chat message", log.Error(err))
}
if resp.Error != "" {
mod.logger.Error("Failed to send chat message", slog.String("code", resp.Error), slog.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", log.Error(err))
return
}
// Recreate templates
if err := mod.updateTemplates(); err != nil {
mod.logger.Error("Failed to update custom commands templates", log.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)
}
func (mod *Module) RegisterCommand(name string, command Command) {
mod.commands.SetKey(name, command)
}
func (mod *Module) UnregisterCommand(name string) {
mod.commands.DeleteKey(name)
}
func (mod *Module) GetChatters() (users []string) {
cursor := ""
for {
userClient, err := twitch.GetUserClient(mod.db, twitch.AuthKey, false)
if err != nil {
slog.Error("Could not get user api client for list of chatters", log.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", log.Error(err))
return
}
for _, user := range res.Data.Chatters {
users = append(users, user.UserLogin)
}
cursor = res.Data.Pagination.Cursor
if cursor == "" {
return
}
}
}
func GetCustomUser(db database.Database) (*helix.Client, helix.User, error) {
userClient, err := twitch.GetUserClient(db, CustomAccountKey, true)
if err != nil {
return nil, helix.User{}, err
}
users, err := userClient.GetUsers(&helix.UsersParams{})
if err != nil {
return nil, helix.User{}, err
}
if len(users.Data.Users) < 1 {
return nil, helix.User{}, errors.New("no users found")
}
return userClient, users.Data.Users[0], nil
}