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 @@ > - + + + + + + + + Connected to Bot + Disconnected from Bot + @@ -162,6 +191,8 @@