Repository: nicklaw5/helix
Pull request #159 (2022-12-10 12:36:29)

Implement Get Chat Settings endpoint
This PR implements the `Get Chat Settings` endpoint

## Open questions

There are a few optional fields in the response.


### 1.
These fields are only included if a moderator with the correct scopes is sent along to the `moderator_id` request parameter
 - `moderator_id`
 - `non_moderator_chat_delay`
 - `non_moderator_chat_delay_duration`

Right now, these fields are not optional in the response struct, so instead of matching the response payload of null, they're set to their default values (so false, empty string, or 0).
While this is technically incorrect, using pointers for those 3 values might be less ergonomic so I leave that choice up to you.

I think these fields should be pointers.  
**Do you think the scope-locked fields should be pointers so users can know whether the fields were included?**

### 2.
These fields are only included if the matching `bool` field is set to true
 - `follower_mode_duration`
 - `slow_mode_wait_time`

These fields are also not pointers, so if follower mode is disabled, `follower_mode_duration` will be 0 while technically the response we got was null.

I think these should stay pointer-free.  
**Do you think the fields should be pointers?**



Patch:
diff --git a/SUPPORTED_ENDPOINTS.md b/SUPPORTED_ENDPOINTS.md
index 05bc213..3aac2a3 100644
--- a/SUPPORTED_ENDPOINTS.md
+++ b/SUPPORTED_ENDPOINTS.md
@@ -33,7 +33,7 @@
 - [x] Get Emote Sets
 - [x] Get Channel Chat Badges
 - [x] Get Global Chat Badges
-- [ ] Get Chat Settings
+- [x] Get Chat Settings
 - [ ] Update Chat Settings
 - [ ] Send Chat Announcement
 - [ ] Get User Chat Color
diff --git a/chat.go b/chat.go
index f5bdc09..47f3e69 100644
--- a/chat.go
+++ b/chat.go
@@ -204,3 +204,67 @@ func (c *Client) SendChatAnnouncement(params *SendChatAnnouncementParams) (*Send
 
 	return chatResp, nil
 }
+
+type GetChatSettingsParams struct {
+	// Required, the ID of the broadcaster whose chat settings you want to get
+	BroadcasterID string `query:"broadcaster_id"`
+
+	// Optional, can be specified if you want the `non_moderator_chat_delay` and `non_moderator_chat_delay_duration` fields in the response. The ID should be a user that has moderation privileges in the broadcaster's chat.
+	// The ID must match the specified User Access Token & the User Access Token must have the `moderator:read:chat_settings` scope
+	ModeratorID string `query:"moderator_id,omitempty"`
+}
+
+type ChatSettings struct {
+	BroadcasterID string `json:"broadcaster_id"`
+
+	EmoteMode bool `json:"emote_mode"`
+
+	FollowerMode bool `json:"follower_mode"`
+	// Follower mode duration in minutes
+	FollowerModeDuration int `json:"follower_mode_duration"`
+
+	SlowMode bool `json:"slow_mode"`
+	// Slow mode wait time in seconds
+	SlowModeWaitTime int `json:"slow_mode_wait_time"`
+
+	SubscriberMode bool `json:"subscriber_mode"`
+
+	UniqueChatMode bool `json:"unique_chat_mode"`
+
+	// Only included if the user access token includes the `moderator:read:chat_settings` scope
+	ModeratorID string `json:"moderator_id"`
+
+	// Boolean value denoting whether the "Non moderator chat delay" setting is enabled.
+	// Only included if the request specifies a user access token that includes the moderator:read:chat_settings scope and the user in the moderator_id query parameter is one of the broadcaster’s moderators.
+	NonModeratorChatDelay bool `json:"non_moderator_chat_delay"`
+	// The amount of time, in seconds, that messages are delayed before appearing in chat.
+	// Only included if the request specifies a user access token that includes the moderator:read:chat_settings scope and the user in the moderator_id query parameter is one of the broadcaster’s moderators.
+	NonModeratorChatDelayDuration int `json:"non_moderator_chat_delay_duration"`
+}
+
+type ManyChatSettings struct {
+	Settings []ChatSettings `json:"data"`
+}
+
+type GetChatSettingsResponse struct {
+	ResponseCommon
+	Data ManyChatSettings
+}
+
+// GetChatSettings gets the chat settings for the broadcaster's chat room.
+// Optional scope: moderator:read:chat_settings
+func (c *Client) GetChatSettings(params *GetChatSettingsParams) (*GetChatSettingsResponse, error) {
+	if params.BroadcasterID == "" {
+		return nil, errors.New("error: broadcaster id must be specified")
+	}
+	resp, err := c.get("/chat/settings", &ManyChatSettings{}, params)
+	if err != nil {
+		return nil, err
+	}
+
+	settings := &GetChatSettingsResponse{}
+	resp.HydrateResponseCommon(&settings.ResponseCommon)
+	settings.Data.Settings = resp.Data.(*ManyChatSettings).Settings
+
+	return settings, nil
+}
diff --git a/docs/chat_docs.md b/docs/chat_docs.md
index 8df5f8b..4ebfefb 100644
--- a/docs/chat_docs.md
+++ b/docs/chat_docs.md
@@ -105,3 +105,26 @@ if err != nil {
 
 fmt.Printf("%+v\n", resp)
 ```
+
+## Get Chat Settings
+
+```go
+client, err := helix.NewClient(&helix.Options{
+    ClientID: "your-client-id",
+    AppAccessToken: "your-app-user-token",
+    // Optionally, a moderator's user token with the `moderator:read:chat_settings` scope can be specified to read some more settings
+})
+if err != nil {
+    // handle error
+}
+
+resp, err := client.GetChatSettings(&helix.GetChatSettingsParams{
+    BroadcasterID: "22484632",
+    // ModeratorID should be specified matching the UserAccessToken if you want the extended information
+})
+if err != nil {
+    // handle error
+}
+
+fmt.Printf("%+v\n", resp)
+```


Test patch:
diff --git a/chat_test.go b/chat_test.go
index 95a0799..bffb46c 100644
--- a/chat_test.go
+++ b/chat_test.go
@@ -541,3 +541,155 @@ func TestSendChatAnnouncement(t *testing.T) {
 		t.Error("expected error does match return error")
 	}
 }
+
+func TestGetChatSettings(t *testing.T) {
+	t.Parallel()
+
+	testCases := []struct {
+		statusCode    int
+		options       *Options
+		params        *GetChatSettingsParams
+		respBody      string
+		validationErr string
+		expected      *ChatSettings
+	}{
+		{ // Early-out error thrown by us
+			http.StatusBadRequest,
+			&Options{ClientID: "my-client-id", UserAccessToken: "moderator-access-token"},
+			&GetChatSettingsParams{BroadcasterID: "", ModeratorID: "82008718"},
+			``,
+			"error: broadcaster id must be specified",
+			nil,
+		},
+		{ // Error thrown by Twitch
+			http.StatusBadRequest,
+			&Options{ClientID: "my-client-id"},
+			&GetChatSettingsParams{BroadcasterID: "2248463222222222222222222222"},
+			`{"error":"Bad Request","status":400,"message":"The parameter \"broadcaster_id\" was malformed: value must be a numeric"}`,
+			"",
+			nil,
+		},
+		{ // Request made with a valid moderator ID
+			http.StatusOK,
+			&Options{ClientID: "my-client-id", UserAccessToken: "moderator-access-token"},
+			&GetChatSettingsParams{BroadcasterID: "22484632", ModeratorID: "11148817"},
+			`{"data":[{"broadcaster_id":"22484632","emote_mode":false,"follower_mode":true,"follower_mode_duration":10080,"moderator_id":"11148817","non_moderator_chat_delay":true,"non_moderator_chat_delay_duration":1,"slow_mode":false,"slow_mode_wait_time":null,"subscriber_mode":false,"unique_chat_mode":false}]}`,
+			``,
+			&ChatSettings{
+				BroadcasterID: "22484632",
+
+				EmoteMode: false,
+
+				FollowerMode:         true,
+				FollowerModeDuration: 10080,
+
+				SlowMode:         false,
+				SlowModeWaitTime: 0,
+
+				SubscriberMode: false,
+
+				UniqueChatMode: false,
+
+				ModeratorID: "11148817",
+
+				NonModeratorChatDelay:         true,
+				NonModeratorChatDelayDuration: 1,
+			},
+		},
+		{ // Request made with no moderator ID
+			http.StatusOK,
+			&Options{ClientID: "my-client-id", UserAccessToken: "moderator-access-token"},
+			&GetChatSettingsParams{BroadcasterID: "22484632", ModeratorID: ""},
+			`{"data":[{"broadcaster_id":"22484632","emote_mode":false,"follower_mode":true,"follower_mode_duration":10080,"slow_mode":false,"slow_mode_wait_time":null,"subscriber_mode":false,"unique_chat_mode":false}]}`,
+			``,
+			&ChatSettings{
+				BroadcasterID: "22484632",
+
+				EmoteMode: false,
+
+				FollowerMode:         true,
+				FollowerModeDuration: 10080,
+
+				SlowMode:         false,
+				SlowModeWaitTime: 0,
+
+				SubscriberMode: false,
+
+				UniqueChatMode: false,
+
+				ModeratorID: "",
+
+				NonModeratorChatDelay:         false,
+				NonModeratorChatDelayDuration: 0,
+			},
+		},
+	}
+
+	for _, testCase := range testCases {
+		c := newMockClient(testCase.options, newMockHandler(testCase.statusCode, testCase.respBody, nil))
+
+		resp, err := c.GetChatSettings(testCase.params)
+		if err != nil {
+			if err.Error() == testCase.validationErr {
+				continue
+			}
+			t.Errorf("Unmatched error, expected '%v', got '%v'", testCase.validationErr, err)
+			continue
+		}
+
+		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)
+			}
+
+			continue
+		}
+
+		if len(resp.Data.Settings) != 1 {
+			t.Errorf("expected %d channel settings, got %d", 1, len(resp.Data.Settings))
+		}
+
+		if resp.Data.Settings[0].BroadcasterID != "22484632" {
+			t.Errorf("expected broadcaster_id to be %s, got %s", "22484632", resp.Data.Settings[0].BroadcasterID)
+		}
+
+		if testCase.expected != nil {
+			expected := testCase.expected
+			actual := resp.Data.Settings[0]
+
+			if *expected != actual {
+				t.Errorf("expected %v channel settings, got %v", *expected, actual)
+			}
+		}
+	}
+
+	// Test with HTTP Failure
+	options := &Options{
+		ClientID: "my-client-id",
+		HTTPClient: &badMockHTTPClient{
+			newMockHandler(0, "", nil),
+		},
+	}
+	c := &Client{
+		opts: options,
+	}
+
+	_, err := c.GetChatSettings(&GetChatSettingsParams{BroadcasterID: "123"})
+	if err == nil {
+		t.Error("expected error but got nil")
+	}
+
+	const expectedHTTPError = "Failed to execute API request: Oops, that's bad :("
+
+	if err.Error() != expectedHTTPError {
+		t.Errorf("expected error does match return error, got '%s'", err.Error())
+	}
+}