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

View File

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

View File

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

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
if n, err := twitchClient.GetAuthorizedUsername(); err == nil {
if _, n, err := twitchClient.GetAuthorizedUser(); err == nil {
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 (
"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
}

View File

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

View File

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

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
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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

2
irc.go
View File

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

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"
"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]

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 (
// 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.
)

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

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 (
"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 }
}

View File

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

View File

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

View File

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