diff --git a/CHANGELOG.md b/CHANGELOG.md index 06f41a0..6d0ce78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Custom chat commands can now be sent as replies, whispers and announcements. Due to some API shenanigans yet to be solved, the latter two will always be sent from your main account, not the bot account (if they are different) +- Added a structured RPC `twitch/bot/@send-message` for sending messages as replies, announcements and whispers. ### Changed diff --git a/twitch/bot.go b/twitch/bot.go index c34ca5d..c999eb0 100644 --- a/twitch/bot.go +++ b/twitch/bot.go @@ -6,6 +6,8 @@ import ( "text/template" "time" + "github.com/nicklaw5/helix/v2" + "git.sr.ht/~hamcha/containers/sync" irc "github.com/gempir/go-twitch-irc/v4" "go.uber.org/zap" @@ -14,8 +16,23 @@ import ( "github.com/strimertul/strimertul/utils" ) +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)) +} + type Bot struct { - Client *irc.Client + Client IRCBot Config BotConfig api *Client @@ -32,8 +49,9 @@ type Bot struct { OnConnect *utils.SyncList[BotConnectHandler] OnMessage *utils.SyncList[BotMessageHandler] - cancelUpdateSub database.CancelFunc - cancelWriteRPCSub database.CancelFunc + cancelUpdateSub database.CancelFunc + cancelWritePlainRPCSub database.CancelFunc + cancelWriteRPCSub database.CancelFunc // Module specific vars Timers *BotTimerModule @@ -61,6 +79,10 @@ func newBot(api *Client, config BotConfig) *Bot { // Create client client := irc.NewClient(config.Username, config.Token) + return newBotWithClient(client, api, config) +} + +func newBotWithClient(client IRCBot, api *Client, config BotConfig) *Bot { bot := &Bot{ Client: client, Config: config, @@ -110,6 +132,10 @@ func newBot(api *Client, config BotConfig) *Bot { if err != nil { bot.logger.Error("Could not set-up bot command reload subscription", zap.Error(err)) } + err, bot.cancelWritePlainRPCSub = api.db.SubscribeKey(WritePlainMessageRPC, bot.handleWritePlainMessageRPC) + if err != nil { + bot.logger.Error("Could not set-up bot command reload subscription", zap.Error(err)) + } err, bot.cancelWriteRPCSub = api.db.SubscribeKey(WriteMessageRPC, bot.handleWriteMessageRPC) if err != nil { bot.logger.Error("Could not set-up bot command reload subscription", zap.Error(err)) @@ -221,6 +247,9 @@ func (b *Bot) Close() error { if b.cancelWriteRPCSub != nil { b.cancelWriteRPCSub() } + if b.cancelWritePlainRPCSub != nil { + b.cancelWritePlainRPCSub() + } if b.Timers != nil { b.Timers.Close() } @@ -243,10 +272,54 @@ func (b *Bot) updateCommands(value string) { } } -func (b *Bot) handleWriteMessageRPC(value string) { +func (b *Bot) handleWritePlainMessageRPC(value string) { b.Client.Say(b.Config.Channel, value) } +func (b *Bot) handleWriteMessageRPC(value string) { + var request WriteMessageRequest + err := json.Unmarshal([]byte(value), &request) + if err != nil { + 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) + 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) + 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) +} + func (b *Bot) updateTemplates() error { b.customTemplates.Set(make(map[string]*template.Template)) for cmd, tmpl := range b.customCommands.Copy() { diff --git a/twitch/data.go b/twitch/data.go index 4027229..afc47eb 100644 --- a/twitch/data.go +++ b/twitch/data.go @@ -77,7 +77,20 @@ type BotCustomCommand struct { const CustomCommandsKey = "twitch/bot-custom-commands" -const WriteMessageRPC = "twitch/@send-chat-message" +const ( + // WritePlainMessageRPC is the old send command, will be renamed someday + WritePlainMessageRPC = "twitch/@send-chat-message" + + WriteMessageRPC = "twitch/bot/@send-message" +) + +// WriteMessageRequest is an RPC to send a chat message with extra options +type WriteMessageRequest struct { + Message string `json:"message" desc:"Chat message to send"` + ReplyTo *string `json:"reply_to" desc:"If specified, send as reply to a message ID"` + WhisperTo *string `json:"whisper_to" desc:"If specified, send as whisper to user ID"` + Announce bool `json:"announce" desc:"If true, send as announcement"` +} const BotCounterPrefix = "twitch/bot-counters/" diff --git a/twitch/doc.go b/twitch/doc.go index 295e543..2d1f769 100644 --- a/twitch/doc.go +++ b/twitch/doc.go @@ -63,11 +63,16 @@ var Keys = interfaces.KeyMap{ Description: "Configuration of chat bot timers", Type: reflect.TypeOf(BotTimersConfig{}), }, - WriteMessageRPC: interfaces.KeyDef{ - Description: "Send plain text chat message", + WritePlainMessageRPC: interfaces.KeyDef{ + Description: "Send plain text chat message (this will be deprecated or renamed someday, please use the other one!)", Type: reflect.TypeOf(""), Tags: []interfaces.KeyTag{interfaces.TagRPC}, }, + WriteMessageRPC: interfaces.KeyDef{ + Description: "Send chat message with extra options (as reply, whisper, etc)", + Type: reflect.TypeOf(WriteMessageRequest{}), + Tags: []interfaces.KeyTag{interfaces.TagRPC}, + }, } var Enums = interfaces.EnumMap{