Repository: nicklaw5/helix
Pull request #90 (2021-05-26 21:16:44)

Add channel points custom rewards creation feature
Update from twitch changelog 2021‑05‑24
 - Polls and Channel Points Predictions Twitch API endpoints have moved from public beta to general availability.
 - Polls and Channel Points Predictions EventSub subscription types have moved from public beta to version 1.

Update from twitch changelog 2021‑05‑21
 - Extension Bits Transaction Create EventSub subscription type has been added as a public beta.
 - Channel Points Custom Rewards - The API reference for Custom Rewards endpoints has been updated for consistency and to clarify conditional requirements for body parameters. For example, max_per_stream is required if a value for is_max_per_stream_enabled is also provided.

Update from twitch changelog 2021‑05‑14
 - Channel Prediction Lock EventSub subscription type - corrected the name of the lock timestamp to locked_at.
 - Channel Prediction End EventSub subscription type - removed the locked_at field due to edge cases with Predictions that are cancelled or closed early.
 - Create EventSub Subscription and Get EventSub Subscriptions - limit removed following the new EventSub subscription limit cost-based system.
 - Get Channel Information - Added delay to return values table and example payload.


Patch:
diff --git a/README.md b/README.md
index 228b45b..0f22a37 100644
--- a/README.md
+++ b/README.md
@@ -37,7 +37,7 @@ looking for the Twitch API docs, see the [Twitch Developer website](https://dev.
 - [x] Get Channel Information
 - [x] Modify Channel Information
 - [x] Get Channel Editors
-- [ ] Create Custom Rewards
+- [x] Create Custom Rewards
 - [ ] Delete Custom Reward
 - [ ] Get Custom Reward
 - [ ] Get Custom Reward Redemption
@@ -60,12 +60,12 @@ looking for the Twitch API docs, see the [Twitch Developer website](https://dev.
 - [x] Get Banned Users
 - [ ] Get Moderators
 - [ ] Get Moderator Events
-- [x] Get Polls         {beta}
-- [x] Create Poll       {beta}
-- [x] End Poll          {beta}
-- [x] Get Predictions   {beta}
-- [x] Create Prediction {beta}
-- [x] End Prediction    {beta}
+- [x] Get Polls
+- [x] Create Poll
+- [x] End Poll
+- [x] Get Predictions
+- [x] Create Prediction
+- [x] End Prediction
 - [ ] Search Categories
 - [ ] Search Channels
 - [ ] Get Stream Key
diff --git a/channels.go b/channels.go
index 3077600..718faad 100644
--- a/channels.go
+++ b/channels.go
@@ -83,12 +83,13 @@ type ManyChannelInformation struct {
 
 // ChannelInformation ...
 type ChannelInformation struct {
-	BroadcasterID         string `json:"broadcaster_id"`
-	BroadcasterName    string `json:"broadcaster_name"`
-	BroadcasterLanguage   string `json:"broadcaster_language"`
-	GameID string `json:"game_id"`
-	GameName   string `json:"game_name"`
-	Title      string `json:"title"`
+	BroadcasterID       string `json:"broadcaster_id"`
+	BroadcasterName     string `json:"broadcaster_name"`
+	BroadcasterLanguage string `json:"broadcaster_language"`
+	Delay               string `json:"delay"`
+	GameID              string `json:"game_id"`
+	GameName            string `json:"game_name"`
+	Title               string `json:"title"`
 }
 
 // GetChannelInformation ...
diff --git a/channels_points.go b/channels_points.go
new file mode 100644
index 0000000..0069913
--- /dev/null
+++ b/channels_points.go
@@ -0,0 +1,93 @@
+package helix
+
+// ChannelCustomRewardsParams ...
+type ChannelCustomRewardsParams struct {
+	BroadcasterID                     string `query:"broadcaster_id"`
+	Title                             string `json:"title"`
+	Cost                              int    `json:"cost"`
+	Prompt                            string `json:"prompt"`
+	IsEnabled                         bool   `json:"is_enabled"`
+	BackgroundColor                   string `json:"background_color"`
+	IsUserInputRequired               bool   `json:"is_user_input_required"`
+	IsMaxPerStreamEnabled             bool   `json:"is_max_per_stream_enabled"`
+	MaxPerStream                      int    `json:"max_per_stream"`
+	IsMaxPerUserPerStreamEnabled      bool   `json:"is_max_per_user_per_stream_enabled"`
+	MaxPerUserPerStream               int    `json:"max_per_user_per_stream"`
+	IsGlobalCooldownEnabled           bool   `json:"is_global_cooldown_enabled"`
+	GlobalCooldownSeconds             int    `json:"global_cooldown_seconds"`
+	ShouldRedemptionsSkipRequestQueue bool   `json:"should_redemptions_skip_request_queue"`
+}
+
+// ChannelCustomRewards ...
+type ManyChannelCustomRewards struct {
+	ChannelCustomRewards []ChannelCustomReward `json:"data"`
+}
+
+// ChannelCustomReward ...
+type ChannelCustomReward struct {
+	BroadcasterID                     string                      `json:"broadcaster_id"`
+	BroadcasterLogin                  string                      `json:"broadcaster_login"`
+	BroadcasterName                   string                      `json:"broadcaster_name"`
+	ID                                string                      `json:"id"`
+	Title                             string                      `json:"title"`
+	Prompt                            string                      `json:"prompt"`
+	Cost                              int                         `json:"cost"`
+	Image                             RewardImage                 `json:"image"`
+	DefaultImage                      RewardImage                 `json:"default_image"`
+	IsEnabled                         bool                        `json:"is_enabled"`
+	IsUserInputRequired               bool                        `json:"is_user_input_required"`
+	MaxPerStreamSetting               MaxPerStreamSettings        `json:"max_per_stream_setting"`
+	MaxPerUserPerStreamSetting        MaxPerUserPerStreamSettings `json:"max_per_user_per_stream_setting"`
+	GlobalCooldownSetting             GlobalCooldownSettings      `json:"global_cooldown_setting"`
+	IsPaused                          bool                        `json:"is_paused"`
+	IsInStock                         bool                        `json:"is_in_stock"`
+	ShouldRedemptionsSkipRequestQueue bool                        `json:"should_redemptions_skip_request_queue"`
+	RedemptionsRedeemedCurrentStream  int                         `json:"redemptions_redeemed_current_stream"`
+	CooldownExpiresAt                 string                      `json:"cooldown_expires_at"`
+}
+
+// RewardImage ...
+type RewardImage struct {
+	Url1 string `json:"url_1x"`
+	Url2 string `json:"url_2x"`
+	Url4 string `json:"url_4x"`
+}
+
+// MaxPerUserPerStreamSettings ...
+type MaxPerUserPerStreamSettings struct {
+	IsEnabled           bool `json:"is_enabled"`
+	MaxPerUserPerStream int  `json:"max_per_user_per_stream"`
+}
+
+// MaxPerStreamSettings ...
+type MaxPerStreamSettings struct {
+	IsEnabled    bool `json:"is_enabled"`
+	MaxPerStream int  `json:"max_per_stream"`
+}
+
+// GlobalCooldownSettings ...
+type GlobalCooldownSettings struct {
+	IsEnabled             bool `json:"is_enabled"`
+	GlobalCooldownSeconds int  `json:"global_cooldown_seconds"`
+}
+
+// ChannelCustomRewardResponse ...
+type ChannelCustomRewardResponse struct {
+	ResponseCommon
+	Data ManyChannelCustomRewards
+}
+
+// CreateCustomReward : Creates a Custom Reward on a channel.
+// Required scope: channel:manage:redemptions
+func (c *Client) CreateCustomReward(params *ChannelCustomRewardsParams) (*ChannelCustomRewardResponse, error) {
+	resp, err := c.postAsJSON("/channel_points/custom_rewards", &ManyChannelCustomRewards{}, params)
+	if err != nil {
+		return nil, err
+	}
+
+	reward := &ChannelCustomRewardResponse{}
+	resp.HydrateResponseCommon(&reward.ResponseCommon)
+	reward.Data.ChannelCustomRewards = resp.Data.(*ManyChannelCustomRewards).ChannelCustomRewards
+
+	return reward, nil
+}
diff --git a/docs/channels_points_docs.md b/docs/channels_points_docs.md
new file mode 100644
index 0000000..cf35709
--- /dev/null
+++ b/docs/channels_points_docs.md
@@ -0,0 +1,25 @@
+# Channels Points Documentation
+
+## Create Custom Rewards
+
+This is an example of how to create a custom reward.
+
+```go
+client, err := helix.NewClient(&helix.Options{
+    ClientID: "your-client-id",
+})
+if err != nil {
+    // handle error
+}
+
+resp, err := client.CreateCustomReward(&helix.ChannelCustomRewardsParams{
+    BroadcasterID : "145328278",
+    Title         : "game analysis 1v1",
+    Cost          : 50000,
+})
+if err != nil {
+    // handle error
+}
+
+fmt.Printf("%+v\n", resp)
+```
diff --git a/eventsub.go b/eventsub.go
index b9dcd2a..751a57e 100644
--- a/eventsub.go
+++ b/eventsub.go
@@ -21,6 +21,7 @@ type EventSubSubscription struct {
 	Condition EventSubCondition `json:"condition"`
 	Transport EventSubTransport `json:"transport"`
 	CreatedAt Time              `json:"created_at"`
+	Cost      int               `json:"cost"`
 }
 
 // Conditions for a subscription, not all are necessary and some only apply to some subscription types, see https://dev.twitch.tv/docs/eventsub/eventsub-reference
@@ -42,10 +43,10 @@ type EventSubTransport struct {
 
 // Twitch Response for getting all current subscriptions
 type ManyEventSubSubscriptions struct {
-	Total                 int                    `json:"total"`
+	TotalCost             int                    `json:"total_cost"`
+	MaxTotalCost          int                    `json:"max_total_cost"`
 	EventSubSubscriptions []EventSubSubscription `json:"data"`
 	Pagination            Pagination             `json:"pagination"`
-	Limit                 int                    `json:"limit"`
 }
 
 // Response for getting all current subscriptions
@@ -93,13 +94,14 @@ const (
 	EventSubTypeChannelPointsCustomRewardRemove           = "channel.channel_points_custom_reward.remove"
 	EventSubTypeChannelPointsCustomRewardRedemptionAdd    = "channel.channel_points_custom_reward_redemption.add"
 	EventSubTypeChannelPointsCustomRewardRedemptionUpdate = "channel.channel_points_custom_reward_redemption.update"
-	EventSubTypeChannelPollBegin                          = "channel.poll.begin"          /* beta */
-	EventSubTypeChannelPollProgress                       = "channel.poll.progress"       /* beta */
-	EventSubTypeChannelPollEnd                            = "channel.poll.end"            /* beta */
-	EventSubTypeChannelPredictionBegin                    = "channel.prediction.begin"    /* beta */
-	EventSubTypeChannelPredictionProgress                 = "channel.prediction.progress" /* beta */
-	EventSubTypeChannelPredictionLock                     = "channel.prediction.lock"     /* beta */
-	EventSubTypeChannelPredictionEnd                      = "channel.prediction.end"      /* beta */
+	EventSubTypeChannelPollBegin                          = "channel.poll.begin"
+	EventSubTypeChannelPollProgress                       = "channel.poll.progress"
+	EventSubTypeChannelPollEnd                            = "channel.poll.end"
+	EventSubTypeChannelPredictionBegin                    = "channel.prediction.begin"
+	EventSubTypeChannelPredictionProgress                 = "channel.prediction.progress"
+	EventSubTypeChannelPredictionLock                     = "channel.prediction.lock"
+	EventSubTypeChannelPredictionEnd                      = "channel.prediction.end"
+	EventSubExtensionBitsTransactionCreate                = "extension.bits_transaction.create" /* beta */
 	EventSubTypeHypeTrainBegin                            = "channel.hype_train.begin"
 	EventSubTypeHypeTrainProgress                         = "channel.hype_train.progress"
 	EventSubTypeHypeTrainEnd                              = "channel.hype_train.end"
@@ -301,7 +303,18 @@ type EventSubChannelPredictionBeginEvent struct {
 type EventSubChannelPredictionProgressEvent = EventSubChannelPredictionBeginEvent
 
 // Data for a channel channel prediction lock event
-type EventSubChannelPredictionLockEvent = EventSubChannelPredictionBeginEvent
+type EventSubChannelPredictionLockEvent 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"`
+	WinningOutcomeID     string            `json:"winning_outcome_id"`
+	Outcomes             []EventSubOutcome `json:"outcomes"`
+	Status               string            `json:"status"`
+	StartedAt            Time              `json:"started_at"`
+	LockedAt             Time              `json:"locked_at"`
+}
 
 // Data for a channel channel prediction end event
 type EventSubChannelPredictionEndEvent struct {
@@ -314,10 +327,22 @@ type EventSubChannelPredictionEndEvent struct {
 	Outcomes             []EventSubOutcome `json:"outcomes"`
 	Status               string            `json:"status"`
 	StartedAt            Time              `json:"started_at"`
-	LocksAt              Time              `json:"outcomes"`
 	EndedAt              Time              `json:"eneded_at"`
 }
 
+// Data for an extension bits transaction creation
+type EventSubExtensionBitsTransactionCreateEvent struct {
+	ExtensionClientID    string          `json:"extension_client_id"`
+	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"`
+	product              EventSubProduct `json:"product"`
+}
+
 // Data for a hype train begin notification
 type EventSubHypeTrainBeginEvent struct {
 	BroadcasterUserID    string                 `json:"broadcaster_user_id"`
@@ -441,6 +466,14 @@ type EventSubOutcome struct {
 	TopPredictors []EventSubTopPredictor `json:"top_predictors"`
 }
 
+// EventSubProduct ...
+type EventSubProduct struct {
+	Name          string `json:"name"`
+	Bits          int    `json:"bots"`
+	Sku           string `json:"sku"`
+	InDevelopment bool   `json:"in_development"`
+}
+
 // This belongs to a reward redemption and defines the reward redeemed
 type EventSubReward struct {
 	ID     string `json:"id"`
@@ -458,10 +491,10 @@ func (c *Client) GetEventSubSubscriptions(params *EventSubSubscriptionsParams) (
 
 	eventSubs := &EventSubSubscriptionsResponse{}
 	resp.HydrateResponseCommon(&eventSubs.ResponseCommon)
-	eventSubs.Data.Total = resp.Data.(*ManyEventSubSubscriptions).Total
+	eventSubs.Data.TotalCost             = resp.Data.(*ManyEventSubSubscriptions).TotalCost
+	eventSubs.Data.MaxTotalCost          = resp.Data.(*ManyEventSubSubscriptions).MaxTotalCost
 	eventSubs.Data.EventSubSubscriptions = resp.Data.(*ManyEventSubSubscriptions).EventSubSubscriptions
-	eventSubs.Data.Pagination = resp.Data.(*ManyEventSubSubscriptions).Pagination
-	eventSubs.Data.Limit = resp.Data.(*ManyEventSubSubscriptions).Limit
+	eventSubs.Data.Pagination            = resp.Data.(*ManyEventSubSubscriptions).Pagination
 
 	return eventSubs, nil
 }


Test patch:
diff --git a/channels_points_test.go b/channels_points_test.go
new file mode 100644
index 0000000..60290f7
--- /dev/null
+++ b/channels_points_test.go
@@ -0,0 +1,76 @@
+package helix
+
+import (
+	"net/http"
+	"testing"
+)
+
+func TestCreateCustomReward(t *testing.T) {
+	t.Parallel()
+
+	testCases := []struct {
+		statusCode  int
+		options     *Options
+		params      *ChannelCustomRewardsParams
+		respBody    string
+	}{
+		{
+			http.StatusOK,
+			&Options{ClientID: "my-client-id"},
+			&ChannelCustomRewardsParams{
+				BroadcasterID : "145328278",
+				Title         : "game analysis 1v1",
+				Cost          : 50000,
+			},
+			`{"data": [{"broadcaster_name": "torpedo09","broadcaster_login": "torpedo09","broadcaster_id": "145328278","id": "afaa7e34-6b17-49f0-a19a-d1e76eaaf673","image": null,"background_color": "#00E5CB","is_enabled": true,"cost": 50000,"title": "game analysis 1v1","prompt": "","is_user_input_required": false,"max_per_stream_setting": {"is_enabled": false,"max_per_stream": 0},"max_per_user_per_stream_setting": {"is_enabled": false,"max_per_user_per_stream": 0},"global_cooldown_setting": {"is_enabled": false,"global_cooldown_seconds": 0},"is_paused": false,"is_in_stock": true,"default_image": {"url_1x": "https://static-cdn.jtvnw.net/custom-reward-images/default-1.png","url_2x": "https://static-cdn.jtvnw.net/custom-reward-images/default-2.png","url_4x": "https://static-cdn.jtvnw.net/custom-reward-images/default-4.png"},"should_redemptions_skip_request_queue": false,"redemptions_redeemed_current_stream": null,"cooldown_expires_at": null}]}`,
+		},
+		{
+			http.StatusBadRequest,
+			&Options{ClientID: "my-client-id"},
+			&ChannelCustomRewardsParams{},
+			`{"error":"Bad Request","status":400,"message":"Missing required parameter \"broadcaster_id\""}`,
+		},
+	}
+
+	for _, testCase := range testCases {
+		c := newMockClient(testCase.options, newMockHandler(testCase.statusCode, testCase.respBody, nil))
+
+		resp, err := c.CreateCustomReward(testCase.params)
+		if err != nil {
+			t.Error(err)
+		}
+
+		// Test Bad Request Responses
+		if resp.StatusCode == http.StatusBadRequest {
+			firstErrStr := "Missing required parameter \"broadcaster_id\""
+			if resp.ErrorMessage != firstErrStr {
+				t.Errorf("expected error message to be \"%s\", got \"%s\"", firstErrStr, resp.ErrorMessage)
+			}
+			continue
+		}
+
+		if resp.StatusCode != testCase.statusCode {
+			t.Errorf("expected status code to be \"%d\", got \"%d\"", testCase.statusCode, resp.StatusCode)
+		}
+	}
+
+    // Test with HTTP Failure
+	options := &Options{
+		ClientID: "my-client-id",
+		HTTPClient: &badMockHTTPClient{
+			newMockHandler(0, "", nil),
+		},
+	}
+	c := &Client{
+		opts: options,
+	}
+
+	_, err := c.CreateCustomReward(&ChannelCustomRewardsParams{})
+	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")
+	}
+}