[core] Extend API and replace deprecated chat commands (#34)

This commit is contained in:
Knut Ahlers 2022-10-25 18:47:30 +02:00 committed by GitHub
parent 1409a4bd34
commit 064c7432ed
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 1133 additions and 639 deletions

View File

@ -79,7 +79,7 @@ func handleAuthUpdateBotToken(w http.ResponseWriter, r *http.Request) {
return 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 { if err != nil {
http.Error(w, errors.Wrap(err, "getting authorized user").Error(), http.StatusInternalServerError) http.Error(w, errors.Wrap(err, "getting authorized user").Error(), http.StatusInternalServerError)
return return
@ -135,7 +135,7 @@ func handleAuthUpdateChannelGrant(w http.ResponseWriter, r *http.Request) {
return 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 { if err != nil {
http.Error(w, errors.Wrap(err, "getting authorized user").Error(), http.StatusInternalServerError) http.Error(w, errors.Wrap(err, "getting authorized user").Error(), http.StatusInternalServerError)
return return

View File

@ -151,7 +151,7 @@ func (a *autoMessage) Send(c *irc.Client) error {
msg = fmt.Sprintf("\001ACTION %s\001", msg) msg = fmt.Sprintf("\001ACTION %s\001", msg)
} }
if err := c.WriteMessage(&irc.Message{ if err := sendMessage(&irc.Message{
Command: "PRIVMSG", Command: "PRIVMSG",
Params: []string{ Params: []string{
fmt.Sprintf("#%s", strings.TrimLeft(a.Channel, "#")), fmt.Sprintf("#%s", strings.TrimLeft(a.Channel, "#")),

View File

@ -17,7 +17,7 @@ func getAuthorizationFromRequest(r *http.Request) (string, *twitch.Client, error
tc := twitch.New(cfg.TwitchClient, cfg.TwitchClientSecret, token, "") 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") return user, tc, errors.Wrap(err, "getting authorized user")
} }

47
chatcommands.go Normal file
View File

@ -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
}

View File

@ -196,7 +196,7 @@ func configEditorHandleGeneralGet(w http.ResponseWriter, r *http.Request) {
} }
var uName *string var uName *string
if n, err := twitchClient.GetAuthorizedUsername(); err == nil { if _, n, err := twitchClient.GetAuthorizedUser(); err == nil {
uName = &n uName = &n
} }

View File

@ -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
}

View File

@ -2,25 +2,28 @@ package ban
import ( import (
"net/http" "net/http"
"strings" "regexp"
"github.com/go-irc/irc" "github.com/go-irc/irc"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/Luzifer/twitch-bot/v2/pkg/twitch"
"github.com/Luzifer/twitch-bot/v2/plugins" "github.com/Luzifer/twitch-bot/v2/plugins"
) )
const actorName = "ban" const actorName = "ban"
var ( var (
botTwitchClient *twitch.Client
formatMessage plugins.MsgFormatter formatMessage plugins.MsgFormatter
send plugins.SendMessageFunc
banChatcommandRegex = regexp.MustCompile(`^/ban +([^\s]+) +(.+)$`)
) )
func Register(args plugins.RegistrationArguments) error { func Register(args plugins.RegistrationArguments) error {
botTwitchClient = args.GetTwitchClient()
formatMessage = args.FormatMessage formatMessage = args.FormatMessage
send = args.SendMessage
args.RegisterActor(actorName, func() plugins.Actor { return &actor{} }) 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", Description: "Reason why the user was banned",
Key: "reason", Key: "reason",
Name: "Reason", Name: "Reason",
Optional: true, Optional: false,
SupportTemplate: true, SupportTemplate: true,
Type: plugins.ActionDocumentationFieldTypeString, Type: plugins.ActionDocumentationFieldTypeString,
}, },
@ -53,7 +56,7 @@ func Register(args plugins.RegistrationArguments) error {
{ {
Description: "Reason to add to the ban", Description: "Reason to add to the ban",
Name: "reason", Name: "reason",
Required: false, Required: true,
Type: "string", Type: "string",
}, },
}, },
@ -71,6 +74,8 @@ func Register(args plugins.RegistrationArguments) error {
}, },
}) })
args.RegisterMessageModFunc("/ban", handleChatCommand)
return nil 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 false, errors.Wrap(err, "executing reason template")
} }
return a.execBan( return false, errors.Wrap(
botTwitchClient.BanUser(
plugins.DeriveChannel(m, eventData), plugins.DeriveChannel(m, eventData),
plugins.DeriveUser(m, eventData), plugins.DeriveUser(m, eventData),
0,
reason, reason,
) ),
} "executing ban",
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",
) )
} }
@ -126,10 +113,25 @@ func handleAPIBan(w http.ResponseWriter, r *http.Request) {
reason = r.FormValue("reason") 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) http.Error(w, errors.Wrap(err, "issuing ban").Error(), http.StatusInternalServerError)
return return
} }
w.WriteHeader(http.StatusNoContent) 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
}

View File

@ -1,17 +1,20 @@
package deleteactor package deleteactor
import ( import (
"fmt"
"github.com/go-irc/irc" "github.com/go-irc/irc"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/Luzifer/twitch-bot/v2/pkg/twitch"
"github.com/Luzifer/twitch-bot/v2/plugins" "github.com/Luzifer/twitch-bot/v2/plugins"
) )
const actorName = "delete" const actorName = "delete"
var botTwitchClient *twitch.Client
func Register(args plugins.RegistrationArguments) error { func Register(args plugins.RegistrationArguments) error {
botTwitchClient = args.GetTwitchClient()
args.RegisterActor(actorName, func() plugins.Actor { return &actor{} }) args.RegisterActor(actorName, func() plugins.Actor { return &actor{} })
args.RegisterActorDocumentation(plugins.ActionDocumentation{ 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( return false, errors.Wrap(
c.WriteMessage(&irc.Message{ botTwitchClient.DeleteMessage(
Command: "PRIVMSG", plugins.DeriveChannel(m, eventData),
Params: []string{ msgID,
m.Params[0], ),
fmt.Sprintf("/delete %s", msgID), "deleting message",
},
}),
"sending delete",
) )
} }

View File

@ -19,10 +19,14 @@ const (
httpTimeout = 5 * time.Second httpTimeout = 5 * time.Second
) )
var formatMessage plugins.MsgFormatter var (
formatMessage plugins.MsgFormatter
send plugins.SendMessageFunc
)
func Register(args plugins.RegistrationArguments) error { func Register(args plugins.RegistrationArguments) error {
formatMessage = args.FormatMessage formatMessage = args.FormatMessage
send = args.SendMessage
args.RegisterActor(actorName, func() plugins.Actor { return &actor{} }) 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) scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() { for scanner.Scan() {
if err = c.WriteMessage(&irc.Message{ if err = send(&irc.Message{
Command: "PRIVMSG", Command: "PRIVMSG",
Params: []string{ Params: []string{
plugins.DeriveChannel(m, eventData), plugins.DeriveChannel(m, eventData),

View File

@ -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",
)
}
}

View File

@ -1,7 +1,6 @@
package nuke package nuke
import ( import (
"fmt"
"regexp" "regexp"
"strings" "strings"
"sync" "sync"
@ -22,6 +21,7 @@ const (
) )
var ( var (
botTwitchClient *twitch.Client
formatMessage plugins.MsgFormatter formatMessage plugins.MsgFormatter
messageStore = map[string][]*storedMessage{} messageStore = map[string][]*storedMessage{}
@ -32,6 +32,7 @@ var (
) )
func Register(args plugins.RegistrationArguments) error { func Register(args plugins.RegistrationArguments) error {
botTwitchClient = args.GetTwitchClient()
formatMessage = args.FormatMessage formatMessage = args.FormatMessage
args.RegisterActor(actorName, func() plugins.Actor { return &actor{} }) 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) scanTime := time.Now().Add(-scan)
var action string var (
action actionFn
actionName string
)
rawAction, err := formatMessage(attrs.MustString("action", ptrStringDelete), m, r, eventData) rawAction, err := formatMessage(attrs.MustString("action", ptrStringDelete), m, r, eventData)
if err != nil { if err != nil {
return false, errors.Wrap(err, "formatting action") return false, errors.Wrap(err, "formatting action")
} }
switch rawAction { switch rawAction {
case "delete": case "delete":
action = "/delete $msgid" action = actionDelete
actionName = "delete $msgid"
case "ban": case "ban":
action = `/ban $user Nuke issued for "$match"` action = actionBan
actionName = "ban $user"
default: default:
to, err := time.ParseDuration(rawAction) to, err := time.ParseDuration(rawAction)
if err != nil { if err != nil {
return false, errors.Wrap(err, "parsing action duration") 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) 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( enforcement := strings.NewReplacer(
"$match", rawMatch,
"$msgid", string(stMsg.Msg.Tags["id"]), "$msgid", string(stMsg.Msg.Tags["id"]),
"$user", plugins.DeriveUser(stMsg.Msg, nil), "$user", plugins.DeriveUser(stMsg.Msg, nil),
).Replace(action) ).Replace(actionName)
if str.StringInSlice(enforcement, executedEnforcement) { if str.StringInSlice(enforcement, executedEnforcement) {
continue continue
} }
if err = c.WriteMessage(&irc.Message{ if err = action(channel, rawMatch, string(stMsg.Msg.Tags["id"]), plugins.DeriveUser(stMsg.Msg, nil)); err != nil {
Command: "PRIVMSG", return false, errors.Wrap(err, "executing action")
Params: []string{
channel,
enforcement,
},
}); err != nil {
return false, errors.Wrap(err, "sending action")
} }
executedEnforcement = append(executedEnforcement, enforcement) executedEnforcement = append(executedEnforcement, enforcement)

View File

@ -2,7 +2,6 @@ package punish
import ( import (
"math" "math"
"strconv"
"strings" "strings"
"time" "time"
@ -10,6 +9,7 @@ import (
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/Luzifer/twitch-bot/v2/pkg/database" "github.com/Luzifer/twitch-bot/v2/pkg/database"
"github.com/Luzifer/twitch-bot/v2/pkg/twitch"
"github.com/Luzifer/twitch-bot/v2/plugins" "github.com/Luzifer/twitch-bot/v2/plugins"
) )
@ -22,6 +22,7 @@ const (
) )
var ( var (
botTwitchClient *twitch.Client
db database.Connector db database.Connector
formatMessage plugins.MsgFormatter formatMessage plugins.MsgFormatter
ptrDefaultCooldown = func(v time.Duration) *time.Duration { return &v }(oneWeek) 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") return errors.Wrap(err, "applying schema migration")
} }
botTwitchClient = args.GetTwitchClient()
formatMessage = args.FormatMessage formatMessage = args.FormatMessage
args.RegisterActor(actorNamePunish, func() plugins.Actor { return &actorPunish{} }) 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))) nLvl := int(math.Min(float64(len(levels)-1), float64(lvl.LastLevel+1)))
var cmd []string
switch lt := levels[nLvl]; lt { switch lt := levels[nLvl]; lt {
case "ban": case "ban":
cmd = []string{"/ban", strings.TrimLeft(user, "@")} if err = botTwitchClient.BanUser(
if reason != "" { plugins.DeriveChannel(m, eventData),
cmd = append(cmd, reason) strings.TrimLeft(user, "@"),
0,
reason,
); err != nil {
return false, errors.Wrap(err, "executing user ban")
} }
case "delete": 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") 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: default:
to, err := time.ParseDuration(lt) 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") return false, errors.Wrap(err, "parsing punishment level")
} }
cmd = []string{"/timeout", strings.TrimLeft(user, "@"), strconv.FormatInt(int64(to/time.Second), 10)} if err = botTwitchClient.BanUser(
if reason != "" {
cmd = append(cmd, reason)
}
}
if err := c.WriteMessage(&irc.Message{
Command: "PRIVMSG",
Params: []string{
plugins.DeriveChannel(m, eventData), plugins.DeriveChannel(m, eventData),
strings.Join(cmd, " "), strings.TrimLeft(user, "@"),
}, to,
}); err != nil { reason,
return false, errors.Wrap(err, "sending command") ); err != nil {
return false, errors.Wrap(err, "executing user ban")
}
} }
lvl.Cooldown = cooldown lvl.Cooldown = cooldown

View File

@ -18,6 +18,7 @@ const (
var ( var (
db database.Connector db database.Connector
formatMessage plugins.MsgFormatter formatMessage plugins.MsgFormatter
send plugins.SendMessageFunc
ptrStringEmpty = func(v string) *string { return &v }("") ptrStringEmpty = func(v string) *string { return &v }("")
ptrStringOutFormat = func(v string) *string { return &v }("Quote #{{ .index }}: {{ .quote }}") ptrStringOutFormat = func(v string) *string { return &v }("Quote #{{ .index }}: {{ .quote }}")
@ -31,6 +32,7 @@ func Register(args plugins.RegistrationArguments) error {
} }
formatMessage = args.FormatMessage formatMessage = args.FormatMessage
send = args.SendMessage
args.RegisterActor(actorName, func() plugins.Actor { return &actor{} }) 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( return false, errors.Wrap(
c.WriteMessage(&irc.Message{ send(&irc.Message{
Command: "PRIVMSG", Command: "PRIVMSG",
Params: []string{ Params: []string{
plugins.DeriveChannel(m, eventData), plugins.DeriveChannel(m, eventData),

View File

@ -9,10 +9,14 @@ import (
const actorName = "raw" const actorName = "raw"
var formatMessage plugins.MsgFormatter var (
formatMessage plugins.MsgFormatter
send plugins.SendMessageFunc
)
func Register(args plugins.RegistrationArguments) error { func Register(args plugins.RegistrationArguments) error {
formatMessage = args.FormatMessage formatMessage = args.FormatMessage
send = args.SendMessage
args.RegisterActor(actorName, func() plugins.Actor { return &actor{} }) 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( return false, errors.Wrap(
c.WriteMessage(msg), send(msg),
"sending raw message", "sending raw message",
) )
} }

View File

@ -15,12 +15,14 @@ const actorName = "respond"
var ( var (
formatMessage plugins.MsgFormatter formatMessage plugins.MsgFormatter
send plugins.SendMessageFunc
ptrBoolFalse = func(v bool) *bool { return &v }(false) ptrBoolFalse = func(v bool) *bool { return &v }(false)
) )
func Register(args plugins.RegistrationArguments) error { func Register(args plugins.RegistrationArguments) error {
formatMessage = args.FormatMessage formatMessage = args.FormatMessage
send = args.SendMessage
args.RegisterActor(actorName, func() plugins.Actor { return &actor{} }) 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( return false, errors.Wrap(
c.WriteMessage(ircMessage), send(ircMessage),
"sending response", "sending response",
) )
} }

View File

@ -1,24 +1,29 @@
package timeout package timeout
import ( import (
"regexp"
"strconv" "strconv"
"strings"
"time" "time"
"github.com/go-irc/irc" "github.com/go-irc/irc"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/Luzifer/twitch-bot/v2/pkg/twitch"
"github.com/Luzifer/twitch-bot/v2/plugins" "github.com/Luzifer/twitch-bot/v2/plugins"
) )
const actorName = "timeout" const actorName = "timeout"
var ( var (
botTwitchClient *twitch.Client
formatMessage plugins.MsgFormatter formatMessage plugins.MsgFormatter
ptrStringEmpty = func(v string) *string { return &v }("") ptrStringEmpty = func(v string) *string { return &v }("")
timeoutChatcommandRegex = regexp.MustCompile(`^/timeout +([^\s]+) +([0-9]+) +(.+)$`)
) )
func Register(args plugins.RegistrationArguments) error { func Register(args plugins.RegistrationArguments) error {
botTwitchClient = args.GetTwitchClient()
formatMessage = args.FormatMessage formatMessage = args.FormatMessage
args.RegisterActor(actorName, func() plugins.Actor { return &actor{} }) 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", Description: "Reason why the user was timed out",
Key: "reason", Key: "reason",
Name: "Reason", Name: "Reason",
Optional: true, Optional: false,
SupportTemplate: true, SupportTemplate: true,
Type: plugins.ActionDocumentationFieldTypeString, Type: plugins.ActionDocumentationFieldTypeString,
}, },
}, },
}) })
args.RegisterMessageModFunc("/timeout", handleChatCommand)
return nil return nil
} }
type actor struct{} 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) { 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) reason, err := formatMessage(attrs.MustString("reason", ptrStringEmpty), m, r, eventData)
if err != nil { if err != nil {
return false, errors.Wrap(err, "executing reason template") return false, errors.Wrap(err, "executing reason template")
} }
if reason != "" {
cmd = append(cmd, reason)
}
return false, errors.Wrap( return false, errors.Wrap(
c.WriteMessage(&irc.Message{ botTwitchClient.BanUser(
Command: "PRIVMSG",
Params: []string{
plugins.DeriveChannel(m, eventData), plugins.DeriveChannel(m, eventData),
strings.Join(cmd, " "), plugins.DeriveUser(m, eventData),
}, attrs.MustDuration("duration", nil),
}), reason,
"sending timeout", ),
"executing timeout",
) )
} }
@ -93,3 +89,23 @@ func (a actor) Validate(attrs *plugins.FieldCollection) (err error) {
return nil 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
}

View File

@ -1,19 +1,22 @@
package whisper package whisper
import ( import (
"fmt"
"github.com/go-irc/irc" "github.com/go-irc/irc"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/Luzifer/twitch-bot/v2/pkg/twitch"
"github.com/Luzifer/twitch-bot/v2/plugins" "github.com/Luzifer/twitch-bot/v2/plugins"
) )
const actorName = "whisper" const actorName = "whisper"
var formatMessage plugins.MsgFormatter var (
botTwitchClient *twitch.Client
formatMessage plugins.MsgFormatter
)
func Register(args plugins.RegistrationArguments) error { func Register(args plugins.RegistrationArguments) error {
botTwitchClient = args.GetTwitchClient()
formatMessage = args.FormatMessage formatMessage = args.FormatMessage
args.RegisterActor(actorName, func() plugins.Actor { return &actor{} }) 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") return false, errors.Wrap(err, "preparing whisper message")
} }
channel := "#tmijs" // As a fallback, copied from tmi.js
return false, errors.Wrap( return false, errors.Wrap(
c.WriteMessage(&irc.Message{ botTwitchClient.SendWhisper(to, msg),
Command: "PRIVMSG",
Params: []string{
channel,
fmt.Sprintf("/w %s %s", to, msg),
},
}),
"sending whisper", "sending whisper",
) )
} }

2
irc.go
View File

@ -55,7 +55,7 @@ type ircHandler struct {
func newIRCHandler() (*ircHandler, error) { func newIRCHandler() (*ircHandler, error) {
h := new(ircHandler) h := new(ircHandler)
username, err := twitchClient.GetAuthorizedUsername() _, username, err := twitchClient.GetAuthorizedUser()
if err != nil { if err != nil {
return nil, errors.Wrap(err, "fetching username") return nil, errors.Wrap(err, "fetching username")
} }

85
pkg/twitch/channels.go Normal file
View File

@ -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",
)
}

55
pkg/twitch/chat.go Normal file
View File

@ -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",
)
}

View File

@ -9,6 +9,7 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/url"
"strings" "strings"
"sync" "sync"
"time" "time"
@ -388,10 +389,89 @@ func (e *EventSubClient) RegisterEventSubHooks(event string, condition EventSubC
return func() { e.unregisterCallback(cacheKey, cbKey) }, nil 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 { func (e *EventSubClient) fullAPIurl() string {
return strings.Join([]string{e.apiURL, e.secretHandle}, "/") 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) { func (e *EventSubClient) unregisterCallback(cacheKey, cbKey string) {
e.subscriptionsLock.RLock() e.subscriptionsLock.RLock()
regSub, ok := e.subscriptions[cacheKey] regSub, ok := e.subscriptions[cacheKey]

140
pkg/twitch/moderation.go Normal file
View File

@ -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",
)
}

View File

@ -2,20 +2,27 @@ package twitch
const ( const (
// API Scopes // API Scopes
ScopeChannelManageRedemptions = "channel:manage:redemptions"
ScopeChannelReadRedemptions = "channel:read:redemptions"
ScopeChannelEditCommercial = "channel:edit:commercial" ScopeChannelEditCommercial = "channel:edit:commercial"
ScopeChannelManageBroadcast = "channel:manage:broadcast" ScopeChannelManageBroadcast = "channel:manage:broadcast"
ScopeChannelManageModerators = "channel:manage:moderators"
ScopeChannelManagePolls = "channel:manage:polls" ScopeChannelManagePolls = "channel:manage:polls"
ScopeChannelManagePredictions = "channel:manage:predictions" 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 // Deprecated v5 scope but used in chat
ScopeV5ChannelEditor = "channel_editor" ScopeV5ChannelEditor = "channel_editor"
// Chat Scopes // 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. ScopeChatEdit = "chat:edit" // Send live stream chat and rooms messages.
ScopeChatRead = "chat:read" // View live stream chat and rooms messages. ScopeChatRead = "chat:read" // View live stream chat and rooms messages.
ScopeWhisperRead = "whispers:read" // View your whisper messages. ScopeWhisperRead = "whispers:read" // View your whisper messages.
ScopeWhisperEdit = "whispers:edit" // Send whisper messages.
) )

57
pkg/twitch/search.go Normal file
View File

@ -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
}

137
pkg/twitch/streams.go Normal file
View File

@ -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
}

View File

@ -1,7 +1,6 @@
package twitch package twitch
import ( import (
"bytes"
"context" "context"
"crypto/sha256" "crypto/sha256"
"encoding/json" "encoding/json"
@ -9,7 +8,6 @@ import (
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
"strconv"
"strings" "strings"
"time" "time"
@ -37,12 +35,6 @@ const (
) )
type ( type (
Category struct {
BoxArtURL string `json:"box_art_url"`
ID string `json:"id"`
Name string `json:"name"`
}
Client struct { Client struct {
clientID string clientID string
clientSecret string clientSecret string
@ -74,30 +66,6 @@ type (
ExpiresIn int `json:"expires_in"` 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 authType uint8
clientRequestOpts struct { 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) 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) { func (c *Client) GetToken() (string, error) {
if err := c.ValidateToken(context.Background(), false); err != nil { if err := c.ValidateToken(context.Background(), false); err != nil {
if err = c.RefreshToken(); err != nil { if err = c.RefreshToken(); err != nil {
@ -234,299 +107,6 @@ func (c *Client) GetToken() (string, error) {
return c.accessToken, nil 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 { func (c *Client) RefreshToken() error {
if c.refreshToken == "" { if c.refreshToken == "" {
return errors.New("no refresh token set") return errors.New("no refresh token set")
@ -630,85 +210,6 @@ func (c *Client) ValidateToken(ctx context.Context, force bool) error {
return nil 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) { func (c *Client) getTwitchAppAccessToken() (string, error) {
if c.appAccessToken != "" { if c.appAccessToken != "" {
return c.appAccessToken, nil return c.appAccessToken, nil

187
pkg/twitch/users.go Normal file
View File

@ -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
}

53
pkg/twitch/whispers.go Normal file
View File

@ -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",
)
}

View File

@ -2,6 +2,7 @@ package plugins
import ( import (
"github.com/go-irc/irc" "github.com/go-irc/irc"
"github.com/pkg/errors"
"github.com/robfig/cron/v3" "github.com/robfig/cron/v3"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
@ -41,6 +42,9 @@ type (
MsgFormatter func(tplString string, m *irc.Message, r *Rule, fields *FieldCollection) (string, error) 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 RawMessageHandlerFunc func(m *irc.Message) error
RawMessageHandlerRegisterFunc func(RawMessageHandlerFunc) error RawMessageHandlerRegisterFunc func(RawMessageHandlerFunc) error
@ -70,6 +74,8 @@ type (
RegisterCron CronRegistrationFunc RegisterCron CronRegistrationFunc
// RegisterEventHandler is a method to register a handler function receiving ALL events // RegisterEventHandler is a method to register a handler function receiving ALL events
RegisterEventHandler EventHandlerRegisterFunc 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 is a method to register an handler to receive ALL messages received
RegisterRawMessageHandler RawMessageHandlerRegisterFunc RegisterRawMessageHandler RawMessageHandlerRegisterFunc
// RegisterTemplateFunction can be used to register a new template functions // RegisterTemplateFunction can be used to register a new template functions
@ -102,6 +108,8 @@ type (
ValidateTokenFunc func(token string, modules ...string) error ValidateTokenFunc func(token string, modules ...string) error
) )
var ErrSkipSendingMessage = errors.New("skip sending message")
func GenericTemplateFunctionGetter(f interface{}) TemplateFuncGetter { func GenericTemplateFunctionGetter(f interface{}) TemplateFuncGetter {
return func(*irc.Message, *Rule, *FieldCollection) interface{} { return f } return func(*irc.Message, *Rule, *FieldCollection) interface{} { return f }
} }

View File

@ -10,6 +10,7 @@ import (
"github.com/Luzifer/go_helpers/v2/backoff" "github.com/Luzifer/go_helpers/v2/backoff"
"github.com/Luzifer/go_helpers/v2/str" "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/ban"
"github.com/Luzifer/twitch-bot/v2/internal/actors/counter" "github.com/Luzifer/twitch-bot/v2/internal/actors/counter"
"github.com/Luzifer/twitch-bot/v2/internal/actors/delay" "github.com/Luzifer/twitch-bot/v2/internal/actors/delay"
@ -42,6 +43,7 @@ const ircHandleWaitRetries = 10
var ( var (
corePluginRegistrations = []plugins.RegisterFunc{ corePluginRegistrations = []plugins.RegisterFunc{
// Actors // Actors
announce.Register,
ban.Register, ban.Register,
counter.Register, counter.Register,
delay.Register, delay.Register,
@ -129,6 +131,7 @@ func getRegistrationArguments() plugins.RegistrationArguments {
RegisterAPIRoute: registerRoute, RegisterAPIRoute: registerRoute,
RegisterCron: cronService.AddFunc, RegisterCron: cronService.AddFunc,
RegisterEventHandler: registerEventHandlers, RegisterEventHandler: registerEventHandlers,
RegisterMessageModFunc: registerChatcommand,
RegisterRawMessageHandler: registerRawMessageHandler, RegisterRawMessageHandler: registerRawMessageHandler,
RegisterTemplateFunction: tplFuncs.Register, RegisterTemplateFunction: tplFuncs.Register,
SendMessage: sendMessage, SendMessage: sendMessage,
@ -144,7 +147,22 @@ func getRegistrationArguments() plugins.RegistrationArguments {
} }
func sendMessage(m *irc.Message) error { 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 { if ircHdl == nil {
return errors.New("irc handle not available") return errors.New("irc handle not available")
} }

View File

@ -7,13 +7,16 @@ var (
twitch.ScopeChannelEditCommercial, twitch.ScopeChannelEditCommercial,
twitch.ScopeChannelManageBroadcast, twitch.ScopeChannelManageBroadcast,
twitch.ScopeChannelReadRedemptions, twitch.ScopeChannelReadRedemptions,
twitch.ScopeChannelManageRaids,
} }
botDefaultScopes = append(channelDefaultScopes, botDefaultScopes = append(channelDefaultScopes,
twitch.ScopeChatRead,
twitch.ScopeChatEdit, twitch.ScopeChatEdit,
twitch.ScopeChatRead,
twitch.ScopeModeratorManageAnnoucements,
twitch.ScopeModeratorManageBannedUsers,
twitch.ScopeModeratorManageChatMessages,
twitch.ScopeModeratorManageChatSettings,
twitch.ScopeWhisperRead, twitch.ScopeWhisperRead,
twitch.ScopeWhisperEdit,
twitch.ScopeChannelModerate,
) )
) )

View File

@ -82,7 +82,7 @@ func handleStatusRequest(w http.ResponseWriter, r *http.Request) {
return errors.New("not initialized") return errors.New("not initialized")
} }
_, err := twitchClient.GetAuthorizedUsername() _, _, err := twitchClient.GetAuthorizedUser()
return errors.Wrap(err, "fetching username") return errors.Wrap(err, "fetching username")
}, },
}, },