mirror of
https://github.com/Luzifer/twitch-bot.git
synced 2024-12-30 00:21:16 +00:00
[core] Extend API and replace deprecated chat commands (#34)
This commit is contained in:
parent
1409a4bd34
commit
064c7432ed
32 changed files with 1133 additions and 639 deletions
4
auth.go
4
auth.go
|
@ -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
|
||||||
|
|
|
@ -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, "#")),
|
||||||
|
|
|
@ -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
47
chatcommands.go
Normal 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
|
||||||
|
}
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
40
internal/actors/announce/actor.go
Normal file
40
internal/actors/announce/actor.go
Normal 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
|
||||||
|
}
|
|
@ -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 (
|
||||||
formatMessage plugins.MsgFormatter
|
botTwitchClient *twitch.Client
|
||||||
send plugins.SendMessageFunc
|
formatMessage plugins.MsgFormatter
|
||||||
|
|
||||||
|
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(
|
|
||||||
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(
|
return false, errors.Wrap(
|
||||||
send(&irc.Message{
|
botTwitchClient.BanUser(
|
||||||
Command: "PRIVMSG",
|
plugins.DeriveChannel(m, eventData),
|
||||||
Params: []string{
|
plugins.DeriveUser(m, eventData),
|
||||||
"#" + strings.TrimLeft(channel, "#"),
|
0,
|
||||||
strings.Join(cmd, " "),
|
reason,
|
||||||
},
|
),
|
||||||
}),
|
"executing ban",
|
||||||
"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
|
||||||
|
}
|
||||||
|
|
|
@ -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",
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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),
|
||||||
|
|
48
internal/actors/nuke/actions.go
Normal file
48
internal/actors/nuke/actions.go
Normal 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",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,7 +1,6 @@
|
||||||
package nuke
|
package nuke
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
@ -22,7 +21,8 @@ const (
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
formatMessage plugins.MsgFormatter
|
botTwitchClient *twitch.Client
|
||||||
|
formatMessage plugins.MsgFormatter
|
||||||
|
|
||||||
messageStore = map[string][]*storedMessage{}
|
messageStore = map[string][]*storedMessage{}
|
||||||
messageStoreLock sync.RWMutex
|
messageStoreLock sync.RWMutex
|
||||||
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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),
|
||||||
|
|
|
@ -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",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 (
|
||||||
formatMessage plugins.MsgFormatter
|
botTwitchClient *twitch.Client
|
||||||
ptrStringEmpty = func(v string) *string { return &v }("")
|
formatMessage plugins.MsgFormatter
|
||||||
|
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",
|
plugins.DeriveChannel(m, eventData),
|
||||||
Params: []string{
|
plugins.DeriveUser(m, eventData),
|
||||||
plugins.DeriveChannel(m, eventData),
|
attrs.MustDuration("duration", nil),
|
||||||
strings.Join(cmd, " "),
|
reason,
|
||||||
},
|
),
|
||||||
}),
|
"executing timeout",
|
||||||
"sending 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
|
||||||
|
}
|
||||||
|
|
|
@ -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
2
irc.go
|
@ -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
85
pkg/twitch/channels.go
Normal 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
55
pkg/twitch/chat.go
Normal 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",
|
||||||
|
)
|
||||||
|
}
|
|
@ -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
140
pkg/twitch/moderation.go
Normal 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",
|
||||||
|
)
|
||||||
|
}
|
|
@ -2,20 +2,27 @@ package twitch
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// API Scopes
|
// API Scopes
|
||||||
ScopeChannelManageRedemptions = "channel:manage:redemptions"
|
ScopeChannelEditCommercial = "channel:edit:commercial"
|
||||||
ScopeChannelReadRedemptions = "channel:read:redemptions"
|
ScopeChannelManageBroadcast = "channel:manage:broadcast"
|
||||||
ScopeChannelEditCommercial = "channel:edit:commercial"
|
ScopeChannelManageModerators = "channel:manage:moderators"
|
||||||
ScopeChannelManageBroadcast = "channel:manage:broadcast"
|
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
57
pkg/twitch/search.go
Normal 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
137
pkg/twitch/streams.go
Normal 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
|
||||||
|
}
|
|
@ -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
187
pkg/twitch/users.go
Normal 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
53
pkg/twitch/whispers.go
Normal 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",
|
||||||
|
)
|
||||||
|
}
|
|
@ -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 }
|
||||||
}
|
}
|
||||||
|
|
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
|
@ -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")
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
Loading…
Reference in a new issue