mirror of
https://github.com/Luzifer/twitch-bot.git
synced 2024-12-20 11:51:17 +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")
|
||||
}
|
||||
|
||||
tc := twitch.New(cfg.TwitchClient, cfg.TwitchClientSecret, token)
|
||||
tc := twitch.New(cfg.TwitchClient, cfg.TwitchClientSecret, token, "")
|
||||
|
||||
user, err := tc.GetAuthorizedUsername()
|
||||
return user, tc, errors.Wrap(err, "getting authorized user")
|
||||
|
|
|
@ -2,7 +2,10 @@ package main
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/gofrs/uuid/v3"
|
||||
"github.com/gorilla/mux"
|
||||
|
@ -14,8 +17,10 @@ import (
|
|||
|
||||
type (
|
||||
configEditorGeneralConfig struct {
|
||||
BotEditors []string `json:"bot_editors"`
|
||||
Channels []string `json:"channels"`
|
||||
BotEditors []string `json:"bot_editors"`
|
||||
BotName string `json:"bot_name"`
|
||||
Channels []string `json:"channels"`
|
||||
ChannelHasScopes map[string]bool `json:"channel_has_scopes"`
|
||||
}
|
||||
)
|
||||
|
||||
|
@ -79,6 +84,16 @@ func registerEditorGeneralConfigRoutes() {
|
|||
RequiresEditorsAuth: true,
|
||||
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 {
|
||||
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) {
|
||||
user, _, err := getAuthorizationFromRequest(r)
|
||||
if err != nil {
|
||||
|
@ -137,9 +183,23 @@ func configEditorHandleGeneralDeleteAuthToken(w http.ResponseWriter, r *http.Req
|
|||
}
|
||||
|
||||
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{
|
||||
BotEditors: config.BotEditors,
|
||||
Channels: config.Channels,
|
||||
BotEditors: config.BotEditors,
|
||||
BotName: uName,
|
||||
Channels: config.Channels,
|
||||
ChannelHasScopes: elevated,
|
||||
}); err != nil {
|
||||
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
|
||||
|
||||
require (
|
||||
github.com/Luzifer/go-openssl/v4 v4.1.0
|
||||
github.com/Luzifer/go_helpers/v2 v2.12.2
|
||||
github.com/Luzifer/korvike/functions v0.6.1
|
||||
github.com/Luzifer/rconfig/v2 v2.3.0
|
||||
|
@ -15,6 +16,7 @@ require (
|
|||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/sirupsen/logrus v1.8.1
|
||||
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/yaml.v2 v2.4.0
|
||||
)
|
||||
|
@ -42,7 +44,6 @@ require (
|
|||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/src-d/gcfg v1.4.0 // 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/sys v0.0.0-20210124154548-22da62e12c0c // 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/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/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/go.mod h1:Jp1wZqtbEFNrLlJW7noomOF7fLRW36k+ELITPDK4SHc=
|
||||
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")
|
||||
}
|
||||
|
||||
token, err := twitchClient.GetToken()
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "getting auth token")
|
||||
}
|
||||
|
||||
h.c = irc.NewClient(conn, irc.ClientConfig{
|
||||
Nick: username,
|
||||
Pass: strings.Join([]string{"oauth", cfg.TwitchToken}, ":"),
|
||||
Pass: strings.Join([]string{"oauth", token}, ":"),
|
||||
User: username,
|
||||
Name: username,
|
||||
Handler: h,
|
||||
|
|
101
main.go
101
main.go
|
@ -5,6 +5,7 @@ import (
|
|||
"context"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
@ -25,22 +26,29 @@ import (
|
|||
"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 (
|
||||
cfg = struct {
|
||||
BaseURL string `flag:"base-url" default:"" description:"External URL of the config-editor interface (set to enable EventSub support)"`
|
||||
CommandTimeout time.Duration `flag:"command-timeout" default:"30s" description:"Timeout for command execution"`
|
||||
Config string `flag:"config,c" default:"./config.yaml" description:"Location of configuration file"`
|
||||
IRCRateLimit time.Duration `flag:"rate-limit" default:"1500ms" description:"How often to send a message (default: 20/30s=1500ms, if your bot is mod everywhere: 100/30s=300ms, different for known/verified bots)"`
|
||||
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"`
|
||||
StorageFile string `flag:"storage-file" default:"./storage.json.gz" description:"Where to store the data"`
|
||||
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"`
|
||||
TwitchToken string `flag:"twitch-token" default:"" description:"OAuth token valid for client"`
|
||||
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"`
|
||||
BaseURL string `flag:"base-url" default:"" description:"External URL of the config-editor interface (set to enable EventSub support)"`
|
||||
CommandTimeout time.Duration `flag:"command-timeout" default:"30s" description:"Timeout for command execution"`
|
||||
Config string `flag:"config,c" default:"./config.yaml" description:"Location of configuration file"`
|
||||
IRCRateLimit time.Duration `flag:"rate-limit" default:"1500ms" description:"How often to send a message (default: 20/30s=1500ms, if your bot is mod everywhere: 100/30s=300ms, different for known/verified bots)"`
|
||||
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"`
|
||||
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"`
|
||||
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 (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"`
|
||||
VersionAndExit bool `flag:"version" default:"false" description:"Prints current version and exits"`
|
||||
}{}
|
||||
|
||||
config *configFile
|
||||
|
@ -85,6 +93,14 @@ func init() {
|
|||
} else {
|
||||
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) {
|
||||
|
@ -139,21 +155,38 @@ func handleSubCommand(args []string) {
|
|||
func main() {
|
||||
var err error
|
||||
|
||||
if err = store.Load(); err != nil {
|
||||
log.WithError(err).Fatal("Unable to load storage file")
|
||||
}
|
||||
|
||||
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()
|
||||
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.HandleFunc("/openapi.html", handleSwaggerHTML)
|
||||
router.HandleFunc("/openapi.json", handleSwaggerRequest)
|
||||
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 {
|
||||
log.WithError(err).Fatal("Unable to load core plugins")
|
||||
}
|
||||
|
@ -196,6 +229,7 @@ func main() {
|
|||
|
||||
var (
|
||||
ircDisconnected = make(chan struct{}, 1)
|
||||
ircRetryBackoff = initialIRCRetryBackoff
|
||||
autoMessageTicker = time.NewTicker(time.Second)
|
||||
)
|
||||
|
||||
|
@ -222,13 +256,16 @@ func main() {
|
|||
twitchEventSubClient, err = twitch.NewEventSubClient(twitchClient, strings.Join([]string{
|
||||
strings.TrimRight(cfg.BaseURL, "/"),
|
||||
"eventsub",
|
||||
handle,
|
||||
}, "/"), secret, handle)
|
||||
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
@ -250,9 +287,17 @@ func main() {
|
|||
}
|
||||
|
||||
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() {
|
||||
if err := ircHdl.Run(); err != nil {
|
||||
log.WithError(err).Error("IRC run exited unexpectedly")
|
||||
|
@ -366,17 +411,19 @@ func startCheck() error {
|
|||
errs = append(errs, "No Twitch-ClientId given")
|
||||
}
|
||||
|
||||
if cfg.TwitchToken == "" {
|
||||
errs = append(errs, "Twitch-Token is unset")
|
||||
if cfg.TwitchClientSecret == "" {
|
||||
errs = append(errs, "No Twitch-ClientSecret given")
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
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
|
||||
you need help with obtaining those credentials please visit the
|
||||
following website:
|
||||
These parameters are required and you need to provide them.
|
||||
|
||||
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/
|
||||
|
||||
|
|
|
@ -75,7 +75,7 @@ func TestAllowExecuteDisableOnOffline(t *testing.T) {
|
|||
r := &Rule{DisableOnOffline: testPtrBool(true)}
|
||||
|
||||
// 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", "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
|
||||
fixed-width
|
||||
class="mr-1 text-warning"
|
||||
class="text-warning"
|
||||
:icon="['fas', 'spinner']"
|
||||
pulse
|
||||
/>
|
||||
</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
|
||||
v-if="configNotifySocketConnected"
|
||||
v-b-tooltip.hover
|
||||
id="socketConnectionStatus"
|
||||
fixed-width
|
||||
class="mr-1 text-success"
|
||||
:icon="['fas', 'ethernet']"
|
||||
title="Connected to Bot"
|
||||
/>
|
||||
<font-awesome-icon
|
||||
v-else
|
||||
v-b-tooltip.hover
|
||||
id="socketConnectionStatus"
|
||||
fixed-width
|
||||
class="mr-1 text-danger"
|
||||
: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-navbar-nav>
|
||||
</b-collapse>
|
||||
|
@ -162,6 +191,8 @@
|
|||
<script>
|
||||
import * as constants from './const.js'
|
||||
|
||||
import axios from 'axios'
|
||||
|
||||
export default {
|
||||
computed: {
|
||||
authURL() {
|
||||
|
@ -200,10 +231,19 @@ export default {
|
|||
configNotifySocketConnected: false,
|
||||
error: null,
|
||||
loadingData: false,
|
||||
status: {},
|
||||
}
|
||||
},
|
||||
|
||||
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) {
|
||||
switch (err.response.status) {
|
||||
case 403:
|
||||
|
@ -256,6 +296,9 @@ export default {
|
|||
if (this.isAuthenticated) {
|
||||
this.openConfigNotifySocket()
|
||||
}
|
||||
|
||||
window.setInterval(() => this.fetchStatus(), 10000)
|
||||
this.fetchStatus()
|
||||
},
|
||||
|
||||
name: 'TwitchBotEditorApp',
|
||||
|
|
|
@ -2,185 +2,310 @@
|
|||
<div>
|
||||
<b-row>
|
||||
<b-col>
|
||||
<b-card-group columns>
|
||||
<b-card no-body>
|
||||
<b-card-header>
|
||||
<font-awesome-icon
|
||||
fixed-width
|
||||
class="mr-1"
|
||||
:icon="['fas', 'hashtag']"
|
||||
/>
|
||||
Channels
|
||||
</b-card-header>
|
||||
<b-list-group flush>
|
||||
<b-list-group-item
|
||||
v-for="channel in sortedChannels"
|
||||
:key="channel"
|
||||
class="d-flex align-items-center align-middle"
|
||||
>
|
||||
<span class="mr-auto">
|
||||
<font-awesome-icon
|
||||
fixed-width
|
||||
class="mr-1"
|
||||
:icon="['fas', 'hashtag']"
|
||||
/>
|
||||
{{ channel }}
|
||||
</span>
|
||||
<b-button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
@click="removeChannel(channel)"
|
||||
>
|
||||
<font-awesome-icon
|
||||
fixed-width
|
||||
class="mr-1"
|
||||
:icon="['fas', 'minus']"
|
||||
/>
|
||||
</b-button>
|
||||
</b-list-group-item>
|
||||
|
||||
<b-list-group-item>
|
||||
<b-input-group>
|
||||
<b-form-input
|
||||
v-model="models.addChannel"
|
||||
@keyup.enter="addChannel"
|
||||
/>
|
||||
<b-input-group-append>
|
||||
<b-button
|
||||
variant="success"
|
||||
@click="addChannel"
|
||||
>
|
||||
<font-awesome-icon
|
||||
fixed-width
|
||||
class="mr-1"
|
||||
:icon="['fas', 'plus']"
|
||||
/>
|
||||
Add
|
||||
</b-button>
|
||||
</b-input-group-append>
|
||||
</b-input-group>
|
||||
</b-list-group-item>
|
||||
</b-list-group>
|
||||
</b-card>
|
||||
|
||||
<b-card no-body>
|
||||
<b-card-header>
|
||||
<font-awesome-icon
|
||||
fixed-width
|
||||
class="mr-1"
|
||||
:icon="['fas', 'users']"
|
||||
/>
|
||||
Bot-Editors
|
||||
</b-card-header>
|
||||
<b-list-group flush>
|
||||
<b-list-group-item
|
||||
v-for="editor in sortedEditors"
|
||||
:key="editor"
|
||||
class="d-flex align-items-center align-middle"
|
||||
>
|
||||
<b-avatar
|
||||
class="mr-3"
|
||||
:src="userProfiles[editor] ? userProfiles[editor].profile_image_url : ''"
|
||||
/>
|
||||
<span class="mr-auto">{{ userProfiles[editor] ? userProfiles[editor].display_name : editor }}</span>
|
||||
<b-button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
@click="removeEditor(editor)"
|
||||
>
|
||||
<font-awesome-icon
|
||||
fixed-width
|
||||
class="mr-1"
|
||||
:icon="['fas', 'minus']"
|
||||
/>
|
||||
</b-button>
|
||||
</b-list-group-item>
|
||||
|
||||
<b-list-group-item>
|
||||
<b-input-group>
|
||||
<b-form-input
|
||||
v-model="models.addEditor"
|
||||
@keyup.enter="addEditor"
|
||||
/>
|
||||
<b-input-group-append>
|
||||
<b-button
|
||||
variant="success"
|
||||
@click="addEditor"
|
||||
>
|
||||
<font-awesome-icon
|
||||
fixed-width
|
||||
class="mr-1"
|
||||
:icon="['fas', 'plus']"
|
||||
/>
|
||||
Add
|
||||
</b-button>
|
||||
</b-input-group-append>
|
||||
</b-input-group>
|
||||
</b-list-group-item>
|
||||
</b-list-group>
|
||||
</b-card>
|
||||
|
||||
<b-card no-body>
|
||||
<b-card-header
|
||||
<b-card no-body>
|
||||
<b-card-header>
|
||||
<font-awesome-icon
|
||||
fixed-width
|
||||
class="mr-1"
|
||||
:icon="['fas', 'hashtag']"
|
||||
/>
|
||||
Channels
|
||||
</b-card-header>
|
||||
<b-list-group flush>
|
||||
<b-list-group-item
|
||||
v-for="channel in sortedChannels"
|
||||
:key="channel"
|
||||
class="d-flex align-items-center align-middle"
|
||||
>
|
||||
<span class="mr-auto">
|
||||
<font-awesome-icon
|
||||
fixed-width
|
||||
class="mr-1"
|
||||
:icon="['fas', 'ticket-alt']"
|
||||
:icon="['fas', 'hashtag']"
|
||||
/>
|
||||
Auth-Tokens
|
||||
{{ 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>
|
||||
<b-button-group size="sm">
|
||||
<b-button
|
||||
variant="success"
|
||||
@click="newAPIToken"
|
||||
>
|
||||
<font-awesome-icon
|
||||
fixed-width
|
||||
class="mr-1"
|
||||
:icon="['fas', 'plus']"
|
||||
/>
|
||||
</b-button>
|
||||
</b-button-group>
|
||||
</b-card-header>
|
||||
<b-list-group flush>
|
||||
<b-list-group-item
|
||||
v-if="createdAPIToken"
|
||||
variant="success"
|
||||
<b-button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
@click="removeChannel(channel)"
|
||||
>
|
||||
Token was created, copy it within 30s as you will not see it again:<br>
|
||||
<code>{{ createdAPIToken.token }}</code>
|
||||
</b-list-group-item>
|
||||
<font-awesome-icon
|
||||
fixed-width
|
||||
class="mr-1"
|
||||
:icon="['fas', 'minus']"
|
||||
/>
|
||||
</b-button>
|
||||
</b-list-group-item>
|
||||
|
||||
<b-list-group-item
|
||||
v-for="(token, uuid) in apiTokens"
|
||||
:key="uuid"
|
||||
class="d-flex align-items-center align-middle"
|
||||
<b-list-group-item>
|
||||
<b-input-group>
|
||||
<b-form-input
|
||||
v-model="models.addChannel"
|
||||
@keyup.enter="addChannel"
|
||||
/>
|
||||
<b-input-group-append>
|
||||
<b-button
|
||||
variant="success"
|
||||
@click="addChannel"
|
||||
>
|
||||
<font-awesome-icon
|
||||
fixed-width
|
||||
class="mr-1"
|
||||
:icon="['fas', 'plus']"
|
||||
/>
|
||||
Add
|
||||
</b-button>
|
||||
</b-input-group-append>
|
||||
</b-input-group>
|
||||
</b-list-group-item>
|
||||
</b-list-group>
|
||||
</b-card>
|
||||
</b-col>
|
||||
<b-col>
|
||||
<b-card
|
||||
no-body
|
||||
class="mb-3"
|
||||
>
|
||||
<b-card-header>
|
||||
<font-awesome-icon
|
||||
fixed-width
|
||||
class="mr-1"
|
||||
:icon="['fas', 'users']"
|
||||
/>
|
||||
Bot-Editors
|
||||
</b-card-header>
|
||||
<b-list-group flush>
|
||||
<b-list-group-item
|
||||
v-for="editor in sortedEditors"
|
||||
:key="editor"
|
||||
class="d-flex align-items-center align-middle"
|
||||
>
|
||||
<b-avatar
|
||||
class="mr-3"
|
||||
:src="userProfiles[editor] ? userProfiles[editor].profile_image_url : ''"
|
||||
/>
|
||||
<span class="mr-auto">{{ userProfiles[editor] ? userProfiles[editor].display_name : editor }}</span>
|
||||
<b-button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
@click="removeEditor(editor)"
|
||||
>
|
||||
<span class="mr-auto">
|
||||
{{ token.name }}<br>
|
||||
<b-badge
|
||||
v-for="module in token.modules"
|
||||
:key="module"
|
||||
>{{ module === '*' ? 'ANY' : module }}</b-badge>
|
||||
</span>
|
||||
<font-awesome-icon
|
||||
fixed-width
|
||||
class="mr-1"
|
||||
:icon="['fas', 'minus']"
|
||||
/>
|
||||
</b-button>
|
||||
</b-list-group-item>
|
||||
|
||||
<b-list-group-item>
|
||||
<b-input-group>
|
||||
<b-form-input
|
||||
v-model="models.addEditor"
|
||||
@keyup.enter="addEditor"
|
||||
/>
|
||||
<b-input-group-append>
|
||||
<b-button
|
||||
variant="success"
|
||||
@click="addEditor"
|
||||
>
|
||||
<font-awesome-icon
|
||||
fixed-width
|
||||
class="mr-1"
|
||||
:icon="['fas', 'plus']"
|
||||
/>
|
||||
Add
|
||||
</b-button>
|
||||
</b-input-group-append>
|
||||
</b-input-group>
|
||||
</b-list-group-item>
|
||||
</b-list-group>
|
||||
</b-card>
|
||||
|
||||
<b-card
|
||||
no-body
|
||||
>
|
||||
<b-card-header
|
||||
class="d-flex align-items-center align-middle"
|
||||
>
|
||||
<span class="mr-auto">
|
||||
<font-awesome-icon
|
||||
fixed-width
|
||||
class="mr-1"
|
||||
:icon="['fas', 'ticket-alt']"
|
||||
/>
|
||||
Auth-Tokens
|
||||
</span>
|
||||
<b-button-group size="sm">
|
||||
<b-button
|
||||
variant="success"
|
||||
@click="newAPIToken"
|
||||
>
|
||||
<font-awesome-icon
|
||||
fixed-width
|
||||
class="mr-1"
|
||||
:icon="['fas', 'plus']"
|
||||
/>
|
||||
</b-button>
|
||||
</b-button-group>
|
||||
</b-card-header>
|
||||
<b-list-group flush>
|
||||
<b-list-group-item
|
||||
v-if="createdAPIToken"
|
||||
variant="success"
|
||||
>
|
||||
Token was created, copy it within 30s as you will not see it again:<br>
|
||||
<code>{{ createdAPIToken.token }}</code>
|
||||
</b-list-group-item>
|
||||
|
||||
<b-list-group-item
|
||||
v-for="(token, uuid) in apiTokens"
|
||||
:key="uuid"
|
||||
class="d-flex align-items-center align-middle"
|
||||
>
|
||||
<span class="mr-auto">
|
||||
{{ token.name }}<br>
|
||||
<b-badge
|
||||
v-for="module in token.modules"
|
||||
:key="module"
|
||||
>{{ module === '*' ? 'ANY' : module }}</b-badge>
|
||||
</span>
|
||||
<b-button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
@click="removeAPIToken(uuid)"
|
||||
>
|
||||
<font-awesome-icon
|
||||
fixed-width
|
||||
class="mr-1"
|
||||
:icon="['fas', 'minus']"
|
||||
/>
|
||||
</b-button>
|
||||
</b-list-group-item>
|
||||
</b-list-group>
|
||||
</b-card>
|
||||
</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
|
||||
size="sm"
|
||||
variant="danger"
|
||||
@click="removeAPIToken(uuid)"
|
||||
:variant="copyButtonVariant.botConnection"
|
||||
@click="copyAuthURL('botConnection')"
|
||||
>
|
||||
<font-awesome-icon
|
||||
fixed-width
|
||||
class="mr-1"
|
||||
:icon="['fas', 'minus']"
|
||||
:icon="['fas', 'clipboard']"
|
||||
/>
|
||||
Copy
|
||||
</b-button>
|
||||
</b-list-group-item>
|
||||
</b-list-group>
|
||||
</b-card>
|
||||
</b-card-group>
|
||||
</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-row>
|
||||
|
||||
|
@ -240,6 +365,13 @@ export default {
|
|||
]
|
||||
},
|
||||
|
||||
botConnectionCardVariant() {
|
||||
if (this.$parent.status.overall_status_success) {
|
||||
return 'secondary'
|
||||
}
|
||||
return 'warning'
|
||||
},
|
||||
|
||||
sortedChannels() {
|
||||
return [...this.generalConfig?.channels || []].sort((a, b) => a.toLocaleLowerCase().localeCompare(b.toLocaleLowerCase()))
|
||||
},
|
||||
|
@ -261,6 +393,12 @@ export default {
|
|||
data() {
|
||||
return {
|
||||
apiTokens: {},
|
||||
authURLs: {},
|
||||
copyButtonVariant: {
|
||||
botConnection: 'primary',
|
||||
channelPermission: 'primary',
|
||||
},
|
||||
|
||||
createdAPIToken: null,
|
||||
generalConfig: {},
|
||||
models: {
|
||||
|
@ -292,6 +430,35 @@ export default {
|
|||
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() {
|
||||
this.$bus.$emit(constants.NOTIFY_LOADING_DATA, true)
|
||||
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))
|
||||
},
|
||||
|
||||
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() {
|
||||
this.$bus.$emit(constants.NOTIFY_LOADING_DATA, true)
|
||||
return axios.get('config-editor/general', this.$root.axiosOptions)
|
||||
|
@ -397,6 +573,7 @@ export default {
|
|||
Promise.all([
|
||||
this.fetchGeneralConfig(),
|
||||
this.fetchAPITokens(),
|
||||
this.fetchAuthURLs(),
|
||||
]).then(() => {
|
||||
this.$bus.$emit(constants.NOTIFY_CHANGE_PENDING, false)
|
||||
this.$bus.$emit(constants.NOTIFY_LOADING_DATA, false)
|
||||
|
@ -406,6 +583,7 @@ export default {
|
|||
Promise.all([
|
||||
this.fetchGeneralConfig(),
|
||||
this.fetchAPITokens(),
|
||||
this.fetchAuthURLs(),
|
||||
this.fetchModules(),
|
||||
]).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{
|
||||
{
|
||||
Name: "IRC connection alive",
|
||||
Description: fmt.Sprintf("IRC connection received a message in last %s", statusIRCMessageReceivedTimeout),
|
||||
Name: "Chat connection alive",
|
||||
Description: fmt.Sprintf("Chat connection received a message in last %s", statusIRCMessageReceivedTimeout),
|
||||
checkFn: func() error {
|
||||
if time.Since(statusIRCMessageReceived) > statusIRCMessageReceivedTimeout {
|
||||
return errors.New("message lifetime expired")
|
||||
|
|
68
store.go
68
store.go
|
@ -12,6 +12,7 @@ import (
|
|||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/Luzifer/go_helpers/v2/str"
|
||||
"github.com/Luzifer/twitch-bot/crypt"
|
||||
"github.com/Luzifer/twitch-bot/plugins"
|
||||
)
|
||||
|
||||
|
@ -26,7 +27,10 @@ type storageFile struct {
|
|||
|
||||
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
|
||||
lock *sync.RWMutex
|
||||
|
@ -65,6 +69,16 @@ func (s *storageFile) DeleteModuleStore(moduleUUID string) error {
|
|||
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 {
|
||||
s.lock.RLock()
|
||||
defer s.lock.RUnlock()
|
||||
|
@ -144,10 +158,15 @@ func (s *storageFile) Load() error {
|
|||
}
|
||||
defer zf.Close()
|
||||
|
||||
return errors.Wrap(
|
||||
json.NewDecoder(zf).Decode(s),
|
||||
"decode storage object",
|
||||
)
|
||||
if err = json.NewDecoder(zf).Decode(s); err != nil {
|
||||
return errors.Wrap(err, "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 {
|
||||
|
@ -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
|
||||
f, err := os.Create(cfg.StorageFile)
|
||||
if err != nil {
|
||||
|
@ -182,16 +206,30 @@ func (s *storageFile) Save() error {
|
|||
zf := gzip.NewWriter(f)
|
||||
defer zf.Close()
|
||||
|
||||
return errors.Wrap(
|
||||
json.NewEncoder(zf).Encode(s),
|
||||
"encode storage object",
|
||||
)
|
||||
if err = json.NewEncoder(zf).Encode(s); err != nil {
|
||||
return errors.Wrap(err, "encode storage object")
|
||||
}
|
||||
|
||||
// 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) error {
|
||||
func (s *storageFile) SetGrantedScopes(user string, scopes []string, merge bool) error {
|
||||
s.lock.Lock()
|
||||
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
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
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 {
|
||||
s.lock.Lock()
|
||||
defer s.lock.Unlock()
|
||||
|
|
|
@ -48,6 +48,8 @@ const (
|
|||
EventSubEventTypeStreamOnline = "stream.online"
|
||||
|
||||
EventSubEventTypeChannelPointCustomRewardRedemptionAdd = "channel.channel_points_custom_reward_redemption.add"
|
||||
|
||||
EventSubEventTypeUserAuthorizationRevoke = "user.authorization.revoke"
|
||||
)
|
||||
|
||||
type (
|
||||
|
@ -130,6 +132,13 @@ type (
|
|||
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 {
|
||||
Challenge string `json:"challenge"`
|
||||
Subscription eventSubSubscription `json:"subscription"`
|
||||
|
@ -271,8 +280,25 @@ func (e *EventSubClient) PreFetchSubscriptions(ctx context.Context) error {
|
|||
for i := range subList {
|
||||
sub := subList[i]
|
||||
|
||||
if !str.StringInSlice(sub.Status, []string{eventSubStatusEnabled, eventSubStatusVerificationPending}) || sub.Transport.Callback != e.apiURL {
|
||||
// Not our callback or not active
|
||||
switch {
|
||||
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
|
||||
}
|
||||
|
||||
|
@ -330,7 +356,7 @@ func (e *EventSubClient) RegisterEventSubHooks(event string, condition EventSubC
|
|||
Condition: condition,
|
||||
Transport: eventSubTransport{
|
||||
Method: "webhook",
|
||||
Callback: e.apiURL,
|
||||
Callback: e.fullAPIurl(),
|
||||
Secret: e.secret,
|
||||
},
|
||||
})
|
||||
|
@ -357,6 +383,10 @@ func (e *EventSubClient) RegisterEventSubHooks(event string, condition EventSubC
|
|||
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) {
|
||||
e.subscriptionsLock.RLock()
|
||||
regSub, ok := e.subscriptions[cacheKey]
|
||||
|
|
|
@ -1,6 +1,18 @@
|
|||
package twitch
|
||||
|
||||
const (
|
||||
// API Scopes
|
||||
ScopeChannelManageRedemptions = "channel:manage:redemptions"
|
||||
ScopeChannelReadRedemptions = "channel:read:redemptions"
|
||||
ScopeChannelEditCommercial = "channel:edit:commercial"
|
||||
ScopeChannelManageBroadcast = "channel:manage:broadcast"
|
||||
ScopeChannelManagePolls = "channel:manage:polls"
|
||||
ScopeChannelManagePredictions = "channel:manage:predictions"
|
||||
|
||||
// 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.
|
||||
)
|
||||
|
|
217
twitch/twitch.go
217
twitch/twitch.go
|
@ -3,6 +3,7 @@ package twitch
|
|||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
|
@ -10,6 +11,7 @@ import (
|
|||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
@ -43,13 +45,33 @@ type (
|
|||
Client struct {
|
||||
clientID string
|
||||
clientSecret string
|
||||
token string
|
||||
|
||||
accessToken string
|
||||
refreshToken string
|
||||
tokenValidity time.Time
|
||||
tokenUpdateHook func(string, string) error
|
||||
|
||||
appAccessToken string
|
||||
|
||||
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 {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
|
@ -77,29 +99,33 @@ type (
|
|||
authType uint8
|
||||
|
||||
clientRequestOpts struct {
|
||||
AuthType authType
|
||||
Body io.Reader
|
||||
Context context.Context
|
||||
Method string
|
||||
OKStatus int
|
||||
Out interface{}
|
||||
URL string
|
||||
AuthType authType
|
||||
Body io.Reader
|
||||
Context context.Context
|
||||
Method string
|
||||
NoRetry bool
|
||||
NoValidateToken bool
|
||||
OKStatus int
|
||||
Out interface{}
|
||||
URL string
|
||||
}
|
||||
)
|
||||
|
||||
func New(clientID, clientSecret, token string) *Client {
|
||||
func New(clientID, clientSecret, accessToken, refreshToken string) *Client {
|
||||
return &Client{
|
||||
clientID: clientID,
|
||||
clientSecret: clientSecret,
|
||||
token: token,
|
||||
|
||||
accessToken: accessToken,
|
||||
refreshToken: refreshToken,
|
||||
|
||||
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 {
|
||||
Data []User `json:"data"`
|
||||
}
|
||||
|
@ -122,7 +148,7 @@ func (c Client) GetAuthorizedUsername() (string, error) {
|
|||
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}
|
||||
if d := c.apiCache.Get(cacheKey); d != nil {
|
||||
return d.(string), nil
|
||||
|
@ -133,7 +159,7 @@ func (c Client) GetDisplayNameForUser(username string) (string, error) {
|
|||
}
|
||||
|
||||
if err := c.request(clientRequestOpts{
|
||||
AuthType: authTypeBearerToken,
|
||||
AuthType: authTypeAppAccessToken,
|
||||
Context: context.Background(),
|
||||
Method: http.MethodGet,
|
||||
Out: &payload,
|
||||
|
@ -152,7 +178,7 @@ func (c Client) GetDisplayNameForUser(username string) (string, error) {
|
|||
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}
|
||||
if d := c.apiCache.Get(cacheKey); d != 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{
|
||||
AuthType: authTypeBearerToken,
|
||||
AuthType: authTypeAppAccessToken,
|
||||
Context: context.Background(),
|
||||
Method: http.MethodGet,
|
||||
OKStatus: http.StatusOK,
|
||||
|
@ -194,7 +220,19 @@ func (c Client) GetFollowDate(from, to string) (time.Time, error) {
|
|||
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 (
|
||||
out User
|
||||
param = "login"
|
||||
|
@ -214,7 +252,7 @@ func (c Client) GetUserInformation(user string) (*User, error) {
|
|||
}
|
||||
|
||||
if err := c.request(clientRequestOpts{
|
||||
AuthType: authTypeBearerToken,
|
||||
AuthType: authTypeAppAccessToken,
|
||||
Context: context.Background(),
|
||||
Method: http.MethodGet,
|
||||
OKStatus: http.StatusOK,
|
||||
|
@ -235,7 +273,7 @@ func (c Client) GetUserInformation(user string) (*User, error) {
|
|||
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
|
||||
|
||||
params := make(url.Values)
|
||||
|
@ -274,7 +312,7 @@ func (c Client) SearchCategories(ctx context.Context, name string) ([]Category,
|
|||
return out, nil
|
||||
}
|
||||
|
||||
func (c Client) HasLiveStream(username string) (bool, error) {
|
||||
func (c *Client) HasLiveStream(username string) (bool, error) {
|
||||
cacheKey := []string{"hasLiveStream", username}
|
||||
if d := c.apiCache.Get(cacheKey); d != 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
|
||||
}
|
||||
|
||||
func (c Client) GetCurrentStreamInfo(username string) (*StreamInfo, error) {
|
||||
func (c *Client) GetCurrentStreamInfo(username string) (*StreamInfo, error) {
|
||||
cacheKey := []string{"currentStreamInfo", username}
|
||||
if si := c.apiCache.Get(cacheKey); si != nil {
|
||||
return si.(*StreamInfo), nil
|
||||
|
@ -341,7 +379,7 @@ func (c Client) GetCurrentStreamInfo(username string) (*StreamInfo, error) {
|
|||
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}
|
||||
if d := c.apiCache.Get(cacheKey); d != nil {
|
||||
return d.(string), nil
|
||||
|
@ -352,7 +390,7 @@ func (c Client) GetIDForUsername(username string) (string, error) {
|
|||
}
|
||||
|
||||
if err := c.request(clientRequestOpts{
|
||||
AuthType: authTypeBearerToken,
|
||||
AuthType: authTypeAppAccessToken,
|
||||
Context: context.Background(),
|
||||
Method: http.MethodGet,
|
||||
OKStatus: http.StatusOK,
|
||||
|
@ -372,7 +410,7 @@ func (c Client) GetIDForUsername(username string) (string, error) {
|
|||
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}
|
||||
if d := c.apiCache.Get(cacheKey); d != 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
|
||||
}
|
||||
|
||||
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 {
|
||||
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) {
|
||||
c.token = token
|
||||
func (c *Client) RefreshToken() error {
|
||||
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) {
|
||||
|
@ -603,11 +729,11 @@ func (c *Client) getTwitchAppAccessToken() (string, error) {
|
|||
func (c *Client) request(opts clientRequestOpts) error {
|
||||
log.WithFields(log.Fields{
|
||||
"method": opts.Method,
|
||||
"url": opts.URL,
|
||||
"url": c.replaceSecrets(opts.URL),
|
||||
}).Trace("Execute Twitch API request")
|
||||
|
||||
var retries uint64 = twitchRequestRetries
|
||||
if opts.Body != nil {
|
||||
if opts.Body != nil || opts.NoRetry {
|
||||
// Body must be read only once, do not retry
|
||||
retries = 1
|
||||
}
|
||||
|
@ -636,7 +762,15 @@ func (c *Client) request(opts clientRequestOpts) error {
|
|||
req.Header.Set("Client-Id", c.clientID)
|
||||
|
||||
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)
|
||||
|
||||
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
|
||||
Title string
|
||||
|
||||
isInitialized bool
|
||||
unregisterFunc func()
|
||||
}
|
||||
|
||||
|
@ -33,6 +34,12 @@ func (t twitchChannelState) Equals(c twitchChannelState) bool {
|
|||
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 {
|
||||
return &twitchWatcher{
|
||||
ChannelStatus: make(map[string]*twitchChannelState),
|
||||
|
@ -48,7 +55,12 @@ func (t *twitchWatcher) AddChannel(channel string) error {
|
|||
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() {
|
||||
|
@ -64,7 +76,7 @@ func (t *twitchWatcher) Check() {
|
|||
t.lock.RUnlock()
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
@ -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 (
|
||||
err error
|
||||
status twitchChannelState
|
||||
err error
|
||||
status twitchChannelState
|
||||
storedStatus = t.ChannelStatus[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")
|
||||
}
|
||||
|
||||
t.lock.Lock()
|
||||
defer t.lock.Unlock()
|
||||
|
||||
if t.ChannelStatus[channel] != nil && t.ChannelStatus[channel].Equals(status) {
|
||||
return nil
|
||||
if storedStatus == nil {
|
||||
storedStatus = &twitchChannelState{}
|
||||
t.ChannelStatus[channel] = storedStatus
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
storedStatus.Update(status)
|
||||
storedStatus.isInitialized = true
|
||||
|
||||
if storedStatus.unregisterFunc != nil {
|
||||
// Do not register twice
|
||||
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")
|
||||
}
|
||||
|
||||
t.ChannelStatus[channel] = &status
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -260,7 +298,13 @@ func (t *twitchWatcher) registerEventSubCallbacks(channel string) (func(), error
|
|||
uf, err := twitchEventSubClient.RegisterEventSubHooks(tr.Topic, tr.Condition, tr.Hook)
|
||||
if err != nil {
|
||||
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)
|
||||
|
@ -273,6 +317,16 @@ func (t *twitchWatcher) registerEventSubCallbacks(channel string) (func(), error
|
|||
}, 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) {
|
||||
if category != nil && t.ChannelStatus[channel].Category != *category {
|
||||
t.ChannelStatus[channel].Category = *category
|
||||
|
|
Loading…
Reference in a new issue