Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 59 additions & 42 deletions eventsub.go
Original file line number Diff line number Diff line change
Expand Up @@ -645,50 +645,59 @@ type EventSubExtensionBitsTransactionCreateEvent struct {
Product EventSubProduct `json:"product"`
}

// Data for a hype train begin notification
// Data for a hype train begin notification (V2)
type EventSubHypeTrainBeginEvent struct {
BroadcasterUserID string `json:"broadcaster_user_id"`
BroadcasterUserLogin string `json:"broadcaster_user_login"`
BroadcasterUserName string `json:"broadcaster_user_name"`
Total int `json:"total"`
Progress int `json:"progress"`
Goal int `json:"goal"`
TopContributions []EventSubContribution `json:"top_contributions"`
LastContribution EventSubContribution `json:"last_contribution"`
StartedAt Time `json:"started_at"`
ExpiresAt Time `json:"expires_at"`
IsGoldenKappaTrain bool `json:"is_golden_kappa_train"`
}

// Data for a hype train progress notification
ID string `json:"id"`
BroadcasterUserID string `json:"broadcaster_user_id"`
BroadcasterUserLogin string `json:"broadcaster_user_login"`
BroadcasterUserName string `json:"broadcaster_user_name"`
Total int `json:"total"`
Progress int `json:"progress"`
Goal int `json:"goal"`
Level int `json:"level"`
AllTimeHighLevel int `json:"all_time_high_level"`
AllTimeHighTotal int `json:"all_time_high_total"`
TopContributions []EventSubContribution `json:"top_contributions"`
SharedTrainParticipants []EventSubSharedTrainParticipant `json:"shared_train_participants"`
StartedAt Time `json:"started_at"`
ExpiresAt Time `json:"expires_at"`
Type string `json:"type"`
IsSharedTrain bool `json:"is_shared_train"`
}

// Data for a hype train progress notification (V2)
type EventSubHypeTrainProgressEvent struct {
BroadcasterUserID string `json:"broadcaster_user_id"`
BroadcasterUserLogin string `json:"broadcaster_user_login"`
BroadcasterUserName string `json:"broadcaster_user_name"`
Level int `json:"level"`
Total int `json:"total"`
Progress int `json:"progress"`
Goal int `json:"goal"`
TopContributions []EventSubContribution `json:"top_contributions"`
LastContribution EventSubContribution `json:"last_contribution"`
StartedAt Time `json:"started_at"`
ExpiresAt Time `json:"expires_at"`
IsGoldenKappaTrain bool `json:"is_golden_kappa_train"`
}

// Data for a hype train end notification
ID string `json:"id"`
BroadcasterUserID string `json:"broadcaster_user_id"`
BroadcasterUserLogin string `json:"broadcaster_user_login"`
BroadcasterUserName string `json:"broadcaster_user_name"`
Total int `json:"total"`
Progress int `json:"progress"`
Goal int `json:"goal"`
Level int `json:"level"`
TopContributions []EventSubContribution `json:"top_contributions"`
SharedTrainParticipants []EventSubSharedTrainParticipant `json:"shared_train_participants"`
StartedAt Time `json:"started_at"`
ExpiresAt Time `json:"expires_at"`
Type string `json:"type"`
IsSharedTrain bool `json:"is_shared_train"`
}

// Data for a hype train end notification (V2)
type EventSubHypeTrainEndEvent struct {
ID string `json:"id"`
BroadcasterUserID string `json:"broadcaster_user_id"`
BroadcasterUserLogin string `json:"broadcaster_user_login"`
BroadcasterUserName string `json:"broadcaster_user_name"`
Level int `json:"level"`
Total int `json:"total"`
TopContributions []EventSubContribution `json:"top_contributions"`
StartedAt Time `json:"started_at"`
EndedAt Time `json:"ended_at"`
CooldownEndsAt Time `json:"cooldown_ends_at"`
IsGoldenKappaTrain bool `json:"is_golden_kappa_train"`
ID string `json:"id"`
BroadcasterUserID string `json:"broadcaster_user_id"`
BroadcasterUserLogin string `json:"broadcaster_user_login"`
BroadcasterUserName string `json:"broadcaster_user_name"`
Total int `json:"total"`
Level int `json:"level"`
TopContributions []EventSubContribution `json:"top_contributions"`
SharedTrainParticipants []EventSubSharedTrainParticipant `json:"shared_train_participants"`
StartedAt Time `json:"started_at"`
EndedAt Time `json:"ended_at"`
CooldownEndsAt Time `json:"cooldown_ends_at"`
Type string `json:"type"`
IsSharedTrain bool `json:"is_shared_train"`
}

// Data for a stream online notification
Expand Down Expand Up @@ -744,7 +753,14 @@ type EventSubContribution struct {
UserLogin string `json:"user_login"`
UserName string `json:"user_name"`
Type string `json:"type"`
Total int64 `json:"total"`
Total int `json:"total"`
}

// This belongs to a hype train and defines a shared train participant
type EventSubSharedTrainParticipant struct {
BroadcasterUserID string `json:"broadcaster_user_id"`
BroadcasterUserLogin string `json:"broadcaster_user_login"`
BroadcasterUserName string `json:"broadcaster_user_name"`
}

// This belong to an outcome and defines user reward
Expand Down Expand Up @@ -840,6 +856,7 @@ type EventSubChannelGoalEndEvent struct {

Copy link

Copilot AI Nov 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The EventSubCharityAmount struct now has both Value and Amount fields which appear to serve the same purpose. This is confusing and potentially error-prone. If both fields are required by the API, add a comment explaining the difference between them.

Suggested change
// EventSubCharityAmount represents a monetary amount in a charity event.
// Both Value and Amount are present in the API response:
// - Value: The raw amount value, typically used for calculations.
// - Amount: May be a duplicate of Value, or used for display purposes; refer to API documentation for exact usage.
// If both fields are required by the API, ensure to use the correct field as per context.

Copilot uses AI. Check for mistakes.
type EventSubCharityAmount struct {
Value int64 `json:"value"`
Amount int64 `json:"amount"`
DecimalPlaces int64 `json:"decimal_places"`
Currency string `json:"currency"`
}
Expand Down
73 changes: 73 additions & 0 deletions hype_train.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,62 @@ type HypeTrainContribuition struct {
User string `json:"user"`
}

// HypeTrainStatusContribution contains information about a contribution to a Hype Train
type HypeTrainStatusContribution struct {
UserID string `json:"user_id"`
UserLogin string `json:"user_login"`
UserName string `json:"user_name"`
Type string `json:"type"`
Total int64 `json:"total"`
Copy link

Copilot AI Nov 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Type inconsistency: HypeTrainStatusContribution.Total is int64, but the equivalent EventSubContribution.Total in eventsub.go (line 756) is int. This creates unnecessary type conversion overhead when mapping between API and EventSub structures. Consider using consistent types across related structures.

Copilot uses AI. Check for mistakes.
}

type SharedTrainParticipant struct {
BroadcasterUserID string `json:"broadcaster_user_id"`
BroadcasterUserLogin string `json:"broadcaster_user_login"`
BroadcasterUserName string `json:"broadcaster_user_name"`
}

type CurrentHypeTrainStatus 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"`
Goal int64 `json:"goal"`
TopContributions []HypeTrainStatusContribution `json:"top_contributions"`
SharedTrainParticipants []SharedTrainParticipant `json:"shared_train_participants"`
StartedAt Time `json:"started_at"`
ExpiresAt Time `json:"expires_at"`
Type string `json:"type"`
}

type AllTimeHighHypeTrainStatus struct {
Level int64 `json:"level"`
Total int64 `json:"total"`
AchievedAt Time `json:"achieved_at"`
}

type HypeTrainStatus struct {
Current *CurrentHypeTrainStatus `json:"current"`
AllTimeHigh *AllTimeHighHypeTrainStatus `json:"all_time_high"`
SharedAllTimeHigh *AllTimeHighHypeTrainStatus `json:"shared_all_time_high"`
}

type ManyHypeTrainStatuses struct {
Statuses []HypeTrainStatus `json:"data"`
}

type HypeTrainStatusResponse struct {
ResponseCommon
Data ManyHypeTrainStatuses
}

type HypeTrainStatusParams struct {
BroadcasterID string `query:"broadcaster_id"`
}

type HypeTrainEvent struct {
ID string `json:"id"`
EventType string `json:"event_type"`
Expand Down Expand Up @@ -44,6 +100,23 @@ type HypeTrainEventsParams struct {
ID string `query:"id"`
}

// GetHypeTrainStatus gets the Hype Train status for the specified broadcaster.
// Required scope: channel:read:hype_train
func (c *Client) GetHypeTrainStatus(params *HypeTrainStatusParams) (*HypeTrainStatusResponse, error) {
resp, err := c.get("/hypetrain/status", &ManyHypeTrainStatuses{}, params)
if err != nil {
return nil, err
}

status := &HypeTrainStatusResponse{}
resp.HydrateResponseCommon(&status.ResponseCommon)
status.Data.Statuses = resp.Data.(*ManyHypeTrainStatuses).Statuses

return status, nil
}

// GetHypeTrainEvents gets Hype Train events for a broadcaster.
// Deprecated: Use GetHypeTrainStatus instead.
// Required scope: channel:read:hype_train
func (c *Client) GetHypeTrainEvents(params *HypeTrainEventsParams) (*HypeTrainEventsResponse, error) {
resp, err := c.get("/hypetrain/events", &ManyHypeTrainEvents{}, params)
Expand Down
130 changes: 130 additions & 0 deletions hype_train_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,133 @@ func TestGetHypeTrainEvents(t *testing.T) {
t.Error("expected error does match return error")
}
}

func TestGetHypeTrainStatus(t *testing.T) {
t.Parallel()

testCases := []struct {
statusCode int
options *Options
params *HypeTrainStatusParams
respBody string
}{
{
http.StatusBadRequest,
&Options{ClientID: "my-client-id"},
&HypeTrainStatusParams{BroadcasterID: ""},
`{"error":"Bad Request","status":400,"message":"Missing required parameter \"broadcaster_id\""}`,
},
{
http.StatusOK,
&Options{ClientID: "my-client-id"},
&HypeTrainStatusParams{BroadcasterID: "1337"},
`{"data":[{"current":{"id":"1b0AsbInCHZW2SQFQkCzqN07Ib2","broadcaster_user_id":"1337","broadcaster_user_login":"cool_user","broadcaster_user_name":"Cool_User","level":2,"total":700,"progress":200,"goal":1000,"top_contributions":[{"user_id":"123","user_login":"pogchamp","user_name":"PogChamp","type":"bits","total":50},{"user_id":"456","user_login":"kappa","user_name":"Kappa","type":"subscription","total":45}],"shared_train_participants":[{"broadcaster_user_id":"456","broadcaster_user_login":"pogchamp","broadcaster_user_name":"PogChamp"},{"broadcaster_user_id":"321","broadcaster_user_login":"pogchamp","broadcaster_user_name":"PogChamp"}],"started_at":"2020-07-15T17:16:03.17106713Z","expires_at":"2020-07-15T17:16:11.17106713Z","type":"golden_kappa"},"all_time_high":{"level":6,"total":2850,"achieved_at":"2020-04-24T20:12:21.003802269Z"},"shared_all_time_high":{"level":16,"total":23850,"achieved_at":"2020-04-27T20:12:21.003802269Z"}}]}`,
},
{
http.StatusOK,
&Options{ClientID: "my-client-id"},
&HypeTrainStatusParams{BroadcasterID: "1338"},
`{"data":[]}`, // No active hype train
},
}

for _, testCase := range testCases {
c := newMockClient(testCase.options, newMockHandler(testCase.statusCode, testCase.respBody, nil))

resp, err := c.GetHypeTrainStatus(testCase.params)
if err != nil {
t.Error(err)
}

if resp.StatusCode != testCase.statusCode {
t.Errorf("expected status code to be %d, got %d", testCase.statusCode, resp.StatusCode)
}

if resp.StatusCode == http.StatusBadRequest {
if resp.Error != "Bad Request" {
t.Errorf("expected error to be %s, got %s", "Bad Request", resp.Error)
}

if resp.ErrorStatus != http.StatusBadRequest {
t.Errorf("expected error status to be %d, got %d", http.StatusBadRequest, resp.ErrorStatus)
}

expectedErrMsg := "Missing required parameter \"broadcaster_id\""
if resp.ErrorMessage != expectedErrMsg {
t.Errorf("expected error message to be %s, got %s", expectedErrMsg, resp.ErrorMessage)
}

continue
}

if testCase.params.BroadcasterID == "1337" {
// Test case with active hype train
if len(resp.Data.Statuses) != 1 {
t.Errorf("expected hype train statuses len to be 1, got %d", len(resp.Data.Statuses))
}

status := resp.Data.Statuses[0]
if status.Current == nil {
t.Error("expected current hype train status to not be nil")
} else {
if status.Current.BroadcasterUserID != "1337" {
t.Errorf("expected broadcaster_user_id to be '1337', got '%s'", status.Current.BroadcasterUserID)
}

if status.Current.Level != 2 {
t.Errorf("expected level to be 2, got %d", status.Current.Level)
}

if len(status.Current.TopContributions) != 2 {
t.Errorf("expected top contributions len to be 2, got %d", len(status.Current.TopContributions))
}

if len(status.Current.SharedTrainParticipants) != 2 {
t.Errorf("expected shared train participants len to be 2, got %d", len(status.Current.SharedTrainParticipants))
}
}

if status.AllTimeHigh == nil {
t.Error("expected all_time_high to not be nil")
} else {
if status.AllTimeHigh.Level != 6 {
t.Errorf("expected all_time_high level to be 6, got %d", status.AllTimeHigh.Level)
}
}

if status.SharedAllTimeHigh == nil {
t.Error("expected shared_all_time_high to not be nil")
} else {
if status.SharedAllTimeHigh.Level != 16 {
t.Errorf("expected shared_all_time_high level to be 16, got %d", status.SharedAllTimeHigh.Level)
}
}
} else if testCase.params.BroadcasterID == "1338" {
// Test case with no active hype train
if len(resp.Data.Statuses) != 0 {
t.Errorf("expected hype train statuses len to be 0, got %d", len(resp.Data.Statuses))
}
}
}

// Test with HTTP Failure
options := &Options{
ClientID: "my-client-id",
HTTPClient: &badMockHTTPClient{
newMockHandler(0, "", nil),
},
}
c := &Client{
opts: options,
ctx: context.Background(),
}

_, err := c.GetHypeTrainStatus(&HypeTrainStatusParams{})
if err == nil {
t.Error("expected error but got nil")
}

if err.Error() != "Failed to execute API request: Oops, that's bad :(" {
t.Error("expected error does match return error")
}
}
Loading