From 0ff5ffdb4e52a762b434987b5baf24206fa91e4d Mon Sep 17 00:00:00 2001 From: Its-donkey Date: Wed, 11 Feb 2026 01:05:10 +1100 Subject: [PATCH 1/4] Align API names with Twitch reference, fix pagination deserialization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename functions to match official Twitch API endpoint names: - GetCustomRewards → GetCustomReward - GetCustomRewardRedemptions → GetCustomRewardRedemption - GetCharityDonations → GetCharityCampaignDonations - AddSuspiciousUserStatus → AddSuspiciousStatusToChatUser - RemoveSuspiciousUserStatus → RemoveSuspiciousStatusFromChatUser Update GetCharityCampaign to accept GetCharityCampaignParams with pagination support and return *Response[CharityCampaign] for consistency with other endpoints. Add custom Pagination.UnmarshalJSON to handle Twitch endpoints that return pagination as an empty string instead of an object. --- CHANGELOG.md | 18 +++++++++ docs/channel-points.md | 18 ++++----- docs/charity.md | 24 +++++++----- docs/examples/analytics-charity.md | 8 ++-- docs/examples/channel-points.md | 10 ++--- docs/extensions.md | 6 +-- docs/moderation.md | 10 ++--- helix/channel_points.go | 16 ++++---- helix/channel_points_test.go | 28 +++++++------- helix/charity.go | 24 +++++++----- helix/charity_test.go | 59 +++++++++++++++++++++++------- helix/client.go | 25 +++++++++++++ helix/extensions_test.go | 30 +++++++++++++++ helix/moderation.go | 16 ++++---- helix/moderation_test.go | 24 ++++++------ 15 files changed, 216 insertions(+), 100 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a9e15e8..327ec0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +## [1.2.0] - 2026-02-11 + +### Added +- `GetCharityCampaignParams` struct with pagination support for `GetCharityCampaign` +- Custom `Pagination.UnmarshalJSON` to handle Twitch endpoints that return pagination as a string instead of an object (e.g. Get Extension Live Channels) + +### Changed +- **BREAKING:** Renamed `GetCustomRewards` → `GetCustomReward` (aligns with Twitch API reference) +- **BREAKING:** Renamed `GetCustomRewardRedemptions` → `GetCustomRewardRedemption` (aligns with Twitch API reference) +- **BREAKING:** Renamed `GetCharityDonations` → `GetCharityCampaignDonations` (aligns with Twitch API reference) +- **BREAKING:** Renamed `AddSuspiciousUserStatus` → `AddSuspiciousStatusToChatUser` (aligns with Twitch API reference) +- **BREAKING:** Renamed `RemoveSuspiciousUserStatus` → `RemoveSuspiciousStatusFromChatUser` (aligns with Twitch API reference) +- **BREAKING:** `GetCharityCampaign` now takes `*GetCharityCampaignParams` instead of a bare `string`, and returns `*Response[CharityCampaign]` instead of `*CharityCampaign` +- All params types renamed to match their function names (`GetCustomRewardsParams` → `GetCustomRewardParams`, etc.) + +### Fixed +- Pagination deserialization for endpoints where Twitch returns `"pagination": ""` instead of `{"cursor": "..."}` + ## [1.1.1] - 2026-02-06 ([#56](https://github.com/Its-donkey/kappopher/pull/56)) ### Added diff --git a/docs/channel-points.md b/docs/channel-points.md index 5a6d26a..419e8e4 100644 --- a/docs/channel-points.md +++ b/docs/channel-points.md @@ -4,7 +4,7 @@ title: Channel Points API description: Manage custom Channel Points rewards and redemptions. --- -## GetCustomRewards +## GetCustomReward Get custom Channel Points rewards for a broadcaster. @@ -12,18 +12,18 @@ Get custom Channel Points rewards for a broadcaster. ```go // Get all rewards -resp, err := client.GetCustomRewards(ctx, &helix.GetCustomRewardsParams{ +resp, err := client.GetCustomReward(ctx, &helix.GetCustomRewardParams{ BroadcasterID: "12345", }) // Get specific rewards by ID (max 50) -resp, err = client.GetCustomRewards(ctx, &helix.GetCustomRewardsParams{ +resp, err = client.GetCustomReward(ctx, &helix.GetCustomRewardParams{ BroadcasterID: "12345", IDs: []string{"reward-id-1", "reward-id-2"}, }) // Get only manageable rewards -resp, err = client.GetCustomRewards(ctx, &helix.GetCustomRewardsParams{ +resp, err = client.GetCustomReward(ctx, &helix.GetCustomRewardParams{ BroadcasterID: "12345", OnlyManageableRewards: true, }) @@ -225,7 +225,7 @@ err := client.DeleteCustomReward(ctx, &helix.DeleteCustomRewardParams{ **Note:** This endpoint returns no data on success (HTTP 204 No Content). -## GetCustomRewardRedemptions +## GetCustomRewardRedemption Get redemptions for a custom Channel Points reward. @@ -233,21 +233,21 @@ Get redemptions for a custom Channel Points reward. ```go // Get all unfulfilled redemptions for a reward -resp, err := client.GetCustomRewardRedemptions(ctx, &helix.GetCustomRewardRedemptionsParams{ +resp, err := client.GetCustomRewardRedemption(ctx, &helix.GetCustomRewardRedemptionParams{ BroadcasterID: "12345", RewardID: "reward-id", Status: "UNFULFILLED", }) // Get specific redemptions by ID -resp, err = client.GetCustomRewardRedemptions(ctx, &helix.GetCustomRewardRedemptionsParams{ +resp, err = client.GetCustomRewardRedemption(ctx, &helix.GetCustomRewardRedemptionParams{ BroadcasterID: "12345", RewardID: "reward-id", IDs: []string{"redemption-id-1", "redemption-id-2"}, }) // Get fulfilled redemptions sorted by newest first -resp, err = client.GetCustomRewardRedemptions(ctx, &helix.GetCustomRewardRedemptionsParams{ +resp, err = client.GetCustomRewardRedemption(ctx, &helix.GetCustomRewardRedemptionParams{ BroadcasterID: "12345", RewardID: "reward-id", Status: "FULFILLED", @@ -258,7 +258,7 @@ resp, err = client.GetCustomRewardRedemptions(ctx, &helix.GetCustomRewardRedempt }) // Get canceled redemptions sorted by oldest first -resp, err = client.GetCustomRewardRedemptions(ctx, &helix.GetCustomRewardRedemptionsParams{ +resp, err = client.GetCustomRewardRedemption(ctx, &helix.GetCustomRewardRedemptionParams{ BroadcasterID: "12345", RewardID: "reward-id", Status: "CANCELED", diff --git a/docs/charity.md b/docs/charity.md index 42dc843..e6fd2e9 100644 --- a/docs/charity.md +++ b/docs/charity.md @@ -26,6 +26,10 @@ for _, campaign := range resp.Data { } ``` +**Parameters:** +- `BroadcasterID` (string, required): The ID of the broadcaster who is running the charity campaign +- Pagination parameters (`First`, `After`) + **Sample Response:** ```json { @@ -54,31 +58,31 @@ for _, campaign := range resp.Data { } ``` -## GetCharityDonations +## GetCharityCampaignDonations Get the list of donations that users have made to the broadcaster's charity campaign. **Requires:** channel:read:charity scope ```go -resp, err := client.GetCharityDonations(ctx, &helix.GetCharityDonationsParams{ - BroadcasterID: "12345", - First: 20, +resp, err := client.GetCharityCampaignDonations(ctx, &helix.GetCharityCampaignDonationsParams{ + BroadcasterID: "12345", + PaginationParams: &helix.PaginationParams{First: 20}, }) if err != nil { log.Fatal(err) } for _, donation := range resp.Data { - fmt.Printf("%s donated %s %s (Campaign: %s)\n", + fmt.Printf("%s donated %d %s (Campaign: %s)\n", donation.UserName, donation.Amount.Value, - donation.Amount.DecimalPlaces, donation.CampaignID) + donation.Amount.Currency, donation.CampaignID) } // Paginate through more results -if resp.Pagination.Cursor != "" { - resp, err = client.GetCharityDonations(ctx, &helix.GetCharityDonationsParams{ - BroadcasterID: "12345", - After: resp.Pagination.Cursor, +if resp.Pagination != nil && resp.Pagination.Cursor != "" { + resp, err = client.GetCharityCampaignDonations(ctx, &helix.GetCharityCampaignDonationsParams{ + BroadcasterID: "12345", + PaginationParams: &helix.PaginationParams{After: resp.Pagination.Cursor}, }) } ``` diff --git a/docs/examples/analytics-charity.md b/docs/examples/analytics-charity.md index 7f4939f..aaa3e93 100644 --- a/docs/examples/analytics-charity.md +++ b/docs/examples/analytics-charity.md @@ -169,7 +169,9 @@ func main() { broadcasterID := "12345" // Get current charity campaign - campaign, err := client.GetCharityCampaign(ctx, broadcasterID) + campaign, err := client.GetCharityCampaign(ctx, &helix.GetCharityCampaignParams{ + BroadcasterID: broadcasterID, + }) if err != nil { log.Fatal(err) } @@ -193,8 +195,8 @@ func main() { // Get charity donations donations, err := client.GetCharityCampaignDonations(ctx, &helix.GetCharityCampaignDonationsParams{ - BroadcasterID: broadcasterID, - First: 20, + BroadcasterID: broadcasterID, + PaginationParams: &helix.PaginationParams{First: 20}, }) if err != nil { log.Fatal(err) diff --git a/docs/examples/channel-points.md b/docs/examples/channel-points.md index 6a6e5b8..a3bfc69 100644 --- a/docs/examples/channel-points.md +++ b/docs/examples/channel-points.md @@ -93,7 +93,7 @@ func boolPtr(b bool) *bool { return &b } ```go // Get all custom rewards -rewards, err := client.GetCustomRewards(ctx, &helix.GetCustomRewardsParams{ +rewards, err := client.GetCustomReward(ctx, &helix.GetCustomRewardParams{ BroadcasterID: broadcasterID, }) if err != nil { @@ -110,13 +110,13 @@ for _, reward := range rewards.Data { } // Get specific rewards by ID -specificRewards, err := client.GetCustomRewards(ctx, &helix.GetCustomRewardsParams{ +specificRewards, err := client.GetCustomReward(ctx, &helix.GetCustomRewardParams{ BroadcasterID: broadcasterID, IDs: []string{"reward-id-1", "reward-id-2"}, }) // Get only manageable rewards (created by your app) -manageableRewards, err := client.GetCustomRewards(ctx, &helix.GetCustomRewardsParams{ +manageableRewards, err := client.GetCustomReward(ctx, &helix.GetCustomRewardParams{ BroadcasterID: broadcasterID, OnlyManageableRewards: true, }) @@ -162,7 +162,7 @@ if err != nil { ```go // Get unfulfilled redemptions -redemptions, err := client.GetCustomRewardRedemptions(ctx, &helix.GetCustomRewardRedemptionsParams{ +redemptions, err := client.GetCustomRewardRedemption(ctx, &helix.GetCustomRewardRedemptionParams{ BroadcasterID: broadcasterID, RewardID: "reward-id", Status: "UNFULFILLED", @@ -180,7 +180,7 @@ for _, redemption := range redemptions.Data { } // Get redemptions with pagination -allRedemptions, err := client.GetCustomRewardRedemptions(ctx, &helix.GetCustomRewardRedemptionsParams{ +allRedemptions, err := client.GetCustomRewardRedemption(ctx, &helix.GetCustomRewardRedemptionParams{ BroadcasterID: broadcasterID, RewardID: "reward-id", Status: "UNFULFILLED", diff --git a/docs/extensions.md b/docs/extensions.md index 0297bd3..a3c70fc 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -130,6 +130,8 @@ for _, channel := range resp.Data { } ``` +**Note:** This endpoint returns `"pagination": ""` (a string) instead of the usual `{"cursor": "..."}` object. The library handles this automatically. + **Sample Response:** ```json { @@ -149,9 +151,7 @@ for _, channel := range resp.Data { "title": "Testing Extensions with Chat" } ], - "pagination": { - "cursor": "eyJiIjpudWxsLCJhIjp7Ik9mZnNldCI6MjB9fQ" - } + "pagination": "" } ``` diff --git a/docs/moderation.md b/docs/moderation.md index ee81ca2..7533dd6 100644 --- a/docs/moderation.md +++ b/docs/moderation.md @@ -785,7 +785,7 @@ for _, channel := range resp.Data { } ``` -## AddSuspiciousUserStatus +## AddSuspiciousStatusToChatUser Add a suspicious status to a chat user. Suspicious users can be marked as "restricted" (cannot chat) or "monitored" (messages are flagged for review). @@ -793,7 +793,7 @@ Add a suspicious status to a chat user. Suspicious users can be marked as "restr ```go // Mark a user as restricted (cannot send messages) -err := client.AddSuspiciousUserStatus(ctx, &helix.AddSuspiciousUserStatusParams{ +err := client.AddSuspiciousStatusToChatUser(ctx, &helix.AddSuspiciousStatusToChatUserParams{ BroadcasterID: "12345", ModeratorID: "67890", UserID: "11111", @@ -801,7 +801,7 @@ err := client.AddSuspiciousUserStatus(ctx, &helix.AddSuspiciousUserStatusParams{ }) // Mark a user as monitored (messages flagged for review) -err = client.AddSuspiciousUserStatus(ctx, &helix.AddSuspiciousUserStatusParams{ +err = client.AddSuspiciousStatusToChatUser(ctx, &helix.AddSuspiciousStatusToChatUserParams{ BroadcasterID: "12345", ModeratorID: "67890", UserID: "22222", @@ -819,14 +819,14 @@ if err != nil { **Response:** This endpoint returns 204 No Content on success. -## RemoveSuspiciousUserStatus +## RemoveSuspiciousStatusFromChatUser Remove a suspicious status from a chat user, allowing them to chat normally again. **Requires:** `moderator:manage:suspicious_users` ```go -err := client.RemoveSuspiciousUserStatus(ctx, &helix.RemoveSuspiciousUserStatusParams{ +err := client.RemoveSuspiciousStatusFromChatUser(ctx, &helix.RemoveSuspiciousStatusFromChatUserParams{ BroadcasterID: "12345", ModeratorID: "67890", UserID: "11111", diff --git a/helix/channel_points.go b/helix/channel_points.go index c313a62..511b521 100644 --- a/helix/channel_points.go +++ b/helix/channel_points.go @@ -55,16 +55,16 @@ type GlobalCooldown struct { GlobalCooldownSeconds int `json:"global_cooldown_seconds"` } -// GetCustomRewardsParams contains parameters for GetCustomRewards. -type GetCustomRewardsParams struct { +// GetCustomRewardParams contains parameters for GetCustomReward. +type GetCustomRewardParams struct { BroadcasterID string IDs []string // Reward IDs (max 50) OnlyManageableRewards bool } -// GetCustomRewards gets custom rewards for a channel. +// GetCustomReward gets custom rewards for a channel. // Requires: channel:read:redemptions or channel:manage:redemptions scope. -func (c *Client) GetCustomRewards(ctx context.Context, params *GetCustomRewardsParams) (*Response[CustomReward], error) { +func (c *Client) GetCustomReward(ctx context.Context, params *GetCustomRewardParams) (*Response[CustomReward], error) { q := url.Values{} q.Set("broadcaster_id", params.BroadcasterID) for _, id := range params.IDs { @@ -182,8 +182,8 @@ type CustomRewardRedemption struct { } `json:"reward"` } -// GetCustomRewardRedemptionsParams contains parameters for GetCustomRewardRedemptions. -type GetCustomRewardRedemptionsParams struct { +// GetCustomRewardRedemptionParams contains parameters for GetCustomRewardRedemption. +type GetCustomRewardRedemptionParams struct { BroadcasterID string RewardID string Status string // CANCELED, FULFILLED, UNFULFILLED @@ -192,9 +192,9 @@ type GetCustomRewardRedemptionsParams struct { *PaginationParams } -// GetCustomRewardRedemptions gets redemptions for a custom reward. +// GetCustomRewardRedemption gets redemptions for a custom reward. // Requires: channel:read:redemptions or channel:manage:redemptions scope. -func (c *Client) GetCustomRewardRedemptions(ctx context.Context, params *GetCustomRewardRedemptionsParams) (*Response[CustomRewardRedemption], error) { +func (c *Client) GetCustomRewardRedemption(ctx context.Context, params *GetCustomRewardRedemptionParams) (*Response[CustomRewardRedemption], error) { q := url.Values{} q.Set("broadcaster_id", params.BroadcasterID) q.Set("reward_id", params.RewardID) diff --git a/helix/channel_points_test.go b/helix/channel_points_test.go index ece8cac..fd23e32 100644 --- a/helix/channel_points_test.go +++ b/helix/channel_points_test.go @@ -8,7 +8,7 @@ import ( "time" ) -func TestClient_GetCustomRewards(t *testing.T) { +func TestClient_GetCustomReward(t *testing.T) { client, server := newTestClient(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/channel_points/custom_rewards" { t.Errorf("expected /channel_points/custom_rewards, got %s", r.URL.Path) @@ -54,7 +54,7 @@ func TestClient_GetCustomRewards(t *testing.T) { }) defer server.Close() - resp, err := client.GetCustomRewards(context.Background(), &GetCustomRewardsParams{ + resp, err := client.GetCustomReward(context.Background(), &GetCustomRewardParams{ BroadcasterID: "12345", }) @@ -72,7 +72,7 @@ func TestClient_GetCustomRewards(t *testing.T) { } } -func TestClient_GetCustomRewards_ByIDs(t *testing.T) { +func TestClient_GetCustomReward_ByIDs(t *testing.T) { client, server := newTestClient(func(w http.ResponseWriter, r *http.Request) { ids := r.URL.Query()["id"] if len(ids) != 2 { @@ -89,7 +89,7 @@ func TestClient_GetCustomRewards_ByIDs(t *testing.T) { }) defer server.Close() - resp, err := client.GetCustomRewards(context.Background(), &GetCustomRewardsParams{ + resp, err := client.GetCustomReward(context.Background(), &GetCustomRewardParams{ BroadcasterID: "12345", IDs: []string{"reward1", "reward2"}, }) @@ -102,7 +102,7 @@ func TestClient_GetCustomRewards_ByIDs(t *testing.T) { } } -func TestClient_GetCustomRewards_OnlyManageable(t *testing.T) { +func TestClient_GetCustomReward_OnlyManageable(t *testing.T) { client, server := newTestClient(func(w http.ResponseWriter, r *http.Request) { onlyManageable := r.URL.Query().Get("only_manageable_rewards") if onlyManageable != "true" { @@ -114,7 +114,7 @@ func TestClient_GetCustomRewards_OnlyManageable(t *testing.T) { }) defer server.Close() - _, err := client.GetCustomRewards(context.Background(), &GetCustomRewardsParams{ + _, err := client.GetCustomReward(context.Background(), &GetCustomRewardParams{ BroadcasterID: "12345", OnlyManageableRewards: true, }) @@ -249,7 +249,7 @@ func TestClient_DeleteCustomReward(t *testing.T) { } } -func TestClient_GetCustomRewardRedemptions(t *testing.T) { +func TestClient_GetCustomRewardRedemption(t *testing.T) { client, server := newTestClient(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/channel_points/custom_rewards/redemptions" { t.Errorf("expected /channel_points/custom_rewards/redemptions, got %s", r.URL.Path) @@ -300,7 +300,7 @@ func TestClient_GetCustomRewardRedemptions(t *testing.T) { }) defer server.Close() - resp, err := client.GetCustomRewardRedemptions(context.Background(), &GetCustomRewardRedemptionsParams{ + resp, err := client.GetCustomRewardRedemption(context.Background(), &GetCustomRewardRedemptionParams{ BroadcasterID: "12345", RewardID: "reward123", Status: "UNFULFILLED", @@ -406,14 +406,14 @@ func TestClient_UpdateRedemptionStatus_Cancel(t *testing.T) { } } -func TestClient_GetCustomRewards_Error(t *testing.T) { +func TestClient_GetCustomReward_Error(t *testing.T) { client, server := newTestClient(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) _, _ = w.Write([]byte(`{"error":"internal error"}`)) }) defer server.Close() - _, err := client.GetCustomRewards(context.Background(), &GetCustomRewardsParams{BroadcasterID: "12345"}) + _, err := client.GetCustomReward(context.Background(), &GetCustomRewardParams{BroadcasterID: "12345"}) if err == nil { t.Fatal("expected error, got nil") } @@ -477,14 +477,14 @@ func TestClient_UpdateCustomReward_EmptyResponse(t *testing.T) { } } -func TestClient_GetCustomRewardRedemptions_Error(t *testing.T) { +func TestClient_GetCustomRewardRedemption_Error(t *testing.T) { client, server := newTestClient(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusForbidden) _, _ = w.Write([]byte(`{"error":"forbidden"}`)) }) defer server.Close() - _, err := client.GetCustomRewardRedemptions(context.Background(), &GetCustomRewardRedemptionsParams{BroadcasterID: "12345", RewardID: "reward123"}) + _, err := client.GetCustomRewardRedemption(context.Background(), &GetCustomRewardRedemptionParams{BroadcasterID: "12345", RewardID: "reward123"}) if err == nil { t.Fatal("expected error, got nil") } @@ -503,7 +503,7 @@ func TestClient_UpdateRedemptionStatus_Error(t *testing.T) { } } -func TestClient_GetCustomRewardRedemptions_WithIDsAndSort(t *testing.T) { +func TestClient_GetCustomRewardRedemption_WithIDsAndSort(t *testing.T) { client, server := newTestClient(func(w http.ResponseWriter, r *http.Request) { ids := r.URL.Query()["id"] if len(ids) != 2 { @@ -525,7 +525,7 @@ func TestClient_GetCustomRewardRedemptions_WithIDsAndSort(t *testing.T) { }) defer server.Close() - resp, err := client.GetCustomRewardRedemptions(context.Background(), &GetCustomRewardRedemptionsParams{ + resp, err := client.GetCustomRewardRedemption(context.Background(), &GetCustomRewardRedemptionParams{ BroadcasterID: "12345", RewardID: "reward123", IDs: []string{"redemption1", "redemption2"}, diff --git a/helix/charity.go b/helix/charity.go index 1750b4f..c17fb12 100644 --- a/helix/charity.go +++ b/helix/charity.go @@ -26,20 +26,24 @@ type CharityAmount struct { Currency string `json:"currency"` } +// GetCharityCampaignParams contains parameters for GetCharityCampaign. +type GetCharityCampaignParams struct { + BroadcasterID string + *PaginationParams +} + // GetCharityCampaign gets the active charity campaign for a channel. // Requires: channel:read:charity scope. -func (c *Client) GetCharityCampaign(ctx context.Context, broadcasterID string) (*CharityCampaign, error) { +func (c *Client) GetCharityCampaign(ctx context.Context, params *GetCharityCampaignParams) (*Response[CharityCampaign], error) { q := url.Values{} - q.Set("broadcaster_id", broadcasterID) + q.Set("broadcaster_id", params.BroadcasterID) + addPaginationParams(q, params.PaginationParams) var resp Response[CharityCampaign] if err := c.get(ctx, "/charity/campaigns", q, &resp); err != nil { return nil, err } - if len(resp.Data) == 0 { - return nil, nil - } - return &resp.Data[0], nil + return &resp, nil } // CharityDonation represents a donation to a charity campaign. @@ -52,15 +56,15 @@ type CharityDonation struct { Amount CharityAmount `json:"amount"` } -// GetCharityDonationsParams contains parameters for GetCharityDonations. -type GetCharityDonationsParams struct { +// GetCharityCampaignDonationsParams contains parameters for GetCharityCampaignDonations. +type GetCharityCampaignDonationsParams struct { BroadcasterID string *PaginationParams } -// GetCharityDonations gets the donations for a charity campaign. +// GetCharityCampaignDonations gets the donations for a charity campaign. // Requires: channel:read:charity scope. -func (c *Client) GetCharityDonations(ctx context.Context, params *GetCharityDonationsParams) (*Response[CharityDonation], error) { +func (c *Client) GetCharityCampaignDonations(ctx context.Context, params *GetCharityCampaignDonationsParams) (*Response[CharityDonation], error) { q := url.Values{} q.Set("broadcaster_id", params.BroadcasterID) addPaginationParams(q, params.PaginationParams) diff --git a/helix/charity_test.go b/helix/charity_test.go index 4dd7671..584d2fe 100644 --- a/helix/charity_test.go +++ b/helix/charity_test.go @@ -49,20 +49,49 @@ func TestClient_GetCharityCampaign(t *testing.T) { }) defer server.Close() - resp, err := client.GetCharityCampaign(context.Background(), "12345") + resp, err := client.GetCharityCampaign(context.Background(), &GetCharityCampaignParams{ + BroadcasterID: "12345", + }) if err != nil { t.Fatalf("unexpected error: %v", err) } - if resp == nil { - t.Fatal("expected campaign, got nil") + if len(resp.Data) != 1 { + t.Fatalf("expected 1 campaign, got %d", len(resp.Data)) + } + if resp.Data[0].CharityName != "Test Charity" { + t.Errorf("expected charity name 'Test Charity', got %s", resp.Data[0].CharityName) } - if resp.CharityName != "Test Charity" { - t.Errorf("expected charity name 'Test Charity', got %s", resp.CharityName) +} + +func TestClient_GetCharityCampaign_Pagination(t *testing.T) { + client, server := newTestClient(func(w http.ResponseWriter, r *http.Request) { + first := r.URL.Query().Get("first") + if first != "1" { + t.Errorf("expected first=1, got %s", first) + } + after := r.URL.Query().Get("after") + if after != "abc123" { + t.Errorf("expected after=abc123, got %s", after) + } + + resp := Response[CharityCampaign]{ + Data: []CharityCampaign{}, + } + _ = json.NewEncoder(w).Encode(resp) + }) + defer server.Close() + + _, err := client.GetCharityCampaign(context.Background(), &GetCharityCampaignParams{ + BroadcasterID: "12345", + PaginationParams: &PaginationParams{First: 1, After: "abc123"}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) } } -func TestClient_GetCharityDonations(t *testing.T) { +func TestClient_GetCharityCampaignDonations(t *testing.T) { client, server := newTestClient(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("expected GET, got %s", r.Method) @@ -92,7 +121,7 @@ func TestClient_GetCharityDonations(t *testing.T) { }) defer server.Close() - resp, err := client.GetCharityDonations(context.Background(), &GetCharityDonationsParams{ + resp, err := client.GetCharityCampaignDonations(context.Background(), &GetCharityCampaignDonationsParams{ BroadcasterID: "12345", }) @@ -114,7 +143,9 @@ func TestClient_GetCharityCampaign_Error(t *testing.T) { }) defer server.Close() - _, err := client.GetCharityCampaign(context.Background(), "12345") + _, err := client.GetCharityCampaign(context.Background(), &GetCharityCampaignParams{ + BroadcasterID: "12345", + }) if err == nil { t.Fatal("expected error, got nil") } @@ -129,23 +160,25 @@ func TestClient_GetCharityCampaign_EmptyResponse(t *testing.T) { }) defer server.Close() - resp, err := client.GetCharityCampaign(context.Background(), "12345") + resp, err := client.GetCharityCampaign(context.Background(), &GetCharityCampaignParams{ + BroadcasterID: "12345", + }) if err != nil { t.Fatalf("unexpected error: %v", err) } - if resp != nil { - t.Error("expected nil, got campaign") + if len(resp.Data) != 0 { + t.Errorf("expected 0 campaigns, got %d", len(resp.Data)) } } -func TestClient_GetCharityDonations_Error(t *testing.T) { +func TestClient_GetCharityCampaignDonations_Error(t *testing.T) { client, server := newTestClient(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusUnauthorized) _, _ = w.Write([]byte(`{"error":"unauthorized"}`)) }) defer server.Close() - _, err := client.GetCharityDonations(context.Background(), &GetCharityDonationsParams{ + _, err := client.GetCharityCampaignDonations(context.Background(), &GetCharityCampaignDonationsParams{ BroadcasterID: "12345", }) if err == nil { diff --git a/helix/client.go b/helix/client.go index e9b64a3..5fd9fba 100644 --- a/helix/client.go +++ b/helix/client.go @@ -176,10 +176,35 @@ type Response[T any] struct { } // Pagination contains pagination information. +// Some Twitch endpoints (e.g. Get Extension Live Channels) return +// pagination as an empty string instead of an object. UnmarshalJSON +// handles both formats. type Pagination struct { Cursor string `json:"cursor,omitempty"` } +// UnmarshalJSON handles both object ({"cursor":"..."}) and string ("") pagination formats. +func (p *Pagination) UnmarshalJSON(data []byte) error { + // Handle empty string or quoted string (e.g. "" or "cursor_value") + if len(data) > 0 && data[0] == '"' { + var s string + if err := json.Unmarshal(data, &s); err != nil { + return err + } + p.Cursor = s + return nil + } + + // Handle normal object format: {"cursor": "..."} + type paginationAlias Pagination + var alias paginationAlias + if err := json.Unmarshal(data, &alias); err != nil { + return err + } + *p = Pagination(alias) + return nil +} + // ErrorResponse represents an API error response. type ErrorResponse struct { Error string `json:"error"` diff --git a/helix/extensions_test.go b/helix/extensions_test.go index cd47b4c..71c4cf4 100644 --- a/helix/extensions_test.go +++ b/helix/extensions_test.go @@ -545,6 +545,36 @@ func TestClient_SendExtensionPubSubMessage_Error(t *testing.T) { } } +func TestClient_GetExtensionLiveChannels_StringPagination(t *testing.T) { + // Twitch returns "pagination": "" (a string) for this endpoint, + // not the usual "pagination": {"cursor": "..."} object format. + client, server := newTestClient(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{ + "data": [ + { + "broadcaster_id": "12345", + "broadcaster_name": "TestUser", + "game_name": "Test Game", + "game_id": "game123", + "title": "Live Stream" + } + ], + "pagination": "" + }`)) + }) + defer server.Close() + + resp, err := client.GetExtensionLiveChannels(context.Background(), &GetExtensionLiveChannelsParams{ + ExtensionID: "ext123", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(resp.Data) != 1 { + t.Fatalf("expected 1 channel, got %d", len(resp.Data)) + } +} + func TestClient_GetExtensionLiveChannels_Error(t *testing.T) { client, server := newTestClient(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) diff --git a/helix/moderation.go b/helix/moderation.go index 35ea1aa..0d6d8d3 100644 --- a/helix/moderation.go +++ b/helix/moderation.go @@ -531,18 +531,18 @@ const ( SuspiciousUserStatusMonitored SuspiciousUserStatus = "monitored" ) -// AddSuspiciousUserStatusParams contains parameters for AddSuspiciousUserStatus. -type AddSuspiciousUserStatusParams struct { +// AddSuspiciousStatusToChatUserParams contains parameters for AddSuspiciousStatusToChatUser. +type AddSuspiciousStatusToChatUserParams struct { BroadcasterID string `json:"-"` ModeratorID string `json:"-"` UserID string `json:"user_id"` Status SuspiciousUserStatus `json:"status"` } -// AddSuspiciousUserStatus adds a suspicious status to a chat user. +// AddSuspiciousStatusToChatUser adds a suspicious status to a chat user. // The status can be "restricted" or "monitored". // Requires: moderator:manage:suspicious_users scope. -func (c *Client) AddSuspiciousUserStatus(ctx context.Context, params *AddSuspiciousUserStatusParams) error { +func (c *Client) AddSuspiciousStatusToChatUser(ctx context.Context, params *AddSuspiciousStatusToChatUserParams) error { q := url.Values{} q.Set("broadcaster_id", params.BroadcasterID) q.Set("moderator_id", params.ModeratorID) @@ -550,16 +550,16 @@ func (c *Client) AddSuspiciousUserStatus(ctx context.Context, params *AddSuspici return c.post(ctx, "/moderation/suspicious_users", q, params, nil) } -// RemoveSuspiciousUserStatusParams contains parameters for RemoveSuspiciousUserStatus. -type RemoveSuspiciousUserStatusParams struct { +// RemoveSuspiciousStatusFromChatUserParams contains parameters for RemoveSuspiciousStatusFromChatUser. +type RemoveSuspiciousStatusFromChatUserParams struct { BroadcasterID string `json:"-"` ModeratorID string `json:"-"` UserID string `json:"-"` } -// RemoveSuspiciousUserStatus removes a suspicious status from a chat user. +// RemoveSuspiciousStatusFromChatUser removes a suspicious status from a chat user. // Requires: moderator:manage:suspicious_users scope. -func (c *Client) RemoveSuspiciousUserStatus(ctx context.Context, params *RemoveSuspiciousUserStatusParams) error { +func (c *Client) RemoveSuspiciousStatusFromChatUser(ctx context.Context, params *RemoveSuspiciousStatusFromChatUserParams) error { q := url.Values{} q.Set("broadcaster_id", params.BroadcasterID) q.Set("moderator_id", params.ModeratorID) diff --git a/helix/moderation_test.go b/helix/moderation_test.go index 5f468e7..c2cf820 100644 --- a/helix/moderation_test.go +++ b/helix/moderation_test.go @@ -1285,7 +1285,7 @@ func TestClient_GetUnbanRequests_WithUserID(t *testing.T) { // Suspicious user status tests -func TestClient_AddSuspiciousUserStatus(t *testing.T) { +func TestClient_AddSuspiciousStatusToChatUser(t *testing.T) { client, server := newTestClient(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Errorf("expected POST, got %s", r.Method) @@ -1304,7 +1304,7 @@ func TestClient_AddSuspiciousUserStatus(t *testing.T) { t.Errorf("expected moderator_id=%s, got %s", twitchBanModeratorID, moderatorID) } - var body AddSuspiciousUserStatusParams + var body AddSuspiciousStatusToChatUserParams _ = json.NewDecoder(r.Body).Decode(&body) if body.UserID != twitchBanUserID { @@ -1318,7 +1318,7 @@ func TestClient_AddSuspiciousUserStatus(t *testing.T) { }) defer server.Close() - err := client.AddSuspiciousUserStatus(context.Background(), &AddSuspiciousUserStatusParams{ + err := client.AddSuspiciousStatusToChatUser(context.Background(), &AddSuspiciousStatusToChatUserParams{ BroadcasterID: twitchBanBroadcasterID, ModeratorID: twitchBanModeratorID, UserID: twitchBanUserID, @@ -1330,9 +1330,9 @@ func TestClient_AddSuspiciousUserStatus(t *testing.T) { } } -func TestClient_AddSuspiciousUserStatus_Monitored(t *testing.T) { +func TestClient_AddSuspiciousStatusToChatUser_Monitored(t *testing.T) { client, server := newTestClient(func(w http.ResponseWriter, r *http.Request) { - var body AddSuspiciousUserStatusParams + var body AddSuspiciousStatusToChatUserParams _ = json.NewDecoder(r.Body).Decode(&body) if body.Status != SuspiciousUserStatusMonitored { @@ -1343,7 +1343,7 @@ func TestClient_AddSuspiciousUserStatus_Monitored(t *testing.T) { }) defer server.Close() - err := client.AddSuspiciousUserStatus(context.Background(), &AddSuspiciousUserStatusParams{ + err := client.AddSuspiciousStatusToChatUser(context.Background(), &AddSuspiciousStatusToChatUserParams{ BroadcasterID: twitchBanBroadcasterID, ModeratorID: twitchBanModeratorID, UserID: twitchBanUserID, @@ -1355,14 +1355,14 @@ func TestClient_AddSuspiciousUserStatus_Monitored(t *testing.T) { } } -func TestClient_AddSuspiciousUserStatus_Error(t *testing.T) { +func TestClient_AddSuspiciousStatusToChatUser_Error(t *testing.T) { client, server := newTestClient(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusForbidden) _, _ = w.Write([]byte(`{"error":"forbidden"}`)) }) defer server.Close() - err := client.AddSuspiciousUserStatus(context.Background(), &AddSuspiciousUserStatusParams{ + err := client.AddSuspiciousStatusToChatUser(context.Background(), &AddSuspiciousStatusToChatUserParams{ BroadcasterID: twitchBanBroadcasterID, ModeratorID: twitchBanModeratorID, UserID: twitchBanUserID, @@ -1374,7 +1374,7 @@ func TestClient_AddSuspiciousUserStatus_Error(t *testing.T) { } } -func TestClient_RemoveSuspiciousUserStatus(t *testing.T) { +func TestClient_RemoveSuspiciousStatusFromChatUser(t *testing.T) { client, server := newTestClient(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodDelete { t.Errorf("expected DELETE, got %s", r.Method) @@ -1401,7 +1401,7 @@ func TestClient_RemoveSuspiciousUserStatus(t *testing.T) { }) defer server.Close() - err := client.RemoveSuspiciousUserStatus(context.Background(), &RemoveSuspiciousUserStatusParams{ + err := client.RemoveSuspiciousStatusFromChatUser(context.Background(), &RemoveSuspiciousStatusFromChatUserParams{ BroadcasterID: twitchBanBroadcasterID, ModeratorID: twitchBanModeratorID, UserID: twitchBanUserID, @@ -1412,14 +1412,14 @@ func TestClient_RemoveSuspiciousUserStatus(t *testing.T) { } } -func TestClient_RemoveSuspiciousUserStatus_Error(t *testing.T) { +func TestClient_RemoveSuspiciousStatusFromChatUser_Error(t *testing.T) { client, server := newTestClient(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"error":"not found"}`)) }) defer server.Close() - err := client.RemoveSuspiciousUserStatus(context.Background(), &RemoveSuspiciousUserStatusParams{ + err := client.RemoveSuspiciousStatusFromChatUser(context.Background(), &RemoveSuspiciousStatusFromChatUserParams{ BroadcasterID: twitchBanBroadcasterID, ModeratorID: twitchBanModeratorID, UserID: twitchBanUserID, From 6d7d2b6e8a58e21aa63483cd359d956787c13318 Mon Sep 17 00:00:00 2001 From: Its-donkey Date: Wed, 11 Feb 2026 01:05:47 +1100 Subject: [PATCH 2/4] Move changelog entries under Unreleased for CI workflow --- CHANGELOG.md | 8 -------- 1 file changed, 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 327ec0e..abce0b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,14 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Added - -### Changed - -### Fixed - -## [1.2.0] - 2026-02-11 - ### Added - `GetCharityCampaignParams` struct with pagination support for `GetCharityCampaign` - Custom `Pagination.UnmarshalJSON` to handle Twitch endpoints that return pagination as a string instead of an object (e.g. Get Extension Live Channels) From 0651df5d60f5b65d418d367dc1dae701eb32847b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 10 Feb 2026 14:08:46 +0000 Subject: [PATCH 3/4] Release v1.2.0: Update CHANGELOG version --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index abce0b3..0a2c422 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +### Changed + +### Fixed + +## [1.2.0] - 2026-02-10 + ### Added - `GetCharityCampaignParams` struct with pagination support for `GetCharityCampaign` - Custom `Pagination.UnmarshalJSON` to handle Twitch endpoints that return pagination as a string instead of an object (e.g. Get Extension Live Channels) From 89ed0420102f501bf5431467812fd3ac74f21e5a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 10 Feb 2026 14:08:51 +0000 Subject: [PATCH 4/4] Add PR #59 link to CHANGELOG for v1.2.0 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a2c422..cb316a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -## [1.2.0] - 2026-02-10 +## [1.2.0] - 2026-02-10 ([#59](https://github.com/Its-donkey/kappopher/pull/59)) ### Added - `GetCharityCampaignParams` struct with pagination support for `GetCharityCampaign`