diff --git a/agent/cmd/chat_moderation_test.go b/agent/cmd/chat_moderation_test.go index 9fb7d84b9..1db735783 100644 --- a/agent/cmd/chat_moderation_test.go +++ b/agent/cmd/chat_moderation_test.go @@ -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"}, @@ -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) + } + }) + } +} diff --git a/agent/internal/clients/twitch/chat.go b/agent/internal/clients/twitch/chat.go index 62aa0ba79..0f8bbd073 100644 --- a/agent/internal/clients/twitch/chat.go +++ b/agent/internal/clients/twitch/chat.go @@ -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 } @@ -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."} @@ -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()