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 ctx context.Context db database.Database api *helix.Client 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, 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), } // 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)) } } 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) 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.user.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.user.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, 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 } } }