diff --git a/auth/src/main/java/com/github/twitch4j/auth/domain/TwitchScopes.java b/auth/src/main/java/com/github/twitch4j/auth/domain/TwitchScopes.java index 88f7375d1..a0384bab8 100644 --- a/auth/src/main/java/com/github/twitch4j/auth/domain/TwitchScopes.java +++ b/auth/src/main/java/com/github/twitch4j/auth/domain/TwitchScopes.java @@ -20,6 +20,7 @@ public enum TwitchScopes { HELIX_CHANNEL_EDITORS_READ("channel:read:editors"), HELIX_CHANNEL_GOALS_READ("channel:read:goals"), HELIX_CHANNEL_HYPE_TRAIN_READ("channel:read:hype_train"), + HELIX_CHANNEL_MODS_MANAGE("channel:manage:moderators"), HELIX_CHANNEL_POLLS_MANAGE("channel:manage:polls"), HELIX_CHANNEL_POLLS_READ("channel:read:polls"), HELIX_CHANNEL_PREDICTIONS_MANAGE("channel:manage:predictions"), @@ -29,6 +30,8 @@ public enum TwitchScopes { HELIX_CHANNEL_STREAM_KEY_READ("channel:read:stream_key"), HELIX_CHANNEL_SUBSCRIPTIONS_READ("channel:read:subscriptions"), HELIX_CHANNEL_VIDEOS_MANAGE("channel:manage:videos"), + HELIX_CHANNEL_VIPS_MANAGE("channel:manage:vips"), + HELIX_CHANNEL_VIPS_READ("channel:read:vips"), HELIX_MODERATION_READ("moderation:read"), HELIX_AUTOMOD_MANAGE("moderator:manage:automod"), HELIX_AUTOMOD_SETTINGS_MANAGE("moderator:manage:automod_settings"), @@ -36,8 +39,11 @@ public enum TwitchScopes { HELIX_BANNED_USERS_MANAGE("moderator:manage:banned_users"), HELIX_BLOCKED_TERMS_MANAGE("moderator:manage:blocked_terms"), HELIX_BLOCKED_TERMS_READ("moderator:read:blocked_terms"), + HELIX_CHAT_ANNOUNCEMENTS_MANAGE("moderator:manage:announcements"), + HELIX_CHAT_MESSAGES_MANAGE("moderator:manage:chat_messages"), HELIX_CHAT_SETTINGS_MANAGE("moderator:manage:chat_settings"), HELIX_CHAT_SETTINGS_READ("moderator:read:chat_settings"), + HELIX_USER_COLOR_MANAGE("user:manage:chat_color"), HELIX_USER_EDIT("user:edit"), HELIX_USER_EDIT_BROADCAST("user:edit:broadcast"), HELIX_USER_EDIT_FOLLOWS("user:edit:follows"), @@ -47,6 +53,7 @@ public enum TwitchScopes { HELIX_USER_SUBSCRIPTIONS_READ("user:read:subscriptions"), HELIX_USER_EMAIL("user:read:email"), HELIX_USER_BLOCKS_MANAGE("user:manage:blocked_users"), + HELIX_USER_WHISPERS_MANAGE("user:manage:whispers"), KRAKEN_CHANNEL_CHECK_SUBSCRIPTION("channel_check_subscription"), KRAKEN_CHANNEL_COMMERCIAL("channel_commercial"), KRAKEN_CHANNEL_EDITOR("channel_editor"), diff --git a/rest-helix/src/main/java/com/github/twitch4j/helix/TwitchHelix.java b/rest-helix/src/main/java/com/github/twitch4j/helix/TwitchHelix.java index e5020bdd7..b301d76c5 100644 --- a/rest-helix/src/main/java/com/github/twitch4j/helix/TwitchHelix.java +++ b/rest-helix/src/main/java/com/github/twitch4j/helix/TwitchHelix.java @@ -13,6 +13,8 @@ import com.netflix.hystrix.HystrixCommand; import feign.*; import org.apache.commons.io.IOUtils; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.io.InputStream; @@ -322,6 +324,34 @@ HystrixCommand updateRedemptionStatus( @Param("status") RedemptionStatus newStatus ); + /** + * Sends an announcement to the broadcaster’s chat room. + *

+ * This endpoint is in open beta. + * + * @param authToken User access token (scope: moderator:manage:announcements) of the broadcaster or a moderator. + * @param broadcasterId The ID of the broadcaster that owns the chat room to send the announcement to. + * @param moderatorId The ID of a user who has permission to moderate the broadcaster’s chat room. This ID must match the user ID in the OAuth token, which can be a moderator or the broadcaster. + * @param message The announcement to make in the broadcaster’s chat room. Announcements are limited to a maximum of 500 characters. + * @param color The color used to highlight the announcement. + * @return 204 No Content upon a successful call + * @see com.github.twitch4j.auth.domain.TwitchScopes#HELIX_CHAT_ANNOUNCEMENTS_MANAGE + */ + @Unofficial // beta + @RequestLine("POST /chat/announcements?broadcaster_id={broadcaster_id}&moderator_id={moderator_id}") + @Headers({ + "Authorization: Bearer {token}", + "Content-Type: application/json" + }) + @Body("%7B\"message\":\"{message}\",\"color\":\"{color}\"%7D") + HystrixCommand sendChatAnnouncement( + @Param("token") String authToken, + @NotNull @Param("broadcaster_id") String broadcasterId, + @NotNull @Param("moderator_id") String moderatorId, + @NotNull @Param(value = "message", expander = JsonStringExpander.class) String message, + @NotNull @Param("color") AnnouncementColor color + ); + /** * Gets a list of custom chat badges that can be used in chat for the specified channel. * This includes subscriber badges and Bit badges. @@ -349,6 +379,47 @@ HystrixCommand getGlobalChatBadges( @Param("token") String authToken ); + /** + * Gets the color used for the user’s name in chat. + *

+ * This endpoint is in open beta. + * + * @param authToken App access token or user access token. + * @param userIds The ID of the users whose color you want to get. Maximum: 100. + * @return UserChatColorList + * @see ChatUserColor#getColor() + */ + @Unofficial // beta + @RequestLine("GET /chat/color?user_id={user_id}") + @Headers("Authorization: Bearer {token}") + HystrixCommand getUserChatColor( + @Param("token") String authToken, + @NotNull @Param("user_id") List userIds + ); + + /** + * Updates the color used for the user’s name in chat. + *

+ * This endpoint is in open beta. + *

+ * All users may specify one of the following named color values in {@link NamedUserChatColor}. + * Turbo and Prime users may specify a named color or a Hex color code like #9146FF. + * + * @param authToken User access token that includes the user:manage:chat_color scope. + * @param userId The ID of the user whose chat color you want to update. This must match the user ID in the access token. + * @param color The color to use for the user’s name in chat. + * @return 204 No Content upon a successful call + * @see com.github.twitch4j.auth.domain.TwitchScopes#HELIX_USER_COLOR_MANAGE + */ + @Unofficial // beta + @RequestLine("PUT /chat/color?user_id={user_id}&color={color}") + @Headers("Authorization: Bearer {token}") + HystrixCommand updateUserChatColor( + @Param("token") String authToken, + @NotNull @Param("user_id") String userId, + @NotNull @Param("color") String color + ); + /** * Gets all custom emotes for a specific Twitch channel including subscriber emotes, Bits tier emotes, and follower emotes. *

@@ -1033,6 +1104,70 @@ HystrixCommand getChannelEditors( @Param("broadcaster_id") String broadcasterId ); + /** + * Gets a list of the channel’s VIPs. + *

+ * This endpoint is in open beta. + * + * @param authToken Broadcaster's user access token that includes the channel:read:vips scope. + * @param broadcasterId The ID of the broadcaster whose list of VIPs you want to get. + * @param userIds Filters the list for specific VIPs. The maximum number of IDs that you may specify is 100. + * @param limit The maximum number of items to return per page in the response. Minimum: 1. Maximum: 100. Default: 20. + * @param after The cursor used to get the next page of results. The Pagination object in the response contains the cursor’s value. + * @return ChannelVipList + * @see com.github.twitch4j.auth.domain.TwitchScopes#HELIX_CHANNEL_VIPS_READ + */ + @Unofficial // beta + @RequestLine("GET /channels/vips?broadcaster_id={broadcaster_id}&user_id={user_id}&first={first}&after={after}") + @Headers("Authorization: Bearer {token}") + HystrixCommand getChannelVips( + @Param("token") String authToken, + @NotNull @Param("broadcaster_id") String broadcasterId, + @Nullable @Param("user_id") List userIds, + @Nullable @Param("first") Integer limit, + @Nullable @Param("after") String after + ); + + /** + * Adds a VIP to the broadcaster’s chat room. + *

+ * This endpoint is in open beta. + * + * @param authToken Broadcaster's user access token that includes the channel:manage:vips scope. + * @param broadcasterId The ID of the broadcaster that’s granting VIP status to the user. + * @param userId The ID of the user to add as a VIP in the broadcaster’s chat room. + * @return 204 No Content upon a successful call + * @see com.github.twitch4j.auth.domain.TwitchScopes#HELIX_CHANNEL_VIPS_MANAGE + */ + @Unofficial // beta + @RequestLine("POST /channels/vips?broadcaster_id={broadcaster_id}&user_id={user_id}") + @Headers("Authorization: Bearer {token}") + HystrixCommand addChannelVip( + @Param("token") String authToken, + @NotNull @Param("broadcaster_id") String broadcasterId, + @NotNull @Param("user_id") String userId + ); + + /** + * Removes a VIP from the broadcaster’s chat room. + *

+ * This endpoint is in open beta. + * + * @param authToken Broadcaster's user access token that includes the channel:manage:vips scope. + * @param broadcasterId The ID of the broadcaster that’s removing VIP status from the user. + * @param userId The ID of the user to remove as a VIP from the broadcaster’s chat room. + * @return 204 No Content upon a successful call + * @see com.github.twitch4j.auth.domain.TwitchScopes#HELIX_CHANNEL_VIPS_MANAGE + */ + @Unofficial // beta + @RequestLine("DELETE /channels/vips?broadcaster_id={broadcaster_id}&user_id={user_id}") + @Headers("Authorization: Bearer {token}") + HystrixCommand removeChannelVip( + @Param("token") String authToken, + @NotNull @Param("broadcaster_id") String broadcasterId, + @NotNull @Param("user_id") String userId + ); + /** * Creates a clip programmatically. This returns both an ID and an edit URL for the new clip. * @@ -1448,6 +1583,39 @@ HystrixCommand removeBlockedTerm( @Param("id") String blockedTermId ); + /** + * Removes a single chat message or all chat messages from the broadcaster’s chat room. + *

+ * This endpoint is in open beta. + *

+ * The ID in the moderator_id query parameter must match the user ID in the access token. + * If the broadcaster wants to remove messages themselves (instead of having the moderator do it), set this parameter to the broadcaster’s ID, too. + *

+ * The id tag in the PRIVMSG contains the message’s ID (see {@code IRCMessageEvent#getMessageId}). + * Restrictions: + *

+ * If id is not specified, the request removes all messages in the broadcaster’s chat room. + * + * @param authToken User access token (scope: moderator:manage:chat_messages) of the broadcaster or a moderator. + * @param broadcasterId The ID of the broadcaster that owns the chat room to remove messages from. + * @param moderatorId The ID of a user that has permission to moderate the broadcaster’s chat room. This ID must match the user ID in the OAuth token. + * @param messageId The ID of the message to remove. If not specified, the request removes all messages in the broadcaster’s chat room. + * @return 204 No Content upon a successful call + * @see com.github.twitch4j.auth.domain.TwitchScopes#HELIX_CHAT_MESSAGES_MANAGE + */ + @Unofficial // beta + @RequestLine("DELETE /moderation/chat?broadcaster_id={broadcaster_id}&moderator_id={moderator_id}&message_id={message_id}") + @Headers("Authorization: Bearer {token}") + HystrixCommand deleteChatMessages( + @Param("token") String authToken, + @NotNull @Param("broadcaster_id") String broadcasterId, + @NotNull @Param("moderator_id") String moderatorId, + @Nullable @Param("message_id") String messageId + ); + /** * Determines whether a string message meets the channel’s AutoMod requirements. * @@ -1487,6 +1655,46 @@ HystrixCommand getModerators( @Param("first") Integer limit ); + /** + * Adds a moderator to the broadcaster’s chat room. + *

+ * This endpoint is in open beta. + * + * @param authToken Broadcaster's user access token that includes the channel:manage:moderators scope. + * @param broadcasterId The ID of the broadcaster that owns the chat room. + * @param userId The ID of the user to add as a moderator in the broadcaster’s chat room. + * @return 204 No Content upon a successful call + * @see com.github.twitch4j.auth.domain.TwitchScopes#HELIX_CHANNEL_MODS_MANAGE + */ + @Unofficial // beta + @RequestLine("POST /moderation/moderators?broadcaster_id={broadcaster_id}&user_id={user_id}") + @Headers("Authorization: Bearer {token}") + HystrixCommand addChannelModerator( + @Param("token") String authToken, + @NotNull @Param("broadcaster_id") String broadcasterId, + @NotNull @Param("user_id") String userId + ); + + /** + * Removes a moderator from the broadcaster’s chat room. + *

+ * This endpoint is in open beta. + * + * @param authToken Broadcaster's user access token that includes the channel:manage:moderators scope. + * @param broadcasterId The ID of the broadcaster that owns the chat room. + * @param userId The ID of the user to remove as a moderator from the broadcaster’s chat room. + * @return 204 No Content upon a successful call + * @see com.github.twitch4j.auth.domain.TwitchScopes#HELIX_CHANNEL_MODS_MANAGE + */ + @Unofficial // beta + @RequestLine("DELETE /moderation/moderators?broadcaster_id={broadcaster_id}&user_id={user_id}") + @Headers("Authorization: Bearer {token}") + HystrixCommand removeChannelModerator( + @Param("token") String authToken, + @NotNull @Param("broadcaster_id") String broadcasterId, + @NotNull @Param("user_id") String userId + ); + /** * Returns all moderators in a channel. * @@ -2456,4 +2664,50 @@ HystrixCommand requestWebhookSubscription( WebhookRequest request, // POJO as first arg is assumed by feign to be body if no @Body annotation @Param("token") String authToken ); + + /** + * Sends a whisper message to the specified user. + *

+ * This endpoint is in open beta. + *

+ * Note: The user sending the whisper must have a verified phone number. + *

+ * Note: The API may silently drop whispers that it suspects of violating Twitch policies. + * (The API does not indicate that it dropped the whisper; it returns a 204 status code as if it succeeded). + *

+ * Rate Limits: You may whisper to a maximum of 40 unique recipients per day. + * Within the per day limit, you may whisper a maximum of 3 whispers per second and a maximum of 100 whispers per minute. + *

+ * The ID in the from_user_id query parameter must match the user ID in the access token. + *

+ * The maximum message lengths are: + *

    + *
  • 500 characters if the user you're sending the message to hasn't whispered you before.
  • + *
  • 10,000 characters if the user you're sending the message to has whispered you before.
  • + *
+ *

+ * Error 400 (Bad Request) can occur if the user that you're sending the whisper to doesn't allow whisper messages from strangers, + * and has not followed the sender's twitch account. + * + * @param authToken User access token for the whisper sender that includes the user:manage:whispers scope + * @param fromUserId The ID of the user sending the whisper. This user must have a verified phone number. + * @param toUserId The ID of the user to receive the whisper. + * @param message The whisper message to send. The message must not be empty. Messages that exceed the maximum length are truncated. + * @return 204 No Content upon a successful call, even if the message was silently dropped + * @see com.github.twitch4j.auth.domain.TwitchScopes#HELIX_USER_WHISPERS_MANAGE + */ + @Unofficial // beta + @RequestLine("POST /whispers?from_user_id={from_user_id}&to_user_id={to_user_id}") + @Headers({ + "Authorization: Bearer {token}", + "Content-Type: application/json" + }) + @Body("%7B\"message\":\"{message}\"%7D") + HystrixCommand sendWhisper( + @Param("token") String authToken, + @NotNull @Param("from_user_id") String fromUserId, + @NotNull @Param("to_user_id") String toUserId, + @NotNull @Param(value = "message", expander = JsonStringExpander.class) String message + ); + } diff --git a/rest-helix/src/main/java/com/github/twitch4j/helix/domain/AnnouncementColor.java b/rest-helix/src/main/java/com/github/twitch4j/helix/domain/AnnouncementColor.java new file mode 100644 index 000000000..dba49f140 --- /dev/null +++ b/rest-helix/src/main/java/com/github/twitch4j/helix/domain/AnnouncementColor.java @@ -0,0 +1,17 @@ +package com.github.twitch4j.helix.domain; + +/** + * The color used to highlight the announcement. + */ +public enum AnnouncementColor { + BLUE, + GREEN, + ORANGE, + PURPLE, + PRIMARY; + + @Override + public String toString() { + return this.name().toLowerCase(); + } +} diff --git a/rest-helix/src/main/java/com/github/twitch4j/helix/domain/ChannelVip.java b/rest-helix/src/main/java/com/github/twitch4j/helix/domain/ChannelVip.java new file mode 100644 index 000000000..75659a5ac --- /dev/null +++ b/rest-helix/src/main/java/com/github/twitch4j/helix/domain/ChannelVip.java @@ -0,0 +1,26 @@ +package com.github.twitch4j.helix.domain; + +import lombok.AccessLevel; +import lombok.Data; +import lombok.Setter; + +@Data +@Setter(AccessLevel.PRIVATE) +public class ChannelVip { + + /** + * An ID that uniquely identifies the VIP user. + */ + private String userId; + + /** + * The user’s display name. + */ + private String userName; + + /** + * The user’s login name. + */ + private String userLogin; + +} diff --git a/rest-helix/src/main/java/com/github/twitch4j/helix/domain/ChannelVipList.java b/rest-helix/src/main/java/com/github/twitch4j/helix/domain/ChannelVipList.java new file mode 100644 index 000000000..9c9ab6007 --- /dev/null +++ b/rest-helix/src/main/java/com/github/twitch4j/helix/domain/ChannelVipList.java @@ -0,0 +1,26 @@ +package com.github.twitch4j.helix.domain; + +import lombok.AccessLevel; +import lombok.Data; +import lombok.Setter; + +import java.util.List; + +@Data +@Setter(AccessLevel.PRIVATE) +public class ChannelVipList { + + /** + * The list of VIPs. + *

+ * The list is empty if the channel doesn't have VIP users. + * The list does not include the broadcaster. + */ + private List data; + + /** + * Contains the information used to page through the list of results. + */ + private HelixPagination pagination; + +} diff --git a/rest-helix/src/main/java/com/github/twitch4j/helix/domain/ChatUserColor.java b/rest-helix/src/main/java/com/github/twitch4j/helix/domain/ChatUserColor.java new file mode 100644 index 000000000..09ac93ae5 --- /dev/null +++ b/rest-helix/src/main/java/com/github/twitch4j/helix/domain/ChatUserColor.java @@ -0,0 +1,33 @@ +package com.github.twitch4j.helix.domain; + +import lombok.AccessLevel; +import lombok.Data; +import lombok.Setter; + +@Data +@Setter(AccessLevel.PRIVATE) +public class ChatUserColor { + + /** + * The ID of the user. + */ + private String userId; + + /** + * The user’s display name. + */ + private String userName; + + /** + * The user’s login name. + */ + private String userLogin; + + /** + * The Hex color code that the user uses in chat for their name. + *

+ * If the user hasn't specified a color in their settings, the string is empty. + */ + private String color; + +} diff --git a/rest-helix/src/main/java/com/github/twitch4j/helix/domain/HelixPagination.java b/rest-helix/src/main/java/com/github/twitch4j/helix/domain/HelixPagination.java index 19b15df96..96a8f988c 100644 --- a/rest-helix/src/main/java/com/github/twitch4j/helix/domain/HelixPagination.java +++ b/rest-helix/src/main/java/com/github/twitch4j/helix/domain/HelixPagination.java @@ -4,6 +4,7 @@ import lombok.Data; import lombok.NoArgsConstructor; import lombok.Setter; +import org.jetbrains.annotations.Nullable; /** * Pagination @@ -13,6 +14,10 @@ @NoArgsConstructor public class HelixPagination { + /** + * The cursor used to get the next page of results. Use the cursor to set the request’s after query parameter. + */ + @Nullable private String cursor; } diff --git a/rest-helix/src/main/java/com/github/twitch4j/helix/domain/NamedUserChatColor.java b/rest-helix/src/main/java/com/github/twitch4j/helix/domain/NamedUserChatColor.java new file mode 100644 index 000000000..3e67d1973 --- /dev/null +++ b/rest-helix/src/main/java/com/github/twitch4j/helix/domain/NamedUserChatColor.java @@ -0,0 +1,34 @@ +package com.github.twitch4j.helix.domain; + +/** + * The color to use for the user’s name in chat. + *

+ * All users may specify one of the following named color values. + *

+ * Turbo and Prime users may specify a named color or a Hex color code like #9146FF. + * If you use a Hex color code, remember to URL encode it. + * + * @see com.github.twitch4j.helix.TwitchHelix#updateUserChatColor(String, String, String) + */ +public enum NamedUserChatColor { + BLUE, + BLUE_VIOLET, + CADET_BLUE, + CHOCOLATE, + CORAL, + DODGER_BLUE, + FIREBRICK, + GOLDEN_ROD, + GREEN, + HOT_PINK, + ORANGE_RED, + RED, + SEA_GREEN, + SPRING_GREEN, + YELLOW_GREEN; + + @Override + public String toString() { + return this.name().toLowerCase(); + } +} diff --git a/rest-helix/src/main/java/com/github/twitch4j/helix/domain/UserChatColorList.java b/rest-helix/src/main/java/com/github/twitch4j/helix/domain/UserChatColorList.java new file mode 100644 index 000000000..25f803182 --- /dev/null +++ b/rest-helix/src/main/java/com/github/twitch4j/helix/domain/UserChatColorList.java @@ -0,0 +1,18 @@ +package com.github.twitch4j.helix.domain; + +import lombok.AccessLevel; +import lombok.Data; +import lombok.Setter; + +import java.util.List; + +@Data +@Setter(AccessLevel.PRIVATE) +public class UserChatColorList { + + /** + * The list of users and the color code that’s used for their name. + */ + private List data; + +} diff --git a/rest-helix/src/main/java/com/github/twitch4j/helix/interceptor/TwitchHelixHttpClient.java b/rest-helix/src/main/java/com/github/twitch4j/helix/interceptor/TwitchHelixHttpClient.java index a168ac01b..71560b3b1 100644 --- a/rest-helix/src/main/java/com/github/twitch4j/helix/interceptor/TwitchHelixHttpClient.java +++ b/rest-helix/src/main/java/com/github/twitch4j/helix/interceptor/TwitchHelixHttpClient.java @@ -67,6 +67,25 @@ public Response execute(Request request, Request.Options options) throws IOExcep private Response delegatedExecute(Request request, Request.Options options) throws IOException { String templatePath = request.requestTemplate().path(); + // Channels API: addChannelVip and removeChannelVip (likely) share a bucket per channel id + if (templatePath.endsWith("/channels/vips")) { + // Obtain the channel id + String channelId = request.requestTemplate().queries().getOrDefault("broadcaster_id", Collections.emptyList()).iterator().next(); + + // Conform to endpoint-specific bucket + Bucket vipBucket; + if (request.httpMethod() == Request.HttpMethod.POST) { + vipBucket = rateLimitTracker.getVipAddBucket(channelId); + } else if (request.httpMethod() == Request.HttpMethod.DELETE) { + vipBucket = rateLimitTracker.getVipRemoveBucket(channelId); + } else { + vipBucket = null; + } + + if (vipBucket != null) + return executeAgainstBucket(vipBucket, () -> client.execute(request, options)); + } + // Moderation API: Check AutoMod Status has a stricter bucket that applies per channel id if (request.httpMethod() == Request.HttpMethod.POST && templatePath.endsWith("/moderation/enforcements/status")) { // Obtain the channel id @@ -97,6 +116,25 @@ private Response delegatedExecute(Request request, Request.Options options) thro return executeAgainstBucket(termsBucket, () -> client.execute(request, options)); } + // Moderation API: addChannelModerator and removeChannelModerator have independent buckets per channel id + if (templatePath.endsWith("/moderation/moderators")) { + // Obtain the channel id + String channelId = request.requestTemplate().queries().getOrDefault("broadcaster_id", Collections.emptyList()).iterator().next(); + + // Conform to endpoint-specific bucket + Bucket modsBucket; + if (request.httpMethod() == Request.HttpMethod.POST) { + modsBucket = rateLimitTracker.getModAddBucket(channelId); + } else if (request.httpMethod() == Request.HttpMethod.DELETE) { + modsBucket = rateLimitTracker.getModRemoveBucket(channelId); + } else { + modsBucket = null; + } + + if (modsBucket != null) + return executeAgainstBucket(modsBucket, () -> client.execute(request, options)); + } + // Clips API: createClip has a stricter bucket that applies per user id if (request.httpMethod() == Request.HttpMethod.POST && templatePath.endsWith("/clips")) { // Obtain user id @@ -120,6 +158,16 @@ private Response delegatedExecute(Request request, Request.Options options) thro return executeAgainstBucket(raidBucket, () -> client.execute(request, options)); } + // Whispers API: sendWhisper has a stricter bucket that applies per user id + if (templatePath.endsWith("/whispers")) { + // Obtain the user id + String userId = request.requestTemplate().queries().getOrDefault("from_user_id", Collections.emptyList()).iterator().next(); + + // Conform to endpoint-specific bucket + Bucket whisperBucket = rateLimitTracker.getWhispersBucket(userId); + return executeAgainstBucket(whisperBucket, () -> client.execute(request, options)); + } + // no endpoint-specific rate limiting was needed; simply perform network request now return client.execute(request, options); } diff --git a/rest-helix/src/main/java/com/github/twitch4j/helix/interceptor/TwitchHelixRateLimitTracker.java b/rest-helix/src/main/java/com/github/twitch4j/helix/interceptor/TwitchHelixRateLimitTracker.java index 434cddc8c..2b8f2af8f 100644 --- a/rest-helix/src/main/java/com/github/twitch4j/helix/interceptor/TwitchHelixRateLimitTracker.java +++ b/rest-helix/src/main/java/com/github/twitch4j/helix/interceptor/TwitchHelixRateLimitTracker.java @@ -25,6 +25,8 @@ public final class TwitchHelixRateLimitTracker { private static final String AUTOMOD_STATUS_MINUTE_ID = TwitchLimitType.HELIX_AUTOMOD_STATUS_LIMIT + "-min"; private static final String AUTOMOD_STATUS_HOUR_ID = TwitchLimitType.HELIX_AUTOMOD_STATUS_LIMIT + "-hr"; + private static final String WHISPER_MINUTE_BANDWIDTH_ID = TwitchLimitType.CHAT_WHISPER_LIMIT.getBandwidthId() + "-minute"; + private static final String WHISPER_SECOND_BANDWIDTH_ID = TwitchLimitType.CHAT_WHISPER_LIMIT.getBandwidthId() + "-second"; /** * @see TwitchLimitType#HELIX_AUTOMOD_STATUS_LIMIT @@ -50,11 +52,31 @@ public final class TwitchHelixRateLimitTracker { Bandwidth.simple(300, Duration.ofHours(1L)).withId(AUTOMOD_STATUS_HOUR_ID) ); + /** + * Officially documented rate limit for {@link com.github.twitch4j.helix.TwitchHelix#addChannelModerator(String, String, String)} and {@link com.github.twitch4j.helix.TwitchHelix#removeChannelModerator(String, String, String)} + */ + private static final Bandwidth MOD_BANDWIDTH = Bandwidth.simple(10, Duration.ofSeconds(10)); + /** * Officially documented rate limit for {@link com.github.twitch4j.helix.TwitchHelix#startRaid(String, String, String)} and {@link com.github.twitch4j.helix.TwitchHelix#cancelRaid(String, String)} */ private static final Bandwidth RAIDS_BANDWIDTH = Bandwidth.simple(10, Duration.ofMinutes(10)); + /** + * Officially documented rate limit for {@link com.github.twitch4j.helix.TwitchHelix#addChannelVip(String, String, String)} and {@link com.github.twitch4j.helix.TwitchHelix#removeChannelVip(String, String, String)} + */ + private static final Bandwidth VIP_BANDWIDTH = Bandwidth.simple(10, Duration.ofSeconds(10)); + + /** + * Officially documented rate limit for {@link com.github.twitch4j.helix.TwitchHelix#sendWhisper(String, String, String, String)} + * + * @see TwitchLimitType#CHAT_WHISPER_LIMIT + */ + private static final List WHISPERS_BANDWIDTH = Arrays.asList( + Bandwidth.simple(100, Duration.ofSeconds(60)).withId(WHISPER_MINUTE_BANDWIDTH_ID), + Bandwidth.simple(3, Duration.ofSeconds(1)).withId(WHISPER_SECOND_BANDWIDTH_ID) + ); + /** * Empirically determined rate limit on helix bans and unbans, per channel */ @@ -80,6 +102,20 @@ public final class TwitchHelixRateLimitTracker { .expireAfterAccess(1, TimeUnit.MINUTES) .build(); + /** + * Moderators API: add moderator rate limit bucket per channel + */ + private final Cache addModByChannelId = Caffeine.newBuilder() + .expireAfterAccess(30, TimeUnit.SECONDS) + .build(); + + /** + * Moderators API: remove moderator rate limit bucket per channel + */ + private final Cache removeModByChannelId = Caffeine.newBuilder() + .expireAfterAccess(30, TimeUnit.SECONDS) + .build(); + /** * Raids API: start and cancel raid rate limit buckets per channel */ @@ -87,6 +123,20 @@ public final class TwitchHelixRateLimitTracker { .expireAfterAccess(10, TimeUnit.MINUTES) .build(); + /** + * Channels API: add VIP rate limit bucket per channel + */ + private final Cache addVipByChannelId = Caffeine.newBuilder() + .expireAfterAccess(30, TimeUnit.SECONDS) + .build(); + + /** + * Channels API: remove VIP rate limit bucket per channel + */ + private final Cache removeVipByChannelId = Caffeine.newBuilder() + .expireAfterAccess(30, TimeUnit.SECONDS) + .build(); + /** * Moderation API: ban and unban rate limit buckets per channel */ @@ -146,11 +196,36 @@ Bucket getAutomodStatusBucket(@NotNull String channelId) { return TwitchLimitRegistry.getInstance().getOrInitializeBucket(channelId, TwitchLimitType.HELIX_AUTOMOD_STATUS_LIMIT, AUTOMOD_STATUS_NORMAL_BANDWIDTH); } + @NotNull + Bucket getModAddBucket(@NotNull String channelId) { + return addModByChannelId.get(channelId, k -> BucketUtils.createBucket(MOD_BANDWIDTH)); + } + + @NotNull + Bucket getModRemoveBucket(@NotNull String channelId) { + return removeModByChannelId.get(channelId, k -> BucketUtils.createBucket(MOD_BANDWIDTH)); + } + @NotNull Bucket getRaidsBucket(@NotNull String channelId) { return raidsByChannelId.get(channelId, k -> BucketUtils.createBucket(RAIDS_BANDWIDTH)); } + @NotNull + Bucket getVipAddBucket(@NotNull String channelId) { + return addVipByChannelId.get(channelId, k -> BucketUtils.createBucket(VIP_BANDWIDTH)); + } + + @NotNull + Bucket getVipRemoveBucket(@NotNull String channelId) { + return removeVipByChannelId.get(channelId, k -> BucketUtils.createBucket(VIP_BANDWIDTH)); + } + + @NotNull + Bucket getWhispersBucket(@NotNull String userId) { + return TwitchLimitRegistry.getInstance().getOrInitializeBucket(userId, TwitchLimitType.CHAT_WHISPER_LIMIT, WHISPERS_BANDWIDTH); + } + @NotNull @Unofficial Bucket getModerationBucket(@NotNull String channelId) {