2024-03-10 16:38:18 +00:00
|
|
|
package chat
|
|
|
|
|
|
|
|
import (
|
|
|
|
"context"
|
2024-03-15 22:48:34 +00:00
|
|
|
"encoding/json"
|
2024-03-10 16:38:18 +00:00
|
|
|
"errors"
|
2024-03-14 12:33:52 +00:00
|
|
|
"log/slog"
|
2024-03-10 16:38:18 +00:00
|
|
|
"strings"
|
|
|
|
textTemplate "text/template"
|
|
|
|
"time"
|
|
|
|
|
2024-03-16 00:20:15 +00:00
|
|
|
"git.sr.ht/~ashkeel/strimertul/log"
|
|
|
|
|
2024-03-15 22:48:34 +00:00
|
|
|
"github.com/nicklaw5/helix/v2"
|
|
|
|
|
2024-03-10 16:38:18 +00:00
|
|
|
"git.sr.ht/~ashkeel/containers/sync"
|
|
|
|
"git.sr.ht/~ashkeel/strimertul/database"
|
2024-03-15 22:48:34 +00:00
|
|
|
"git.sr.ht/~ashkeel/strimertul/twitch"
|
2024-03-10 16:38:18 +00:00
|
|
|
"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
|
2024-03-12 22:39:18 +00:00
|
|
|
db database.Database
|
2024-03-10 16:38:18 +00:00
|
|
|
api *helix.Client
|
2024-03-12 22:39:18 +00:00
|
|
|
user helix.User
|
2024-03-14 12:33:52 +00:00
|
|
|
logger *slog.Logger
|
2024-03-10 16:38:18 +00:00
|
|
|
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
|
|
|
|
}
|
|
|
|
|
2024-03-14 12:33:52 +00:00
|
|
|
func Setup(ctx context.Context, db database.Database, api *helix.Client, user helix.User, logger *slog.Logger, templater template.Engine) *Module {
|
2024-03-10 16:38:18 +00:00
|
|
|
mod := &Module{
|
2024-03-12 23:50:59 +00:00
|
|
|
ctx: ctx,
|
2024-03-10 16:38:18 +00:00
|
|
|
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 {
|
2024-03-16 00:20:15 +00:00
|
|
|
logger.Error("Failed to load chat module config", log.Error(err))
|
2024-03-10 16:38:18 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-03-12 23:50:59 +00:00
|
|
|
if err := db.SubscribeKeyContext(ctx, eventsub.EventKeyPrefix+helix.EventSubTypeChannelChatMessage, mod.onChatMessage); err != nil {
|
2024-03-16 00:20:15 +00:00
|
|
|
logger.Error("Could not subscribe to chat messages", log.Error(err))
|
2024-03-10 16:38:18 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// Load custom commands
|
|
|
|
var customCommands map[string]CustomCommand
|
2024-03-12 23:50:59 +00:00
|
|
|
if err := db.GetJSON(CustomCommandsKey, &customCommands); err != nil {
|
2024-03-10 16:38:18 +00:00
|
|
|
if errors.Is(err, database.ErrEmptyKey) {
|
|
|
|
customCommands = make(map[string]CustomCommand)
|
|
|
|
} else {
|
2024-03-16 00:20:15 +00:00
|
|
|
logger.Error("Failed to load custom commands", log.Error(err))
|
2024-03-10 16:38:18 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
mod.customCommands.Set(customCommands)
|
|
|
|
|
2024-03-12 23:50:59 +00:00
|
|
|
if err := mod.updateTemplates(); err != nil {
|
2024-03-16 00:20:15 +00:00
|
|
|
logger.Error("Failed to parse custom commands", log.Error(err))
|
2024-03-10 16:38:18 +00:00
|
|
|
}
|
2024-03-12 23:50:59 +00:00
|
|
|
|
|
|
|
if err := db.SubscribeKeyContext(ctx, CustomCommandsKey, mod.updateCommands); err != nil {
|
2024-03-16 00:20:15 +00:00
|
|
|
logger.Error("Could not set-up chat command reload subscription", log.Error(err))
|
2024-03-10 16:38:18 +00:00
|
|
|
}
|
2024-03-12 23:50:59 +00:00
|
|
|
|
|
|
|
if err := db.SubscribeKeyContext(ctx, WriteMessageRPC, mod.handleWriteMessageRPC); err != nil {
|
2024-03-16 00:20:15 +00:00
|
|
|
logger.Error("Could not set-up chat command reload subscription", log.Error(err))
|
2024-03-10 16:38:18 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
return mod
|
|
|
|
}
|
|
|
|
|
|
|
|
func (mod *Module) onChatMessage(newValue string) {
|
2024-03-12 22:39:18 +00:00
|
|
|
var chatMessage struct {
|
|
|
|
Event helix.EventSubChannelChatMessageEvent `json:"event"`
|
|
|
|
}
|
2024-03-15 22:48:34 +00:00
|
|
|
if err := json.Unmarshal([]byte(newValue), &chatMessage); err != nil {
|
2024-03-16 00:20:15 +00:00
|
|
|
mod.logger.Error("Failed to decode incoming chat message", log.Error(err))
|
2024-03-10 16:38:18 +00:00
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
// TODO Command cooldown logic here!
|
|
|
|
|
2024-03-12 22:39:18 +00:00
|
|
|
lowercaseMessage := strings.TrimSpace(strings.ToLower(chatMessage.Event.Message.Text))
|
2024-03-10 16:38:18 +00:00
|
|
|
|
|
|
|
// 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
|
|
|
|
}
|
2024-03-12 22:39:18 +00:00
|
|
|
go data.Handler(chatMessage.Event)
|
2024-03-10 16:38:18 +00:00
|
|
|
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
|
|
|
|
}
|
2024-03-12 22:39:18 +00:00
|
|
|
go cmdCustom(mod, cmd, data, chatMessage.Event)
|
2024-03-10 16:38:18 +00:00
|
|
|
mod.lastMessage.Set(time.Now())
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func (mod *Module) handleWriteMessageRPC(value string) {
|
|
|
|
var request WriteMessageRequest
|
|
|
|
if err := json.Unmarshal([]byte(value), &request); err != nil {
|
2024-03-16 00:20:15 +00:00
|
|
|
mod.logger.Warn("Failed to decode write message request", log.Error(err))
|
2024-03-10 16:38:18 +00:00
|
|
|
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 {
|
2024-03-16 00:20:15 +00:00
|
|
|
mod.logger.Error("Failed to send announcement", log.Error(err))
|
2024-03-10 16:38:18 +00:00
|
|
|
}
|
|
|
|
if resp.Error != "" {
|
2024-03-14 12:33:52 +00:00
|
|
|
mod.logger.Error("Failed to send announcement", slog.String("code", resp.Error), slog.String("message", resp.ErrorMessage))
|
2024-03-10 16:38:18 +00:00
|
|
|
}
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
if request.WhisperTo != "" {
|
|
|
|
resp, err := mod.api.SendUserWhisper(&helix.SendUserWhisperParams{
|
|
|
|
FromUserID: mod.user.ID,
|
|
|
|
ToUserID: request.WhisperTo,
|
|
|
|
Message: request.Message,
|
|
|
|
})
|
|
|
|
if err != nil {
|
2024-03-16 00:20:15 +00:00
|
|
|
mod.logger.Error("Failed to send whisper", log.Error(err))
|
2024-03-10 16:38:18 +00:00
|
|
|
}
|
|
|
|
if resp.Error != "" {
|
2024-03-14 12:33:52 +00:00
|
|
|
mod.logger.Error("Failed to send whisper", slog.String("code", resp.Error), slog.String("message", resp.ErrorMessage))
|
2024-03-10 16:38:18 +00:00
|
|
|
}
|
|
|
|
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 {
|
2024-03-16 00:20:15 +00:00
|
|
|
mod.logger.Error("Failed to send chat message", log.Error(err))
|
2024-03-10 16:38:18 +00:00
|
|
|
}
|
|
|
|
if resp.Error != "" {
|
2024-03-14 12:33:52 +00:00
|
|
|
mod.logger.Error("Failed to send chat message", slog.String("code", resp.Error), slog.String("message", resp.ErrorMessage))
|
2024-03-10 16:38:18 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func (mod *Module) updateCommands(value string) {
|
|
|
|
err := utils.LoadJSONToWrapped[map[string]CustomCommand](value, mod.customCommands)
|
|
|
|
if err != nil {
|
2024-03-16 00:20:15 +00:00
|
|
|
mod.logger.Error("Failed to decode new custom commands", log.Error(err))
|
2024-03-10 16:38:18 +00:00
|
|
|
return
|
|
|
|
}
|
|
|
|
// Recreate templates
|
|
|
|
if err := mod.updateTemplates(); err != nil {
|
2024-03-16 00:20:15 +00:00
|
|
|
mod.logger.Error("Failed to update custom commands templates", log.Error(err))
|
2024-03-10 16:38:18 +00:00
|
|
|
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)
|
|
|
|
}
|
2024-03-15 22:48:34 +00:00
|
|
|
|
|
|
|
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 {
|
2024-03-16 00:20:15 +00:00
|
|
|
slog.Error("Could not get user api client for list of chatters", log.Error(err))
|
2024-03-15 22:48:34 +00:00
|
|
|
return
|
|
|
|
}
|
|
|
|
res, err := userClient.GetChannelChatChatters(&helix.GetChatChattersParams{
|
|
|
|
BroadcasterID: mod.user.ID,
|
|
|
|
ModeratorID: mod.user.ID,
|
|
|
|
First: "1000",
|
|
|
|
After: cursor,
|
|
|
|
})
|
|
|
|
if err != nil {
|
2024-03-16 00:20:15 +00:00
|
|
|
mod.logger.Error("Could not retrieve list of chatters", log.Error(err))
|
2024-03-15 22:48:34 +00:00
|
|
|
return
|
|
|
|
}
|
|
|
|
for _, user := range res.Data.Chatters {
|
|
|
|
users = append(users, user.UserLogin)
|
|
|
|
}
|
|
|
|
cursor = res.Data.Pagination.Cursor
|
|
|
|
if cursor == "" {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|