Skip to content

Commit

Permalink
twitch: new package for twitch api
Browse files Browse the repository at this point in the history
  • Loading branch information
zephyrtronium committed Apr 7, 2024
1 parent 0c3ad51 commit 2b45724
Show file tree
Hide file tree
Showing 2 changed files with 66 additions and 47 deletions.
52 changes: 5 additions & 47 deletions robot.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,8 @@ package main
import (
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
Expand All @@ -21,6 +19,7 @@ import (
"github.com/zephyrtronium/robot/brain/kvbrain"
"github.com/zephyrtronium/robot/channel"
"github.com/zephyrtronium/robot/privacy"
"github.com/zephyrtronium/robot/twitch"
)

// Robot is the overall configuration for the bot.
Expand Down Expand Up @@ -115,13 +114,15 @@ func twitchToken(ctx context.Context, tokens auth.TokenSource) (*oauth2.Token, e
if err != nil {
return nil, fmt.Errorf("couldn't obtain access token for TMI login: %w", err)
}
client := http.Client{Timeout: 30 * time.Second}
for range 5 {
err := validateTwitch(ctx, tok)
v, err := twitch.Validate(ctx, &client, tok)
slog.InfoContext(ctx, "Twitch validation", slog.Any("response", v), slog.Any("err", err))
switch {
case err == nil:
// Current token is good.
return tok, nil
case errors.Is(err, errNeedRefresh):
case errors.Is(err, twitch.ErrNeedRefresh):
// Refresh and try again.
tok, err = tokens.Refresh(ctx, tok)
if err != nil {
Expand All @@ -134,47 +135,6 @@ func twitchToken(ctx context.Context, tokens auth.TokenSource) (*oauth2.Token, e
return nil, fmt.Errorf("giving up on refresh retries")
}

// validateTwitch validates a Twitch access token. If the returned error Is
// errNeedRefresh, then the caller should refresh it and try again.
func validateTwitch(ctx context.Context, tok *oauth2.Token) error {
req, err := http.NewRequestWithContext(ctx, "GET", "https://id.twitch.tv/oauth2/validate", nil)
if err != nil {
return fmt.Errorf("couldn't make validate request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+tok.AccessToken)
client := http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("couldn't validate access token: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return fmt.Errorf("couldn't read token validation response: %w", err)
}
var s struct {
ClientID string `json:"client_id"`
Login string `json:"login"`
Scopes []string `json:"scopes"`
UserID string `json:"user_id"`
ExpiresIn int `json:"expires_in"`
Message string `json:"message"`
Status int `json:"status"`
}
if err := json.Unmarshal(body, &s); err != nil {
return fmt.Errorf("couldn't unmarshal token validation response: %w", err)
}
slog.InfoContext(ctx, "token validation", slog.Any("result", s))
if resp.StatusCode == http.StatusUnauthorized {
// Token expired or otherwise invalid. We need a refresh.
return fmt.Errorf("token validation failed: %s (%w)", s.Message, errNeedRefresh)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("token validation failed: %s (%s)", s.Message, resp.Status)
}
return nil
}

func (robo *Robot) tmiLoop(ctx context.Context, group *errgroup.Group, send chan<- *tmi.Message, recv <-chan *tmi.Message) {
for {
select {
Expand Down Expand Up @@ -259,5 +219,3 @@ func (l *tmiSlog) Recv(s string) { l.l.Debug("TMI recv", slog.String("message"
func (l *tmiSlog) Ping(s string) {
l.l.Log(context.Background(), slog.LevelDebug-1, "TMI ping", slog.String("message", s))
}

var errNeedRefresh = errors.New("need refresh")
61 changes: 61 additions & 0 deletions twitch/token.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package twitch

import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"

"golang.org/x/oauth2"
)

// Validate checks the status of an access token.
// If the API response indicates that the access token is invalid, the returned
// error wraps [ErrNeedRefresh].
// The returned Validation may be non-nil even if the error is also non-nil.
func Validate(ctx context.Context, client *http.Client, tok *oauth2.Token) (*Validation, error) {
req, err := http.NewRequestWithContext(ctx, "GET", "https://id.twitch.tv/oauth2/validate", nil)
if err != nil {
return nil, fmt.Errorf("couldn't make validate request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+tok.AccessToken)
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("couldn't validate access token: %w", err)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, fmt.Errorf("couldn't read token validation response: %w", err)
}
resp.Body.Close()
var s Validation
if err := json.Unmarshal(body, &s); err != nil {
return nil, fmt.Errorf("couldn't unmarshal token validation response: %w", err)
}
switch resp.StatusCode {
case http.StatusOK: // do nothing
case http.StatusUnauthorized:
err = fmt.Errorf("token validation failed: %s (%w)", s.Message, ErrNeedRefresh)
default:
err = fmt.Errorf("token validation failed: %s (%s)", s.Message, resp.Status)
}
return &s, err
}

// Validation describes an access token's validation status.
type Validation struct {
ClientID string `json:"client_id"`
Login string `json:"login"`
Scopes []string `json:"scopes"`
UserID string `json:"user_id"`
ExpiresIn int `json:"expires_in"`

Message string `json:"message"`
Status int `json:"status"`
}

// ErrNeedRefresh is an error returned by Twitch API operations when a response
// indicates that the access token needs to be refreshed.
var ErrNeedRefresh = errors.New("need refresh")

0 comments on commit 2b45724

Please sign in to comment.