2021-11-08 19:17:07 +00:00
|
|
|
package twitch
|
|
|
|
|
|
|
|
import (
|
|
|
|
"bytes"
|
|
|
|
"context"
|
|
|
|
"encoding/json"
|
|
|
|
"fmt"
|
|
|
|
"net/http"
|
2024-03-27 23:09:28 +00:00
|
|
|
"net/url"
|
2021-11-08 19:17:07 +00:00
|
|
|
"time"
|
|
|
|
|
|
|
|
"github.com/mitchellh/hashstructure/v2"
|
|
|
|
"github.com/pkg/errors"
|
2024-03-27 23:09:28 +00:00
|
|
|
"github.com/sirupsen/logrus"
|
2021-11-08 19:17:07 +00:00
|
|
|
)
|
|
|
|
|
2024-01-01 16:52:18 +00:00
|
|
|
// Collection of known EventSub event-types
|
2021-11-08 19:17:07 +00:00
|
|
|
const (
|
2023-10-21 14:43:44 +00:00
|
|
|
EventSubEventTypeChannelAdBreakBegin = "channel.ad_break.begin"
|
2023-02-11 22:00:15 +00:00
|
|
|
EventSubEventTypeChannelFollow = "channel.follow"
|
2021-12-24 23:47:40 +00:00
|
|
|
EventSubEventTypeChannelPointCustomRewardRedemptionAdd = "channel.channel_points_custom_reward_redemption.add"
|
2024-02-29 18:00:44 +00:00
|
|
|
EventSubEventTypeChannelHypetrainBegin = "channel.hype_train.begin"
|
|
|
|
EventSubEventTypeChannelHypetrainProgress = "channel.hype_train.progress"
|
|
|
|
EventSubEventTypeChannelHypetrainEnd = "channel.hype_train.end"
|
2023-02-11 22:00:15 +00:00
|
|
|
EventSubEventTypeChannelRaid = "channel.raid"
|
2023-03-21 09:51:34 +00:00
|
|
|
EventSubEventTypeChannelShoutoutCreate = "channel.shoutout.create"
|
2023-02-11 22:00:15 +00:00
|
|
|
EventSubEventTypeChannelShoutoutReceive = "channel.shoutout.receive"
|
|
|
|
EventSubEventTypeChannelUpdate = "channel.update"
|
2023-05-21 12:59:06 +00:00
|
|
|
EventSubEventTypeChannelPollBegin = "channel.poll.begin"
|
|
|
|
EventSubEventTypeChannelPollEnd = "channel.poll.end"
|
|
|
|
EventSubEventTypeChannelPollProgress = "channel.poll.progress"
|
2023-02-11 22:00:15 +00:00
|
|
|
EventSubEventTypeStreamOffline = "stream.offline"
|
|
|
|
EventSubEventTypeStreamOnline = "stream.online"
|
|
|
|
EventSubEventTypeUserAuthorizationRevoke = "user.authorization.revoke"
|
2024-01-01 16:52:18 +00:00
|
|
|
)
|
2023-02-04 11:25:46 +00:00
|
|
|
|
2024-01-01 16:52:18 +00:00
|
|
|
// Collection of topic versions known to the API
|
|
|
|
const (
|
2023-02-04 11:25:46 +00:00
|
|
|
EventSubTopicVersion1 = "1"
|
|
|
|
EventSubTopicVersion2 = "2"
|
|
|
|
EventSubTopicVersionBeta = "beta"
|
2021-11-08 19:17:07 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
type (
|
2024-01-01 16:52:18 +00:00
|
|
|
// EventSubCondition defines the condition the subscription should
|
|
|
|
// listen on - all fields are optional and those defined in the
|
|
|
|
// EventSub documentation for the given topic should be set
|
2021-11-08 19:17:07 +00:00
|
|
|
EventSubCondition struct {
|
|
|
|
BroadcasterUserID string `json:"broadcaster_user_id,omitempty"`
|
|
|
|
CampaignID string `json:"campaign_id,omitempty"`
|
|
|
|
CategoryID string `json:"category_id,omitempty"`
|
|
|
|
ClientID string `json:"client_id,omitempty"`
|
|
|
|
ExtensionClientID string `json:"extension_client_id,omitempty"`
|
|
|
|
FromBroadcasterUserID string `json:"from_broadcaster_user_id,omitempty"`
|
|
|
|
OrganizationID string `json:"organization_id,omitempty"`
|
|
|
|
RewardID string `json:"reward_id,omitempty"`
|
|
|
|
ToBroadcasterUserID string `json:"to_broadcaster_user_id,omitempty"`
|
|
|
|
UserID string `json:"user_id,omitempty"`
|
2023-02-11 22:00:15 +00:00
|
|
|
ModeratorUserID string `json:"moderator_user_id,omitempty"`
|
2021-11-08 19:17:07 +00:00
|
|
|
}
|
|
|
|
|
2024-01-01 16:52:18 +00:00
|
|
|
// EventSubEventAdBreakBegin contains the payload for an AdBreak event
|
2023-10-21 14:43:44 +00:00
|
|
|
EventSubEventAdBreakBegin struct {
|
2023-12-12 14:59:03 +00:00
|
|
|
Duration int64 `json:"duration_seconds"`
|
2024-03-25 21:23:31 +00:00
|
|
|
StartedAt time.Time `json:"started_at"`
|
2023-10-21 14:43:44 +00:00
|
|
|
IsAutomatic bool `json:"is_automatic"`
|
|
|
|
BroadcasterUserID string `json:"broadcaster_user_id"`
|
|
|
|
BroadcasterUserLogin string `json:"broadcaster_user_login"`
|
|
|
|
BroadcasterUserName string `json:"broadcaster_user_name"`
|
|
|
|
RequesterUserID string `json:"requester_user_id"`
|
2023-12-12 14:59:03 +00:00
|
|
|
RequesterUserLogin string `json:"requester_user_login"`
|
|
|
|
RequesterUserName string `json:"requester_user_name"`
|
2023-10-21 14:43:44 +00:00
|
|
|
}
|
|
|
|
|
2024-01-01 16:52:18 +00:00
|
|
|
// EventSubEventChannelPointCustomRewardRedemptionAdd contains the
|
|
|
|
// payload for an channel-point redeem event
|
2021-12-24 23:47:40 +00:00
|
|
|
EventSubEventChannelPointCustomRewardRedemptionAdd struct {
|
|
|
|
ID string `json:"id"`
|
|
|
|
BroadcasterUserID string `json:"broadcaster_user_id"`
|
|
|
|
BroadcasterUserLogin string `json:"broadcaster_user_login"`
|
|
|
|
BroadcasterUserName string `json:"broadcaster_user_name"`
|
|
|
|
UserID string `json:"user_id"`
|
|
|
|
UserLogin string `json:"user_login"`
|
|
|
|
UserName string `json:"user_name"`
|
|
|
|
UserInput string `json:"user_input"`
|
|
|
|
Status string `json:"status"`
|
|
|
|
Reward struct {
|
|
|
|
ID string `json:"id"`
|
|
|
|
Title string `json:"title"`
|
|
|
|
Cost int64 `json:"cost"`
|
|
|
|
Prompt string `json:"prompt"`
|
|
|
|
} `json:"reward"`
|
|
|
|
RedeemedAt time.Time `json:"redeemed_at"`
|
|
|
|
}
|
|
|
|
|
2024-01-01 16:52:18 +00:00
|
|
|
// EventSubEventChannelUpdate contains the payload for a channel
|
|
|
|
// update event
|
2021-11-08 19:17:07 +00:00
|
|
|
EventSubEventChannelUpdate struct {
|
2023-07-03 22:05:04 +00:00
|
|
|
BroadcasterUserID string `json:"broadcaster_user_id"`
|
|
|
|
BroadcasterUserLogin string `json:"broadcaster_user_login"`
|
|
|
|
BroadcasterUserName string `json:"broadcaster_user_name"`
|
|
|
|
Title string `json:"title"`
|
|
|
|
Language string `json:"language"`
|
|
|
|
CategoryID string `json:"category_id"`
|
|
|
|
CategoryName string `json:"category_name"`
|
|
|
|
ContentClassificationLabels []string `json:"content_classification_labels"`
|
2021-11-08 19:17:07 +00:00
|
|
|
}
|
|
|
|
|
2024-01-01 16:52:18 +00:00
|
|
|
// EventSubEventFollow contains the payload for a follow event
|
2021-11-08 19:17:07 +00:00
|
|
|
EventSubEventFollow struct {
|
|
|
|
UserID string `json:"user_id"`
|
|
|
|
UserLogin string `json:"user_login"`
|
|
|
|
UserName string `json:"user_name"`
|
|
|
|
BroadcasterUserID string `json:"broadcaster_user_id"`
|
|
|
|
BroadcasterUserLogin string `json:"broadcaster_user_login"`
|
|
|
|
BroadcasterUserName string `json:"broadcaster_user_name"`
|
|
|
|
FollowedAt time.Time `json:"followed_at"`
|
|
|
|
}
|
|
|
|
|
2024-02-29 18:00:44 +00:00
|
|
|
// EventSubEventHypetrain contains the payload for all three (begin,
|
|
|
|
// progress and end) hypetrain events. Certain fields are not
|
|
|
|
// available at all event types
|
|
|
|
EventSubEventHypetrain struct {
|
|
|
|
ID string `json:"id"`
|
|
|
|
BroadcasterUserID string `json:"broadcaster_user_id"`
|
|
|
|
BroadcasterUserLogin string `json:"broadcaster_user_login"`
|
|
|
|
BroadcasterUserName string `json:"broadcaster_user_name"`
|
|
|
|
Level int64 `json:"level"`
|
|
|
|
Total int64 `json:"total"`
|
|
|
|
Progress int64 `json:"progress"` // Only Beginn, Progress
|
|
|
|
Goal int64 `json:"goal"` // Only Beginn, Progress
|
|
|
|
TopContributions []struct {
|
|
|
|
UserID string `json:"user_id"`
|
|
|
|
UserLogin string `json:"user_login"`
|
|
|
|
UserName string `json:"user_name"`
|
|
|
|
Type string `json:"type"`
|
|
|
|
Total int64 `json:"total"`
|
|
|
|
} `json:"top_contributions"`
|
|
|
|
LastContribution *struct { // Only Begin, Progress
|
|
|
|
UserID string `json:"user_id"`
|
|
|
|
UserLogin string `json:"user_login"`
|
|
|
|
UserName string `json:"user_name"`
|
|
|
|
Type string `json:"type"`
|
|
|
|
Total int64 `json:"total"`
|
|
|
|
} `json:"last_contribution,omitempty"`
|
|
|
|
StartedAt time.Time `json:"started_at"`
|
|
|
|
ExpiresAt *time.Time `json:"expires_at,omitempty"` // Only Begin, Progress
|
|
|
|
EndedAt *time.Time `json:"ended_at,omitempty"` // Only End
|
|
|
|
CooldownEndsAt *time.Time `json:"cooldown_ends_at,omitempty"` // Only End
|
|
|
|
}
|
|
|
|
|
2024-01-01 16:52:18 +00:00
|
|
|
// EventSubEventPoll contains the payload for a poll change event
|
|
|
|
// (not all fields are present in all poll events, see docs!)
|
2023-05-21 12:59:06 +00:00
|
|
|
EventSubEventPoll struct {
|
|
|
|
ID string `json:"id"`
|
|
|
|
BroadcasterUserID string `json:"broadcaster_user_id"`
|
|
|
|
BroadcasterUserLogin string `json:"broadcaster_user_login"`
|
|
|
|
BroadcasterUserName string `json:"broadcaster_user_name"`
|
|
|
|
Title string `json:"title"`
|
|
|
|
Choices []struct {
|
|
|
|
ID string `json:"id"`
|
|
|
|
Title string `json:"title"`
|
|
|
|
ChannelPointsVotes int `json:"channel_points_votes"`
|
|
|
|
Votes int `json:"votes"`
|
|
|
|
} `json:"choices"`
|
|
|
|
ChannelPointsVoting struct {
|
|
|
|
IsEnabled bool `json:"is_enabled"`
|
|
|
|
AmountPerVote int `json:"amount_per_vote"`
|
|
|
|
} `json:"channel_points_voting"`
|
|
|
|
|
|
|
|
StartedAt time.Time `json:"started_at"` // begin, progress, end
|
|
|
|
EndsAt time.Time `json:"ends_at,omitempty"` // begin, progress
|
|
|
|
Status string `json:"status,omitempty"` // end -- enum(completed, archived, terminated)
|
|
|
|
EndedAt time.Time `json:"ended_at,omitempty"` // end
|
|
|
|
}
|
|
|
|
|
2024-01-01 16:52:18 +00:00
|
|
|
// EventSubEventRaid contains the payload for a raid event
|
2022-10-29 13:16:30 +00:00
|
|
|
EventSubEventRaid struct {
|
|
|
|
FromBroadcasterUserID string `json:"from_broadcaster_user_id"`
|
|
|
|
FromBroadcasterUserLogin string `json:"from_broadcaster_user_login"`
|
|
|
|
FromBroadcasterUserName string `json:"from_broadcaster_user_name"`
|
|
|
|
ToBroadcasterUserID string `json:"to_broadcaster_user_id"`
|
|
|
|
ToBroadcasterUserLogin string `json:"to_broadcaster_user_login"`
|
|
|
|
ToBroadcasterUserName string `json:"to_broadcaster_user_name"`
|
|
|
|
Viewers int64 `json:"viewers"`
|
|
|
|
}
|
|
|
|
|
2024-01-01 16:52:18 +00:00
|
|
|
// EventSubEventShoutoutCreated contains the payload for a shoutout
|
|
|
|
// created event
|
2023-03-21 09:51:34 +00:00
|
|
|
EventSubEventShoutoutCreated struct {
|
|
|
|
BroadcasterUserID string `json:"broadcaster_user_id"`
|
|
|
|
BroadcasterUserLogin string `json:"broadcaster_user_login"`
|
|
|
|
BroadcasterUserName string `json:"broadcaster_user_name"`
|
|
|
|
ModeratorUserID string `json:"moderator_user_id"`
|
|
|
|
ModeratorUserLogin string `json:"moderator_user_login"`
|
|
|
|
ModeratorUserName string `json:"moderator_user_name"`
|
|
|
|
ToBroadcasterUserID string `json:"to_broadcaster_user_id"`
|
|
|
|
ToBroadcasterUserLogin string `json:"to_broadcaster_user_login"`
|
|
|
|
ToBroadcasterUserName string `json:"to_broadcaster_user_name"`
|
|
|
|
ViewerCount int64 `json:"viewer_count"`
|
|
|
|
StartedAt time.Time `json:"started_at"`
|
|
|
|
CooldownEndsAt time.Time `json:"cooldown_ends_at"`
|
|
|
|
TargetCooldownEndsAt time.Time `json:"target_cooldown_ends_at"`
|
|
|
|
}
|
|
|
|
|
2024-01-01 16:52:18 +00:00
|
|
|
// EventSubEventShoutoutReceived contains the payload for a shoutout
|
|
|
|
// received event
|
2023-02-11 22:00:15 +00:00
|
|
|
EventSubEventShoutoutReceived struct {
|
|
|
|
BroadcasterUserID string `json:"broadcaster_user_id"`
|
|
|
|
BroadcasterUserLogin string `json:"broadcaster_user_login"`
|
|
|
|
BroadcasterUserName string `json:"broadcaster_user_name"`
|
|
|
|
FromBroadcasterUserID string `json:"from_broadcaster_user_id"`
|
|
|
|
FromBroadcasterUserLogin string `json:"from_broadcaster_user_login"`
|
|
|
|
FromBroadcasterUserName string `json:"from_broadcaster_user_name"`
|
|
|
|
ViewerCount int64 `json:"viewer_count"`
|
|
|
|
StartedAt time.Time `json:"started_at"`
|
|
|
|
}
|
|
|
|
|
2024-01-01 16:52:18 +00:00
|
|
|
// EventSubEventStreamOffline contains the payload for a stream
|
|
|
|
// offline event
|
2021-11-08 19:17:07 +00:00
|
|
|
EventSubEventStreamOffline struct {
|
|
|
|
BroadcasterUserID string `json:"broadcaster_user_id"`
|
|
|
|
BroadcasterUserLogin string `json:"broadcaster_user_login"`
|
|
|
|
BroadcasterUserName string `json:"broadcaster_user_name"`
|
|
|
|
}
|
|
|
|
|
2024-01-01 16:52:18 +00:00
|
|
|
// EventSubEventStreamOnline contains the payload for a stream
|
|
|
|
// online event
|
2021-11-08 19:17:07 +00:00
|
|
|
EventSubEventStreamOnline struct {
|
|
|
|
ID string `json:"id"`
|
|
|
|
BroadcasterUserID string `json:"broadcaster_user_id"`
|
|
|
|
BroadcasterUserLogin string `json:"broadcaster_user_login"`
|
|
|
|
BroadcasterUserName string `json:"broadcaster_user_name"`
|
|
|
|
Type string `json:"type"`
|
|
|
|
StartedAt time.Time `json:"started_at"`
|
|
|
|
}
|
|
|
|
|
2024-01-01 16:52:18 +00:00
|
|
|
// EventSubEventUserAuthorizationRevoke contains the payload for an
|
|
|
|
// authorization revoke event
|
2021-12-31 12:42:37 +00:00
|
|
|
EventSubEventUserAuthorizationRevoke struct {
|
|
|
|
ClientID string `json:"client_id"`
|
|
|
|
UserID string `json:"user_id"`
|
|
|
|
UserLogin string `json:"user_login"`
|
|
|
|
UserName string `json:"user_name"`
|
|
|
|
}
|
|
|
|
|
2021-11-08 19:17:07 +00:00
|
|
|
eventSubSubscription struct {
|
|
|
|
ID string `json:"id,omitempty"` // READONLY
|
|
|
|
Status string `json:"status,omitempty"` // READONLY
|
|
|
|
Type string `json:"type"`
|
|
|
|
Version string `json:"version"`
|
|
|
|
Cost int64 `json:"cost,omitempty"` // READONLY
|
|
|
|
Condition EventSubCondition `json:"condition"`
|
|
|
|
Transport eventSubTransport `json:"transport"`
|
|
|
|
CreatedAt time.Time `json:"created_at,omitempty"` // READONLY
|
|
|
|
}
|
|
|
|
|
|
|
|
eventSubTransport struct {
|
2023-05-18 13:05:43 +00:00
|
|
|
Method string `json:"method"`
|
|
|
|
Callback string `json:"callback"`
|
|
|
|
Secret string `json:"secret"`
|
|
|
|
SessionID string `json:"session_id"`
|
2021-11-08 19:17:07 +00:00
|
|
|
}
|
|
|
|
)
|
|
|
|
|
2024-01-01 16:52:18 +00:00
|
|
|
// Hash generates a hashstructure hash for the condition for comparison
|
2021-11-08 19:17:07 +00:00
|
|
|
func (e EventSubCondition) Hash() (string, error) {
|
|
|
|
h, err := hashstructure.Hash(e, hashstructure.FormatV2, &hashstructure.HashOptions{TagName: "json"})
|
|
|
|
if err != nil {
|
|
|
|
return "", errors.Wrap(err, "hashing struct")
|
|
|
|
}
|
|
|
|
|
|
|
|
return fmt.Sprintf("%x", h), nil
|
|
|
|
}
|
|
|
|
|
2023-05-18 13:05:43 +00:00
|
|
|
func (c *Client) createEventSubSubscriptionWebsocket(ctx context.Context, sub eventSubSubscription) (*eventSubSubscription, error) {
|
2023-07-01 14:48:21 +00:00
|
|
|
return c.createEventSubSubscription(ctx, AuthTypeBearerToken, sub)
|
2021-11-08 19:17:07 +00:00
|
|
|
}
|
|
|
|
|
2023-07-01 14:48:21 +00:00
|
|
|
func (c *Client) createEventSubSubscription(ctx context.Context, auth AuthType, sub eventSubSubscription) (*eventSubSubscription, error) {
|
2022-10-25 16:47:30 +00:00
|
|
|
var (
|
2024-03-27 23:09:28 +00:00
|
|
|
buf = new(bytes.Buffer)
|
|
|
|
err error
|
|
|
|
mustFetchSubsctiption bool
|
|
|
|
resp struct {
|
2022-10-25 16:47:30 +00:00
|
|
|
Total int64 `json:"total"`
|
|
|
|
Data []eventSubSubscription `json:"data"`
|
|
|
|
Pagination struct {
|
|
|
|
Cursor string `json:"cursor"`
|
|
|
|
} `json:"pagination"`
|
|
|
|
}
|
|
|
|
)
|
|
|
|
|
2024-03-27 23:09:28 +00:00
|
|
|
conHash, err := sub.Condition.Hash()
|
|
|
|
if err != nil {
|
|
|
|
return nil, fmt.Errorf("hashing input condition: %w", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
if err = json.NewEncoder(buf).Encode(sub); err != nil {
|
2022-10-25 16:47:30 +00:00
|
|
|
return nil, errors.Wrap(err, "assemble subscribe payload")
|
|
|
|
}
|
|
|
|
|
2024-03-27 23:09:28 +00:00
|
|
|
if err = c.Request(ctx, ClientRequestOpts{
|
2023-05-18 13:05:43 +00:00
|
|
|
AuthType: auth,
|
2022-10-25 16:47:30 +00:00
|
|
|
Body: buf,
|
|
|
|
Method: http.MethodPost,
|
|
|
|
OKStatus: http.StatusAccepted,
|
|
|
|
Out: &resp,
|
|
|
|
URL: "https://api.twitch.tv/helix/eventsub/subscriptions",
|
2024-03-22 20:28:14 +00:00
|
|
|
ValidateFunc: func(opts ClientRequestOpts, resp *http.Response) error {
|
|
|
|
if resp.StatusCode == http.StatusConflict {
|
|
|
|
// This is fine: We needed that subscription, it exists
|
2024-03-27 23:09:28 +00:00
|
|
|
mustFetchSubsctiption = true
|
2024-03-22 20:28:14 +00:00
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// Fallback to default handling
|
|
|
|
return ValidateStatus(opts, resp)
|
|
|
|
},
|
2022-10-25 16:47:30 +00:00
|
|
|
}); err != nil {
|
2024-03-27 23:09:28 +00:00
|
|
|
return nil, fmt.Errorf("creating subscription: %w", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
if mustFetchSubsctiption {
|
|
|
|
params := make(url.Values)
|
|
|
|
params.Set("status", "enabled")
|
|
|
|
|
|
|
|
if err = c.Request(ctx, ClientRequestOpts{
|
|
|
|
AuthType: auth,
|
|
|
|
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, fmt.Errorf("fetching subscription: %w", err)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
for i := range resp.Data {
|
|
|
|
s := resp.Data[i]
|
|
|
|
|
|
|
|
if s.Type != sub.Type || s.Version != sub.Version {
|
|
|
|
// Not the subscription we're searching for
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
sConHash, err := s.Condition.Hash()
|
|
|
|
if err != nil {
|
|
|
|
logrus.WithError(err).Error("hashing eventsub subscription condition")
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
if sConHash != conHash {
|
|
|
|
// Different conditions
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
return &s, nil
|
2022-10-25 16:47:30 +00:00
|
|
|
}
|
|
|
|
|
2024-03-27 23:09:28 +00:00
|
|
|
return nil, fmt.Errorf("no subscription matching input found")
|
2022-10-25 16:47:30 +00:00
|
|
|
}
|