From 064c7432edd58f718a8bc38ce456c18c2f32e986 Mon Sep 17 00:00:00 2001 From: Knut Ahlers Date: Tue, 25 Oct 2022 18:47:30 +0200 Subject: [PATCH] [core] Extend API and replace deprecated chat commands (#34) --- auth.go | 4 +- automessage.go | 2 +- botEditor.go | 2 +- chatcommands.go | 47 +++ configEditor_general.go | 2 +- internal/actors/announce/actor.go | 40 +++ internal/actors/ban/actor.go | 66 ++-- internal/actors/delete/actor.go | 20 +- internal/actors/filesay/actor.go | 8 +- internal/actors/nuke/actions.go | 48 +++ internal/actors/nuke/actor.go | 32 +- internal/actors/punish/actor.go | 43 +-- internal/actors/quotedb/actor.go | 4 +- internal/actors/raw/actor.go | 8 +- internal/actors/respond/actor.go | 4 +- internal/actors/timeout/actor.go | 60 ++-- internal/actors/whisper/actor.go | 19 +- irc.go | 2 +- pkg/twitch/channels.go | 85 +++++ pkg/twitch/chat.go | 55 ++++ pkg/twitch/eventsub.go | 80 +++++ pkg/twitch/moderation.go | 140 +++++++++ pkg/twitch/scopes.go | 29 +- pkg/twitch/search.go | 57 ++++ pkg/twitch/streams.go | 137 ++++++++ pkg/twitch/twitch.go | 499 ------------------------------ pkg/twitch/users.go | 187 +++++++++++ pkg/twitch/whispers.go | 53 ++++ plugins/interface.go | 8 + plugins_core.go | 20 +- scopes.go | 9 +- status.go | 2 +- 32 files changed, 1133 insertions(+), 639 deletions(-) create mode 100644 chatcommands.go create mode 100644 internal/actors/announce/actor.go create mode 100644 internal/actors/nuke/actions.go create mode 100644 pkg/twitch/channels.go create mode 100644 pkg/twitch/chat.go create mode 100644 pkg/twitch/moderation.go create mode 100644 pkg/twitch/search.go create mode 100644 pkg/twitch/streams.go create mode 100644 pkg/twitch/users.go create mode 100644 pkg/twitch/whispers.go diff --git a/auth.go b/auth.go index 3d7a69e..193b142 100644 --- a/auth.go +++ b/auth.go @@ -79,7 +79,7 @@ func handleAuthUpdateBotToken(w http.ResponseWriter, r *http.Request) { return } - botUser, err := twitch.New(cfg.TwitchClient, cfg.TwitchClientSecret, rData.AccessToken, "").GetAuthorizedUsername() + _, botUser, err := twitch.New(cfg.TwitchClient, cfg.TwitchClientSecret, rData.AccessToken, "").GetAuthorizedUser() if err != nil { http.Error(w, errors.Wrap(err, "getting authorized user").Error(), http.StatusInternalServerError) return @@ -135,7 +135,7 @@ func handleAuthUpdateChannelGrant(w http.ResponseWriter, r *http.Request) { return } - grantUser, err := twitch.New(cfg.TwitchClient, cfg.TwitchClientSecret, rData.AccessToken, "").GetAuthorizedUsername() + _, grantUser, err := twitch.New(cfg.TwitchClient, cfg.TwitchClientSecret, rData.AccessToken, "").GetAuthorizedUser() if err != nil { http.Error(w, errors.Wrap(err, "getting authorized user").Error(), http.StatusInternalServerError) return diff --git a/automessage.go b/automessage.go index e766d03..afa2a83 100644 --- a/automessage.go +++ b/automessage.go @@ -151,7 +151,7 @@ func (a *autoMessage) Send(c *irc.Client) error { msg = fmt.Sprintf("\001ACTION %s\001", msg) } - if err := c.WriteMessage(&irc.Message{ + if err := sendMessage(&irc.Message{ Command: "PRIVMSG", Params: []string{ fmt.Sprintf("#%s", strings.TrimLeft(a.Channel, "#")), diff --git a/botEditor.go b/botEditor.go index 1c1bf15..c7e8d97 100644 --- a/botEditor.go +++ b/botEditor.go @@ -17,7 +17,7 @@ func getAuthorizationFromRequest(r *http.Request) (string, *twitch.Client, error tc := twitch.New(cfg.TwitchClient, cfg.TwitchClientSecret, token, "") - user, err := tc.GetAuthorizedUsername() + _, user, err := tc.GetAuthorizedUser() return user, tc, errors.Wrap(err, "getting authorized user") } diff --git a/chatcommands.go b/chatcommands.go new file mode 100644 index 0000000..8ab9d16 --- /dev/null +++ b/chatcommands.go @@ -0,0 +1,47 @@ +package main + +import ( + "strings" + "sync" + + "github.com/go-irc/irc" + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" + + "github.com/Luzifer/twitch-bot/v2/plugins" +) + +var ( + availableChatcommands = map[string]plugins.MsgModificationFunc{} + availableChatcommandsLock = new(sync.RWMutex) +) + +func registerChatcommand(linePrefix string, modFn plugins.MsgModificationFunc) { + availableChatcommandsLock.Lock() + defer availableChatcommandsLock.Unlock() + + if _, ok := availableChatcommands[linePrefix]; ok { + log.WithField("linePrefix", linePrefix).Fatal("Duplicate registration of chatcommand") + } + + availableChatcommands[linePrefix] = modFn +} + +func handleChatcommandModifications(m *irc.Message) error { + availableChatcommandsLock.RLock() + defer availableChatcommandsLock.RUnlock() + + msg := m.Trailing() + + for prefix, modFn := range availableChatcommands { + if !strings.HasPrefix(msg, prefix) { + continue + } + + if err := modFn(m); err != nil { + return errors.Wrap(err, "modifying message") + } + } + + return nil +} diff --git a/configEditor_general.go b/configEditor_general.go index 1f9d6be..43504cf 100644 --- a/configEditor_general.go +++ b/configEditor_general.go @@ -196,7 +196,7 @@ func configEditorHandleGeneralGet(w http.ResponseWriter, r *http.Request) { } var uName *string - if n, err := twitchClient.GetAuthorizedUsername(); err == nil { + if _, n, err := twitchClient.GetAuthorizedUser(); err == nil { uName = &n } diff --git a/internal/actors/announce/actor.go b/internal/actors/announce/actor.go new file mode 100644 index 0000000..4bc27d0 --- /dev/null +++ b/internal/actors/announce/actor.go @@ -0,0 +1,40 @@ +package announce + +import ( + "regexp" + + "github.com/go-irc/irc" + "github.com/pkg/errors" + + "github.com/Luzifer/twitch-bot/v2/pkg/twitch" + "github.com/Luzifer/twitch-bot/v2/plugins" +) + +var ( + botTwitchClient *twitch.Client + + announceChatcommandRegex = regexp.MustCompile(`^/announce(|blue|green|orange|purple) +(.+)$`) +) + +func Register(args plugins.RegistrationArguments) error { + botTwitchClient = args.GetTwitchClient() + + args.RegisterMessageModFunc("/announce", handleChatCommand) + + return nil +} + +func handleChatCommand(m *irc.Message) error { + channel := plugins.DeriveChannel(m, nil) + + matches := announceChatcommandRegex.FindStringSubmatch(m.Trailing()) + if matches == nil { + return errors.New("announce message does not match required format") + } + + if err := botTwitchClient.SendChatAnnouncement(channel, matches[1], matches[2]); err != nil { + return errors.Wrap(err, "sending announcement") + } + + return plugins.ErrSkipSendingMessage +} diff --git a/internal/actors/ban/actor.go b/internal/actors/ban/actor.go index 1e3d908..77101d8 100644 --- a/internal/actors/ban/actor.go +++ b/internal/actors/ban/actor.go @@ -2,25 +2,28 @@ package ban import ( "net/http" - "strings" + "regexp" "github.com/go-irc/irc" "github.com/gorilla/mux" "github.com/pkg/errors" + "github.com/Luzifer/twitch-bot/v2/pkg/twitch" "github.com/Luzifer/twitch-bot/v2/plugins" ) const actorName = "ban" var ( - formatMessage plugins.MsgFormatter - send plugins.SendMessageFunc + botTwitchClient *twitch.Client + formatMessage plugins.MsgFormatter + + banChatcommandRegex = regexp.MustCompile(`^/ban +([^\s]+) +(.+)$`) ) func Register(args plugins.RegistrationArguments) error { + botTwitchClient = args.GetTwitchClient() formatMessage = args.FormatMessage - send = args.SendMessage args.RegisterActor(actorName, func() plugins.Actor { return &actor{} }) @@ -35,7 +38,7 @@ func Register(args plugins.RegistrationArguments) error { Description: "Reason why the user was banned", Key: "reason", Name: "Reason", - Optional: true, + Optional: false, SupportTemplate: true, Type: plugins.ActionDocumentationFieldTypeString, }, @@ -53,7 +56,7 @@ func Register(args plugins.RegistrationArguments) error { { Description: "Reason to add to the ban", Name: "reason", - Required: false, + Required: true, Type: "string", }, }, @@ -71,6 +74,8 @@ func Register(args plugins.RegistrationArguments) error { }, }) + args.RegisterMessageModFunc("/ban", handleChatCommand) + return nil } @@ -84,32 +89,14 @@ func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData return false, errors.Wrap(err, "executing reason template") } - return a.execBan( - plugins.DeriveChannel(m, eventData), - plugins.DeriveUser(m, eventData), - reason, - ) -} - -func (actor) execBan(channel, user, reason string) (bool, error) { - cmd := []string{ - "/ban", - user, - } - - if reason != "" { - cmd = append(cmd, reason) - } - return false, errors.Wrap( - send(&irc.Message{ - Command: "PRIVMSG", - Params: []string{ - "#" + strings.TrimLeft(channel, "#"), - strings.Join(cmd, " "), - }, - }), - "sending ban", + botTwitchClient.BanUser( + plugins.DeriveChannel(m, eventData), + plugins.DeriveUser(m, eventData), + 0, + reason, + ), + "executing ban", ) } @@ -126,10 +113,25 @@ func handleAPIBan(w http.ResponseWriter, r *http.Request) { reason = r.FormValue("reason") ) - if _, err := (actor{}).execBan(channel, user, reason); err != nil { + if err := botTwitchClient.BanUser(channel, user, 0, reason); err != nil { http.Error(w, errors.Wrap(err, "issuing ban").Error(), http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } + +func handleChatCommand(m *irc.Message) error { + channel := plugins.DeriveChannel(m, nil) + + matches := banChatcommandRegex.FindStringSubmatch(m.Trailing()) + if matches == nil { + return errors.New("ban message does not match required format") + } + + if err := botTwitchClient.BanUser(channel, matches[1], 0, matches[3]); err != nil { + return errors.Wrap(err, "executing ban") + } + + return plugins.ErrSkipSendingMessage +} diff --git a/internal/actors/delete/actor.go b/internal/actors/delete/actor.go index 95d3551..120fb2e 100644 --- a/internal/actors/delete/actor.go +++ b/internal/actors/delete/actor.go @@ -1,17 +1,20 @@ package deleteactor import ( - "fmt" - "github.com/go-irc/irc" "github.com/pkg/errors" + "github.com/Luzifer/twitch-bot/v2/pkg/twitch" "github.com/Luzifer/twitch-bot/v2/plugins" ) const actorName = "delete" +var botTwitchClient *twitch.Client + func Register(args plugins.RegistrationArguments) error { + botTwitchClient = args.GetTwitchClient() + args.RegisterActor(actorName, func() plugins.Actor { return &actor{} }) args.RegisterActorDocumentation(plugins.ActionDocumentation{ @@ -32,14 +35,11 @@ func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData } return false, errors.Wrap( - c.WriteMessage(&irc.Message{ - Command: "PRIVMSG", - Params: []string{ - m.Params[0], - fmt.Sprintf("/delete %s", msgID), - }, - }), - "sending delete", + botTwitchClient.DeleteMessage( + plugins.DeriveChannel(m, eventData), + msgID, + ), + "deleting message", ) } diff --git a/internal/actors/filesay/actor.go b/internal/actors/filesay/actor.go index 14f2a8c..fd470b2 100644 --- a/internal/actors/filesay/actor.go +++ b/internal/actors/filesay/actor.go @@ -19,10 +19,14 @@ const ( httpTimeout = 5 * time.Second ) -var formatMessage plugins.MsgFormatter +var ( + formatMessage plugins.MsgFormatter + send plugins.SendMessageFunc +) func Register(args plugins.RegistrationArguments) error { formatMessage = args.FormatMessage + send = args.SendMessage args.RegisterActor(actorName, func() plugins.Actor { return &actor{} }) @@ -85,7 +89,7 @@ func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData scanner := bufio.NewScanner(resp.Body) for scanner.Scan() { - if err = c.WriteMessage(&irc.Message{ + if err = send(&irc.Message{ Command: "PRIVMSG", Params: []string{ plugins.DeriveChannel(m, eventData), diff --git a/internal/actors/nuke/actions.go b/internal/actors/nuke/actions.go new file mode 100644 index 0000000..3f13da2 --- /dev/null +++ b/internal/actors/nuke/actions.go @@ -0,0 +1,48 @@ +package nuke + +import ( + "fmt" + "time" + + "github.com/pkg/errors" +) + +type ( + actionFn func(channel, match, msgid, user string) error +) + +func actionBan(channel, match, msgid, user string) error { + return errors.Wrap( + botTwitchClient.BanUser( + channel, + user, + 0, + fmt.Sprintf("Nuke issued for %q", match), + ), + "executing ban", + ) +} + +func actionDelete(channel, match, msgid, user string) (err error) { + return errors.Wrap( + botTwitchClient.DeleteMessage( + channel, + msgid, + ), + "deleting message", + ) +} + +func getActionTimeout(duration time.Duration) actionFn { + return func(channel, match, msgid, user string) error { + return errors.Wrap( + botTwitchClient.BanUser( + channel, + user, + duration, + fmt.Sprintf("Nuke issued for %q", match), + ), + "executing timeout", + ) + } +} diff --git a/internal/actors/nuke/actor.go b/internal/actors/nuke/actor.go index 9869995..d92302b 100644 --- a/internal/actors/nuke/actor.go +++ b/internal/actors/nuke/actor.go @@ -1,7 +1,6 @@ package nuke import ( - "fmt" "regexp" "strings" "sync" @@ -22,7 +21,8 @@ const ( ) var ( - formatMessage plugins.MsgFormatter + botTwitchClient *twitch.Client + formatMessage plugins.MsgFormatter messageStore = map[string][]*storedMessage{} messageStoreLock sync.RWMutex @@ -32,6 +32,7 @@ var ( ) func Register(args plugins.RegistrationArguments) error { + botTwitchClient = args.GetTwitchClient() formatMessage = args.FormatMessage args.RegisterActor(actorName, func() plugins.Actor { return &actor{} }) @@ -161,22 +162,28 @@ func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData } scanTime := time.Now().Add(-scan) - var action string + var ( + action actionFn + actionName string + ) rawAction, err := formatMessage(attrs.MustString("action", ptrStringDelete), m, r, eventData) if err != nil { return false, errors.Wrap(err, "formatting action") } switch rawAction { case "delete": - action = "/delete $msgid" + action = actionDelete + actionName = "delete $msgid" case "ban": - action = `/ban $user Nuke issued for "$match"` + action = actionBan + actionName = "ban $user" default: to, err := time.ParseDuration(rawAction) if err != nil { return false, errors.Wrap(err, "parsing action duration") } - action = fmt.Sprintf(`/timeout $user %d Nuke issued for "$match"`, to/time.Second) + action = getActionTimeout(to) + actionName = "timeout $user" } channel := plugins.DeriveChannel(m, eventData) @@ -202,23 +209,16 @@ func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData } enforcement := strings.NewReplacer( - "$match", rawMatch, "$msgid", string(stMsg.Msg.Tags["id"]), "$user", plugins.DeriveUser(stMsg.Msg, nil), - ).Replace(action) + ).Replace(actionName) if str.StringInSlice(enforcement, executedEnforcement) { continue } - if err = c.WriteMessage(&irc.Message{ - Command: "PRIVMSG", - Params: []string{ - channel, - enforcement, - }, - }); err != nil { - return false, errors.Wrap(err, "sending action") + if err = action(channel, rawMatch, string(stMsg.Msg.Tags["id"]), plugins.DeriveUser(stMsg.Msg, nil)); err != nil { + return false, errors.Wrap(err, "executing action") } executedEnforcement = append(executedEnforcement, enforcement) diff --git a/internal/actors/punish/actor.go b/internal/actors/punish/actor.go index 013d804..31a29ef 100644 --- a/internal/actors/punish/actor.go +++ b/internal/actors/punish/actor.go @@ -2,7 +2,6 @@ package punish import ( "math" - "strconv" "strings" "time" @@ -10,6 +9,7 @@ import ( "github.com/pkg/errors" "github.com/Luzifer/twitch-bot/v2/pkg/database" + "github.com/Luzifer/twitch-bot/v2/pkg/twitch" "github.com/Luzifer/twitch-bot/v2/plugins" ) @@ -22,6 +22,7 @@ const ( ) var ( + botTwitchClient *twitch.Client db database.Connector formatMessage plugins.MsgFormatter ptrDefaultCooldown = func(v time.Duration) *time.Duration { return &v }(oneWeek) @@ -34,6 +35,7 @@ func Register(args plugins.RegistrationArguments) error { return errors.Wrap(err, "applying schema migration") } + botTwitchClient = args.GetTwitchClient() formatMessage = args.FormatMessage args.RegisterActor(actorNamePunish, func() plugins.Actor { return &actorPunish{} }) @@ -159,13 +161,15 @@ func (a actorPunish) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eve } nLvl := int(math.Min(float64(len(levels)-1), float64(lvl.LastLevel+1))) - var cmd []string - switch lt := levels[nLvl]; lt { case "ban": - cmd = []string{"/ban", strings.TrimLeft(user, "@")} - if reason != "" { - cmd = append(cmd, reason) + if err = botTwitchClient.BanUser( + plugins.DeriveChannel(m, eventData), + strings.TrimLeft(user, "@"), + 0, + reason, + ); err != nil { + return false, errors.Wrap(err, "executing user ban") } case "delete": @@ -174,7 +178,12 @@ func (a actorPunish) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eve return false, errors.New("found no mesage id") } - cmd = []string{"/delete", msgID} + if err = botTwitchClient.DeleteMessage( + plugins.DeriveChannel(m, eventData), + msgID, + ); err != nil { + return false, errors.Wrap(err, "deleting message") + } default: to, err := time.ParseDuration(lt) @@ -182,20 +191,14 @@ func (a actorPunish) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eve return false, errors.Wrap(err, "parsing punishment level") } - cmd = []string{"/timeout", strings.TrimLeft(user, "@"), strconv.FormatInt(int64(to/time.Second), 10)} - if reason != "" { - cmd = append(cmd, reason) - } - } - - if err := c.WriteMessage(&irc.Message{ - Command: "PRIVMSG", - Params: []string{ + if err = botTwitchClient.BanUser( plugins.DeriveChannel(m, eventData), - strings.Join(cmd, " "), - }, - }); err != nil { - return false, errors.Wrap(err, "sending command") + strings.TrimLeft(user, "@"), + to, + reason, + ); err != nil { + return false, errors.Wrap(err, "executing user ban") + } } lvl.Cooldown = cooldown diff --git a/internal/actors/quotedb/actor.go b/internal/actors/quotedb/actor.go index 1c8bc55..3ec52d8 100644 --- a/internal/actors/quotedb/actor.go +++ b/internal/actors/quotedb/actor.go @@ -18,6 +18,7 @@ const ( var ( db database.Connector formatMessage plugins.MsgFormatter + send plugins.SendMessageFunc ptrStringEmpty = func(v string) *string { return &v }("") ptrStringOutFormat = func(v string) *string { return &v }("Quote #{{ .index }}: {{ .quote }}") @@ -31,6 +32,7 @@ func Register(args plugins.RegistrationArguments) error { } formatMessage = args.FormatMessage + send = args.SendMessage args.RegisterActor(actorName, func() plugins.Actor { return &actor{} }) @@ -154,7 +156,7 @@ func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData } return false, errors.Wrap( - c.WriteMessage(&irc.Message{ + send(&irc.Message{ Command: "PRIVMSG", Params: []string{ plugins.DeriveChannel(m, eventData), diff --git a/internal/actors/raw/actor.go b/internal/actors/raw/actor.go index 698e73f..3f4a76c 100644 --- a/internal/actors/raw/actor.go +++ b/internal/actors/raw/actor.go @@ -9,10 +9,14 @@ import ( const actorName = "raw" -var formatMessage plugins.MsgFormatter +var ( + formatMessage plugins.MsgFormatter + send plugins.SendMessageFunc +) func Register(args plugins.RegistrationArguments) error { formatMessage = args.FormatMessage + send = args.SendMessage args.RegisterActor(actorName, func() plugins.Actor { return &actor{} }) @@ -51,7 +55,7 @@ func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData } return false, errors.Wrap( - c.WriteMessage(msg), + send(msg), "sending raw message", ) } diff --git a/internal/actors/respond/actor.go b/internal/actors/respond/actor.go index 888e92a..7869c52 100644 --- a/internal/actors/respond/actor.go +++ b/internal/actors/respond/actor.go @@ -15,12 +15,14 @@ const actorName = "respond" var ( formatMessage plugins.MsgFormatter + send plugins.SendMessageFunc ptrBoolFalse = func(v bool) *bool { return &v }(false) ) func Register(args plugins.RegistrationArguments) error { formatMessage = args.FormatMessage + send = args.SendMessage args.RegisterActor(actorName, func() plugins.Actor { return &actor{} }) @@ -111,7 +113,7 @@ func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData } return false, errors.Wrap( - c.WriteMessage(ircMessage), + send(ircMessage), "sending response", ) } diff --git a/internal/actors/timeout/actor.go b/internal/actors/timeout/actor.go index 7de091e..f945c6a 100644 --- a/internal/actors/timeout/actor.go +++ b/internal/actors/timeout/actor.go @@ -1,24 +1,29 @@ package timeout import ( + "regexp" "strconv" - "strings" "time" "github.com/go-irc/irc" "github.com/pkg/errors" + "github.com/Luzifer/twitch-bot/v2/pkg/twitch" "github.com/Luzifer/twitch-bot/v2/plugins" ) const actorName = "timeout" var ( - formatMessage plugins.MsgFormatter - ptrStringEmpty = func(v string) *string { return &v }("") + botTwitchClient *twitch.Client + formatMessage plugins.MsgFormatter + ptrStringEmpty = func(v string) *string { return &v }("") + + timeoutChatcommandRegex = regexp.MustCompile(`^/timeout +([^\s]+) +([0-9]+) +(.+)$`) ) func Register(args plugins.RegistrationArguments) error { + botTwitchClient = args.GetTwitchClient() formatMessage = args.FormatMessage args.RegisterActor(actorName, func() plugins.Actor { return &actor{} }) @@ -43,43 +48,34 @@ func Register(args plugins.RegistrationArguments) error { Description: "Reason why the user was timed out", Key: "reason", Name: "Reason", - Optional: true, + Optional: false, SupportTemplate: true, Type: plugins.ActionDocumentationFieldTypeString, }, }, }) + args.RegisterMessageModFunc("/timeout", handleChatCommand) + return nil } type actor struct{} func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData *plugins.FieldCollection, attrs *plugins.FieldCollection) (preventCooldown bool, err error) { - cmd := []string{ - "/timeout", - plugins.DeriveUser(m, eventData), - strconv.FormatInt(int64(attrs.MustDuration("duration", nil)/time.Second), 10), - } - reason, err := formatMessage(attrs.MustString("reason", ptrStringEmpty), m, r, eventData) if err != nil { return false, errors.Wrap(err, "executing reason template") } - if reason != "" { - cmd = append(cmd, reason) - } - return false, errors.Wrap( - c.WriteMessage(&irc.Message{ - Command: "PRIVMSG", - Params: []string{ - plugins.DeriveChannel(m, eventData), - strings.Join(cmd, " "), - }, - }), - "sending timeout", + botTwitchClient.BanUser( + plugins.DeriveChannel(m, eventData), + plugins.DeriveUser(m, eventData), + attrs.MustDuration("duration", nil), + reason, + ), + "executing timeout", ) } @@ -93,3 +89,23 @@ func (a actor) Validate(attrs *plugins.FieldCollection) (err error) { return nil } + +func handleChatCommand(m *irc.Message) error { + channel := plugins.DeriveChannel(m, nil) + + matches := timeoutChatcommandRegex.FindStringSubmatch(m.Trailing()) + if matches == nil { + return errors.New("timeout message does not match required format") + } + + duration, err := strconv.ParseInt(matches[2], 10, 64) + if err != nil { + return errors.Wrap(err, "parsing timeout duration") + } + + if err = botTwitchClient.BanUser(channel, matches[1], time.Duration(duration)*time.Second, matches[3]); err != nil { + return errors.Wrap(err, "executing timeout") + } + + return plugins.ErrSkipSendingMessage +} diff --git a/internal/actors/whisper/actor.go b/internal/actors/whisper/actor.go index 09aebbb..09b79d9 100644 --- a/internal/actors/whisper/actor.go +++ b/internal/actors/whisper/actor.go @@ -1,19 +1,22 @@ package whisper import ( - "fmt" - "github.com/go-irc/irc" "github.com/pkg/errors" + "github.com/Luzifer/twitch-bot/v2/pkg/twitch" "github.com/Luzifer/twitch-bot/v2/plugins" ) const actorName = "whisper" -var formatMessage plugins.MsgFormatter +var ( + botTwitchClient *twitch.Client + formatMessage plugins.MsgFormatter +) func Register(args plugins.RegistrationArguments) error { + botTwitchClient = args.GetTwitchClient() formatMessage = args.FormatMessage args.RegisterActor(actorName, func() plugins.Actor { return &actor{} }) @@ -61,16 +64,8 @@ func (a actor) Execute(c *irc.Client, m *irc.Message, r *plugins.Rule, eventData return false, errors.Wrap(err, "preparing whisper message") } - channel := "#tmijs" // As a fallback, copied from tmi.js - return false, errors.Wrap( - c.WriteMessage(&irc.Message{ - Command: "PRIVMSG", - Params: []string{ - channel, - fmt.Sprintf("/w %s %s", to, msg), - }, - }), + botTwitchClient.SendWhisper(to, msg), "sending whisper", ) } diff --git a/irc.go b/irc.go index 3ecceaf..c888e72 100644 --- a/irc.go +++ b/irc.go @@ -55,7 +55,7 @@ type ircHandler struct { func newIRCHandler() (*ircHandler, error) { h := new(ircHandler) - username, err := twitchClient.GetAuthorizedUsername() + _, username, err := twitchClient.GetAuthorizedUser() if err != nil { return nil, errors.Wrap(err, "fetching username") } diff --git a/pkg/twitch/channels.go b/pkg/twitch/channels.go new file mode 100644 index 0000000..eaf8583 --- /dev/null +++ b/pkg/twitch/channels.go @@ -0,0 +1,85 @@ +package twitch + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/pkg/errors" +) + +func (c *Client) ModifyChannelInformation(ctx context.Context, broadcasterName string, game, title *string) error { + if game == nil && title == nil { + return errors.New("netiher game nor title provided") + } + + broadcaster, err := c.GetIDForUsername(broadcasterName) + if err != nil { + return errors.Wrap(err, "getting ID for broadcaster name") + } + + data := struct { + GameID *string `json:"game_id,omitempty"` + Title *string `json:"title,omitempty"` + }{ + Title: title, + } + + switch { + case game == nil: + // We don't set the GameID + + case (*game)[0] == '@': + // We got an ID and don't need to resolve + gameID := (*game)[1:] + data.GameID = &gameID + + default: + categories, err := c.SearchCategories(ctx, *game) + if err != nil { + return errors.Wrap(err, "searching for game") + } + + switch len(categories) { + case 0: + return errors.New("no matching game found") + + case 1: + data.GameID = &categories[0].ID + + default: + // Multiple matches: Search for exact one + for _, c := range categories { + if c.Name == *game { + gid := c.ID + data.GameID = &gid + break + } + } + + if data.GameID == nil { + // No exact match found: This is an error + return errors.New("no exact game match found") + } + } + } + + body := new(bytes.Buffer) + if err := json.NewEncoder(body).Encode(data); err != nil { + return errors.Wrap(err, "encoding payload") + } + + return errors.Wrap( + c.request(clientRequestOpts{ + AuthType: authTypeBearerToken, + Body: body, + Context: ctx, + Method: http.MethodPatch, + OKStatus: http.StatusNoContent, + URL: fmt.Sprintf("https://api.twitch.tv/helix/channels?broadcaster_id=%s", broadcaster), + }), + "executing request", + ) +} diff --git a/pkg/twitch/chat.go b/pkg/twitch/chat.go new file mode 100644 index 0000000..77844ca --- /dev/null +++ b/pkg/twitch/chat.go @@ -0,0 +1,55 @@ +package twitch + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/pkg/errors" +) + +// SendChatAnnouncement sends an announcement in the specified +// channel with the given message. Colors must be blue, green, +// orange, purple or primary (empty color = primary) +func (c *Client) SendChatAnnouncement(channel, color, message string) error { + var payload struct { + Color string `json:"color,omitempty"` + Message string `json:"message"` + } + + payload.Color = color + payload.Message = message + + botID, _, err := c.GetAuthorizedUser() + if err != nil { + return errors.Wrap(err, "getting bot user-id") + } + + channelID, err := c.GetIDForUsername(strings.TrimLeft(channel, "#@")) + if err != nil { + return errors.Wrap(err, "getting channel user-id") + } + + body := new(bytes.Buffer) + if err = json.NewEncoder(body).Encode(payload); err != nil { + return errors.Wrap(err, "encoding payload") + } + + return errors.Wrap( + c.request(clientRequestOpts{ + AuthType: authTypeBearerToken, + Context: context.Background(), + Method: http.MethodPost, + OKStatus: http.StatusNoContent, + Body: body, + URL: fmt.Sprintf( + "https://api.twitch.tv/helix/chat/announcements?broadcaster_id=%s&moderator_id=%s", + channelID, botID, + ), + }), + "executing request", + ) +} diff --git a/pkg/twitch/eventsub.go b/pkg/twitch/eventsub.go index 9c427d1..7735b08 100644 --- a/pkg/twitch/eventsub.go +++ b/pkg/twitch/eventsub.go @@ -9,6 +9,7 @@ import ( "fmt" "io" "net/http" + "net/url" "strings" "sync" "time" @@ -388,10 +389,89 @@ func (e *EventSubClient) RegisterEventSubHooks(event string, condition EventSubC return func() { e.unregisterCallback(cacheKey, cbKey) }, nil } +func (c *Client) createEventSubSubscription(ctx context.Context, sub eventSubSubscription) (*eventSubSubscription, error) { + var ( + buf = new(bytes.Buffer) + resp struct { + Total int64 `json:"total"` + Data []eventSubSubscription `json:"data"` + Pagination struct { + Cursor string `json:"cursor"` + } `json:"pagination"` + } + ) + + if err := json.NewEncoder(buf).Encode(sub); err != nil { + return nil, errors.Wrap(err, "assemble subscribe payload") + } + + if err := c.request(clientRequestOpts{ + AuthType: authTypeAppAccessToken, + Body: buf, + Context: ctx, + Method: http.MethodPost, + OKStatus: http.StatusAccepted, + Out: &resp, + URL: "https://api.twitch.tv/helix/eventsub/subscriptions", + }); err != nil { + return nil, errors.Wrap(err, "executing request") + } + + return &resp.Data[0], nil +} + +func (c *Client) deleteEventSubSubscription(ctx context.Context, id string) error { + return errors.Wrap(c.request(clientRequestOpts{ + AuthType: authTypeAppAccessToken, + Context: ctx, + Method: http.MethodDelete, + OKStatus: http.StatusNoContent, + URL: fmt.Sprintf("https://api.twitch.tv/helix/eventsub/subscriptions?id=%s", id), + }), "executing request") +} + func (e *EventSubClient) fullAPIurl() string { return strings.Join([]string{e.apiURL, e.secretHandle}, "/") } +func (c *Client) getEventSubSubscriptions(ctx context.Context) ([]eventSubSubscription, error) { + var ( + out []eventSubSubscription + params = make(url.Values) + resp struct { + Total int64 `json:"total"` + Data []eventSubSubscription `json:"data"` + Pagination struct { + Cursor string `json:"cursor"` + } `json:"pagination"` + } + ) + + for { + if err := c.request(clientRequestOpts{ + AuthType: authTypeAppAccessToken, + Context: ctx, + Method: http.MethodGet, + OKStatus: http.StatusOK, + Out: &resp, + URL: fmt.Sprintf("https://api.twitch.tv/helix/eventsub/subscriptions?%s", params.Encode()), + }); err != nil { + return nil, errors.Wrap(err, "executing request") + } + + out = append(out, resp.Data...) + + if resp.Pagination.Cursor == "" { + break + } + + params.Set("after", resp.Pagination.Cursor) + resp.Pagination.Cursor = "" // Clear from struct as struct is reused + } + + return out, nil +} + func (e *EventSubClient) unregisterCallback(cacheKey, cbKey string) { e.subscriptionsLock.RLock() regSub, ok := e.subscriptions[cacheKey] diff --git a/pkg/twitch/moderation.go b/pkg/twitch/moderation.go new file mode 100644 index 0000000..2343622 --- /dev/null +++ b/pkg/twitch/moderation.go @@ -0,0 +1,140 @@ +package twitch + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/pkg/errors" +) + +const maxTimeoutDuration = 1209600 * time.Second + +// BanUser bans or timeouts a user in the given channel. Setting the +// duration to 0 will result in a ban, setting if greater than 0 will +// result in a timeout. The timeout is automatically converted to +// full seconds. The timeout duration must be less than 1209600s. +func (c *Client) BanUser(channel, username string, duration time.Duration, reason string) error { + var payload struct { + Data struct { + Duration int64 `json:"duration,omitempty"` + Reason string `json:"reason"` + UserID string `json:"user_id"` + } `json:"data"` + } + + if duration > maxTimeoutDuration { + return errors.New("timeout duration exceeds maximum") + } + + payload.Data.Duration = int64(duration / time.Second) + payload.Data.Reason = reason + + botID, _, err := c.GetAuthorizedUser() + if err != nil { + return errors.Wrap(err, "getting bot user-id") + } + + channelID, err := c.GetIDForUsername(strings.TrimLeft(channel, "#@")) + if err != nil { + return errors.Wrap(err, "getting channel user-id") + } + + if payload.Data.UserID, err = c.GetIDForUsername(username); err != nil { + return errors.Wrap(err, "getting target user-id") + } + + body := new(bytes.Buffer) + if err = json.NewEncoder(body).Encode(payload); err != nil { + return errors.Wrap(err, "encoding payload") + } + + return errors.Wrap( + c.request(clientRequestOpts{ + AuthType: authTypeBearerToken, + Context: context.Background(), + Method: http.MethodPost, + OKStatus: http.StatusOK, + Body: body, + URL: fmt.Sprintf( + "https://api.twitch.tv/helix/moderation/bans?broadcaster_id=%s&moderator_id=%s", + channelID, botID, + ), + }), + "executing ban request", + ) +} + +// DeleteMessage deletes one or all messages from the specified chat. +// If no messageID is given all messages are deleted. If a message ID +// is given the message must be no older than 6 hours and it must not +// be posted by broadcaster or moderator. +func (c *Client) DeleteMessage(channel, messageID string) error { + botID, _, err := c.GetAuthorizedUser() + if err != nil { + return errors.Wrap(err, "getting bot user-id") + } + + channelID, err := c.GetIDForUsername(strings.TrimLeft(channel, "#@")) + if err != nil { + return errors.Wrap(err, "getting channel user-id") + } + + params := make(url.Values) + params.Set("broadcaster_id", channelID) + params.Set("moderator_id", botID) + if messageID != "" { + params.Set("message_id", messageID) + } + + return errors.Wrap( + c.request(clientRequestOpts{ + AuthType: authTypeBearerToken, + Context: context.Background(), + Method: http.MethodDelete, + OKStatus: http.StatusNoContent, + URL: fmt.Sprintf( + "https://api.twitch.tv/helix/moderation/chat?%s", + params.Encode(), + ), + }), + "executing delete request", + ) +} + +// UnbanUser removes a timeout or ban given to the user in the channel +func (c *Client) UnbanUser(channel, username string) error { + botID, _, err := c.GetAuthorizedUser() + if err != nil { + return errors.Wrap(err, "getting bot user-id") + } + + channelID, err := c.GetIDForUsername(strings.TrimLeft(channel, "#@")) + if err != nil { + return errors.Wrap(err, "getting channel user-id") + } + + userID, err := c.GetIDForUsername(username) + if err != nil { + return errors.Wrap(err, "getting target user-id") + } + + return errors.Wrap( + c.request(clientRequestOpts{ + AuthType: authTypeBearerToken, + Context: context.Background(), + Method: http.MethodDelete, + OKStatus: http.StatusNoContent, + URL: fmt.Sprintf( + "https://api.twitch.tv/helix/moderation/bans?broadcaster_id=%s&moderator_id=%s&user_id=%s", + channelID, botID, userID, + ), + }), + "executing unban request", + ) +} diff --git a/pkg/twitch/scopes.go b/pkg/twitch/scopes.go index 1e06326..b033174 100644 --- a/pkg/twitch/scopes.go +++ b/pkg/twitch/scopes.go @@ -2,20 +2,27 @@ package twitch const ( // API Scopes - ScopeChannelManageRedemptions = "channel:manage:redemptions" - ScopeChannelReadRedemptions = "channel:read:redemptions" - ScopeChannelEditCommercial = "channel:edit:commercial" - ScopeChannelManageBroadcast = "channel:manage:broadcast" - ScopeChannelManagePolls = "channel:manage:polls" - ScopeChannelManagePredictions = "channel:manage:predictions" + ScopeChannelEditCommercial = "channel:edit:commercial" + ScopeChannelManageBroadcast = "channel:manage:broadcast" + ScopeChannelManageModerators = "channel:manage:moderators" + ScopeChannelManagePolls = "channel:manage:polls" + ScopeChannelManagePredictions = "channel:manage:predictions" + ScopeChannelManageRaids = "channel:manage:raids" + ScopeChannelManageRedemptions = "channel:manage:redemptions" + ScopeChannelManageVIPS = "channel:manage:vips" + ScopeChannelManageWhispers = "user:manage:whispers" + ScopeChannelReadRedemptions = "channel:read:redemptions" + ScopeModeratorManageAnnoucements = "moderator:manage:announcements" + ScopeModeratorManageBannedUsers = "moderator:manage:banned_users" + ScopeModeratorManageChatMessages = "moderator:manage:chat_messages" + ScopeModeratorManageChatSettings = "moderator:manage:chat_settings" + ScopeUserManageChatColor = "user:manage:chat_color" // Deprecated v5 scope but used in chat ScopeV5ChannelEditor = "channel_editor" // Chat Scopes - ScopeChannelModerate = "channel:moderate" // Perform moderation actions in a channel. The user requesting the scope must be a moderator in the channel. - ScopeChatEdit = "chat:edit" // Send live stream chat and rooms messages. - ScopeChatRead = "chat:read" // View live stream chat and rooms messages. - ScopeWhisperRead = "whispers:read" // View your whisper messages. - ScopeWhisperEdit = "whispers:edit" // Send whisper messages. + ScopeChatEdit = "chat:edit" // Send live stream chat and rooms messages. + ScopeChatRead = "chat:read" // View live stream chat and rooms messages. + ScopeWhisperRead = "whispers:read" // View your whisper messages. ) diff --git a/pkg/twitch/search.go b/pkg/twitch/search.go new file mode 100644 index 0000000..cc9d771 --- /dev/null +++ b/pkg/twitch/search.go @@ -0,0 +1,57 @@ +package twitch + +import ( + "context" + "fmt" + "net/http" + "net/url" + + "github.com/pkg/errors" +) + +type ( + Category struct { + BoxArtURL string `json:"box_art_url"` + ID string `json:"id"` + Name string `json:"name"` + } +) + +func (c *Client) SearchCategories(ctx context.Context, name string) ([]Category, error) { + var out []Category + + params := make(url.Values) + params.Set("query", name) + params.Set("first", "100") + + var resp struct { + Data []Category `json:"data"` + Pagination struct { + Cursor string `json:"cursor"` + } `json:"pagination"` + } + + for { + if err := c.request(clientRequestOpts{ + AuthType: authTypeBearerToken, + Context: ctx, + Method: http.MethodGet, + OKStatus: http.StatusOK, + Out: &resp, + URL: fmt.Sprintf("https://api.twitch.tv/helix/search/categories?%s", params.Encode()), + }); err != nil { + return nil, errors.Wrap(err, "executing request") + } + + out = append(out, resp.Data...) + + if resp.Pagination.Cursor == "" { + break + } + + params.Set("after", resp.Pagination.Cursor) + resp.Pagination.Cursor = "" // Clear from struct as struct is reused + } + + return out, nil +} diff --git a/pkg/twitch/streams.go b/pkg/twitch/streams.go new file mode 100644 index 0000000..6e41d24 --- /dev/null +++ b/pkg/twitch/streams.go @@ -0,0 +1,137 @@ +package twitch + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/pkg/errors" +) + +type ( + StreamInfo struct { + ID string `json:"id"` + UserID string `json:"user_id"` + UserLogin string `json:"user_login"` + UserName string `json:"user_name"` + GameID string `json:"game_id"` + GameName string `json:"game_name"` + Type string `json:"type"` + Title string `json:"title"` + ViewerCount int64 `json:"viewer_count"` + StartedAt time.Time `json:"started_at"` + Language string `json:"language"` + ThumbnailURL string `json:"thumbnail_url"` + TagIds []string `json:"tag_ids"` + IsMature bool `json:"is_mature"` + } +) + +func (c *Client) GetCurrentStreamInfo(username string) (*StreamInfo, error) { + cacheKey := []string{"currentStreamInfo", username} + if si := c.apiCache.Get(cacheKey); si != nil { + return si.(*StreamInfo), nil + } + + id, err := c.GetIDForUsername(username) + if err != nil { + return nil, errors.Wrap(err, "getting ID for username") + } + + var payload struct { + Data []*StreamInfo `json:"data"` + } + + if err := c.request(clientRequestOpts{ + AuthType: authTypeBearerToken, + Context: context.Background(), + Method: http.MethodGet, + OKStatus: http.StatusOK, + Out: &payload, + URL: fmt.Sprintf("https://api.twitch.tv/helix/streams?user_id=%s", id), + }); err != nil { + return nil, errors.Wrap(err, "request channel info") + } + + if l := len(payload.Data); l != 1 { + return nil, errors.Errorf("unexpected number of users returned: %d", l) + } + + // Stream-info can be changed at any moment, cache for a short period of time + c.apiCache.Set(cacheKey, twitchMinCacheTime, payload.Data[0]) + + return payload.Data[0], nil +} + +func (c *Client) GetRecentStreamInfo(username string) (string, string, error) { + cacheKey := []string{"recentStreamInfo", username} + if d := c.apiCache.Get(cacheKey); d != nil { + return d.([2]string)[0], d.([2]string)[1], nil + } + + id, err := c.GetIDForUsername(username) + if err != nil { + return "", "", errors.Wrap(err, "getting ID for username") + } + + var payload struct { + Data []struct { + BroadcasterID string `json:"broadcaster_id"` + GameID string `json:"game_id"` + GameName string `json:"game_name"` + Title string `json:"title"` + } `json:"data"` + } + + if err := c.request(clientRequestOpts{ + AuthType: authTypeBearerToken, + Context: context.Background(), + Method: http.MethodGet, + OKStatus: http.StatusOK, + Out: &payload, + URL: fmt.Sprintf("https://api.twitch.tv/helix/channels?broadcaster_id=%s", id), + }); err != nil { + return "", "", errors.Wrap(err, "request channel info") + } + + if l := len(payload.Data); l != 1 { + return "", "", errors.Errorf("unexpected number of users returned: %d", l) + } + + // Stream-info can be changed at any moment, cache for a short period of time + c.apiCache.Set(cacheKey, twitchMinCacheTime, [2]string{payload.Data[0].GameName, payload.Data[0].Title}) + + return payload.Data[0].GameName, payload.Data[0].Title, nil +} + +func (c *Client) HasLiveStream(username string) (bool, error) { + cacheKey := []string{"hasLiveStream", username} + if d := c.apiCache.Get(cacheKey); d != nil { + return d.(bool), nil + } + + var payload struct { + Data []struct { + ID string `json:"id"` + UserLogin string `json:"user_login"` + Type string `json:"type"` + } `json:"data"` + } + + if err := c.request(clientRequestOpts{ + AuthType: authTypeBearerToken, + Context: context.Background(), + Method: http.MethodGet, + OKStatus: http.StatusOK, + Out: &payload, + URL: fmt.Sprintf("https://api.twitch.tv/helix/streams?user_login=%s", username), + }); err != nil { + return false, errors.Wrap(err, "request stream info") + } + + // Live status might change recently, cache for one minute + c.apiCache.Set(cacheKey, twitchMinCacheTime, len(payload.Data) == 1 && payload.Data[0].Type == "live") + + return len(payload.Data) == 1 && payload.Data[0].Type == "live", nil +} diff --git a/pkg/twitch/twitch.go b/pkg/twitch/twitch.go index 7ce7ecd..4c51223 100644 --- a/pkg/twitch/twitch.go +++ b/pkg/twitch/twitch.go @@ -1,7 +1,6 @@ package twitch import ( - "bytes" "context" "crypto/sha256" "encoding/json" @@ -9,7 +8,6 @@ import ( "io" "net/http" "net/url" - "strconv" "strings" "time" @@ -37,12 +35,6 @@ const ( ) type ( - Category struct { - BoxArtURL string `json:"box_art_url"` - ID string `json:"id"` - Name string `json:"name"` - } - Client struct { clientID string clientSecret string @@ -74,30 +66,6 @@ type ( ExpiresIn int `json:"expires_in"` } - StreamInfo struct { - ID string `json:"id"` - UserID string `json:"user_id"` - UserLogin string `json:"user_login"` - UserName string `json:"user_name"` - GameID string `json:"game_id"` - GameName string `json:"game_name"` - Type string `json:"type"` - Title string `json:"title"` - ViewerCount int64 `json:"viewer_count"` - StartedAt time.Time `json:"started_at"` - Language string `json:"language"` - ThumbnailURL string `json:"thumbnail_url"` - TagIds []string `json:"tag_ids"` - IsMature bool `json:"is_mature"` - } - - User struct { - DisplayName string `json:"display_name"` - ID string `json:"id"` - Login string `json:"login"` - ProfileImageURL string `json:"profile_image_url"` - } - authType uint8 clientRequestOpts struct { @@ -127,101 +95,6 @@ func New(clientID, clientSecret, accessToken, refreshToken string) *Client { func (c *Client) APICache() *APICache { return c.apiCache } -func (c *Client) GetAuthorizedUsername() (string, error) { - var payload struct { - Data []User `json:"data"` - } - - if err := c.request(clientRequestOpts{ - AuthType: authTypeBearerToken, - Context: context.Background(), - Method: http.MethodGet, - OKStatus: http.StatusOK, - Out: &payload, - URL: "https://api.twitch.tv/helix/users", - }); err != nil { - return "", errors.Wrap(err, "request channel info") - } - - if l := len(payload.Data); l != 1 { - return "", errors.Errorf("unexpected number of users returned: %d", l) - } - - return payload.Data[0].Login, nil -} - -func (c *Client) GetDisplayNameForUser(username string) (string, error) { - cacheKey := []string{"displayNameForUsername", username} - if d := c.apiCache.Get(cacheKey); d != nil { - return d.(string), nil - } - - var payload struct { - Data []User `json:"data"` - } - - if err := c.request(clientRequestOpts{ - AuthType: authTypeAppAccessToken, - Context: context.Background(), - Method: http.MethodGet, - Out: &payload, - URL: fmt.Sprintf("https://api.twitch.tv/helix/users?login=%s", username), - }); err != nil { - return "", errors.Wrap(err, "request channel info") - } - - if l := len(payload.Data); l != 1 { - return "", errors.Errorf("unexpected number of users returned: %d", l) - } - - // The DisplayName for an username will not change (often), cache for a decent time - c.apiCache.Set(cacheKey, time.Hour, payload.Data[0].DisplayName) - - return payload.Data[0].DisplayName, nil -} - -func (c *Client) GetFollowDate(from, to string) (time.Time, error) { - cacheKey := []string{"followDate", from, to} - if d := c.apiCache.Get(cacheKey); d != nil { - return d.(time.Time), nil - } - - fromID, err := c.GetIDForUsername(from) - if err != nil { - return time.Time{}, errors.Wrap(err, "getting id for 'from' user") - } - toID, err := c.GetIDForUsername(to) - if err != nil { - return time.Time{}, errors.Wrap(err, "getting id for 'to' user") - } - - var payload struct { - Data []struct { - FollowedAt time.Time `json:"followed_at"` - } `json:"data"` - } - - if err := c.request(clientRequestOpts{ - AuthType: authTypeAppAccessToken, - Context: context.Background(), - Method: http.MethodGet, - OKStatus: http.StatusOK, - Out: &payload, - URL: fmt.Sprintf("https://api.twitch.tv/helix/users/follows?to_id=%s&from_id=%s", toID, fromID), - }); err != nil { - return time.Time{}, errors.Wrap(err, "request follow info") - } - - if l := len(payload.Data); l != 1 { - return time.Time{}, errors.Errorf("unexpected number of records returned: %d", l) - } - - // Follow date will not change that often, cache for a long time - c.apiCache.Set(cacheKey, timeDay, payload.Data[0].FollowedAt) - - return payload.Data[0].FollowedAt, nil -} - func (c *Client) GetToken() (string, error) { if err := c.ValidateToken(context.Background(), false); err != nil { if err = c.RefreshToken(); err != nil { @@ -234,299 +107,6 @@ func (c *Client) GetToken() (string, error) { return c.accessToken, nil } -func (c *Client) GetUserInformation(user string) (*User, error) { - var ( - out User - param = "login" - payload struct { - Data []User `json:"data"` - } - ) - - cacheKey := []string{"userInformation", user} - if d := c.apiCache.Get(cacheKey); d != nil { - out = d.(User) - return &out, nil - } - - if _, err := strconv.ParseInt(user, 10, 64); err == nil { - param = "id" - } - - if err := c.request(clientRequestOpts{ - AuthType: authTypeAppAccessToken, - Context: context.Background(), - Method: http.MethodGet, - OKStatus: http.StatusOK, - Out: &payload, - URL: fmt.Sprintf("https://api.twitch.tv/helix/users?%s=%s", param, user), - }); err != nil { - return nil, errors.Wrap(err, "request user info") - } - - if l := len(payload.Data); l != 1 { - return nil, errors.Errorf("unexpected number of records returned: %d", l) - } - - // Follow date will not change that often, cache for a long time - c.apiCache.Set(cacheKey, timeDay, payload.Data[0]) - out = payload.Data[0] - - return &out, nil -} - -func (c *Client) SearchCategories(ctx context.Context, name string) ([]Category, error) { - var out []Category - - params := make(url.Values) - params.Set("query", name) - params.Set("first", "100") - - var resp struct { - Data []Category `json:"data"` - Pagination struct { - Cursor string `json:"cursor"` - } `json:"pagination"` - } - - for { - if err := c.request(clientRequestOpts{ - AuthType: authTypeBearerToken, - Context: ctx, - Method: http.MethodGet, - OKStatus: http.StatusOK, - Out: &resp, - URL: fmt.Sprintf("https://api.twitch.tv/helix/search/categories?%s", params.Encode()), - }); err != nil { - return nil, errors.Wrap(err, "executing request") - } - - out = append(out, resp.Data...) - - if resp.Pagination.Cursor == "" { - break - } - - params.Set("after", resp.Pagination.Cursor) - resp.Pagination.Cursor = "" // Clear from struct as struct is reused - } - - return out, nil -} - -func (c *Client) HasLiveStream(username string) (bool, error) { - cacheKey := []string{"hasLiveStream", username} - if d := c.apiCache.Get(cacheKey); d != nil { - return d.(bool), nil - } - - var payload struct { - Data []struct { - ID string `json:"id"` - UserLogin string `json:"user_login"` - Type string `json:"type"` - } `json:"data"` - } - - if err := c.request(clientRequestOpts{ - AuthType: authTypeBearerToken, - Context: context.Background(), - Method: http.MethodGet, - OKStatus: http.StatusOK, - Out: &payload, - URL: fmt.Sprintf("https://api.twitch.tv/helix/streams?user_login=%s", username), - }); err != nil { - return false, errors.Wrap(err, "request stream info") - } - - // Live status might change recently, cache for one minute - c.apiCache.Set(cacheKey, twitchMinCacheTime, len(payload.Data) == 1 && payload.Data[0].Type == "live") - - return len(payload.Data) == 1 && payload.Data[0].Type == "live", nil -} - -func (c *Client) GetCurrentStreamInfo(username string) (*StreamInfo, error) { - cacheKey := []string{"currentStreamInfo", username} - if si := c.apiCache.Get(cacheKey); si != nil { - return si.(*StreamInfo), nil - } - - id, err := c.GetIDForUsername(username) - if err != nil { - return nil, errors.Wrap(err, "getting ID for username") - } - - var payload struct { - Data []*StreamInfo `json:"data"` - } - - if err := c.request(clientRequestOpts{ - AuthType: authTypeBearerToken, - Context: context.Background(), - Method: http.MethodGet, - OKStatus: http.StatusOK, - Out: &payload, - URL: fmt.Sprintf("https://api.twitch.tv/helix/streams?user_id=%s", id), - }); err != nil { - return nil, errors.Wrap(err, "request channel info") - } - - if l := len(payload.Data); l != 1 { - return nil, errors.Errorf("unexpected number of users returned: %d", l) - } - - // Stream-info can be changed at any moment, cache for a short period of time - c.apiCache.Set(cacheKey, twitchMinCacheTime, payload.Data[0]) - - return payload.Data[0], nil -} - -func (c *Client) GetIDForUsername(username string) (string, error) { - cacheKey := []string{"idForUsername", username} - if d := c.apiCache.Get(cacheKey); d != nil { - return d.(string), nil - } - - var payload struct { - Data []User `json:"data"` - } - - if err := c.request(clientRequestOpts{ - AuthType: authTypeAppAccessToken, - Context: context.Background(), - Method: http.MethodGet, - OKStatus: http.StatusOK, - Out: &payload, - URL: fmt.Sprintf("https://api.twitch.tv/helix/users?login=%s", username), - }); err != nil { - return "", errors.Wrap(err, "request channel info") - } - - if l := len(payload.Data); l != 1 { - return "", errors.Errorf("unexpected number of users returned: %d", l) - } - - // The ID for an username will not change (often), cache for a long time - c.apiCache.Set(cacheKey, timeDay, payload.Data[0].ID) - - return payload.Data[0].ID, nil -} - -func (c *Client) GetRecentStreamInfo(username string) (string, string, error) { - cacheKey := []string{"recentStreamInfo", username} - if d := c.apiCache.Get(cacheKey); d != nil { - return d.([2]string)[0], d.([2]string)[1], nil - } - - id, err := c.GetIDForUsername(username) - if err != nil { - return "", "", errors.Wrap(err, "getting ID for username") - } - - var payload struct { - Data []struct { - BroadcasterID string `json:"broadcaster_id"` - GameID string `json:"game_id"` - GameName string `json:"game_name"` - Title string `json:"title"` - } `json:"data"` - } - - if err := c.request(clientRequestOpts{ - AuthType: authTypeBearerToken, - Context: context.Background(), - Method: http.MethodGet, - OKStatus: http.StatusOK, - Out: &payload, - URL: fmt.Sprintf("https://api.twitch.tv/helix/channels?broadcaster_id=%s", id), - }); err != nil { - return "", "", errors.Wrap(err, "request channel info") - } - - if l := len(payload.Data); l != 1 { - return "", "", errors.Errorf("unexpected number of users returned: %d", l) - } - - // Stream-info can be changed at any moment, cache for a short period of time - c.apiCache.Set(cacheKey, twitchMinCacheTime, [2]string{payload.Data[0].GameName, payload.Data[0].Title}) - - return payload.Data[0].GameName, payload.Data[0].Title, nil -} - -func (c *Client) ModifyChannelInformation(ctx context.Context, broadcasterName string, game, title *string) error { - if game == nil && title == nil { - return errors.New("netiher game nor title provided") - } - - broadcaster, err := c.GetIDForUsername(broadcasterName) - if err != nil { - return errors.Wrap(err, "getting ID for broadcaster name") - } - - data := struct { - GameID *string `json:"game_id,omitempty"` - Title *string `json:"title,omitempty"` - }{ - Title: title, - } - - switch { - case game == nil: - // We don't set the GameID - - case (*game)[0] == '@': - // We got an ID and don't need to resolve - gameID := (*game)[1:] - data.GameID = &gameID - - default: - categories, err := c.SearchCategories(ctx, *game) - if err != nil { - return errors.Wrap(err, "searching for game") - } - - switch len(categories) { - case 0: - return errors.New("no matching game found") - - case 1: - data.GameID = &categories[0].ID - - default: - // Multiple matches: Search for exact one - for _, c := range categories { - if c.Name == *game { - gid := c.ID - data.GameID = &gid - break - } - } - - if data.GameID == nil { - // No exact match found: This is an error - return errors.New("no exact game match found") - } - } - } - - body := new(bytes.Buffer) - if err := json.NewEncoder(body).Encode(data); err != nil { - return errors.Wrap(err, "encoding payload") - } - - return errors.Wrap( - c.request(clientRequestOpts{ - AuthType: authTypeBearerToken, - Body: body, - Context: ctx, - Method: http.MethodPatch, - OKStatus: http.StatusNoContent, - URL: fmt.Sprintf("https://api.twitch.tv/helix/channels?broadcaster_id=%s", broadcaster), - }), - "executing request", - ) -} - func (c *Client) RefreshToken() error { if c.refreshToken == "" { return errors.New("no refresh token set") @@ -630,85 +210,6 @@ func (c *Client) ValidateToken(ctx context.Context, force bool) error { return nil } -func (c *Client) createEventSubSubscription(ctx context.Context, sub eventSubSubscription) (*eventSubSubscription, error) { - var ( - buf = new(bytes.Buffer) - resp struct { - Total int64 `json:"total"` - Data []eventSubSubscription `json:"data"` - Pagination struct { - Cursor string `json:"cursor"` - } `json:"pagination"` - } - ) - - if err := json.NewEncoder(buf).Encode(sub); err != nil { - return nil, errors.Wrap(err, "assemble subscribe payload") - } - - if err := c.request(clientRequestOpts{ - AuthType: authTypeAppAccessToken, - Body: buf, - Context: ctx, - Method: http.MethodPost, - OKStatus: http.StatusAccepted, - Out: &resp, - URL: "https://api.twitch.tv/helix/eventsub/subscriptions", - }); err != nil { - return nil, errors.Wrap(err, "executing request") - } - - return &resp.Data[0], nil -} - -func (c *Client) deleteEventSubSubscription(ctx context.Context, id string) error { - return errors.Wrap(c.request(clientRequestOpts{ - AuthType: authTypeAppAccessToken, - Context: ctx, - Method: http.MethodDelete, - OKStatus: http.StatusNoContent, - URL: fmt.Sprintf("https://api.twitch.tv/helix/eventsub/subscriptions?id=%s", id), - }), "executing request") -} - -func (c *Client) getEventSubSubscriptions(ctx context.Context) ([]eventSubSubscription, error) { - var ( - out []eventSubSubscription - params = make(url.Values) - resp struct { - Total int64 `json:"total"` - Data []eventSubSubscription `json:"data"` - Pagination struct { - Cursor string `json:"cursor"` - } `json:"pagination"` - } - ) - - for { - if err := c.request(clientRequestOpts{ - AuthType: authTypeAppAccessToken, - Context: ctx, - Method: http.MethodGet, - OKStatus: http.StatusOK, - Out: &resp, - URL: fmt.Sprintf("https://api.twitch.tv/helix/eventsub/subscriptions?%s", params.Encode()), - }); err != nil { - return nil, errors.Wrap(err, "executing request") - } - - out = append(out, resp.Data...) - - if resp.Pagination.Cursor == "" { - break - } - - params.Set("after", resp.Pagination.Cursor) - resp.Pagination.Cursor = "" // Clear from struct as struct is reused - } - - return out, nil -} - func (c *Client) getTwitchAppAccessToken() (string, error) { if c.appAccessToken != "" { return c.appAccessToken, nil diff --git a/pkg/twitch/users.go b/pkg/twitch/users.go new file mode 100644 index 0000000..b1dc299 --- /dev/null +++ b/pkg/twitch/users.go @@ -0,0 +1,187 @@ +package twitch + +import ( + "context" + "fmt" + "net/http" + "strconv" + "time" + + "github.com/pkg/errors" +) + +type ( + User struct { + DisplayName string `json:"display_name"` + ID string `json:"id"` + Login string `json:"login"` + ProfileImageURL string `json:"profile_image_url"` + } +) + +func (c *Client) GetAuthorizedUser() (userID string, userName string, err error) { + var payload struct { + Data []User `json:"data"` + } + + if err := c.request(clientRequestOpts{ + AuthType: authTypeBearerToken, + Context: context.Background(), + Method: http.MethodGet, + OKStatus: http.StatusOK, + Out: &payload, + URL: "https://api.twitch.tv/helix/users", + }); err != nil { + return "", "", errors.Wrap(err, "request channel info") + } + + if l := len(payload.Data); l != 1 { + return "", "", errors.Errorf("unexpected number of users returned: %d", l) + } + + return payload.Data[0].ID, payload.Data[0].Login, nil +} + +func (c *Client) GetDisplayNameForUser(username string) (string, error) { + cacheKey := []string{"displayNameForUsername", username} + if d := c.apiCache.Get(cacheKey); d != nil { + return d.(string), nil + } + + var payload struct { + Data []User `json:"data"` + } + + if err := c.request(clientRequestOpts{ + AuthType: authTypeAppAccessToken, + Context: context.Background(), + Method: http.MethodGet, + Out: &payload, + URL: fmt.Sprintf("https://api.twitch.tv/helix/users?login=%s", username), + }); err != nil { + return "", errors.Wrap(err, "request channel info") + } + + if l := len(payload.Data); l != 1 { + return "", errors.Errorf("unexpected number of users returned: %d", l) + } + + // The DisplayName for an username will not change (often), cache for a decent time + c.apiCache.Set(cacheKey, time.Hour, payload.Data[0].DisplayName) + + return payload.Data[0].DisplayName, nil +} + +func (c *Client) GetFollowDate(from, to string) (time.Time, error) { + cacheKey := []string{"followDate", from, to} + if d := c.apiCache.Get(cacheKey); d != nil { + return d.(time.Time), nil + } + + fromID, err := c.GetIDForUsername(from) + if err != nil { + return time.Time{}, errors.Wrap(err, "getting id for 'from' user") + } + toID, err := c.GetIDForUsername(to) + if err != nil { + return time.Time{}, errors.Wrap(err, "getting id for 'to' user") + } + + var payload struct { + Data []struct { + FollowedAt time.Time `json:"followed_at"` + } `json:"data"` + } + + if err := c.request(clientRequestOpts{ + AuthType: authTypeAppAccessToken, + Context: context.Background(), + Method: http.MethodGet, + OKStatus: http.StatusOK, + Out: &payload, + URL: fmt.Sprintf("https://api.twitch.tv/helix/users/follows?to_id=%s&from_id=%s", toID, fromID), + }); err != nil { + return time.Time{}, errors.Wrap(err, "request follow info") + } + + if l := len(payload.Data); l != 1 { + return time.Time{}, errors.Errorf("unexpected number of records returned: %d", l) + } + + // Follow date will not change that often, cache for a long time + c.apiCache.Set(cacheKey, timeDay, payload.Data[0].FollowedAt) + + return payload.Data[0].FollowedAt, nil +} + +func (c *Client) GetIDForUsername(username string) (string, error) { + cacheKey := []string{"idForUsername", username} + if d := c.apiCache.Get(cacheKey); d != nil { + return d.(string), nil + } + + var payload struct { + Data []User `json:"data"` + } + + if err := c.request(clientRequestOpts{ + AuthType: authTypeAppAccessToken, + Context: context.Background(), + Method: http.MethodGet, + OKStatus: http.StatusOK, + Out: &payload, + URL: fmt.Sprintf("https://api.twitch.tv/helix/users?login=%s", username), + }); err != nil { + return "", errors.Wrap(err, "request channel info") + } + + if l := len(payload.Data); l != 1 { + return "", errors.Errorf("unexpected number of users returned: %d", l) + } + + // The ID for an username will not change (often), cache for a long time + c.apiCache.Set(cacheKey, timeDay, payload.Data[0].ID) + + return payload.Data[0].ID, nil +} + +func (c *Client) GetUserInformation(user string) (*User, error) { + var ( + out User + param = "login" + payload struct { + Data []User `json:"data"` + } + ) + + cacheKey := []string{"userInformation", user} + if d := c.apiCache.Get(cacheKey); d != nil { + out = d.(User) + return &out, nil + } + + if _, err := strconv.ParseInt(user, 10, 64); err == nil { + param = "id" + } + + if err := c.request(clientRequestOpts{ + AuthType: authTypeAppAccessToken, + Context: context.Background(), + Method: http.MethodGet, + OKStatus: http.StatusOK, + Out: &payload, + URL: fmt.Sprintf("https://api.twitch.tv/helix/users?%s=%s", param, user), + }); err != nil { + return nil, errors.Wrap(err, "request user info") + } + + if l := len(payload.Data); l != 1 { + return nil, errors.Errorf("unexpected number of records returned: %d", l) + } + + // Follow date will not change that often, cache for a long time + c.apiCache.Set(cacheKey, timeDay, payload.Data[0]) + out = payload.Data[0] + + return &out, nil +} diff --git a/pkg/twitch/whispers.go b/pkg/twitch/whispers.go new file mode 100644 index 0000000..e4c6d2f --- /dev/null +++ b/pkg/twitch/whispers.go @@ -0,0 +1,53 @@ +package twitch + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/pkg/errors" +) + +// SendWhisper sends a whisper from the bot to the specified user. +// +// For details about message limits see the official documentation: +// https://dev.twitch.tv/docs/api/reference#send-whisper +func (c *Client) SendWhisper(toUser, message string) error { + var payload struct { + Message string `json:"message"` + } + + payload.Message = message + + botID, _, err := c.GetAuthorizedUser() + if err != nil { + return errors.Wrap(err, "getting bot user-id") + } + + targetID, err := c.GetIDForUsername(toUser) + if err != nil { + return errors.Wrap(err, "getting target user-id") + } + + body := new(bytes.Buffer) + if err = json.NewEncoder(body).Encode(payload); err != nil { + return errors.Wrap(err, "encoding payload") + } + + return errors.Wrap( + c.request(clientRequestOpts{ + AuthType: authTypeBearerToken, + Context: context.Background(), + Method: http.MethodPost, + OKStatus: http.StatusNoContent, + Body: body, + URL: fmt.Sprintf( + "https://api.twitch.tv/helix/whispers?from_user_id=%s&to_user_id=%s", + botID, targetID, + ), + }), + "executing whisper request", + ) +} diff --git a/plugins/interface.go b/plugins/interface.go index d4f8443..387b414 100644 --- a/plugins/interface.go +++ b/plugins/interface.go @@ -2,6 +2,7 @@ package plugins import ( "github.com/go-irc/irc" + "github.com/pkg/errors" "github.com/robfig/cron/v3" log "github.com/sirupsen/logrus" @@ -41,6 +42,9 @@ type ( MsgFormatter func(tplString string, m *irc.Message, r *Rule, fields *FieldCollection) (string, error) + MsgModificationFunc func(*irc.Message) error + MsgModificationRegistrationFunc func(linePrefix string, modFn MsgModificationFunc) + RawMessageHandlerFunc func(m *irc.Message) error RawMessageHandlerRegisterFunc func(RawMessageHandlerFunc) error @@ -70,6 +74,8 @@ type ( RegisterCron CronRegistrationFunc // RegisterEventHandler is a method to register a handler function receiving ALL events RegisterEventHandler EventHandlerRegisterFunc + // RegisterMessageModFunc is a method to register a handler to modify / react on messages + RegisterMessageModFunc MsgModificationRegistrationFunc // RegisterRawMessageHandler is a method to register an handler to receive ALL messages received RegisterRawMessageHandler RawMessageHandlerRegisterFunc // RegisterTemplateFunction can be used to register a new template functions @@ -102,6 +108,8 @@ type ( ValidateTokenFunc func(token string, modules ...string) error ) +var ErrSkipSendingMessage = errors.New("skip sending message") + func GenericTemplateFunctionGetter(f interface{}) TemplateFuncGetter { return func(*irc.Message, *Rule, *FieldCollection) interface{} { return f } } diff --git a/plugins_core.go b/plugins_core.go index ba05771..3e37719 100644 --- a/plugins_core.go +++ b/plugins_core.go @@ -10,6 +10,7 @@ import ( "github.com/Luzifer/go_helpers/v2/backoff" "github.com/Luzifer/go_helpers/v2/str" + "github.com/Luzifer/twitch-bot/v2/internal/actors/announce" "github.com/Luzifer/twitch-bot/v2/internal/actors/ban" "github.com/Luzifer/twitch-bot/v2/internal/actors/counter" "github.com/Luzifer/twitch-bot/v2/internal/actors/delay" @@ -42,6 +43,7 @@ const ircHandleWaitRetries = 10 var ( corePluginRegistrations = []plugins.RegisterFunc{ // Actors + announce.Register, ban.Register, counter.Register, delay.Register, @@ -129,6 +131,7 @@ func getRegistrationArguments() plugins.RegistrationArguments { RegisterAPIRoute: registerRoute, RegisterCron: cronService.AddFunc, RegisterEventHandler: registerEventHandlers, + RegisterMessageModFunc: registerChatcommand, RegisterRawMessageHandler: registerRawMessageHandler, RegisterTemplateFunction: tplFuncs.Register, SendMessage: sendMessage, @@ -144,7 +147,22 @@ func getRegistrationArguments() plugins.RegistrationArguments { } func sendMessage(m *irc.Message) error { - if err := backoff.NewBackoff().WithMaxIterations(ircHandleWaitRetries).Retry(func() error { + err := handleChatcommandModifications(m) + switch { + case err == nil: + // There was no error, the message should be sent normally + + case errors.Is(err, plugins.ErrSkipSendingMessage): + // One chatcommand handler cancelled sending the message + // (probably because it was handled otherwise) + return nil + + default: + // Something in a chatcommand handler went wrong + return errors.Wrap(err, "handling chat commands") + } + + if err = backoff.NewBackoff().WithMaxIterations(ircHandleWaitRetries).Retry(func() error { if ircHdl == nil { return errors.New("irc handle not available") } diff --git a/scopes.go b/scopes.go index cec3003..e039e69 100644 --- a/scopes.go +++ b/scopes.go @@ -7,13 +7,16 @@ var ( twitch.ScopeChannelEditCommercial, twitch.ScopeChannelManageBroadcast, twitch.ScopeChannelReadRedemptions, + twitch.ScopeChannelManageRaids, } botDefaultScopes = append(channelDefaultScopes, - twitch.ScopeChatRead, twitch.ScopeChatEdit, + twitch.ScopeChatRead, + twitch.ScopeModeratorManageAnnoucements, + twitch.ScopeModeratorManageBannedUsers, + twitch.ScopeModeratorManageChatMessages, + twitch.ScopeModeratorManageChatSettings, twitch.ScopeWhisperRead, - twitch.ScopeWhisperEdit, - twitch.ScopeChannelModerate, ) ) diff --git a/status.go b/status.go index c9f4cde..ab8dd7d 100644 --- a/status.go +++ b/status.go @@ -82,7 +82,7 @@ func handleStatusRequest(w http.ResponseWriter, r *http.Request) { return errors.New("not initialized") } - _, err := twitchClient.GetAuthorizedUsername() + _, _, err := twitchClient.GetAuthorizedUser() return errors.Wrap(err, "fetching username") }, },