diff --git a/auth.go b/auth.go
new file mode 100644
index 0000000..3848f6c
--- /dev/null
+++ b/auth.go
@@ -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)
+}
diff --git a/botEditor.go b/botEditor.go
index e28d7e4..bfa5798 100644
--- a/botEditor.go
+++ b/botEditor.go
@@ -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")
diff --git a/configEditor_general.go b/configEditor_general.go
index 5c3897b..720266d 100644
--- a/configEditor_general.go
+++ b/configEditor_general.go
@@ -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)
}
diff --git a/crypt/crypt.go b/crypt/crypt.go
new file mode 100644
index 0000000..a5e0163
--- /dev/null
+++ b/crypt/crypt.go
@@ -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")
+ }
+}
diff --git a/go.mod b/go.mod
index 69d9a99..d7671ce 100644
--- a/go.mod
+++ b/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
diff --git a/go.sum b/go.sum
index 1074a27..57ada62 100644
--- a/go.sum
+++ b/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=
diff --git a/irc.go b/irc.go
index c00b21c..c78e080 100644
--- a/irc.go
+++ b/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,
diff --git a/main.go b/main.go
index ee66334..3f8bdbe 100644
--- a/main.go
+++ b/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/
diff --git a/plugins/rule_test.go b/plugins/rule_test.go
index 96e244e..19e7bef 100644
--- a/plugins/rule_test.go
+++ b/plugins/rule_test.go
@@ -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)
diff --git a/scopes.go b/scopes.go
new file mode 100644
index 0000000..26c4102
--- /dev/null
+++ b/scopes.go
@@ -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,
+ }
+)
diff --git a/src/app.vue b/src/app.vue
index 7089e9a..cced348 100644
--- a/src/app.vue
+++ b/src/app.vue
@@ -60,28 +60,57 @@
>
-
+
+
+
+
+
+ {{ check.description }}
+
+
+
+
+
+
+ Connected to Bot
+ Disconnected from Bot
+
@@ -162,6 +191,8 @@