mirror of
https://github.com/Luzifer/twitch-bot.git
synced 2024-11-08 16:20:02 +00:00
[core] Improve EventSub API request design
Signed-off-by: Knut Ahlers <knut@ahlers.me>
This commit is contained in:
parent
9cfcdaaf25
commit
246bb2811d
5 changed files with 282 additions and 221 deletions
|
@ -15,7 +15,7 @@ func getAuthorizationFromRequest(r *http.Request) (string, *twitch.Client, error
|
||||||
return "", nil, errors.New("no authorization provided")
|
return "", nil, errors.New("no authorization provided")
|
||||||
}
|
}
|
||||||
|
|
||||||
tc := twitch.New(cfg.TwitchClient, token)
|
tc := twitch.New(cfg.TwitchClient, cfg.TwitchClientSecret, token)
|
||||||
|
|
||||||
user, err := tc.GetAuthorizedUsername()
|
user, err := tc.GetAuthorizedUsername()
|
||||||
return user, tc, errors.Wrap(err, "getting authorized user")
|
return user, tc, errors.Wrap(err, "getting authorized user")
|
||||||
|
|
8
main.go
8
main.go
|
@ -140,7 +140,7 @@ func main() {
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
cronService = cron.New()
|
cronService = cron.New()
|
||||||
twitchClient = twitch.New(cfg.TwitchClient, cfg.TwitchToken)
|
twitchClient = twitch.New(cfg.TwitchClient, cfg.TwitchClientSecret, cfg.TwitchToken)
|
||||||
|
|
||||||
twitchWatch := newTwitchWatcher()
|
twitchWatch := newTwitchWatcher()
|
||||||
cronService.AddFunc("@every 10s", twitchWatch.Check) // Query may run that often as the twitchClient has an internal cache
|
cronService.AddFunc("@every 10s", twitchWatch.Check) // Query may run that often as the twitchClient has an internal cache
|
||||||
|
@ -219,16 +219,12 @@ func main() {
|
||||||
log.WithError(err).Fatal("Unable to get or create eventsub secret")
|
log.WithError(err).Fatal("Unable to get or create eventsub secret")
|
||||||
}
|
}
|
||||||
|
|
||||||
twitchEventSubClient = twitch.NewEventSubClient(strings.Join([]string{
|
twitchEventSubClient = twitch.NewEventSubClient(twitchClient, strings.Join([]string{
|
||||||
strings.TrimRight(cfg.BaseURL, "/"),
|
strings.TrimRight(cfg.BaseURL, "/"),
|
||||||
"eventsub",
|
"eventsub",
|
||||||
handle,
|
handle,
|
||||||
}, "/"), secret, handle)
|
}, "/"), secret, handle)
|
||||||
|
|
||||||
if err = twitchEventSubClient.Authorize(cfg.TwitchClient, cfg.TwitchClientSecret); err != nil {
|
|
||||||
log.WithError(err).Fatal("Unable to authorize Twitch EventSub client")
|
|
||||||
}
|
|
||||||
|
|
||||||
router.HandleFunc("/eventsub/{keyhandle}", twitchEventSubClient.HandleEventsubPush).Methods(http.MethodPost)
|
router.HandleFunc("/eventsub/{keyhandle}", twitchEventSubClient.HandleEventsubPush).Methods(http.MethodPost)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -75,7 +75,7 @@ func TestAllowExecuteDisableOnOffline(t *testing.T) {
|
||||||
r := &Rule{DisableOnOffline: testPtrBool(true)}
|
r := &Rule{DisableOnOffline: testPtrBool(true)}
|
||||||
|
|
||||||
// Fake cache entries to prevent calling the real Twitch API
|
// 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", "channel1"}, time.Minute, true)
|
||||||
r.twitchClient.APICache().Set([]string{"hasLiveStream", "channel2"}, time.Minute, false)
|
r.twitchClient.APICache().Set([]string{"hasLiveStream", "channel2"}, time.Minute, false)
|
||||||
|
|
||||||
|
|
|
@ -8,9 +8,7 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"io/ioutil"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
@ -55,9 +53,7 @@ type (
|
||||||
secret string
|
secret string
|
||||||
secretHandle string
|
secretHandle string
|
||||||
|
|
||||||
twitchClientID string
|
twitchClient *Client
|
||||||
twitchClientSecret string
|
|
||||||
twitchAccessToken string
|
|
||||||
|
|
||||||
subscriptions map[string]*registeredSubscription
|
subscriptions map[string]*registeredSubscription
|
||||||
subscriptionsLock sync.RWMutex
|
subscriptionsLock sync.RWMutex
|
||||||
|
@ -151,71 +147,18 @@ func (e EventSubCondition) Hash() (string, error) {
|
||||||
return fmt.Sprintf("%x", h), nil
|
return fmt.Sprintf("%x", h), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewEventSubClient(apiURL, secret, secretHandle string) *EventSubClient {
|
func NewEventSubClient(twitchClient *Client, apiURL, secret, secretHandle string) *EventSubClient {
|
||||||
return &EventSubClient{
|
return &EventSubClient{
|
||||||
apiURL: apiURL,
|
apiURL: apiURL,
|
||||||
secret: secret,
|
secret: secret,
|
||||||
secretHandle: secretHandle,
|
secretHandle: secretHandle,
|
||||||
|
|
||||||
|
twitchClient: twitchClient,
|
||||||
|
|
||||||
subscriptions: map[string]*registeredSubscription{},
|
subscriptions: map[string]*registeredSubscription{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *EventSubClient) Authorize(clientID, clientSecret string) error {
|
|
||||||
e.twitchClientID = clientID
|
|
||||||
e.twitchClientSecret = clientSecret
|
|
||||||
|
|
||||||
_, err := e.getTwitchAppAccessToken()
|
|
||||||
return errors.Wrap(err, "fetching app access token")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *EventSubClient) getTwitchAppAccessToken() (string, error) {
|
|
||||||
if e.twitchAccessToken != "" {
|
|
||||||
return e.twitchAccessToken, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var rData struct {
|
|
||||||
AccessToken string `json:"access_token"`
|
|
||||||
RefreshToken string `json:"refresh_token"`
|
|
||||||
ExpiresIn int `json:"expires_in"`
|
|
||||||
Scope []interface{} `json:"scope"`
|
|
||||||
TokenType string `json:"token_type"`
|
|
||||||
}
|
|
||||||
|
|
||||||
params := make(url.Values)
|
|
||||||
params.Set("client_id", e.twitchClientID)
|
|
||||||
params.Set("client_secret", e.twitchClientSecret)
|
|
||||||
params.Set("grant_type", "client_credentials")
|
|
||||||
|
|
||||||
u, _ := url.Parse("https://id.twitch.tv/oauth2/token")
|
|
||||||
u.RawQuery = params.Encode()
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), twitchRequestTimeout)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), nil)
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", errors.Wrap(err, "fetching response")
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
body, err := ioutil.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return "", errors.Wrapf(err, "unexpected status %d and cannot read body", resp.StatusCode)
|
|
||||||
}
|
|
||||||
return "", errors.Errorf("unexpected status %d: %s", resp.StatusCode, body)
|
|
||||||
}
|
|
||||||
|
|
||||||
e.twitchAccessToken = rData.AccessToken
|
|
||||||
|
|
||||||
return rData.AccessToken, errors.Wrap(
|
|
||||||
json.NewDecoder(resp.Body).Decode(&rData),
|
|
||||||
"decoding response",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *EventSubClient) HandleEventsubPush(w http.ResponseWriter, r *http.Request) {
|
func (e *EventSubClient) HandleEventsubPush(w http.ResponseWriter, r *http.Request) {
|
||||||
var (
|
var (
|
||||||
body = new(bytes.Buffer)
|
body = new(bytes.Buffer)
|
||||||
|
@ -292,7 +235,7 @@ func (e *EventSubClient) HandleEventsubPush(w http.ResponseWriter, r *http.Reque
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//nolint:funlen,gocyclo // Not splitting to keep logic unit
|
//nolint:funlen // Not splitting to keep logic unit
|
||||||
func (e *EventSubClient) RegisterEventSubHooks(event string, condition EventSubCondition, callback func(json.RawMessage) error) (func(), error) {
|
func (e *EventSubClient) RegisterEventSubHooks(event string, condition EventSubCondition, callback func(json.RawMessage) error) (func(), error) {
|
||||||
condHash, err := condition.Hash()
|
condHash, err := condition.Hash()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -321,39 +264,20 @@ func (e *EventSubClient) RegisterEventSubHooks(event string, condition EventSubC
|
||||||
return func() { e.unregisterCallback(cacheKey, cbKey) }, nil
|
return func() { e.unregisterCallback(cacheKey, cbKey) }, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
accessToken, err := e.getTwitchAppAccessToken()
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.Wrap(err, "getting app-access-token")
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), twitchRequestTimeout)
|
ctx, cancel := context.WithTimeout(context.Background(), twitchRequestTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
// List existing subscriptions
|
// List existing subscriptions
|
||||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.twitch.tv/helix/eventsub/subscriptions", nil)
|
subList, err := e.twitchClient.getEventSubSubscriptions(ctx)
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.Header.Set("Client-Id", e.twitchClientID)
|
|
||||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
|
||||||
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.Wrap(err, "requesting subscribscriptions")
|
return nil, errors.Wrap(err, "listing existing subscriptions")
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
var subscriptionList struct {
|
|
||||||
Data []eventSubSubscription
|
|
||||||
}
|
|
||||||
|
|
||||||
if err = json.NewDecoder(resp.Body).Decode(&subscriptionList); err != nil {
|
|
||||||
return nil, errors.Wrap(err, "decoding subscription list")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register subscriptions
|
// Register subscriptions
|
||||||
var (
|
var (
|
||||||
existingSub *eventSubSubscription
|
existingSub *eventSubSubscription
|
||||||
)
|
)
|
||||||
for i, sub := range subscriptionList.Data {
|
for i, sub := range subList {
|
||||||
existingConditionHash, err := sub.Condition.Hash()
|
existingConditionHash, err := sub.Condition.Hash()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.Wrap(err, "hashing existing condition")
|
return nil, errors.Wrap(err, "hashing existing condition")
|
||||||
|
@ -371,7 +295,7 @@ func (e *EventSubClient) RegisterEventSubHooks(event string, condition EventSubC
|
||||||
"id": sub.ID,
|
"id": sub.ID,
|
||||||
"status": sub.Status,
|
"status": sub.Status,
|
||||||
})
|
})
|
||||||
existingSub = &subscriptionList.Data[i]
|
existingSub = &subList[i]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -395,7 +319,10 @@ func (e *EventSubClient) RegisterEventSubHooks(event string, condition EventSubC
|
||||||
return func() { e.unregisterCallback(cacheKey, cbKey) }, nil
|
return func() { e.unregisterCallback(cacheKey, cbKey) }, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
payload := eventSubSubscription{
|
ctx, cancel = context.WithTimeout(context.Background(), twitchRequestTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
newSub, err := e.twitchClient.createEventSubSubscription(ctx, eventSubSubscription{
|
||||||
Type: event,
|
Type: event,
|
||||||
Version: "1",
|
Version: "1",
|
||||||
Condition: condition,
|
Condition: condition,
|
||||||
|
@ -404,43 +331,9 @@ func (e *EventSubClient) RegisterEventSubHooks(event string, condition EventSubC
|
||||||
Callback: e.apiURL,
|
Callback: e.apiURL,
|
||||||
Secret: e.secret,
|
Secret: e.secret,
|
||||||
},
|
},
|
||||||
}
|
})
|
||||||
|
|
||||||
buf := new(bytes.Buffer)
|
|
||||||
if err := json.NewEncoder(buf).Encode(payload); err != nil {
|
|
||||||
return nil, errors.Wrap(err, "assemble subscribe payload")
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel = context.WithTimeout(context.Background(), twitchRequestTimeout)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
req, err = http.NewRequestWithContext(ctx, http.MethodPost, "https://api.twitch.tv/helix/eventsub/subscriptions", buf)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.Wrap(err, "creating subscribe request")
|
return nil, errors.Wrap(err, "creating subscription")
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.Header.Set("Client-Id", e.twitchClientID)
|
|
||||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
|
||||||
|
|
||||||
resp, err = http.DefaultClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.Wrap(err, "requesting subscribe")
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusAccepted {
|
|
||||||
body, err := ioutil.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.Wrapf(err, "unexpected status %d, unable to read body", resp.StatusCode)
|
|
||||||
}
|
|
||||||
return nil, errors.Errorf("unexpected status %d: %s", resp.StatusCode, body)
|
|
||||||
}
|
|
||||||
|
|
||||||
var response struct {
|
|
||||||
Data []eventSubSubscription `json:"data"`
|
|
||||||
}
|
|
||||||
if err = json.NewDecoder(resp.Body).Decode(&response); err != nil {
|
|
||||||
return nil, errors.Wrap(err, "reading eventsub sub response")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
e.subscriptionsLock.Lock()
|
e.subscriptionsLock.Lock()
|
||||||
|
@ -454,7 +347,7 @@ func (e *EventSubClient) RegisterEventSubHooks(event string, condition EventSubC
|
||||||
Callbacks: map[string]func(json.RawMessage) error{
|
Callbacks: map[string]func(json.RawMessage) error{
|
||||||
cbKey: callback,
|
cbKey: callback,
|
||||||
},
|
},
|
||||||
Subscription: response.Data[0],
|
Subscription: *newSub,
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Debug("Registered eventsub subscription")
|
logger.Debug("Registered eventsub subscription")
|
||||||
|
@ -491,30 +384,13 @@ func (e *EventSubClient) unregisterCallback(cacheKey, cbKey string) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
accessToken, err := e.getTwitchAppAccessToken()
|
|
||||||
if err != nil {
|
|
||||||
log.WithError(err).Error("Unable to get access token")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), twitchRequestTimeout)
|
ctx, cancel := context.WithTimeout(context.Background(), twitchRequestTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, fmt.Sprintf("https://api.twitch.tv/helix/eventsub/subscriptions?id=%s", regSub.Subscription.ID), nil)
|
if err := e.twitchClient.deleteEventSubSubscription(ctx, regSub.Subscription.ID); err != nil {
|
||||||
if err != nil {
|
|
||||||
log.WithError(err).Error("Unable to create delete subscription request")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.Header.Set("Client-Id", e.twitchClientID)
|
|
||||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
|
||||||
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
log.WithError(err).Error("Unable to execute delete subscription request")
|
log.WithError(err).Error("Unable to execute delete subscription request")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
e.subscriptionsLock.Lock()
|
e.subscriptionsLock.Lock()
|
||||||
defer e.subscriptionsLock.Unlock()
|
defer e.subscriptionsLock.Unlock()
|
||||||
|
|
333
twitch/twitch.go
333
twitch/twitch.go
|
@ -6,6 +6,7 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"io/ioutil"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
@ -26,6 +27,12 @@ const (
|
||||||
twitchRequestTimeout = 2 * time.Second
|
twitchRequestTimeout = 2 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
authTypeUnauthorized authType = iota
|
||||||
|
authTypeAppAccessToken
|
||||||
|
authTypeBearerToken
|
||||||
|
)
|
||||||
|
|
||||||
type (
|
type (
|
||||||
Category struct {
|
Category struct {
|
||||||
BoxArtURL string `json:"box_art_url"`
|
BoxArtURL string `json:"box_art_url"`
|
||||||
|
@ -34,8 +41,11 @@ type (
|
||||||
}
|
}
|
||||||
|
|
||||||
Client struct {
|
Client struct {
|
||||||
clientID string
|
clientID string
|
||||||
token string
|
clientSecret string
|
||||||
|
token string
|
||||||
|
|
||||||
|
appAccessToken string
|
||||||
|
|
||||||
apiCache *APICache
|
apiCache *APICache
|
||||||
}
|
}
|
||||||
|
@ -63,12 +73,25 @@ type (
|
||||||
Login string `json:"login"`
|
Login string `json:"login"`
|
||||||
ProfileImageURL string `json:"profile_image_url"`
|
ProfileImageURL string `json:"profile_image_url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
authType uint8
|
||||||
|
|
||||||
|
clientRequestOpts struct {
|
||||||
|
AuthType authType
|
||||||
|
Body io.Reader
|
||||||
|
Context context.Context
|
||||||
|
Method string
|
||||||
|
OKStatus int
|
||||||
|
Out interface{}
|
||||||
|
URL string
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
func New(clientID, token string) *Client {
|
func New(clientID, clientSecret, token string) *Client {
|
||||||
return &Client{
|
return &Client{
|
||||||
clientID: clientID,
|
clientID: clientID,
|
||||||
token: token,
|
clientSecret: clientSecret,
|
||||||
|
token: token,
|
||||||
|
|
||||||
apiCache: newTwitchAPICache(),
|
apiCache: newTwitchAPICache(),
|
||||||
}
|
}
|
||||||
|
@ -81,13 +104,14 @@ func (c Client) GetAuthorizedUsername() (string, error) {
|
||||||
Data []User `json:"data"`
|
Data []User `json:"data"`
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := c.request(
|
if err := c.request(clientRequestOpts{
|
||||||
context.Background(),
|
AuthType: authTypeBearerToken,
|
||||||
http.MethodGet,
|
Context: context.Background(),
|
||||||
"https://api.twitch.tv/helix/users",
|
Method: http.MethodGet,
|
||||||
nil,
|
OKStatus: http.StatusOK,
|
||||||
&payload,
|
Out: &payload,
|
||||||
); err != nil {
|
URL: "https://api.twitch.tv/helix/users",
|
||||||
|
}); err != nil {
|
||||||
return "", errors.Wrap(err, "request channel info")
|
return "", errors.Wrap(err, "request channel info")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -108,13 +132,13 @@ func (c Client) GetDisplayNameForUser(username string) (string, error) {
|
||||||
Data []User `json:"data"`
|
Data []User `json:"data"`
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := c.request(
|
if err := c.request(clientRequestOpts{
|
||||||
context.Background(),
|
AuthType: authTypeBearerToken,
|
||||||
http.MethodGet,
|
Context: context.Background(),
|
||||||
fmt.Sprintf("https://api.twitch.tv/helix/users?login=%s", username),
|
Method: http.MethodGet,
|
||||||
nil,
|
Out: &payload,
|
||||||
&payload,
|
URL: fmt.Sprintf("https://api.twitch.tv/helix/users?login=%s", username),
|
||||||
); err != nil {
|
}); err != nil {
|
||||||
return "", errors.Wrap(err, "request channel info")
|
return "", errors.Wrap(err, "request channel info")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -149,13 +173,14 @@ func (c Client) GetFollowDate(from, to string) (time.Time, error) {
|
||||||
} `json:"data"`
|
} `json:"data"`
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := c.request(
|
if err := c.request(clientRequestOpts{
|
||||||
context.Background(),
|
AuthType: authTypeBearerToken,
|
||||||
http.MethodGet,
|
Context: context.Background(),
|
||||||
fmt.Sprintf("https://api.twitch.tv/helix/users/follows?to_id=%s&from_id=%s", toID, fromID),
|
Method: http.MethodGet,
|
||||||
nil,
|
OKStatus: http.StatusOK,
|
||||||
&payload,
|
Out: &payload,
|
||||||
); err != nil {
|
URL: fmt.Sprintf("https://api.twitch.tv/helix/users/follows?to_id=%s&from_id=%s", toID, fromID),
|
||||||
|
}); err != nil {
|
||||||
return time.Time{}, errors.Wrap(err, "request follow info")
|
return time.Time{}, errors.Wrap(err, "request follow info")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -188,13 +213,14 @@ func (c Client) GetUserInformation(user string) (*User, error) {
|
||||||
param = "id"
|
param = "id"
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := c.request(
|
if err := c.request(clientRequestOpts{
|
||||||
context.Background(),
|
AuthType: authTypeBearerToken,
|
||||||
http.MethodGet,
|
Context: context.Background(),
|
||||||
fmt.Sprintf("https://api.twitch.tv/helix/users?%s=%s", param, user),
|
Method: http.MethodGet,
|
||||||
nil,
|
OKStatus: http.StatusOK,
|
||||||
&payload,
|
Out: &payload,
|
||||||
); err != nil {
|
URL: fmt.Sprintf("https://api.twitch.tv/helix/users?%s=%s", param, user),
|
||||||
|
}); err != nil {
|
||||||
return nil, errors.Wrap(err, "request user info")
|
return nil, errors.Wrap(err, "request user info")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -224,7 +250,14 @@ func (c Client) SearchCategories(ctx context.Context, name string) ([]Category,
|
||||||
}
|
}
|
||||||
|
|
||||||
for {
|
for {
|
||||||
if err := c.request(ctx, http.MethodGet, fmt.Sprintf("https://api.twitch.tv/helix/search/categories?%s", params.Encode()), nil, &resp); err != nil {
|
if err := c.request(clientRequestOpts{
|
||||||
|
AuthType: authTypeBearerToken,
|
||||||
|
Context: ctx,
|
||||||
|
Method: http.MethodGet,
|
||||||
|
OKStatus: http.StatusOK,
|
||||||
|
Out: &resp,
|
||||||
|
URL: fmt.Sprintf("https://api.twitch.tv/helix/search/categories?%s", params.Encode()),
|
||||||
|
}); err != nil {
|
||||||
return nil, errors.Wrap(err, "executing request")
|
return nil, errors.Wrap(err, "executing request")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -255,13 +288,14 @@ func (c Client) HasLiveStream(username string) (bool, error) {
|
||||||
} `json:"data"`
|
} `json:"data"`
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := c.request(
|
if err := c.request(clientRequestOpts{
|
||||||
context.Background(),
|
AuthType: authTypeBearerToken,
|
||||||
http.MethodGet,
|
Context: context.Background(),
|
||||||
fmt.Sprintf("https://api.twitch.tv/helix/streams?user_login=%s", username),
|
Method: http.MethodGet,
|
||||||
nil,
|
OKStatus: http.StatusOK,
|
||||||
&payload,
|
Out: &payload,
|
||||||
); err != nil {
|
URL: fmt.Sprintf("https://api.twitch.tv/helix/streams?user_login=%s", username),
|
||||||
|
}); err != nil {
|
||||||
return false, errors.Wrap(err, "request stream info")
|
return false, errors.Wrap(err, "request stream info")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -286,13 +320,14 @@ func (c Client) GetCurrentStreamInfo(username string) (*StreamInfo, error) {
|
||||||
Data []*StreamInfo `json:"data"`
|
Data []*StreamInfo `json:"data"`
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := c.request(
|
if err := c.request(clientRequestOpts{
|
||||||
context.Background(),
|
AuthType: authTypeBearerToken,
|
||||||
http.MethodGet,
|
Context: context.Background(),
|
||||||
fmt.Sprintf("https://api.twitch.tv/helix/streams?user_id=%s", id),
|
Method: http.MethodGet,
|
||||||
nil,
|
OKStatus: http.StatusOK,
|
||||||
&payload,
|
Out: &payload,
|
||||||
); err != nil {
|
URL: fmt.Sprintf("https://api.twitch.tv/helix/streams?user_id=%s", id),
|
||||||
|
}); err != nil {
|
||||||
return nil, errors.Wrap(err, "request channel info")
|
return nil, errors.Wrap(err, "request channel info")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -316,13 +351,14 @@ func (c Client) GetIDForUsername(username string) (string, error) {
|
||||||
Data []User `json:"data"`
|
Data []User `json:"data"`
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := c.request(
|
if err := c.request(clientRequestOpts{
|
||||||
context.Background(),
|
AuthType: authTypeBearerToken,
|
||||||
http.MethodGet,
|
Context: context.Background(),
|
||||||
fmt.Sprintf("https://api.twitch.tv/helix/users?login=%s", username),
|
Method: http.MethodGet,
|
||||||
nil,
|
OKStatus: http.StatusOK,
|
||||||
&payload,
|
Out: &payload,
|
||||||
); err != nil {
|
URL: fmt.Sprintf("https://api.twitch.tv/helix/users?login=%s", username),
|
||||||
|
}); err != nil {
|
||||||
return "", errors.Wrap(err, "request channel info")
|
return "", errors.Wrap(err, "request channel info")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -356,13 +392,14 @@ func (c Client) GetRecentStreamInfo(username string) (string, string, error) {
|
||||||
} `json:"data"`
|
} `json:"data"`
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := c.request(
|
if err := c.request(clientRequestOpts{
|
||||||
context.Background(),
|
AuthType: authTypeBearerToken,
|
||||||
http.MethodGet,
|
Context: context.Background(),
|
||||||
fmt.Sprintf("https://api.twitch.tv/helix/channels?broadcaster_id=%s", id),
|
Method: http.MethodGet,
|
||||||
nil,
|
OKStatus: http.StatusOK,
|
||||||
&payload,
|
Out: &payload,
|
||||||
); err != nil {
|
URL: fmt.Sprintf("https://api.twitch.tv/helix/channels?broadcaster_id=%s", id),
|
||||||
|
}); err != nil {
|
||||||
return "", "", errors.Wrap(err, "request channel info")
|
return "", "", errors.Wrap(err, "request channel info")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -429,28 +466,172 @@ func (c Client) ModifyChannelInformation(ctx context.Context, broadcasterName st
|
||||||
}
|
}
|
||||||
|
|
||||||
return errors.Wrap(
|
return errors.Wrap(
|
||||||
c.request(ctx, http.MethodPatch, fmt.Sprintf("https://api.twitch.tv/helix/channels?broadcaster_id=%s", broadcaster), body, nil),
|
c.request(clientRequestOpts{
|
||||||
|
AuthType: authTypeBearerToken,
|
||||||
|
Body: body,
|
||||||
|
Context: ctx,
|
||||||
|
Method: http.MethodPatch,
|
||||||
|
OKStatus: http.StatusOK,
|
||||||
|
URL: fmt.Sprintf("https://api.twitch.tv/helix/channels?broadcaster_id=%s", broadcaster),
|
||||||
|
}),
|
||||||
"executing request",
|
"executing request",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c Client) request(ctx context.Context, method, url string, body io.Reader, out interface{}) error {
|
func (c *Client) createEventSubSubscription(ctx context.Context, sub eventSubSubscription) (*eventSubSubscription, error) {
|
||||||
|
var (
|
||||||
|
buf = new(bytes.Buffer)
|
||||||
|
resp struct {
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
Data []eventSubSubscription `json:"data"`
|
||||||
|
Pagination struct {
|
||||||
|
Cursor string `json:"cursor"`
|
||||||
|
} `json:"pagination"`
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if err := json.NewEncoder(buf).Encode(sub); err != nil {
|
||||||
|
return nil, errors.Wrap(err, "assemble subscribe payload")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.request(clientRequestOpts{
|
||||||
|
AuthType: authTypeAppAccessToken,
|
||||||
|
Body: buf,
|
||||||
|
Context: ctx,
|
||||||
|
Method: http.MethodPost,
|
||||||
|
OKStatus: http.StatusAccepted,
|
||||||
|
Out: &resp,
|
||||||
|
URL: "https://api.twitch.tv/helix/eventsub/subscriptions",
|
||||||
|
}); err != nil {
|
||||||
|
return nil, errors.Wrap(err, "executing request")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &resp.Data[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) deleteEventSubSubscription(ctx context.Context, id string) error {
|
||||||
|
return errors.Wrap(c.request(clientRequestOpts{
|
||||||
|
AuthType: authTypeAppAccessToken,
|
||||||
|
Context: ctx,
|
||||||
|
Method: http.MethodDelete,
|
||||||
|
OKStatus: http.StatusNoContent,
|
||||||
|
URL: fmt.Sprintf("https://api.twitch.tv/helix/eventsub/subscriptions?id=%s", id),
|
||||||
|
}), "executing request")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) getEventSubSubscriptions(ctx context.Context) ([]eventSubSubscription, error) {
|
||||||
|
var (
|
||||||
|
out []eventSubSubscription
|
||||||
|
params = make(url.Values)
|
||||||
|
resp struct {
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
Data []eventSubSubscription `json:"data"`
|
||||||
|
Pagination struct {
|
||||||
|
Cursor string `json:"cursor"`
|
||||||
|
} `json:"pagination"`
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
for {
|
||||||
|
if err := c.request(clientRequestOpts{
|
||||||
|
AuthType: authTypeAppAccessToken,
|
||||||
|
Context: ctx,
|
||||||
|
Method: http.MethodGet,
|
||||||
|
OKStatus: http.StatusOK,
|
||||||
|
Out: &resp,
|
||||||
|
URL: fmt.Sprintf("https://api.twitch.tv/helix/eventsub/subscriptions?%s", params.Encode()),
|
||||||
|
}); err != nil {
|
||||||
|
return nil, errors.Wrap(err, "executing request")
|
||||||
|
}
|
||||||
|
|
||||||
|
out = append(out, resp.Data...)
|
||||||
|
|
||||||
|
if resp.Pagination.Cursor == "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
params.Set("after", resp.Pagination.Cursor)
|
||||||
|
resp.Pagination.Cursor = "" // Clear from struct as struct is reused
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) getTwitchAppAccessToken() (string, error) {
|
||||||
|
if c.appAccessToken != "" {
|
||||||
|
return c.appAccessToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var rData struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
ExpiresIn int `json:"expires_in"`
|
||||||
|
Scope []interface{} `json:"scope"`
|
||||||
|
TokenType string `json:"token_type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
params := make(url.Values)
|
||||||
|
params.Set("client_id", c.clientID)
|
||||||
|
params.Set("client_secret", c.clientSecret)
|
||||||
|
params.Set("grant_type", "client_credentials")
|
||||||
|
|
||||||
|
u, _ := url.Parse("https://id.twitch.tv/oauth2/token")
|
||||||
|
u.RawQuery = params.Encode()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), twitchRequestTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := c.request(clientRequestOpts{
|
||||||
|
AuthType: authTypeUnauthorized,
|
||||||
|
Context: ctx,
|
||||||
|
Method: http.MethodPost,
|
||||||
|
OKStatus: http.StatusOK,
|
||||||
|
Out: &rData,
|
||||||
|
URL: u.String(),
|
||||||
|
}); err != nil {
|
||||||
|
return "", errors.Wrap(err, "fetching token response")
|
||||||
|
}
|
||||||
|
|
||||||
|
c.appAccessToken = rData.AccessToken
|
||||||
|
return rData.AccessToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) request(opts clientRequestOpts) error {
|
||||||
log.WithFields(log.Fields{
|
log.WithFields(log.Fields{
|
||||||
"method": method,
|
"method": opts.Method,
|
||||||
"url": url,
|
"url": opts.URL,
|
||||||
}).Trace("Execute Twitch API request")
|
}).Trace("Execute Twitch API request")
|
||||||
|
|
||||||
return backoff.NewBackoff().WithMaxIterations(twitchRequestRetries).Retry(func() error {
|
return backoff.NewBackoff().WithMaxIterations(twitchRequestRetries).Retry(func() error {
|
||||||
reqCtx, cancel := context.WithTimeout(ctx, twitchRequestTimeout)
|
reqCtx, cancel := context.WithTimeout(opts.Context, twitchRequestTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(reqCtx, method, url, body)
|
req, err := http.NewRequestWithContext(reqCtx, opts.Method, opts.URL, opts.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "assemble request")
|
return errors.Wrap(err, "assemble request")
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
req.Header.Set("Client-Id", c.clientID)
|
|
||||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
switch opts.AuthType {
|
||||||
|
case authTypeUnauthorized:
|
||||||
|
// Nothing to do
|
||||||
|
|
||||||
|
case authTypeAppAccessToken:
|
||||||
|
accessToken, err := c.getTwitchAppAccessToken()
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "getting app-access-token")
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||||
|
req.Header.Set("Client-Id", c.clientID)
|
||||||
|
|
||||||
|
case authTypeBearerToken:
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||||
|
req.Header.Set("Client-Id", c.clientID)
|
||||||
|
|
||||||
|
default:
|
||||||
|
return errors.New("invalid auth type specified")
|
||||||
|
}
|
||||||
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
resp, err := http.DefaultClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -458,12 +639,20 @@ func (c Client) request(ctx context.Context, method, url string, body io.Reader,
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if out == nil {
|
if opts.OKStatus != 0 && resp.StatusCode != opts.OKStatus {
|
||||||
|
body, err := ioutil.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrapf(err, "unexpected status %d and cannot read body", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return errors.Errorf("unexpected status %d: %s", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.Out == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return errors.Wrap(
|
return errors.Wrap(
|
||||||
json.NewDecoder(resp.Body).Decode(out),
|
json.NewDecoder(resp.Body).Decode(opts.Out),
|
||||||
"parse user info",
|
"parse user info",
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
Loading…
Reference in a new issue