Skip to content
Merged
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
19 changes: 19 additions & 0 deletions agent/cmd/chat_moderation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ func TestTwitchModerationRejectionReporting(t *testing.T) {
{"protected user", "ban", "user may not be banned", 400, 400, "FAILED_PRECONDITION", "Twitch does not allow banning this user."},
{"protected timeout", "timeout", "user may not be put in a timeout", 400, 400, "FAILED_PRECONDITION", "Twitch does not allow timing out this user."},
{"already unbanned", "unban", "user is not banned", 400, 400, "FAILED_PRECONDITION", "This user is not banned."},
{"unrecognized unban rejection", "unban", "private-provider-detail", 400, 400, "FAILED_PRECONDITION", "Twitch rejected this unban request."},
{"empty unban rejection", "unban", "", 400, 500, "INTERNAL", "INTERNAL"},
{"blank unban rejection", "unban", " ", 400, 500, "INTERNAL", "INTERNAL"},
{"unban server failure", "unban", "user is not banned", 503, 500, "INTERNAL", "INTERNAL"},
{"unban forbidden", "unban", "private-provider-detail", 403, 500, "INTERNAL", "INTERNAL"},
{"documented wording", "ban", "The user specified in the user_id field is already banned.", 400, 400, "FAILED_PRECONDITION", "This user is already banned."},
{"missing provider parameter", "ban", "user_id is required", 400, 500, "INTERNAL", "INTERNAL"},
{"unexpected response", "ban", "private-provider-detail", 400, 500, "INTERNAL", "INTERNAL"},
Expand Down Expand Up @@ -97,3 +102,17 @@ func TestTwitchModerationMissingUserDoesNotSendInvalidBan(t *testing.T) {
})
}
}

func TestTwitchUnbanMalformedRejection(t *testing.T) {
for _, payload := range []string{`not JSON`, `{"message":42}`, `{"message":"private-provider-detail"`, `{}`, `null`} {
t.Run(payload, func(t *testing.T) {
s, _ := newMock(func(r *http.Request) (*http.Response, error) {
return respond(400, payload), nil
})
code, body := call(t, s, "unban", map[string]any{"provider": "twitch", "channelId": "broadcaster", "userId": "target"}, true)
if code != 500 || object(body["error"])["status"] != "INTERNAL" {
t.Fatal(code, body)
}
})
}
}
10 changes: 7 additions & 3 deletions agent/internal/clients/twitch/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ func (c *Client) Unban(ctx context.Context, channel, moderator, user string) err
return c.writeBan(ctx, "DELETE", moderationPath("/moderation/bans", channel, moderator, url.Values{"user_id": {user}}), nil)
}

// ModerationRejected is an expected refusal based on the target's ban state.
// ModerationRejected is a refusal of a moderation action by Twitch.
// Other provider failures retain their redacted HTTP error for reporting.
type ModerationRejected struct{ Message string }

Expand All @@ -88,8 +88,7 @@ func (c *Client) writeBan(ctx context.Context, method, path string, body any) er
if response.StatusCode == 400 {
var result struct{ Message string }
if response.Decode(&result) == nil {
// Recognize only ban-state rejections; preserve other errors so
// malformed provider requests still trigger diagnostics.
// Prefer stable, specific messages for recognized ban-state rejections.
switch strings.TrimSuffix(strings.ToLower(strings.TrimSpace(result.Message)), ".") {
case "user is already banned", "the user specified in the user_id field is already banned":
return &ModerationRejected{Message: "This user is already banned."}
Expand All @@ -100,6 +99,11 @@ func (c *Client) writeBan(ctx context.Context, method, path string, body any) er
case "user may not be put in a timeout", "the user specified in the user_id field may not be put in a timeout":
return &ModerationRejected{Message: "Twitch does not allow timing out this user."}
}
// Unban has no configurable body. A valid rejection should reach the
// caller even when Twitch changes its wording. Never expose raw text.
if method == "DELETE" && strings.TrimSpace(result.Message) != "" {
return &ModerationRejected{Message: "Twitch rejected this unban request."}
}
}
}
return response.Err()
Expand Down
Loading