mirror of
https://github.com/Luzifer/twitch-bot.git
synced 2024-12-20 11:51:17 +00:00
[spotify] Fix: Refresh-Token gets revoked when using two functions
Signed-off-by: Knut Ahlers <knut@ahlers.me>
This commit is contained in:
parent
5dd6a5323c
commit
30305600e7
5 changed files with 124 additions and 37 deletions
|
@ -467,12 +467,12 @@ Example:
|
||||||
|
|
||||||
```
|
```
|
||||||
# Your int this hour: {{ printf "%.0f" (mulf (seededRandom (list "int" .username (now | date "2006-01-02 15") | join ":")) 100) }}%
|
# Your int this hour: {{ printf "%.0f" (mulf (seededRandom (list "int" .username (now | date "2006-01-02 15") | join ":")) 100) }}%
|
||||||
< Your int this hour: 72%
|
< Your int this hour: 82%
|
||||||
```
|
```
|
||||||
|
|
||||||
### `spotifyCurrentPlaying`
|
### `spotifyCurrentPlaying`
|
||||||
|
|
||||||
Retrieves the current playing track for the given channel
|
Retrieves the current playing track for the given channel (returns an empty string when nothing is playing)
|
||||||
|
|
||||||
Syntax: `spotifyCurrentPlaying <channel>`
|
Syntax: `spotifyCurrentPlaying <channel>`
|
||||||
|
|
||||||
|
@ -487,7 +487,7 @@ Example:
|
||||||
|
|
||||||
### `spotifyLink`
|
### `spotifyLink`
|
||||||
|
|
||||||
Retrieves the link for the playing track for the given channel
|
Retrieves the link for the playing track for the given channel (returns an empty string when nothing is playing)
|
||||||
|
|
||||||
Syntax: `spotifyLink <channel>`
|
Syntax: `spotifyLink <channel>`
|
||||||
|
|
||||||
|
|
101
internal/actors/spotify/auth.go
Normal file
101
internal/actors/spotify/auth.go
Normal file
|
@ -0,0 +1,101 @@
|
||||||
|
package spotify
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Luzifer/twitch-bot/v3/internal/locker"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
"golang.org/x/oauth2"
|
||||||
|
)
|
||||||
|
|
||||||
|
const expiryGrace = 10 * time.Second
|
||||||
|
|
||||||
|
func getAuthorizedClient(channel, redirectURL string) (client *http.Client, err error) {
|
||||||
|
// In templating functions are called multiple times at once which
|
||||||
|
// with Spotify replacing the refresh-token on each renew would kill
|
||||||
|
// the stored token when multiple spotify functions are called at
|
||||||
|
// once. Therefore we do have this method locking itself until it
|
||||||
|
// has successfully made one request to the users profile and therefore
|
||||||
|
// renewed the token. The next request then will use the token the
|
||||||
|
// previous request renewed.
|
||||||
|
locker.LockByKey(strings.Join([]string{"spotify", "api-access", channel}, ":"))
|
||||||
|
defer locker.UnlockByKey(strings.Join([]string{"spotify", "api-access", channel}, ":"))
|
||||||
|
|
||||||
|
conf, err := oauthConfig(channel, redirectURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("getting oauth config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var token *oauth2.Token
|
||||||
|
if err = db.ReadEncryptedCoreMeta(strings.Join([]string{"spotify-auth", channel}, ":"), &token); err != nil {
|
||||||
|
return nil, fmt.Errorf("loading oauth token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ts := conf.TokenSource(context.Background(), token)
|
||||||
|
|
||||||
|
if token.Expiry.After(time.Now().Add(expiryGrace)) {
|
||||||
|
// Token is still valid long enough, we spare the resources to do
|
||||||
|
// the profile fetch and directly return the client with the token
|
||||||
|
// as the scenario described here does not apply.
|
||||||
|
return oauth2.NewClient(context.Background(), ts), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
logrus.WithField("channel", channel).Debug("refreshing spotify token")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), spotifyRequestTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// We do a request to /me once to refresh the token if needed
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.spotify.com/v1/me", nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("creating currently-playing request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
oauthClient := oauth2.NewClient(context.Background(), ts)
|
||||||
|
|
||||||
|
resp, err := oauthClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("executing request: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := resp.Body.Close(); err != nil {
|
||||||
|
logrus.WithError(err).Error("closing Spotify response body (leaked fd)")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("requesting user profile: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
updToken, err := ts.Token()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("getting updated token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.StoreEncryptedCoreMeta(strings.Join([]string{"spotify-auth", channel}, ":"), updToken); err != nil {
|
||||||
|
logrus.WithError(err).Error("storing back Spotify auth token")
|
||||||
|
}
|
||||||
|
|
||||||
|
return oauthClient, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func oauthConfig(channel, redirectURL string) (conf *oauth2.Config, err error) {
|
||||||
|
clientID, err := getModuleConfig(actorName, channel).String("clientId")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("getting clientId for channel: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &oauth2.Config{
|
||||||
|
ClientID: clientID,
|
||||||
|
Endpoint: oauth2.Endpoint{
|
||||||
|
AuthURL: "https://accounts.spotify.com/authorize",
|
||||||
|
TokenURL: "https://accounts.spotify.com/api/token",
|
||||||
|
},
|
||||||
|
RedirectURL: redirectURL,
|
||||||
|
Scopes: []string{"user-read-currently-playing"},
|
||||||
|
}, nil
|
||||||
|
}
|
|
@ -3,34 +3,25 @@ package spotify
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
"golang.org/x/oauth2"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var errNotPlaying = errors.New("nothing playing")
|
||||||
|
|
||||||
func getCurrentTrackForChannel(channel string) (track currentPlayingTrackResponse, err error) {
|
func getCurrentTrackForChannel(channel string) (track currentPlayingTrackResponse, err error) {
|
||||||
channel = strings.TrimLeft(channel, "#")
|
channel = strings.TrimLeft(channel, "#")
|
||||||
|
|
||||||
conf, err := oauthConfig(channel, "")
|
client, err := getAuthorizedClient(channel, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return track, fmt.Errorf("getting oauth config: %w", err)
|
return track, fmt.Errorf("retrieving authorized Spotify client: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var token *oauth2.Token
|
|
||||||
if err = db.ReadEncryptedCoreMeta(strings.Join([]string{"spotify-auth", channel}, ":"), &token); err != nil {
|
|
||||||
return track, fmt.Errorf("loading oauth token: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
if err := db.StoreEncryptedCoreMeta(strings.Join([]string{"spotify-auth", channel}, ":"), token); err != nil {
|
|
||||||
logrus.WithError(err).Error("storing back Spotify auth token")
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), spotifyRequestTimeout)
|
ctx, cancel := context.WithTimeout(context.Background(), spotifyRequestTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
@ -39,7 +30,7 @@ func getCurrentTrackForChannel(channel string) (track currentPlayingTrackRespons
|
||||||
return track, fmt.Errorf("creating currently-playing request: %w", err)
|
return track, fmt.Errorf("creating currently-playing request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := conf.Client(context.Background(), token).Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return track, fmt.Errorf("executing request: %w", err)
|
return track, fmt.Errorf("executing request: %w", err)
|
||||||
}
|
}
|
||||||
|
@ -58,6 +49,10 @@ func getCurrentTrackForChannel(channel string) (track currentPlayingTrackRespons
|
||||||
case http.StatusOK:
|
case http.StatusOK:
|
||||||
// This is perfect, continue below
|
// This is perfect, continue below
|
||||||
|
|
||||||
|
case http.StatusNoContent:
|
||||||
|
// User is not playing anything
|
||||||
|
return track, errNotPlaying
|
||||||
|
|
||||||
case http.StatusUnauthorized:
|
case http.StatusUnauthorized:
|
||||||
// The token is FUBAR
|
// The token is FUBAR
|
||||||
return track, fmt.Errorf("token expired (HTTP 401 - unauthorized)")
|
return track, fmt.Errorf("token expired (HTTP 401 - unauthorized)")
|
||||||
|
@ -85,6 +80,10 @@ func getCurrentTrackForChannel(channel string) (track currentPlayingTrackRespons
|
||||||
func getCurrentArtistTitleForChannel(channel string) (artistTitle string, err error) {
|
func getCurrentArtistTitleForChannel(channel string) (artistTitle string, err error) {
|
||||||
track, err := getCurrentTrackForChannel(channel)
|
track, err := getCurrentTrackForChannel(channel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, errNotPlaying) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
return "", fmt.Errorf("getting track info: %w", err)
|
return "", fmt.Errorf("getting track info: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -102,6 +101,10 @@ func getCurrentArtistTitleForChannel(channel string) (artistTitle string, err er
|
||||||
func getCurrentLinkForChannel(channel string) (link string, err error) {
|
func getCurrentLinkForChannel(channel string) (link string, err error) {
|
||||||
track, err := getCurrentTrackForChannel(channel)
|
track, err := getCurrentTrackForChannel(channel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, errNotPlaying) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
return "", fmt.Errorf("getting track info: %w", err)
|
return "", fmt.Errorf("getting track info: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -70,20 +70,3 @@ func handleStartAuth(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
fmt.Fprintln(w, "Spotify is now authorized for this channel, you can close this page")
|
fmt.Fprintln(w, "Spotify is now authorized for this channel, you can close this page")
|
||||||
}
|
}
|
||||||
|
|
||||||
func oauthConfig(channel, redirectURL string) (conf *oauth2.Config, err error) {
|
|
||||||
clientID, err := getModuleConfig(actorName, channel).String("clientId")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("getting clientId for channel: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &oauth2.Config{
|
|
||||||
ClientID: clientID,
|
|
||||||
Endpoint: oauth2.Endpoint{
|
|
||||||
AuthURL: "https://accounts.spotify.com/authorize",
|
|
||||||
TokenURL: "https://accounts.spotify.com/api/token",
|
|
||||||
},
|
|
||||||
RedirectURL: redirectURL,
|
|
||||||
Scopes: []string{"user-read-currently-playing"},
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
|
@ -35,7 +35,7 @@ func Register(args plugins.RegistrationArguments) (err error) {
|
||||||
plugins.GenericTemplateFunctionGetter(getCurrentArtistTitleForChannel),
|
plugins.GenericTemplateFunctionGetter(getCurrentArtistTitleForChannel),
|
||||||
plugins.TemplateFuncDocumentation{
|
plugins.TemplateFuncDocumentation{
|
||||||
Name: "spotifyCurrentPlaying",
|
Name: "spotifyCurrentPlaying",
|
||||||
Description: "Retrieves the current playing track for the given channel",
|
Description: "Retrieves the current playing track for the given channel (returns an empty string when nothing is playing)",
|
||||||
Syntax: "spotifyCurrentPlaying <channel>",
|
Syntax: "spotifyCurrentPlaying <channel>",
|
||||||
Example: &plugins.TemplateFuncDocumentationExample{
|
Example: &plugins.TemplateFuncDocumentationExample{
|
||||||
MatchMessage: "^!spotify",
|
MatchMessage: "^!spotify",
|
||||||
|
@ -51,7 +51,7 @@ func Register(args plugins.RegistrationArguments) (err error) {
|
||||||
plugins.GenericTemplateFunctionGetter(getCurrentLinkForChannel),
|
plugins.GenericTemplateFunctionGetter(getCurrentLinkForChannel),
|
||||||
plugins.TemplateFuncDocumentation{
|
plugins.TemplateFuncDocumentation{
|
||||||
Name: "spotifyLink",
|
Name: "spotifyLink",
|
||||||
Description: "Retrieves the link for the playing track for the given channel",
|
Description: "Retrieves the link for the playing track for the given channel (returns an empty string when nothing is playing)",
|
||||||
Syntax: "spotifyLink <channel>",
|
Syntax: "spotifyLink <channel>",
|
||||||
Example: &plugins.TemplateFuncDocumentationExample{
|
Example: &plugins.TemplateFuncDocumentationExample{
|
||||||
MatchMessage: "^!spotifylink",
|
MatchMessage: "^!spotifylink",
|
||||||
|
|
Loading…
Reference in a new issue