mirror of
https://github.com/Luzifer/twitch-bot.git
synced 2024-11-08 16:20:02 +00:00
[core] Implement dynamic token update and broadcaster permissions (#13)
This commit is contained in:
parent
77334aca94
commit
437ef14fb0
18 changed files with 1196 additions and 261 deletions
150
auth.go
Normal file
150
auth.go
Normal file
|
@ -0,0 +1,150 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gofrs/uuid/v3"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
|
"github.com/Luzifer/twitch-bot/plugins"
|
||||||
|
"github.com/Luzifer/twitch-bot/twitch"
|
||||||
|
)
|
||||||
|
|
||||||
|
var instanceState = uuid.Must(uuid.NewV4()).String()
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
for _, rd := range []plugins.HTTPRouteRegistrationArgs{
|
||||||
|
{
|
||||||
|
Description: "Updates the bots token for connection to chat and API",
|
||||||
|
HandlerFunc: handleAuthUpdateBotToken,
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Module: "auth",
|
||||||
|
Name: "Update bot token",
|
||||||
|
Path: "/update-bot-token",
|
||||||
|
ResponseType: plugins.HTTPRouteResponseTypeTextPlain,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Description: "Updates scope configuration for EventSub subscription of a channel",
|
||||||
|
HandlerFunc: handleAuthUpdateChannelGrant,
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Module: "auth",
|
||||||
|
Name: "Update channel scopes",
|
||||||
|
Path: "/update-channel-scopes",
|
||||||
|
ResponseType: plugins.HTTPRouteResponseTypeTextPlain,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
if err := registerRoute(rd); err != nil {
|
||||||
|
log.WithError(err).Fatal("Unable to register auth routes")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleAuthUpdateBotToken(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var (
|
||||||
|
code = r.FormValue("code")
|
||||||
|
state = r.FormValue("state")
|
||||||
|
)
|
||||||
|
|
||||||
|
if state != instanceState {
|
||||||
|
http.Error(w, "invalid state, please start again", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
params := make(url.Values)
|
||||||
|
params.Set("client_id", cfg.TwitchClient)
|
||||||
|
params.Set("client_secret", cfg.TwitchClientSecret)
|
||||||
|
params.Set("code", code)
|
||||||
|
params.Set("grant_type", "authorization_code")
|
||||||
|
params.Set("redirect_uri", strings.Join([]string{
|
||||||
|
strings.TrimRight(cfg.BaseURL, "/"),
|
||||||
|
"auth", "update-bot-token",
|
||||||
|
}, "/"))
|
||||||
|
|
||||||
|
req, _ := http.NewRequestWithContext(r.Context(), http.MethodPost, fmt.Sprintf("https://id.twitch.tv/oauth2/token?%s", params.Encode()), nil)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, errors.Wrap(err, "getting access token").Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var rData twitch.OAuthTokenResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&rData); err != nil {
|
||||||
|
http.Error(w, errors.Wrap(err, "decoding access token").Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
botUser, err := twitch.New(cfg.TwitchClient, cfg.TwitchClientSecret, rData.AccessToken, "").GetAuthorizedUsername()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, errors.Wrap(err, "getting authorized user").Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = store.UpdateBotToken(rData.AccessToken, rData.RefreshToken); err != nil {
|
||||||
|
http.Error(w, errors.Wrap(err, "storing access token").Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
twitchClient.UpdateToken(rData.AccessToken, rData.RefreshToken)
|
||||||
|
|
||||||
|
if err = store.SetGrantedScopes(botUser, rData.Scope, true); err != nil {
|
||||||
|
http.Error(w, errors.Wrap(err, "storing access scopes").Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Error(w, fmt.Sprintf("Authorization as %q complete, you can now close this window.", botUser), http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleAuthUpdateChannelGrant(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var (
|
||||||
|
code = r.FormValue("code")
|
||||||
|
state = r.FormValue("state")
|
||||||
|
)
|
||||||
|
|
||||||
|
if state != instanceState {
|
||||||
|
http.Error(w, "invalid state, please start again", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
params := make(url.Values)
|
||||||
|
params.Set("client_id", cfg.TwitchClient)
|
||||||
|
params.Set("client_secret", cfg.TwitchClientSecret)
|
||||||
|
params.Set("code", code)
|
||||||
|
params.Set("grant_type", "authorization_code")
|
||||||
|
params.Set("redirect_uri", strings.Join([]string{
|
||||||
|
strings.TrimRight(cfg.BaseURL, "/"),
|
||||||
|
"auth", "update-channel-scopes",
|
||||||
|
}, "/"))
|
||||||
|
|
||||||
|
req, _ := http.NewRequestWithContext(r.Context(), http.MethodPost, fmt.Sprintf("https://id.twitch.tv/oauth2/token?%s", params.Encode()), nil)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, errors.Wrap(err, "getting access token").Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var rData twitch.OAuthTokenResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&rData); err != nil {
|
||||||
|
http.Error(w, errors.Wrap(err, "decoding access token").Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
grantUser, err := twitch.New(cfg.TwitchClient, cfg.TwitchClientSecret, rData.AccessToken, "").GetAuthorizedUsername()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, errors.Wrap(err, "getting authorized user").Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = store.SetGrantedScopes(grantUser, rData.Scope, false); err != nil {
|
||||||
|
http.Error(w, errors.Wrap(err, "storing access scopes").Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Error(w, fmt.Sprintf("Scopes for %q updated, you can now close this window.", grantUser), http.StatusOK)
|
||||||
|
}
|
|
@ -15,7 +15,7 @@ func getAuthorizationFromRequest(r *http.Request) (string, *twitch.Client, error
|
||||||
return "", nil, errors.New("no authorization provided")
|
return "", nil, errors.New("no authorization provided")
|
||||||
}
|
}
|
||||||
|
|
||||||
tc := twitch.New(cfg.TwitchClient, cfg.TwitchClientSecret, token)
|
tc := twitch.New(cfg.TwitchClient, cfg.TwitchClientSecret, token, "")
|
||||||
|
|
||||||
user, err := tc.GetAuthorizedUsername()
|
user, err := tc.GetAuthorizedUsername()
|
||||||
return user, tc, errors.Wrap(err, "getting authorized user")
|
return user, tc, errors.Wrap(err, "getting authorized user")
|
||||||
|
|
|
@ -2,7 +2,10 @@ package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/gofrs/uuid/v3"
|
"github.com/gofrs/uuid/v3"
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
|
@ -15,7 +18,9 @@ import (
|
||||||
type (
|
type (
|
||||||
configEditorGeneralConfig struct {
|
configEditorGeneralConfig struct {
|
||||||
BotEditors []string `json:"bot_editors"`
|
BotEditors []string `json:"bot_editors"`
|
||||||
|
BotName string `json:"bot_name"`
|
||||||
Channels []string `json:"channels"`
|
Channels []string `json:"channels"`
|
||||||
|
ChannelHasScopes map[string]bool `json:"channel_has_scopes"`
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -79,6 +84,16 @@ func registerEditorGeneralConfigRoutes() {
|
||||||
RequiresEditorsAuth: true,
|
RequiresEditorsAuth: true,
|
||||||
ResponseType: plugins.HTTPRouteResponseTypeTextPlain,
|
ResponseType: plugins.HTTPRouteResponseTypeTextPlain,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Description: "Get Bot-Auth URLs for updating bot token and channel scopes",
|
||||||
|
HandlerFunc: configEditorHandleGeneralAuthURLs,
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Module: "config-editor",
|
||||||
|
Name: "Get Bot-Auth-URLs",
|
||||||
|
Path: "/auth-urls",
|
||||||
|
RequiresEditorsAuth: true,
|
||||||
|
ResponseType: plugins.HTTPRouteResponseTypeJSON,
|
||||||
|
},
|
||||||
} {
|
} {
|
||||||
if err := registerRoute(rd); err != nil {
|
if err := registerRoute(rd); err != nil {
|
||||||
log.WithError(err).Fatal("Unable to register config editor route")
|
log.WithError(err).Fatal("Unable to register config editor route")
|
||||||
|
@ -118,6 +133,37 @@ func configEditorHandleGeneralAddAuthToken(w http.ResponseWriter, r *http.Reques
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func configEditorHandleGeneralAuthURLs(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var out struct {
|
||||||
|
UpdateBotToken string `json:"update_bot_token"`
|
||||||
|
UpdateChannelScopes string `json:"update_channel_scopes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
params := make(url.Values)
|
||||||
|
params.Set("client_id", cfg.TwitchClient)
|
||||||
|
params.Set("redirect_uri", strings.Join([]string{
|
||||||
|
strings.TrimRight(cfg.BaseURL, "/"),
|
||||||
|
"auth", "update-bot-token",
|
||||||
|
}, "/"))
|
||||||
|
params.Set("response_type", "code")
|
||||||
|
params.Set("scope", strings.Join(botDefaultScopes, " "))
|
||||||
|
params.Set("state", instanceState)
|
||||||
|
|
||||||
|
out.UpdateBotToken = fmt.Sprintf("https://id.twitch.tv/oauth2/authorize?%s", params.Encode())
|
||||||
|
|
||||||
|
params.Set("redirect_uri", strings.Join([]string{
|
||||||
|
strings.TrimRight(cfg.BaseURL, "/"),
|
||||||
|
"auth", "update-channel-scopes",
|
||||||
|
}, "/"))
|
||||||
|
params.Set("scope", strings.Join(channelDefaultScopes, " "))
|
||||||
|
|
||||||
|
out.UpdateChannelScopes = fmt.Sprintf("https://id.twitch.tv/oauth2/authorize?%s", params.Encode())
|
||||||
|
|
||||||
|
if err := json.NewEncoder(w).Encode(out); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func configEditorHandleGeneralDeleteAuthToken(w http.ResponseWriter, r *http.Request) {
|
func configEditorHandleGeneralDeleteAuthToken(w http.ResponseWriter, r *http.Request) {
|
||||||
user, _, err := getAuthorizationFromRequest(r)
|
user, _, err := getAuthorizationFromRequest(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -137,9 +183,23 @@ func configEditorHandleGeneralDeleteAuthToken(w http.ResponseWriter, r *http.Req
|
||||||
}
|
}
|
||||||
|
|
||||||
func configEditorHandleGeneralGet(w http.ResponseWriter, r *http.Request) {
|
func configEditorHandleGeneralGet(w http.ResponseWriter, r *http.Request) {
|
||||||
|
elevated := make(map[string]bool)
|
||||||
|
|
||||||
|
for _, ch := range config.Channels {
|
||||||
|
elevated[ch] = store.UserHasGrantedScopes(ch, channelDefaultScopes...)
|
||||||
|
}
|
||||||
|
|
||||||
|
uName, err := twitchClient.GetAuthorizedUsername()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if err := json.NewEncoder(w).Encode(configEditorGeneralConfig{
|
if err := json.NewEncoder(w).Encode(configEditorGeneralConfig{
|
||||||
BotEditors: config.BotEditors,
|
BotEditors: config.BotEditors,
|
||||||
|
BotName: uName,
|
||||||
Channels: config.Channels,
|
Channels: config.Channels,
|
||||||
|
ChannelHasScopes: elevated,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|
129
crypt/crypt.go
Normal file
129
crypt/crypt.go
Normal file
|
@ -0,0 +1,129 @@
|
||||||
|
package crypt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
|
||||||
|
"github.com/Luzifer/go-openssl/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
const encryptedValuePrefix = "enc:"
|
||||||
|
|
||||||
|
type encryptAction uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
handleTagsDecrypt encryptAction = iota
|
||||||
|
handleTagsEncrypt
|
||||||
|
)
|
||||||
|
|
||||||
|
var osslClient = openssl.New()
|
||||||
|
|
||||||
|
// DecryptFields iterates through the given struct and decrypts all
|
||||||
|
// fields marked with a struct tag of `encrypt:"true"`. The fields
|
||||||
|
// are directly manipulated and the value is replaced.
|
||||||
|
//
|
||||||
|
// The input object needs to be a pointer to a struct!
|
||||||
|
func DecryptFields(obj interface{}, passphrase string) error {
|
||||||
|
return handleEncryptedTags(obj, passphrase, handleTagsDecrypt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncryptFields iterates through the given struct and encrypts all
|
||||||
|
// fields marked with a struct tag of `encrypt:"true"`. The fields
|
||||||
|
// are directly manipulated and the value is replaced.
|
||||||
|
//
|
||||||
|
// The input object needs to be a pointer to a struct!
|
||||||
|
func EncryptFields(obj interface{}, passphrase string) error {
|
||||||
|
return handleEncryptedTags(obj, passphrase, handleTagsEncrypt)
|
||||||
|
}
|
||||||
|
|
||||||
|
//nolint:gocognit,gocyclo // Reflect loop, cannot reduce complexity
|
||||||
|
func handleEncryptedTags(obj interface{}, passphrase string, action encryptAction) error {
|
||||||
|
// Check we got a pointer and can manipulate the struct
|
||||||
|
if kind := reflect.TypeOf(obj).Kind(); kind != reflect.Ptr {
|
||||||
|
return errors.Errorf("expected pointer to struct, got %s", kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check we got a struct in the pointer
|
||||||
|
if kind := reflect.ValueOf(obj).Elem().Kind(); kind != reflect.Struct {
|
||||||
|
return errors.Errorf("expected pointer to struct, got pointer to %s", kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Iterate over fields to find encrypted fields to manipulate
|
||||||
|
st := reflect.ValueOf(obj).Elem()
|
||||||
|
for i := 0; i < st.NumField(); i++ {
|
||||||
|
v := st.Field(i)
|
||||||
|
t := st.Type().Field(i)
|
||||||
|
|
||||||
|
if t.PkgPath != "" && !t.Anonymous {
|
||||||
|
// Caught us an non-exported field, ignore that one
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
hasEncryption := t.Tag.Get("encrypt") == "true"
|
||||||
|
|
||||||
|
switch t.Type.Kind() {
|
||||||
|
// Type: Pointer - Recurse if not nil and struct inside
|
||||||
|
case reflect.Ptr:
|
||||||
|
if !v.IsNil() && v.Elem().Kind() == reflect.Struct && t.Type != reflect.TypeOf(&time.Time{}) {
|
||||||
|
if err := handleEncryptedTags(v.Interface(), passphrase, action); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type: String - Replace value if required
|
||||||
|
case reflect.String:
|
||||||
|
if hasEncryption {
|
||||||
|
newValue, err := manipulateValue(v.String(), passphrase, action)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "manipulating value")
|
||||||
|
}
|
||||||
|
v.SetString(newValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type: Struct - Welcome to recursion
|
||||||
|
case reflect.Struct:
|
||||||
|
if t.Type != reflect.TypeOf(time.Time{}) {
|
||||||
|
if err := handleEncryptedTags(v.Addr().Interface(), passphrase, action); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// We don't support anything else. Yet.
|
||||||
|
default:
|
||||||
|
if hasEncryption {
|
||||||
|
return errors.Errorf("unsupported field type for encyption: %s", t.Type.Kind())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func manipulateValue(val, passphrase string, action encryptAction) (string, error) {
|
||||||
|
switch action {
|
||||||
|
case handleTagsDecrypt:
|
||||||
|
if !strings.HasPrefix(val, encryptedValuePrefix) {
|
||||||
|
// This is not an encrypted string: Return the value itself for
|
||||||
|
// working with legacy values in storage
|
||||||
|
return val, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
d, err := osslClient.DecryptBytes(passphrase, []byte(strings.TrimPrefix(val, encryptedValuePrefix)), openssl.PBKDF2SHA256)
|
||||||
|
return string(d), errors.Wrap(err, "decrypting value")
|
||||||
|
|
||||||
|
case handleTagsEncrypt:
|
||||||
|
if strings.HasPrefix(val, encryptedValuePrefix) {
|
||||||
|
// This is an encrypted string: shouldn't happen but whatever
|
||||||
|
return val, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
e, err := osslClient.EncryptBytes(passphrase, []byte(val), openssl.PBKDF2SHA256)
|
||||||
|
return encryptedValuePrefix + string(e), errors.Wrap(err, "encrypting value")
|
||||||
|
|
||||||
|
default:
|
||||||
|
return "", errors.New("invalid action")
|
||||||
|
}
|
||||||
|
}
|
3
go.mod
3
go.mod
|
@ -3,6 +3,7 @@ module github.com/Luzifer/twitch-bot
|
||||||
go 1.17
|
go 1.17
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/Luzifer/go-openssl/v4 v4.1.0
|
||||||
github.com/Luzifer/go_helpers/v2 v2.12.2
|
github.com/Luzifer/go_helpers/v2 v2.12.2
|
||||||
github.com/Luzifer/korvike/functions v0.6.1
|
github.com/Luzifer/korvike/functions v0.6.1
|
||||||
github.com/Luzifer/rconfig/v2 v2.3.0
|
github.com/Luzifer/rconfig/v2 v2.3.0
|
||||||
|
@ -15,6 +16,7 @@ require (
|
||||||
github.com/robfig/cron/v3 v3.0.1
|
github.com/robfig/cron/v3 v3.0.1
|
||||||
github.com/sirupsen/logrus v1.8.1
|
github.com/sirupsen/logrus v1.8.1
|
||||||
github.com/wzshiming/openapi v0.0.0-20200703171632-c7220b3c9cfb
|
github.com/wzshiming/openapi v0.0.0-20200703171632-c7220b3c9cfb
|
||||||
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9
|
||||||
gopkg.in/src-d/go-git.v4 v4.13.1
|
gopkg.in/src-d/go-git.v4 v4.13.1
|
||||||
gopkg.in/yaml.v2 v2.4.0
|
gopkg.in/yaml.v2 v2.4.0
|
||||||
)
|
)
|
||||||
|
@ -42,7 +44,6 @@ require (
|
||||||
github.com/spf13/pflag v1.0.5 // indirect
|
github.com/spf13/pflag v1.0.5 // indirect
|
||||||
github.com/src-d/gcfg v1.4.0 // indirect
|
github.com/src-d/gcfg v1.4.0 // indirect
|
||||||
github.com/xanzy/ssh-agent v0.2.1 // indirect
|
github.com/xanzy/ssh-agent v0.2.1 // indirect
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 // indirect
|
|
||||||
golang.org/x/net v0.0.0-20210119194325-5f4716e94777 // indirect
|
golang.org/x/net v0.0.0-20210119194325-5f4716e94777 // indirect
|
||||||
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c // indirect
|
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c // indirect
|
||||||
golang.org/x/text v0.3.6 // indirect
|
golang.org/x/text v0.3.6 // indirect
|
||||||
|
|
2
go.sum
2
go.sum
|
@ -3,6 +3,8 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMT
|
||||||
github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8=
|
github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8=
|
||||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||||
github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ=
|
github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ=
|
||||||
|
github.com/Luzifer/go-openssl/v4 v4.1.0 h1:8qi3Z6f8Aflwub/Cs4FVSmKUEg/lC8GlODbR2TyZ+nM=
|
||||||
|
github.com/Luzifer/go-openssl/v4 v4.1.0/go.mod h1:3i1T3Pe6eQK19d86WhuQzjLyMwBaNmGmt3ZceWpWVa4=
|
||||||
github.com/Luzifer/go_helpers/v2 v2.12.2 h1:B6ekpZ2d938tRaFXQtLZZdBSjVi2jabt2uzLGj3bYc8=
|
github.com/Luzifer/go_helpers/v2 v2.12.2 h1:B6ekpZ2d938tRaFXQtLZZdBSjVi2jabt2uzLGj3bYc8=
|
||||||
github.com/Luzifer/go_helpers/v2 v2.12.2/go.mod h1:Jp1wZqtbEFNrLlJW7noomOF7fLRW36k+ELITPDK4SHc=
|
github.com/Luzifer/go_helpers/v2 v2.12.2/go.mod h1:Jp1wZqtbEFNrLlJW7noomOF7fLRW36k+ELITPDK4SHc=
|
||||||
github.com/Luzifer/korvike/functions v0.6.1 h1:OGDaEciVzQh0NUMUxcEK1/vmHLIn4lmneoU/iuKc8YI=
|
github.com/Luzifer/korvike/functions v0.6.1 h1:OGDaEciVzQh0NUMUxcEK1/vmHLIn4lmneoU/iuKc8YI=
|
||||||
|
|
7
irc.go
7
irc.go
|
@ -65,9 +65,14 @@ func newIRCHandler() (*ircHandler, error) {
|
||||||
return nil, errors.Wrap(err, "connect to IRC server")
|
return nil, errors.Wrap(err, "connect to IRC server")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
token, err := twitchClient.GetToken()
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "getting auth token")
|
||||||
|
}
|
||||||
|
|
||||||
h.c = irc.NewClient(conn, irc.ClientConfig{
|
h.c = irc.NewClient(conn, irc.ClientConfig{
|
||||||
Nick: username,
|
Nick: username,
|
||||||
Pass: strings.Join([]string{"oauth", cfg.TwitchToken}, ":"),
|
Pass: strings.Join([]string{"oauth", token}, ":"),
|
||||||
User: username,
|
User: username,
|
||||||
Name: username,
|
Name: username,
|
||||||
Handler: h,
|
Handler: h,
|
||||||
|
|
79
main.go
79
main.go
|
@ -5,6 +5,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
|
"math"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
@ -25,7 +26,13 @@ import (
|
||||||
"github.com/Luzifer/twitch-bot/twitch"
|
"github.com/Luzifer/twitch-bot/twitch"
|
||||||
)
|
)
|
||||||
|
|
||||||
const ircReconnectDelay = 100 * time.Millisecond
|
const (
|
||||||
|
ircReconnectDelay = 100 * time.Millisecond
|
||||||
|
|
||||||
|
initialIRCRetryBackoff = 500 * time.Millisecond
|
||||||
|
ircRetryBackoffMultiplier = 1.5
|
||||||
|
maxIRCRetryBackoff = time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
cfg = struct {
|
cfg = struct {
|
||||||
|
@ -36,9 +43,10 @@ var (
|
||||||
LogLevel string `flag:"log-level" default:"info" description:"Log level (debug, info, warn, error, fatal)"`
|
LogLevel string `flag:"log-level" default:"info" description:"Log level (debug, info, warn, error, fatal)"`
|
||||||
PluginDir string `flag:"plugin-dir" default:"/usr/lib/twitch-bot" description:"Where to find and load plugins"`
|
PluginDir string `flag:"plugin-dir" default:"/usr/lib/twitch-bot" description:"Where to find and load plugins"`
|
||||||
StorageFile string `flag:"storage-file" default:"./storage.json.gz" description:"Where to store the data"`
|
StorageFile string `flag:"storage-file" default:"./storage.json.gz" description:"Where to store the data"`
|
||||||
|
StorageEncryptionPass string `flag:"storage-encryption-pass" default:"" description:"Passphrase to encrypt secrets inside storage (defaults to twitch-client:twitch-client-secret)"`
|
||||||
TwitchClient string `flag:"twitch-client" default:"" description:"Client ID to act as"`
|
TwitchClient string `flag:"twitch-client" default:"" description:"Client ID to act as"`
|
||||||
TwitchClientSecret string `flag:"twitch-client-secret" default:"" description:"Secret for the Client ID"`
|
TwitchClientSecret string `flag:"twitch-client-secret" default:"" description:"Secret for the Client ID"`
|
||||||
TwitchToken string `flag:"twitch-token" default:"" description:"OAuth token valid for client"`
|
TwitchToken string `flag:"twitch-token" default:"" description:"OAuth token valid for client (fallback if no token was set in interface)"`
|
||||||
ValidateConfig bool `flag:"validate-config,v" default:"false" description:"Loads the config, logs any errors and quits with status 0 on success"`
|
ValidateConfig bool `flag:"validate-config,v" default:"false" description:"Loads the config, logs any errors and quits with status 0 on success"`
|
||||||
VersionAndExit bool `flag:"version" default:"false" description:"Prints current version and exits"`
|
VersionAndExit bool `flag:"version" default:"false" description:"Prints current version and exits"`
|
||||||
}{}
|
}{}
|
||||||
|
@ -85,6 +93,14 @@ func init() {
|
||||||
} else {
|
} else {
|
||||||
log.SetLevel(l)
|
log.SetLevel(l)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cfg.StorageEncryptionPass == "" {
|
||||||
|
log.Warn("No storage encryption passphrase was set, falling back to client-id:client-secret")
|
||||||
|
cfg.StorageEncryptionPass = strings.Join([]string{
|
||||||
|
cfg.TwitchClient,
|
||||||
|
cfg.TwitchClientSecret,
|
||||||
|
}, ":")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleSubCommand(args []string) {
|
func handleSubCommand(args []string) {
|
||||||
|
@ -139,21 +155,38 @@ func handleSubCommand(args []string) {
|
||||||
func main() {
|
func main() {
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
|
if err = store.Load(); err != nil {
|
||||||
|
log.WithError(err).Fatal("Unable to load storage file")
|
||||||
|
}
|
||||||
|
|
||||||
cronService = cron.New()
|
cronService = cron.New()
|
||||||
twitchClient = twitch.New(cfg.TwitchClient, cfg.TwitchClientSecret, cfg.TwitchToken)
|
twitchClient = twitch.New(cfg.TwitchClient, cfg.TwitchClientSecret, store.GetBotToken(cfg.TwitchToken), store.BotRefreshToken)
|
||||||
|
twitchClient.SetTokenUpdateHook(func(at, rt string) error {
|
||||||
|
if err := store.UpdateBotToken(at, rt); err != nil {
|
||||||
|
return errors.Wrap(err, "updating store")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Misuse the config reload hook to let the frontend reload its state
|
||||||
|
configReloadHooksLock.RLock()
|
||||||
|
defer configReloadHooksLock.RUnlock()
|
||||||
|
for _, fn := range configReloadHooks {
|
||||||
|
fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
twitchWatch := newTwitchWatcher()
|
twitchWatch := newTwitchWatcher()
|
||||||
cronService.AddFunc("@every 10s", twitchWatch.Check) // Query may run that often as the twitchClient has an internal cache
|
// Query may run that often as the twitchClient has an internal
|
||||||
|
// cache but shouldn't run more often as EventSub subscriptions
|
||||||
|
// are retried on error each time
|
||||||
|
cronService.AddFunc("@every 30s", twitchWatch.Check)
|
||||||
|
|
||||||
router.Use(corsMiddleware)
|
router.Use(corsMiddleware)
|
||||||
router.HandleFunc("/openapi.html", handleSwaggerHTML)
|
router.HandleFunc("/openapi.html", handleSwaggerHTML)
|
||||||
router.HandleFunc("/openapi.json", handleSwaggerRequest)
|
router.HandleFunc("/openapi.json", handleSwaggerRequest)
|
||||||
router.HandleFunc("/selfcheck", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(runID)) })
|
router.HandleFunc("/selfcheck", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(runID)) })
|
||||||
|
|
||||||
if err = store.Load(); err != nil {
|
|
||||||
log.WithError(err).Fatal("Unable to load storage file")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err = initCorePlugins(); err != nil {
|
if err = initCorePlugins(); err != nil {
|
||||||
log.WithError(err).Fatal("Unable to load core plugins")
|
log.WithError(err).Fatal("Unable to load core plugins")
|
||||||
}
|
}
|
||||||
|
@ -196,6 +229,7 @@ func main() {
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ircDisconnected = make(chan struct{}, 1)
|
ircDisconnected = make(chan struct{}, 1)
|
||||||
|
ircRetryBackoff = initialIRCRetryBackoff
|
||||||
autoMessageTicker = time.NewTicker(time.Second)
|
autoMessageTicker = time.NewTicker(time.Second)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -222,13 +256,16 @@ func main() {
|
||||||
twitchEventSubClient, err = twitch.NewEventSubClient(twitchClient, strings.Join([]string{
|
twitchEventSubClient, err = twitch.NewEventSubClient(twitchClient, strings.Join([]string{
|
||||||
strings.TrimRight(cfg.BaseURL, "/"),
|
strings.TrimRight(cfg.BaseURL, "/"),
|
||||||
"eventsub",
|
"eventsub",
|
||||||
handle,
|
|
||||||
}, "/"), secret, handle)
|
}, "/"), secret, handle)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.WithError(err).Fatal("Unable to create eventsub client")
|
log.WithError(err).Fatal("Unable to create eventsub client")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := twitchWatch.registerGlobalHooks(); err != nil {
|
||||||
|
log.WithError(err).Fatal("Unable to register global eventsub hooks")
|
||||||
|
}
|
||||||
|
|
||||||
router.HandleFunc("/eventsub/{keyhandle}", twitchEventSubClient.HandleEventsubPush).Methods(http.MethodPost)
|
router.HandleFunc("/eventsub/{keyhandle}", twitchEventSubClient.HandleEventsubPush).Methods(http.MethodPost)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -250,9 +287,17 @@ func main() {
|
||||||
}
|
}
|
||||||
|
|
||||||
if ircHdl, err = newIRCHandler(); err != nil {
|
if ircHdl, err = newIRCHandler(); err != nil {
|
||||||
log.WithError(err).Fatal("Unable to create IRC client")
|
log.WithError(err).Error("Unable to connect to IRC")
|
||||||
|
go func() {
|
||||||
|
time.Sleep(ircRetryBackoff)
|
||||||
|
ircRetryBackoff = time.Duration(math.Min(float64(maxIRCRetryBackoff), float64(ircRetryBackoff)*ircRetryBackoffMultiplier))
|
||||||
|
ircDisconnected <- struct{}{}
|
||||||
|
}()
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ircRetryBackoff = initialIRCRetryBackoff // Successfully created, reset backoff
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
if err := ircHdl.Run(); err != nil {
|
if err := ircHdl.Run(); err != nil {
|
||||||
log.WithError(err).Error("IRC run exited unexpectedly")
|
log.WithError(err).Error("IRC run exited unexpectedly")
|
||||||
|
@ -366,17 +411,19 @@ func startCheck() error {
|
||||||
errs = append(errs, "No Twitch-ClientId given")
|
errs = append(errs, "No Twitch-ClientId given")
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.TwitchToken == "" {
|
if cfg.TwitchClientSecret == "" {
|
||||||
errs = append(errs, "Twitch-Token is unset")
|
errs = append(errs, "No Twitch-ClientSecret given")
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(errs) > 0 {
|
if len(errs) > 0 {
|
||||||
fmt.Println(`
|
fmt.Println(`
|
||||||
You've not provided a Twitch-ClientId and/or a Twitch-Token.
|
You've not provided a Twitch-ClientId and/or a Twitch-ClientSecret.
|
||||||
|
|
||||||
These parameters are required and you need to provide them. In case
|
These parameters are required and you need to provide them.
|
||||||
you need help with obtaining those credentials please visit the
|
|
||||||
following website:
|
The Twitch Token can be set through the web-interface. In case you
|
||||||
|
want to set it through parameters and need help with obtaining it,
|
||||||
|
please visit the following website:
|
||||||
|
|
||||||
https://luzifer.github.io/twitch-bot/
|
https://luzifer.github.io/twitch-bot/
|
||||||
|
|
||||||
|
|
|
@ -75,7 +75,7 @@ func TestAllowExecuteDisableOnOffline(t *testing.T) {
|
||||||
r := &Rule{DisableOnOffline: testPtrBool(true)}
|
r := &Rule{DisableOnOffline: testPtrBool(true)}
|
||||||
|
|
||||||
// Fake cache entries to prevent calling the real Twitch API
|
// Fake cache entries to prevent calling the real Twitch API
|
||||||
r.twitchClient = twitch.New("", "", "")
|
r.twitchClient = twitch.New("", "", "", "")
|
||||||
r.twitchClient.APICache().Set([]string{"hasLiveStream", "channel1"}, time.Minute, true)
|
r.twitchClient.APICache().Set([]string{"hasLiveStream", "channel1"}, time.Minute, true)
|
||||||
r.twitchClient.APICache().Set([]string{"hasLiveStream", "channel2"}, time.Minute, false)
|
r.twitchClient.APICache().Set([]string{"hasLiveStream", "channel2"}, time.Minute, false)
|
||||||
|
|
||||||
|
|
19
scopes.go
Normal file
19
scopes.go
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import "github.com/Luzifer/twitch-bot/twitch"
|
||||||
|
|
||||||
|
var (
|
||||||
|
botDefaultScopes = []string{
|
||||||
|
twitch.ScopeChatRead,
|
||||||
|
twitch.ScopeChatEdit,
|
||||||
|
twitch.ScopeWhisperRead,
|
||||||
|
twitch.ScopeWhisperEdit,
|
||||||
|
twitch.ScopeChannelModerate,
|
||||||
|
twitch.ScopeChannelManageBroadcast,
|
||||||
|
twitch.ScopeChannelEditCommercial,
|
||||||
|
}
|
||||||
|
|
||||||
|
channelDefaultScopes = []string{
|
||||||
|
twitch.ScopeChannelReadRedemptions,
|
||||||
|
}
|
||||||
|
)
|
55
src/app.vue
55
src/app.vue
|
@ -60,28 +60,57 @@
|
||||||
>
|
>
|
||||||
<font-awesome-icon
|
<font-awesome-icon
|
||||||
fixed-width
|
fixed-width
|
||||||
class="mr-1 text-warning"
|
class="text-warning"
|
||||||
:icon="['fas', 'spinner']"
|
:icon="['fas', 'spinner']"
|
||||||
pulse
|
pulse
|
||||||
/>
|
/>
|
||||||
</b-nav-text>
|
</b-nav-text>
|
||||||
<b-nav-text>
|
|
||||||
|
<b-nav-text
|
||||||
|
class="ml-2"
|
||||||
|
>
|
||||||
|
<template
|
||||||
|
v-for="check in status.checks"
|
||||||
|
>
|
||||||
|
<font-awesome-icon
|
||||||
|
:id="`statusCheck${check.name}`"
|
||||||
|
:key="check.key"
|
||||||
|
fixed-width
|
||||||
|
:class="{ 'text-danger': !check.success, 'text-success': check.success }"
|
||||||
|
:icon="['fas', 'question-circle']"
|
||||||
|
/>
|
||||||
|
<b-tooltip
|
||||||
|
:key="check.key"
|
||||||
|
:target="`statusCheck${check.name}`"
|
||||||
|
triggers="hover"
|
||||||
|
>
|
||||||
|
{{ check.description }}
|
||||||
|
</b-tooltip>
|
||||||
|
</template>
|
||||||
|
</b-nav-text>
|
||||||
|
|
||||||
|
<b-nav-text class="ml-2">
|
||||||
<font-awesome-icon
|
<font-awesome-icon
|
||||||
v-if="configNotifySocketConnected"
|
v-if="configNotifySocketConnected"
|
||||||
v-b-tooltip.hover
|
id="socketConnectionStatus"
|
||||||
fixed-width
|
fixed-width
|
||||||
class="mr-1 text-success"
|
class="mr-1 text-success"
|
||||||
:icon="['fas', 'ethernet']"
|
:icon="['fas', 'ethernet']"
|
||||||
title="Connected to Bot"
|
|
||||||
/>
|
/>
|
||||||
<font-awesome-icon
|
<font-awesome-icon
|
||||||
v-else
|
v-else
|
||||||
v-b-tooltip.hover
|
id="socketConnectionStatus"
|
||||||
fixed-width
|
fixed-width
|
||||||
class="mr-1 text-danger"
|
class="mr-1 text-danger"
|
||||||
:icon="['fas', 'ethernet']"
|
:icon="['fas', 'ethernet']"
|
||||||
title="Disconnected to Bot"
|
|
||||||
/>
|
/>
|
||||||
|
<b-tooltip
|
||||||
|
target="socketConnectionStatus"
|
||||||
|
triggers="hover"
|
||||||
|
>
|
||||||
|
<span v-if="configNotifySocketConnected">Connected to Bot</span>
|
||||||
|
<span v-else>Disconnected from Bot</span>
|
||||||
|
</b-tooltip>
|
||||||
</b-nav-text>
|
</b-nav-text>
|
||||||
</b-navbar-nav>
|
</b-navbar-nav>
|
||||||
</b-collapse>
|
</b-collapse>
|
||||||
|
@ -162,6 +191,8 @@
|
||||||
<script>
|
<script>
|
||||||
import * as constants from './const.js'
|
import * as constants from './const.js'
|
||||||
|
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
computed: {
|
computed: {
|
||||||
authURL() {
|
authURL() {
|
||||||
|
@ -200,10 +231,19 @@ export default {
|
||||||
configNotifySocketConnected: false,
|
configNotifySocketConnected: false,
|
||||||
error: null,
|
error: null,
|
||||||
loadingData: false,
|
loadingData: false,
|
||||||
|
status: {},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
|
fetchStatus() {
|
||||||
|
return axios.get('status/status.json?fail-status=200')
|
||||||
|
.then(resp => {
|
||||||
|
this.status = resp.data
|
||||||
|
})
|
||||||
|
.catch(err => this.$bus.$emit(constants.NOTIFY_FETCH_ERROR, err))
|
||||||
|
},
|
||||||
|
|
||||||
handleFetchError(err) {
|
handleFetchError(err) {
|
||||||
switch (err.response.status) {
|
switch (err.response.status) {
|
||||||
case 403:
|
case 403:
|
||||||
|
@ -256,6 +296,9 @@ export default {
|
||||||
if (this.isAuthenticated) {
|
if (this.isAuthenticated) {
|
||||||
this.openConfigNotifySocket()
|
this.openConfigNotifySocket()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
window.setInterval(() => this.fetchStatus(), 10000)
|
||||||
|
this.fetchStatus()
|
||||||
},
|
},
|
||||||
|
|
||||||
name: 'TwitchBotEditorApp',
|
name: 'TwitchBotEditorApp',
|
||||||
|
|
|
@ -2,7 +2,6 @@
|
||||||
<div>
|
<div>
|
||||||
<b-row>
|
<b-row>
|
||||||
<b-col>
|
<b-col>
|
||||||
<b-card-group columns>
|
|
||||||
<b-card no-body>
|
<b-card no-body>
|
||||||
<b-card-header>
|
<b-card-header>
|
||||||
<font-awesome-icon
|
<font-awesome-icon
|
||||||
|
@ -25,6 +24,20 @@
|
||||||
:icon="['fas', 'hashtag']"
|
:icon="['fas', 'hashtag']"
|
||||||
/>
|
/>
|
||||||
{{ channel }}
|
{{ channel }}
|
||||||
|
<font-awesome-icon
|
||||||
|
v-if="!generalConfig.channel_has_scopes[channel]"
|
||||||
|
:id="`channelPublicWarn${channel}`"
|
||||||
|
fixed-width
|
||||||
|
class="ml-1 text-warning"
|
||||||
|
:icon="['fas', 'exclamation-triangle']"
|
||||||
|
/>
|
||||||
|
<b-tooltip
|
||||||
|
:target="`channelPublicWarn${channel}`"
|
||||||
|
triggers="hover"
|
||||||
|
>
|
||||||
|
Channel cannot use features like channel-point redemptions.
|
||||||
|
See "Channel Permissions" for more info how to authorize.
|
||||||
|
</b-tooltip>
|
||||||
</span>
|
</span>
|
||||||
<b-button
|
<b-button
|
||||||
size="sm"
|
size="sm"
|
||||||
|
@ -62,8 +75,12 @@
|
||||||
</b-list-group-item>
|
</b-list-group-item>
|
||||||
</b-list-group>
|
</b-list-group>
|
||||||
</b-card>
|
</b-card>
|
||||||
|
</b-col>
|
||||||
<b-card no-body>
|
<b-col>
|
||||||
|
<b-card
|
||||||
|
no-body
|
||||||
|
class="mb-3"
|
||||||
|
>
|
||||||
<b-card-header>
|
<b-card-header>
|
||||||
<font-awesome-icon
|
<font-awesome-icon
|
||||||
fixed-width
|
fixed-width
|
||||||
|
@ -120,7 +137,9 @@
|
||||||
</b-list-group>
|
</b-list-group>
|
||||||
</b-card>
|
</b-card>
|
||||||
|
|
||||||
<b-card no-body>
|
<b-card
|
||||||
|
no-body
|
||||||
|
>
|
||||||
<b-card-header
|
<b-card-header
|
||||||
class="d-flex align-items-center align-middle"
|
class="d-flex align-items-center align-middle"
|
||||||
>
|
>
|
||||||
|
@ -180,7 +199,113 @@
|
||||||
</b-list-group-item>
|
</b-list-group-item>
|
||||||
</b-list-group>
|
</b-list-group>
|
||||||
</b-card>
|
</b-card>
|
||||||
</b-card-group>
|
</b-col>
|
||||||
|
<b-col>
|
||||||
|
<b-card
|
||||||
|
no-body
|
||||||
|
class="mb-3"
|
||||||
|
:border-variant="botConnectionCardVariant"
|
||||||
|
>
|
||||||
|
<b-card-header
|
||||||
|
class="d-flex align-items-center align-middle"
|
||||||
|
:header-bg-variant="botConnectionCardVariant"
|
||||||
|
>
|
||||||
|
<span class="mr-auto">
|
||||||
|
<font-awesome-icon
|
||||||
|
fixed-width
|
||||||
|
class="mr-1"
|
||||||
|
:icon="['fas', 'sign-in-alt']"
|
||||||
|
/>
|
||||||
|
Bot Connection
|
||||||
|
</span>
|
||||||
|
<code id="botUserName">{{ generalConfig.bot_name }}</code>
|
||||||
|
<b-tooltip
|
||||||
|
target="botUserName"
|
||||||
|
triggers="hover"
|
||||||
|
>
|
||||||
|
Twitch Login-Name of the bot user currently authorized
|
||||||
|
</b-tooltip>
|
||||||
|
</b-card-header>
|
||||||
|
|
||||||
|
<b-card-body>
|
||||||
|
<p>
|
||||||
|
Here you can manage your bots auth-token: it's required to communicate with Twitch Chat and APIs. This will override the token you might have provided when starting the bot and will be automatically renewed as long as you don't change your password or revoke the apps permission on your bot account.
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>Copy the URL provided below</li>
|
||||||
|
<li>Open an inkognito tab or different browser you are not logged into Twitch or are logged in with your bot account</li>
|
||||||
|
<li>Open the copied URL, sign in with the bot account and accept the permissions</li>
|
||||||
|
<li>The bot will display a message containing the authorized account. If this account is wrong, just start over, the token will be overwritten.</li>
|
||||||
|
</ul>
|
||||||
|
<b-input-group>
|
||||||
|
<b-form-input
|
||||||
|
placeholder="Loading..."
|
||||||
|
readonly
|
||||||
|
:value="authURLs.update_bot_token"
|
||||||
|
@focus="$event.target.select()"
|
||||||
|
/>
|
||||||
|
<b-input-group-append>
|
||||||
|
<b-button
|
||||||
|
:variant="copyButtonVariant.botConnection"
|
||||||
|
@click="copyAuthURL('botConnection')"
|
||||||
|
>
|
||||||
|
<font-awesome-icon
|
||||||
|
fixed-width
|
||||||
|
class="mr-1"
|
||||||
|
:icon="['fas', 'clipboard']"
|
||||||
|
/>
|
||||||
|
Copy
|
||||||
|
</b-button>
|
||||||
|
</b-input-group-append>
|
||||||
|
</b-input-group>
|
||||||
|
</b-card-body>
|
||||||
|
</b-card>
|
||||||
|
|
||||||
|
<b-card
|
||||||
|
no-body
|
||||||
|
class="mb-3"
|
||||||
|
>
|
||||||
|
<b-card-header>
|
||||||
|
<font-awesome-icon
|
||||||
|
fixed-width
|
||||||
|
class="mr-1"
|
||||||
|
:icon="['fas', 'sign-in-alt']"
|
||||||
|
/>
|
||||||
|
Channel Permissions
|
||||||
|
</b-card-header>
|
||||||
|
|
||||||
|
<b-card-body>
|
||||||
|
<p>
|
||||||
|
In order to access non-public information as channel-point redemptions the bot needs additional permissions. The <strong>owner</strong> of the channel needs to grant those!
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>Copy the URL provided below</li>
|
||||||
|
<li>Pass the URL to the channel owner and tell them to open it with their personal account logged in</li>
|
||||||
|
<li>The bot will display a message containing the updated account</li>
|
||||||
|
</ul>
|
||||||
|
<b-input-group>
|
||||||
|
<b-form-input
|
||||||
|
placeholder="Loading..."
|
||||||
|
readonly
|
||||||
|
:value="authURLs.update_channel_scopes"
|
||||||
|
@focus="$event.target.select()"
|
||||||
|
/>
|
||||||
|
<b-input-group-append>
|
||||||
|
<b-button
|
||||||
|
:variant="copyButtonVariant.channelPermission"
|
||||||
|
@click="copyAuthURL('channelPermission')"
|
||||||
|
>
|
||||||
|
<font-awesome-icon
|
||||||
|
fixed-width
|
||||||
|
class="mr-1"
|
||||||
|
:icon="['fas', 'clipboard']"
|
||||||
|
/>
|
||||||
|
Copy
|
||||||
|
</b-button>
|
||||||
|
</b-input-group-append>
|
||||||
|
</b-input-group>
|
||||||
|
</b-card-body>
|
||||||
|
</b-card>
|
||||||
</b-col>
|
</b-col>
|
||||||
</b-row>
|
</b-row>
|
||||||
|
|
||||||
|
@ -240,6 +365,13 @@ export default {
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
||||||
|
botConnectionCardVariant() {
|
||||||
|
if (this.$parent.status.overall_status_success) {
|
||||||
|
return 'secondary'
|
||||||
|
}
|
||||||
|
return 'warning'
|
||||||
|
},
|
||||||
|
|
||||||
sortedChannels() {
|
sortedChannels() {
|
||||||
return [...this.generalConfig?.channels || []].sort((a, b) => a.toLocaleLowerCase().localeCompare(b.toLocaleLowerCase()))
|
return [...this.generalConfig?.channels || []].sort((a, b) => a.toLocaleLowerCase().localeCompare(b.toLocaleLowerCase()))
|
||||||
},
|
},
|
||||||
|
@ -261,6 +393,12 @@ export default {
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
apiTokens: {},
|
apiTokens: {},
|
||||||
|
authURLs: {},
|
||||||
|
copyButtonVariant: {
|
||||||
|
botConnection: 'primary',
|
||||||
|
channelPermission: 'primary',
|
||||||
|
},
|
||||||
|
|
||||||
createdAPIToken: null,
|
createdAPIToken: null,
|
||||||
generalConfig: {},
|
generalConfig: {},
|
||||||
models: {
|
models: {
|
||||||
|
@ -292,6 +430,35 @@ export default {
|
||||||
this.updateGeneralConfig()
|
this.updateGeneralConfig()
|
||||||
},
|
},
|
||||||
|
|
||||||
|
copyAuthURL(type) {
|
||||||
|
let prom = null
|
||||||
|
let btnField = null
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'botConnection':
|
||||||
|
prom = navigator.clipboard.writeText(this.authURLs.update_bot_token)
|
||||||
|
btnField = 'botConnection'
|
||||||
|
break
|
||||||
|
case 'channelPermission':
|
||||||
|
prom = navigator.clipboard.writeText(this.authURLs.update_channel_scopes)
|
||||||
|
btnField = 'channelPermission'
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
return prom
|
||||||
|
.then(() => {
|
||||||
|
this.copyButtonVariant[btnField] = 'success'
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
this.copyButtonVariant[btnField] = 'danger'
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
this.copyButtonVariant[btnField] = 'primary'
|
||||||
|
}, 2000)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
fetchAPITokens() {
|
fetchAPITokens() {
|
||||||
this.$bus.$emit(constants.NOTIFY_LOADING_DATA, true)
|
this.$bus.$emit(constants.NOTIFY_LOADING_DATA, true)
|
||||||
return axios.get('config-editor/auth-tokens', this.$root.axiosOptions)
|
return axios.get('config-editor/auth-tokens', this.$root.axiosOptions)
|
||||||
|
@ -301,6 +468,15 @@ export default {
|
||||||
.catch(err => this.$bus.$emit(constants.NOTIFY_FETCH_ERROR, err))
|
.catch(err => this.$bus.$emit(constants.NOTIFY_FETCH_ERROR, err))
|
||||||
},
|
},
|
||||||
|
|
||||||
|
fetchAuthURLs() {
|
||||||
|
this.$bus.$emit(constants.NOTIFY_LOADING_DATA, true)
|
||||||
|
return axios.get('config-editor/auth-urls', this.$root.axiosOptions)
|
||||||
|
.then(resp => {
|
||||||
|
this.authURLs = resp.data
|
||||||
|
})
|
||||||
|
.catch(err => this.$bus.$emit(constants.NOTIFY_FETCH_ERROR, err))
|
||||||
|
},
|
||||||
|
|
||||||
fetchGeneralConfig() {
|
fetchGeneralConfig() {
|
||||||
this.$bus.$emit(constants.NOTIFY_LOADING_DATA, true)
|
this.$bus.$emit(constants.NOTIFY_LOADING_DATA, true)
|
||||||
return axios.get('config-editor/general', this.$root.axiosOptions)
|
return axios.get('config-editor/general', this.$root.axiosOptions)
|
||||||
|
@ -397,6 +573,7 @@ export default {
|
||||||
Promise.all([
|
Promise.all([
|
||||||
this.fetchGeneralConfig(),
|
this.fetchGeneralConfig(),
|
||||||
this.fetchAPITokens(),
|
this.fetchAPITokens(),
|
||||||
|
this.fetchAuthURLs(),
|
||||||
]).then(() => {
|
]).then(() => {
|
||||||
this.$bus.$emit(constants.NOTIFY_CHANGE_PENDING, false)
|
this.$bus.$emit(constants.NOTIFY_CHANGE_PENDING, false)
|
||||||
this.$bus.$emit(constants.NOTIFY_LOADING_DATA, false)
|
this.$bus.$emit(constants.NOTIFY_LOADING_DATA, false)
|
||||||
|
@ -406,6 +583,7 @@ export default {
|
||||||
Promise.all([
|
Promise.all([
|
||||||
this.fetchGeneralConfig(),
|
this.fetchGeneralConfig(),
|
||||||
this.fetchAPITokens(),
|
this.fetchAPITokens(),
|
||||||
|
this.fetchAuthURLs(),
|
||||||
this.fetchModules(),
|
this.fetchModules(),
|
||||||
]).then(() => this.$bus.$emit(constants.NOTIFY_LOADING_DATA, false))
|
]).then(() => this.$bus.$emit(constants.NOTIFY_LOADING_DATA, false))
|
||||||
},
|
},
|
||||||
|
|
|
@ -63,8 +63,8 @@ func handleStatusRequest(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
for _, chk := range []statusResponseCheck{
|
for _, chk := range []statusResponseCheck{
|
||||||
{
|
{
|
||||||
Name: "IRC connection alive",
|
Name: "Chat connection alive",
|
||||||
Description: fmt.Sprintf("IRC connection received a message in last %s", statusIRCMessageReceivedTimeout),
|
Description: fmt.Sprintf("Chat connection received a message in last %s", statusIRCMessageReceivedTimeout),
|
||||||
checkFn: func() error {
|
checkFn: func() error {
|
||||||
if time.Since(statusIRCMessageReceived) > statusIRCMessageReceivedTimeout {
|
if time.Since(statusIRCMessageReceived) > statusIRCMessageReceivedTimeout {
|
||||||
return errors.New("message lifetime expired")
|
return errors.New("message lifetime expired")
|
||||||
|
|
68
store.go
68
store.go
|
@ -12,6 +12,7 @@ import (
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
|
|
||||||
"github.com/Luzifer/go_helpers/v2/str"
|
"github.com/Luzifer/go_helpers/v2/str"
|
||||||
|
"github.com/Luzifer/twitch-bot/crypt"
|
||||||
"github.com/Luzifer/twitch-bot/plugins"
|
"github.com/Luzifer/twitch-bot/plugins"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -26,7 +27,10 @@ type storageFile struct {
|
||||||
|
|
||||||
GrantedScopes map[string][]string `json:"granted_scopes"`
|
GrantedScopes map[string][]string `json:"granted_scopes"`
|
||||||
|
|
||||||
EventSubSecret string `json:"event_sub_secret,omitempty"`
|
EventSubSecret string `encrypt:"true" json:"event_sub_secret,omitempty"`
|
||||||
|
|
||||||
|
BotAccessToken string `encrypt:"true" json:"bot_access_token,omitempty"`
|
||||||
|
BotRefreshToken string `encrypt:"true" json:"bot_refresh_token,omitempty"`
|
||||||
|
|
||||||
inMem bool
|
inMem bool
|
||||||
lock *sync.RWMutex
|
lock *sync.RWMutex
|
||||||
|
@ -65,6 +69,16 @@ func (s *storageFile) DeleteModuleStore(moduleUUID string) error {
|
||||||
return errors.Wrap(s.Save(), "saving store")
|
return errors.Wrap(s.Save(), "saving store")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *storageFile) GetBotToken(fallback string) string {
|
||||||
|
s.lock.RLock()
|
||||||
|
defer s.lock.RUnlock()
|
||||||
|
|
||||||
|
if v := s.BotAccessToken; v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
func (s *storageFile) GetCounterValue(counter string) int64 {
|
func (s *storageFile) GetCounterValue(counter string) int64 {
|
||||||
s.lock.RLock()
|
s.lock.RLock()
|
||||||
defer s.lock.RUnlock()
|
defer s.lock.RUnlock()
|
||||||
|
@ -144,10 +158,15 @@ func (s *storageFile) Load() error {
|
||||||
}
|
}
|
||||||
defer zf.Close()
|
defer zf.Close()
|
||||||
|
|
||||||
return errors.Wrap(
|
if err = json.NewDecoder(zf).Decode(s); err != nil {
|
||||||
json.NewDecoder(zf).Decode(s),
|
return errors.Wrap(err, "decode storage object")
|
||||||
"decode storage object",
|
}
|
||||||
)
|
|
||||||
|
if err = crypt.DecryptFields(s, cfg.StorageEncryptionPass); err != nil {
|
||||||
|
return errors.Wrap(err, "decrypting storage object")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *storageFile) Save() error {
|
func (s *storageFile) Save() error {
|
||||||
|
@ -172,6 +191,11 @@ func (s *storageFile) Save() error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Encrypt fields in memory before writing
|
||||||
|
if err := crypt.EncryptFields(s, cfg.StorageEncryptionPass); err != nil {
|
||||||
|
return errors.Wrap(err, "encrypting storage object")
|
||||||
|
}
|
||||||
|
|
||||||
// Write store to disk
|
// Write store to disk
|
||||||
f, err := os.Create(cfg.StorageFile)
|
f, err := os.Create(cfg.StorageFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -182,16 +206,30 @@ func (s *storageFile) Save() error {
|
||||||
zf := gzip.NewWriter(f)
|
zf := gzip.NewWriter(f)
|
||||||
defer zf.Close()
|
defer zf.Close()
|
||||||
|
|
||||||
return errors.Wrap(
|
if err = json.NewEncoder(zf).Encode(s); err != nil {
|
||||||
json.NewEncoder(zf).Encode(s),
|
return errors.Wrap(err, "encode storage object")
|
||||||
"encode storage object",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *storageFile) SetGrantedScopes(user string, scopes []string) error {
|
// Decrypt the values to make them accessible again
|
||||||
|
if err = crypt.DecryptFields(s, cfg.StorageEncryptionPass); err != nil {
|
||||||
|
return errors.Wrap(err, "decrypting storage object")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *storageFile) SetGrantedScopes(user string, scopes []string, merge bool) error {
|
||||||
s.lock.Lock()
|
s.lock.Lock()
|
||||||
defer s.lock.Unlock()
|
defer s.lock.Unlock()
|
||||||
|
|
||||||
|
if merge {
|
||||||
|
for _, sc := range s.GrantedScopes[user] {
|
||||||
|
if !str.StringInSlice(sc, scopes) {
|
||||||
|
scopes = append(scopes, sc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
s.GrantedScopes[user] = scopes
|
s.GrantedScopes[user] = scopes
|
||||||
|
|
||||||
return errors.Wrap(s.Save(), "saving store")
|
return errors.Wrap(s.Save(), "saving store")
|
||||||
|
@ -238,6 +276,16 @@ func (s *storageFile) RemoveVariable(key string) error {
|
||||||
return errors.Wrap(s.Save(), "saving store")
|
return errors.Wrap(s.Save(), "saving store")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *storageFile) UpdateBotToken(accessToken, refreshToken string) error {
|
||||||
|
s.lock.Lock()
|
||||||
|
defer s.lock.Unlock()
|
||||||
|
|
||||||
|
s.BotAccessToken = accessToken
|
||||||
|
s.BotRefreshToken = refreshToken
|
||||||
|
|
||||||
|
return errors.Wrap(s.Save(), "saving store")
|
||||||
|
}
|
||||||
|
|
||||||
func (s *storageFile) UpdateCounter(counter string, value int64, absolute bool) error {
|
func (s *storageFile) UpdateCounter(counter string, value int64, absolute bool) error {
|
||||||
s.lock.Lock()
|
s.lock.Lock()
|
||||||
defer s.lock.Unlock()
|
defer s.lock.Unlock()
|
||||||
|
|
|
@ -48,6 +48,8 @@ const (
|
||||||
EventSubEventTypeStreamOnline = "stream.online"
|
EventSubEventTypeStreamOnline = "stream.online"
|
||||||
|
|
||||||
EventSubEventTypeChannelPointCustomRewardRedemptionAdd = "channel.channel_points_custom_reward_redemption.add"
|
EventSubEventTypeChannelPointCustomRewardRedemptionAdd = "channel.channel_points_custom_reward_redemption.add"
|
||||||
|
|
||||||
|
EventSubEventTypeUserAuthorizationRevoke = "user.authorization.revoke"
|
||||||
)
|
)
|
||||||
|
|
||||||
type (
|
type (
|
||||||
|
@ -130,6 +132,13 @@ type (
|
||||||
StartedAt time.Time `json:"started_at"`
|
StartedAt time.Time `json:"started_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
EventSubEventUserAuthorizationRevoke struct {
|
||||||
|
ClientID string `json:"client_id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
UserLogin string `json:"user_login"`
|
||||||
|
UserName string `json:"user_name"`
|
||||||
|
}
|
||||||
|
|
||||||
eventSubPostMessage struct {
|
eventSubPostMessage struct {
|
||||||
Challenge string `json:"challenge"`
|
Challenge string `json:"challenge"`
|
||||||
Subscription eventSubSubscription `json:"subscription"`
|
Subscription eventSubSubscription `json:"subscription"`
|
||||||
|
@ -271,8 +280,25 @@ func (e *EventSubClient) PreFetchSubscriptions(ctx context.Context) error {
|
||||||
for i := range subList {
|
for i := range subList {
|
||||||
sub := subList[i]
|
sub := subList[i]
|
||||||
|
|
||||||
if !str.StringInSlice(sub.Status, []string{eventSubStatusEnabled, eventSubStatusVerificationPending}) || sub.Transport.Callback != e.apiURL {
|
switch {
|
||||||
// Not our callback or not active
|
case !str.StringInSlice(sub.Status, []string{eventSubStatusEnabled, eventSubStatusVerificationPending}):
|
||||||
|
// Is not an active hook, we don't need to care: It will be
|
||||||
|
// confirmed later or will expire but should not be counted
|
||||||
|
continue
|
||||||
|
|
||||||
|
case strings.HasPrefix(sub.Transport.Callback, e.apiURL) && sub.Transport.Callback != e.fullAPIurl():
|
||||||
|
// Uses the same API URL but with another secret handle: Must
|
||||||
|
// have been registered by another instance with another secret
|
||||||
|
// so we should be able to deregister it without causing any
|
||||||
|
// trouble
|
||||||
|
logger := log.WithFields(log.Fields{
|
||||||
|
"id": sub.ID,
|
||||||
|
"topic": sub.Type,
|
||||||
|
})
|
||||||
|
logger.Debug("Removing deprecated EventSub subscription")
|
||||||
|
if err = e.twitchClient.deleteEventSubSubscription(ctx, sub.ID); err != nil {
|
||||||
|
logger.WithError(err).Error("Unable to deregister deprecated EventSub subscription")
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -330,7 +356,7 @@ func (e *EventSubClient) RegisterEventSubHooks(event string, condition EventSubC
|
||||||
Condition: condition,
|
Condition: condition,
|
||||||
Transport: eventSubTransport{
|
Transport: eventSubTransport{
|
||||||
Method: "webhook",
|
Method: "webhook",
|
||||||
Callback: e.apiURL,
|
Callback: e.fullAPIurl(),
|
||||||
Secret: e.secret,
|
Secret: e.secret,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
@ -357,6 +383,10 @@ func (e *EventSubClient) RegisterEventSubHooks(event string, condition EventSubC
|
||||||
return func() { e.unregisterCallback(cacheKey, cbKey) }, nil
|
return func() { e.unregisterCallback(cacheKey, cbKey) }, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (e *EventSubClient) fullAPIurl() string {
|
||||||
|
return strings.Join([]string{e.apiURL, e.secretHandle}, "/")
|
||||||
|
}
|
||||||
|
|
||||||
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]
|
||||||
|
|
|
@ -1,6 +1,18 @@
|
||||||
package twitch
|
package twitch
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
// API Scopes
|
||||||
ScopeChannelManageRedemptions = "channel:manage:redemptions"
|
ScopeChannelManageRedemptions = "channel:manage:redemptions"
|
||||||
ScopeChannelReadRedemptions = "channel:read:redemptions"
|
ScopeChannelReadRedemptions = "channel:read:redemptions"
|
||||||
|
ScopeChannelEditCommercial = "channel:edit:commercial"
|
||||||
|
ScopeChannelManageBroadcast = "channel:manage:broadcast"
|
||||||
|
ScopeChannelManagePolls = "channel:manage:polls"
|
||||||
|
ScopeChannelManagePredictions = "channel:manage:predictions"
|
||||||
|
|
||||||
|
// Chat Scopes
|
||||||
|
ScopeChannelModerate = "channel:moderate" // Perform moderation actions in a channel. The user requesting the scope must be a moderator in the channel.
|
||||||
|
ScopeChatEdit = "chat:edit" // Send live stream chat and rooms messages.
|
||||||
|
ScopeChatRead = "chat:read" // View live stream chat and rooms messages.
|
||||||
|
ScopeWhisperRead = "whispers:read" // View your whisper messages.
|
||||||
|
ScopeWhisperEdit = "whispers:edit" // Send whisper messages.
|
||||||
)
|
)
|
||||||
|
|
203
twitch/twitch.go
203
twitch/twitch.go
|
@ -3,6 +3,7 @@ package twitch
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
@ -10,6 +11,7 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
|
@ -43,13 +45,33 @@ type (
|
||||||
Client struct {
|
Client struct {
|
||||||
clientID string
|
clientID string
|
||||||
clientSecret string
|
clientSecret string
|
||||||
token string
|
|
||||||
|
accessToken string
|
||||||
|
refreshToken string
|
||||||
|
tokenValidity time.Time
|
||||||
|
tokenUpdateHook func(string, string) error
|
||||||
|
|
||||||
appAccessToken string
|
appAccessToken string
|
||||||
|
|
||||||
apiCache *APICache
|
apiCache *APICache
|
||||||
}
|
}
|
||||||
|
|
||||||
|
OAuthTokenResponse struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
ExpiresIn int `json:"expires_in"`
|
||||||
|
Scope []string `json:"scope"`
|
||||||
|
TokenType string `json:"token_type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
OAuthTokenValidationResponse struct {
|
||||||
|
ClientID string `json:"client_id"`
|
||||||
|
Login string `json:"login"`
|
||||||
|
Scopes []string `json:"scopes"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
ExpiresIn int `json:"expires_in"`
|
||||||
|
}
|
||||||
|
|
||||||
StreamInfo struct {
|
StreamInfo struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
UserID string `json:"user_id"`
|
UserID string `json:"user_id"`
|
||||||
|
@ -81,25 +103,29 @@ type (
|
||||||
Body io.Reader
|
Body io.Reader
|
||||||
Context context.Context
|
Context context.Context
|
||||||
Method string
|
Method string
|
||||||
|
NoRetry bool
|
||||||
|
NoValidateToken bool
|
||||||
OKStatus int
|
OKStatus int
|
||||||
Out interface{}
|
Out interface{}
|
||||||
URL string
|
URL string
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
func New(clientID, clientSecret, token string) *Client {
|
func New(clientID, clientSecret, accessToken, refreshToken string) *Client {
|
||||||
return &Client{
|
return &Client{
|
||||||
clientID: clientID,
|
clientID: clientID,
|
||||||
clientSecret: clientSecret,
|
clientSecret: clientSecret,
|
||||||
token: token,
|
|
||||||
|
accessToken: accessToken,
|
||||||
|
refreshToken: refreshToken,
|
||||||
|
|
||||||
apiCache: newTwitchAPICache(),
|
apiCache: newTwitchAPICache(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c Client) APICache() *APICache { return c.apiCache }
|
func (c *Client) APICache() *APICache { return c.apiCache }
|
||||||
|
|
||||||
func (c Client) GetAuthorizedUsername() (string, error) {
|
func (c *Client) GetAuthorizedUsername() (string, error) {
|
||||||
var payload struct {
|
var payload struct {
|
||||||
Data []User `json:"data"`
|
Data []User `json:"data"`
|
||||||
}
|
}
|
||||||
|
@ -122,7 +148,7 @@ func (c Client) GetAuthorizedUsername() (string, error) {
|
||||||
return payload.Data[0].Login, nil
|
return payload.Data[0].Login, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c Client) GetDisplayNameForUser(username string) (string, error) {
|
func (c *Client) GetDisplayNameForUser(username string) (string, error) {
|
||||||
cacheKey := []string{"displayNameForUsername", username}
|
cacheKey := []string{"displayNameForUsername", username}
|
||||||
if d := c.apiCache.Get(cacheKey); d != nil {
|
if d := c.apiCache.Get(cacheKey); d != nil {
|
||||||
return d.(string), nil
|
return d.(string), nil
|
||||||
|
@ -133,7 +159,7 @@ func (c Client) GetDisplayNameForUser(username string) (string, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := c.request(clientRequestOpts{
|
if err := c.request(clientRequestOpts{
|
||||||
AuthType: authTypeBearerToken,
|
AuthType: authTypeAppAccessToken,
|
||||||
Context: context.Background(),
|
Context: context.Background(),
|
||||||
Method: http.MethodGet,
|
Method: http.MethodGet,
|
||||||
Out: &payload,
|
Out: &payload,
|
||||||
|
@ -152,7 +178,7 @@ func (c Client) GetDisplayNameForUser(username string) (string, error) {
|
||||||
return payload.Data[0].DisplayName, nil
|
return payload.Data[0].DisplayName, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c Client) GetFollowDate(from, to string) (time.Time, error) {
|
func (c *Client) GetFollowDate(from, to string) (time.Time, error) {
|
||||||
cacheKey := []string{"followDate", from, to}
|
cacheKey := []string{"followDate", from, to}
|
||||||
if d := c.apiCache.Get(cacheKey); d != nil {
|
if d := c.apiCache.Get(cacheKey); d != nil {
|
||||||
return d.(time.Time), nil
|
return d.(time.Time), nil
|
||||||
|
@ -174,7 +200,7 @@ func (c Client) GetFollowDate(from, to string) (time.Time, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := c.request(clientRequestOpts{
|
if err := c.request(clientRequestOpts{
|
||||||
AuthType: authTypeBearerToken,
|
AuthType: authTypeAppAccessToken,
|
||||||
Context: context.Background(),
|
Context: context.Background(),
|
||||||
Method: http.MethodGet,
|
Method: http.MethodGet,
|
||||||
OKStatus: http.StatusOK,
|
OKStatus: http.StatusOK,
|
||||||
|
@ -194,7 +220,19 @@ func (c Client) GetFollowDate(from, to string) (time.Time, error) {
|
||||||
return payload.Data[0].FollowedAt, nil
|
return payload.Data[0].FollowedAt, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c Client) GetUserInformation(user string) (*User, error) {
|
func (c *Client) GetToken() (string, error) {
|
||||||
|
if err := c.ValidateToken(context.Background(), false); err != nil {
|
||||||
|
if err = c.RefreshToken(); err != nil {
|
||||||
|
return "", errors.Wrap(err, "refreshing token after validation error")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token was refreshed, therefore should now be valid
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.accessToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetUserInformation(user string) (*User, error) {
|
||||||
var (
|
var (
|
||||||
out User
|
out User
|
||||||
param = "login"
|
param = "login"
|
||||||
|
@ -214,7 +252,7 @@ func (c Client) GetUserInformation(user string) (*User, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := c.request(clientRequestOpts{
|
if err := c.request(clientRequestOpts{
|
||||||
AuthType: authTypeBearerToken,
|
AuthType: authTypeAppAccessToken,
|
||||||
Context: context.Background(),
|
Context: context.Background(),
|
||||||
Method: http.MethodGet,
|
Method: http.MethodGet,
|
||||||
OKStatus: http.StatusOK,
|
OKStatus: http.StatusOK,
|
||||||
|
@ -235,7 +273,7 @@ func (c Client) GetUserInformation(user string) (*User, error) {
|
||||||
return &out, nil
|
return &out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c Client) SearchCategories(ctx context.Context, name string) ([]Category, error) {
|
func (c *Client) SearchCategories(ctx context.Context, name string) ([]Category, error) {
|
||||||
var out []Category
|
var out []Category
|
||||||
|
|
||||||
params := make(url.Values)
|
params := make(url.Values)
|
||||||
|
@ -274,7 +312,7 @@ func (c Client) SearchCategories(ctx context.Context, name string) ([]Category,
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c Client) HasLiveStream(username string) (bool, error) {
|
func (c *Client) HasLiveStream(username string) (bool, error) {
|
||||||
cacheKey := []string{"hasLiveStream", username}
|
cacheKey := []string{"hasLiveStream", username}
|
||||||
if d := c.apiCache.Get(cacheKey); d != nil {
|
if d := c.apiCache.Get(cacheKey); d != nil {
|
||||||
return d.(bool), nil
|
return d.(bool), nil
|
||||||
|
@ -305,7 +343,7 @@ func (c Client) HasLiveStream(username string) (bool, error) {
|
||||||
return len(payload.Data) == 1 && payload.Data[0].Type == "live", nil
|
return len(payload.Data) == 1 && payload.Data[0].Type == "live", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c Client) GetCurrentStreamInfo(username string) (*StreamInfo, error) {
|
func (c *Client) GetCurrentStreamInfo(username string) (*StreamInfo, error) {
|
||||||
cacheKey := []string{"currentStreamInfo", username}
|
cacheKey := []string{"currentStreamInfo", username}
|
||||||
if si := c.apiCache.Get(cacheKey); si != nil {
|
if si := c.apiCache.Get(cacheKey); si != nil {
|
||||||
return si.(*StreamInfo), nil
|
return si.(*StreamInfo), nil
|
||||||
|
@ -341,7 +379,7 @@ func (c Client) GetCurrentStreamInfo(username string) (*StreamInfo, error) {
|
||||||
return payload.Data[0], nil
|
return payload.Data[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c Client) GetIDForUsername(username string) (string, error) {
|
func (c *Client) GetIDForUsername(username string) (string, error) {
|
||||||
cacheKey := []string{"idForUsername", username}
|
cacheKey := []string{"idForUsername", username}
|
||||||
if d := c.apiCache.Get(cacheKey); d != nil {
|
if d := c.apiCache.Get(cacheKey); d != nil {
|
||||||
return d.(string), nil
|
return d.(string), nil
|
||||||
|
@ -352,7 +390,7 @@ func (c Client) GetIDForUsername(username string) (string, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := c.request(clientRequestOpts{
|
if err := c.request(clientRequestOpts{
|
||||||
AuthType: authTypeBearerToken,
|
AuthType: authTypeAppAccessToken,
|
||||||
Context: context.Background(),
|
Context: context.Background(),
|
||||||
Method: http.MethodGet,
|
Method: http.MethodGet,
|
||||||
OKStatus: http.StatusOK,
|
OKStatus: http.StatusOK,
|
||||||
|
@ -372,7 +410,7 @@ func (c Client) GetIDForUsername(username string) (string, error) {
|
||||||
return payload.Data[0].ID, nil
|
return payload.Data[0].ID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c Client) GetRecentStreamInfo(username string) (string, string, error) {
|
func (c *Client) GetRecentStreamInfo(username string) (string, string, error) {
|
||||||
cacheKey := []string{"recentStreamInfo", username}
|
cacheKey := []string{"recentStreamInfo", username}
|
||||||
if d := c.apiCache.Get(cacheKey); d != nil {
|
if d := c.apiCache.Get(cacheKey); d != nil {
|
||||||
return d.([2]string)[0], d.([2]string)[1], nil
|
return d.([2]string)[0], d.([2]string)[1], nil
|
||||||
|
@ -413,7 +451,7 @@ func (c Client) GetRecentStreamInfo(username string) (string, string, error) {
|
||||||
return payload.Data[0].GameName, payload.Data[0].Title, nil
|
return payload.Data[0].GameName, payload.Data[0].Title, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c Client) ModifyChannelInformation(ctx context.Context, broadcasterName string, game, title *string) error {
|
func (c *Client) ModifyChannelInformation(ctx context.Context, broadcasterName string, game, title *string) error {
|
||||||
if game == nil && title == nil {
|
if game == nil && title == nil {
|
||||||
return errors.New("netiher game nor title provided")
|
return errors.New("netiher game nor title provided")
|
||||||
}
|
}
|
||||||
|
@ -478,8 +516,96 @@ func (c Client) ModifyChannelInformation(ctx context.Context, broadcasterName st
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) UpdateToken(token string) {
|
func (c *Client) RefreshToken() error {
|
||||||
c.token = token
|
if c.refreshToken == "" {
|
||||||
|
return errors.New("no refresh token set")
|
||||||
|
}
|
||||||
|
|
||||||
|
params := make(url.Values)
|
||||||
|
params.Set("client_id", c.clientID)
|
||||||
|
params.Set("client_secret", c.clientSecret)
|
||||||
|
params.Set("refresh_token", c.refreshToken)
|
||||||
|
params.Set("grant_type", "refresh_token")
|
||||||
|
|
||||||
|
var resp OAuthTokenResponse
|
||||||
|
|
||||||
|
if err := c.request(clientRequestOpts{
|
||||||
|
AuthType: authTypeUnauthorized,
|
||||||
|
Context: context.Background(),
|
||||||
|
Method: http.MethodPost,
|
||||||
|
OKStatus: http.StatusOK,
|
||||||
|
Out: &resp,
|
||||||
|
URL: fmt.Sprintf("https://id.twitch.tv/oauth2/token?%s", params.Encode()),
|
||||||
|
}); err != nil {
|
||||||
|
// Retried refresh failed, wipe tokens
|
||||||
|
c.UpdateToken("", "")
|
||||||
|
if c.tokenUpdateHook != nil {
|
||||||
|
if herr := c.tokenUpdateHook("", ""); herr != nil {
|
||||||
|
log.WithError(err).Error("Unable to store token wipe after refresh failure")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.Wrap(err, "executing request")
|
||||||
|
}
|
||||||
|
|
||||||
|
c.UpdateToken(resp.AccessToken, resp.RefreshToken)
|
||||||
|
c.tokenValidity = time.Now().Add(time.Duration(resp.ExpiresIn) * time.Second)
|
||||||
|
log.WithField("expiry", c.tokenValidity).Trace("Access token refreshed")
|
||||||
|
|
||||||
|
if c.tokenUpdateHook == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.Wrap(c.tokenUpdateHook(resp.AccessToken, resp.RefreshToken), "calling token update hook")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) SetTokenUpdateHook(f func(string, string) error) {
|
||||||
|
c.tokenUpdateHook = f
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) UpdateToken(accessToken, refreshToken string) {
|
||||||
|
c.accessToken = accessToken
|
||||||
|
c.refreshToken = refreshToken
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) ValidateToken(ctx context.Context, force bool) error {
|
||||||
|
if c.tokenValidity.After(time.Now()) && !force {
|
||||||
|
// We do have an expiration time and it's not expired
|
||||||
|
// so we can assume we've checked the token and it should
|
||||||
|
// still be valid.
|
||||||
|
// NOTE(kahlers): In case of a token revokation this
|
||||||
|
// assumption is invalid and will lead to failing requests
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.accessToken == "" {
|
||||||
|
return errors.New("no access token present")
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp OAuthTokenValidationResponse
|
||||||
|
|
||||||
|
if err := c.request(clientRequestOpts{
|
||||||
|
AuthType: authTypeBearerToken,
|
||||||
|
Context: ctx,
|
||||||
|
Method: http.MethodGet,
|
||||||
|
NoRetry: true,
|
||||||
|
NoValidateToken: true,
|
||||||
|
OKStatus: http.StatusOK,
|
||||||
|
Out: &resp,
|
||||||
|
URL: "https://id.twitch.tv/oauth2/validate",
|
||||||
|
}); err != nil {
|
||||||
|
return errors.Wrap(err, "executing request")
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.ClientID != c.clientID {
|
||||||
|
return errors.New("token belongs to different app")
|
||||||
|
}
|
||||||
|
|
||||||
|
c.tokenValidity = time.Now().Add(time.Duration(resp.ExpiresIn) * time.Second)
|
||||||
|
log.WithField("expiry", c.tokenValidity).Trace("Access token validated")
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) createEventSubSubscription(ctx context.Context, sub eventSubSubscription) (*eventSubSubscription, error) {
|
func (c *Client) createEventSubSubscription(ctx context.Context, sub eventSubSubscription) (*eventSubSubscription, error) {
|
||||||
|
@ -603,11 +729,11 @@ func (c *Client) getTwitchAppAccessToken() (string, error) {
|
||||||
func (c *Client) request(opts clientRequestOpts) error {
|
func (c *Client) request(opts clientRequestOpts) error {
|
||||||
log.WithFields(log.Fields{
|
log.WithFields(log.Fields{
|
||||||
"method": opts.Method,
|
"method": opts.Method,
|
||||||
"url": opts.URL,
|
"url": c.replaceSecrets(opts.URL),
|
||||||
}).Trace("Execute Twitch API request")
|
}).Trace("Execute Twitch API request")
|
||||||
|
|
||||||
var retries uint64 = twitchRequestRetries
|
var retries uint64 = twitchRequestRetries
|
||||||
if opts.Body != nil {
|
if opts.Body != nil || opts.NoRetry {
|
||||||
// Body must be read only once, do not retry
|
// Body must be read only once, do not retry
|
||||||
retries = 1
|
retries = 1
|
||||||
}
|
}
|
||||||
|
@ -636,7 +762,15 @@ func (c *Client) request(opts clientRequestOpts) error {
|
||||||
req.Header.Set("Client-Id", c.clientID)
|
req.Header.Set("Client-Id", c.clientID)
|
||||||
|
|
||||||
case authTypeBearerToken:
|
case authTypeBearerToken:
|
||||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
accessToken := c.accessToken
|
||||||
|
if !opts.NoValidateToken {
|
||||||
|
accessToken, err = c.GetToken()
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "getting bearer access token")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||||
req.Header.Set("Client-Id", c.clientID)
|
req.Header.Set("Client-Id", c.clientID)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
|
@ -667,3 +801,26 @@ func (c *Client) request(opts clientRequestOpts) error {
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Client) replaceSecrets(u string) string {
|
||||||
|
var replacements []string
|
||||||
|
|
||||||
|
for _, secret := range []string{
|
||||||
|
c.accessToken,
|
||||||
|
c.refreshToken,
|
||||||
|
c.clientSecret,
|
||||||
|
} {
|
||||||
|
if secret == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
replacements = append(replacements, secret, c.hashSecret(secret))
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.NewReplacer(replacements...).Replace(u)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*Client) hashSecret(secret string) string {
|
||||||
|
h := sha256.New()
|
||||||
|
h.Write([]byte(secret))
|
||||||
|
return fmt.Sprintf("[sha256:%x]", h.Sum(nil))
|
||||||
|
}
|
||||||
|
|
|
@ -17,6 +17,7 @@ type (
|
||||||
IsLive bool
|
IsLive bool
|
||||||
Title string
|
Title string
|
||||||
|
|
||||||
|
isInitialized bool
|
||||||
unregisterFunc func()
|
unregisterFunc func()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -33,6 +34,12 @@ func (t twitchChannelState) Equals(c twitchChannelState) bool {
|
||||||
t.Title == c.Title
|
t.Title == c.Title
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t *twitchChannelState) Update(c twitchChannelState) {
|
||||||
|
t.Category = c.Category
|
||||||
|
t.IsLive = c.IsLive
|
||||||
|
t.Title = c.Title
|
||||||
|
}
|
||||||
|
|
||||||
func newTwitchWatcher() *twitchWatcher {
|
func newTwitchWatcher() *twitchWatcher {
|
||||||
return &twitchWatcher{
|
return &twitchWatcher{
|
||||||
ChannelStatus: make(map[string]*twitchChannelState),
|
ChannelStatus: make(map[string]*twitchChannelState),
|
||||||
|
@ -48,7 +55,12 @@ func (t *twitchWatcher) AddChannel(channel string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return t.updateChannelFromAPI(channel, false)
|
// Initialize for check loop
|
||||||
|
t.lock.Lock()
|
||||||
|
t.ChannelStatus[channel] = &twitchChannelState{}
|
||||||
|
t.lock.Unlock()
|
||||||
|
|
||||||
|
return t.updateChannelFromAPI(channel)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *twitchWatcher) Check() {
|
func (t *twitchWatcher) Check() {
|
||||||
|
@ -64,7 +76,7 @@ func (t *twitchWatcher) Check() {
|
||||||
t.lock.RUnlock()
|
t.lock.RUnlock()
|
||||||
|
|
||||||
for _, ch := range channels {
|
for _, ch := range channels {
|
||||||
if err := t.updateChannelFromAPI(ch, true); err != nil {
|
if err := t.updateChannelFromAPI(ch); err != nil {
|
||||||
log.WithError(err).WithField("channel", ch).Error("Unable to update channel status")
|
log.WithError(err).WithField("channel", ch).Error("Unable to update channel status")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -147,10 +159,31 @@ func (t *twitchWatcher) handleEventSubStreamOnOff(isOnline bool) func(json.RawMe
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *twitchWatcher) updateChannelFromAPI(channel string, sendUpdate bool) error {
|
func (t *twitchWatcher) handleEventUserAuthRevoke(m json.RawMessage) error {
|
||||||
|
var payload twitch.EventSubEventUserAuthorizationRevoke
|
||||||
|
if err := json.Unmarshal(m, &payload); err != nil {
|
||||||
|
return errors.Wrap(err, "unmarshalling event")
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.ClientID != cfg.TwitchClient {
|
||||||
|
// We got an revoke for a different ID: Shouldn't happen but whatever.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.Wrap(
|
||||||
|
store.DeleteGrantedScopes(payload.UserLogin),
|
||||||
|
"deleting granted scopes",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *twitchWatcher) updateChannelFromAPI(channel string) error {
|
||||||
|
t.lock.Lock()
|
||||||
|
defer t.lock.Unlock()
|
||||||
|
|
||||||
var (
|
var (
|
||||||
err error
|
err error
|
||||||
status twitchChannelState
|
status twitchChannelState
|
||||||
|
storedStatus = t.ChannelStatus[channel]
|
||||||
)
|
)
|
||||||
|
|
||||||
status.IsLive, err = twitchClient.HasLiveStream(channel)
|
status.IsLive, err = twitchClient.HasLiveStream(channel)
|
||||||
|
@ -163,23 +196,28 @@ func (t *twitchWatcher) updateChannelFromAPI(channel string, sendUpdate bool) er
|
||||||
return errors.Wrap(err, "getting stream info")
|
return errors.Wrap(err, "getting stream info")
|
||||||
}
|
}
|
||||||
|
|
||||||
t.lock.Lock()
|
if storedStatus == nil {
|
||||||
defer t.lock.Unlock()
|
storedStatus = &twitchChannelState{}
|
||||||
|
t.ChannelStatus[channel] = storedStatus
|
||||||
if t.ChannelStatus[channel] != nil && t.ChannelStatus[channel].Equals(status) {
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if sendUpdate && t.ChannelStatus[channel] != nil {
|
if storedStatus.isInitialized && !storedStatus.Equals(status) {
|
||||||
|
// Send updates only when we do have an update
|
||||||
t.triggerUpdate(channel, &status.Title, &status.Category, &status.IsLive)
|
t.triggerUpdate(channel, &status.Title, &status.Category, &status.IsLive)
|
||||||
|
}
|
||||||
|
|
||||||
|
storedStatus.Update(status)
|
||||||
|
storedStatus.isInitialized = true
|
||||||
|
|
||||||
|
if storedStatus.unregisterFunc != nil {
|
||||||
|
// Do not register twice
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if status.unregisterFunc, err = t.registerEventSubCallbacks(channel); err != nil {
|
if storedStatus.unregisterFunc, err = t.registerEventSubCallbacks(channel); err != nil {
|
||||||
return errors.Wrap(err, "registering eventsub callbacks")
|
return errors.Wrap(err, "registering eventsub callbacks")
|
||||||
}
|
}
|
||||||
|
|
||||||
t.ChannelStatus[channel] = &status
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -260,7 +298,13 @@ func (t *twitchWatcher) registerEventSubCallbacks(channel string) (func(), error
|
||||||
uf, err := twitchEventSubClient.RegisterEventSubHooks(tr.Topic, tr.Condition, tr.Hook)
|
uf, err := twitchEventSubClient.RegisterEventSubHooks(tr.Topic, tr.Condition, tr.Hook)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.WithError(err).Error("Unable to register topic")
|
logger.WithError(err).Error("Unable to register topic")
|
||||||
continue
|
|
||||||
|
for _, f := range unsubHandlers {
|
||||||
|
// Error will cause unsub handlers not to be stored, therefore we unsub them now
|
||||||
|
f()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, errors.Wrap(err, "registering topic")
|
||||||
}
|
}
|
||||||
|
|
||||||
unsubHandlers = append(unsubHandlers, uf)
|
unsubHandlers = append(unsubHandlers, uf)
|
||||||
|
@ -273,6 +317,16 @@ func (t *twitchWatcher) registerEventSubCallbacks(channel string) (func(), error
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t *twitchWatcher) registerGlobalHooks() error {
|
||||||
|
_, err := twitchEventSubClient.RegisterEventSubHooks(
|
||||||
|
twitch.EventSubEventTypeUserAuthorizationRevoke,
|
||||||
|
twitch.EventSubCondition{ClientID: cfg.TwitchClient},
|
||||||
|
t.handleEventUserAuthRevoke,
|
||||||
|
)
|
||||||
|
|
||||||
|
return errors.Wrap(err, "registering user auth hook")
|
||||||
|
}
|
||||||
|
|
||||||
func (t *twitchWatcher) triggerUpdate(channel string, title, category *string, online *bool) {
|
func (t *twitchWatcher) triggerUpdate(channel string, title, category *string, online *bool) {
|
||||||
if category != nil && t.ChannelStatus[channel].Category != *category {
|
if category != nil && t.ChannelStatus[channel].Category != *category {
|
||||||
t.ChannelStatus[channel].Category = *category
|
t.ChannelStatus[channel].Category = *category
|
||||||
|
|
Loading…
Reference in a new issue