diff --git a/src/main/java/com/botdetector/BotDetectorClanHighlighter.java b/src/main/java/com/botdetector/BotDetectorClanHighlighter.java new file mode 100644 index 00000000..802a90b2 --- /dev/null +++ b/src/main/java/com/botdetector/BotDetectorClanHighlighter.java @@ -0,0 +1,212 @@ +/* + * Copyright (c) 2021, Ferrariic, Seltzer Bro, Cyborger1 + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.botdetector; + +import com.botdetector.model.CaseInsensitiveString; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import java.util.HashMap; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import javax.inject.Inject; +import net.runelite.api.Client; +import net.runelite.api.clan.ClanMember; +import net.runelite.api.clan.ClanRank; +import net.runelite.api.clan.ClanSettings; +import net.runelite.api.clan.ClanTitle; +import net.runelite.api.widgets.Widget; +import net.runelite.client.util.Text; + +public class BotDetectorClanHighlighter +{ + private static final String CLAN_NAME = "Bot Detector"; + + private static final int HIGHLIGHT_COLOR = 0x00ff00; + + private static final int NAME_OFFSET = 1; + private static final int SPRITE_OFFSET = 2; + private static final int WIDGETS_PER_NAME = 3; + + private static final Pattern POPUP_TITLE_PLAYER_NAME_PATTERN = Pattern.compile("^Set rank for ([\\w\\-\\s]{1,12}):$"); + + private static final ImmutableSet EXCLUDE_RANKS = ImmutableSet.of( + ClanRank.GUEST, ClanRank.OWNER, ClanRank.JMOD + ); + + @Inject + private Client client; + + private Map toHighlight; + + protected void startUp() + { + toHighlight = null; + } + + protected void shutDown() + { + toHighlight = null; + } + + /** + * Gets the left-hand side name list widgets from the clan members interface. + * @return An array of widgets, or {@code null} if the clan members interface is not currently loaded. + */ + private Widget[] getNameWidgets() + { + Widget members = client.getWidget(693, 10); + if (members == null) + { + return null; + } + + Widget[] dyn = members.getDynamicChildren(); + if (dyn.length % WIDGETS_PER_NAME != 0) + { + return null; + } + + return dyn; + } + + /** + * Gets the current ranks for the members in the clan. The caller must be in {@link #CLAN_NAME}. + * @return A map of clan member names and their current rank, or {@code null} if the caller is not currently in the correct clan. + */ + public ImmutableMap getClanMemberRanks() + { + ClanSettings cs = client.getClanSettings(); + if (cs == null || !CLAN_NAME.equals(cs.getName())) + { + return null; + } + + return cs.getMembers().stream().collect(ImmutableMap.toImmutableMap( + cm -> BotDetectorPlugin.normalizeAndWrapPlayerName(cm.getName()), ClanMember::getRank)); + } + + /** + * Sets {@link #toHighlight}, then calls {@link #updateHighlight()}. + * @param toHighlight The map of clan members to highlight and the rank they should be. + */ + public void setHighlight(Map toHighlight) + { + this.toHighlight = toHighlight; + updateHighlight(); + } + + /** + * Highlights the players that need their ranks changed according to {@link #toHighlight}, + * assuming the clan members interface is currently loaded. If the rank changer popup is up, + * the correct rank to set will also be highlighted. + * The caller must be in {@link #CLAN_NAME}, also see {@link #setHighlight(Map)}. + */ + public void updateHighlight() + { + if (toHighlight == null) + { + return; + } + + ClanSettings cs = client.getClanSettings(); + if (cs == null || !CLAN_NAME.equals(cs.getName())) + { + return; + } + + ImmutableMap currentRanks = getClanMemberRanks(); + if (currentRanks == null) + { + return; + } + + Widget[] nameWidgets = getNameWidgets(); + if (nameWidgets == null) + { + return; + } + + Map checkPopupNames = new HashMap<>(); + for (int i = 0; i < nameWidgets.length; i += WIDGETS_PER_NAME) + { + Widget nameWidget = nameWidgets[i + NAME_OFFSET]; + CaseInsensitiveString name = BotDetectorPlugin.normalizeAndWrapPlayerName(nameWidget.getText()); + ClanRank newRank = toHighlight.get(name); + if (newRank == null || newRank == currentRanks.get(name) || EXCLUDE_RANKS.contains(newRank)) + { + continue; + } + + ClanTitle title = cs.titleForRank(newRank); + if (title == null) + { + continue; + } + + nameWidget.setTextColor(HIGHLIGHT_COLOR); + checkPopupNames.put(name, title.getName()); + } + + // Highlight correct rank in popup if present + Widget popupTitle = client.getWidget(289, 4); + Widget popupRanks = client.getWidget(289, 6); + if (popupTitle == null || popupRanks == null) + { + return; + } + + Widget[] popupRanksDyn = popupRanks.getDynamicChildren(); + if (popupRanks.getDynamicChildren().length % WIDGETS_PER_NAME != 0) + { + return; + } + + Matcher match = POPUP_TITLE_PLAYER_NAME_PATTERN.matcher(popupTitle.getChild(1).getText()); + if (!match.matches()) + { + return; + } + + CaseInsensitiveString popupName = BotDetectorPlugin.normalizeAndWrapPlayerName(match.group(1)); + + String highlightRank = checkPopupNames.get(popupName); + if (highlightRank == null) + { + return; + } + + for (int i = 0; i < popupRanksDyn.length; i += WIDGETS_PER_NAME) + { + Widget w = popupRanksDyn[i + NAME_OFFSET]; + if (highlightRank.equals(Text.removeTags(w.getText()))) + { + w.setTextColor(HIGHLIGHT_COLOR); + break; + } + } + } +} diff --git a/src/main/java/com/botdetector/BotDetectorPlugin.java b/src/main/java/com/botdetector/BotDetectorPlugin.java index aed1b50d..88be81d5 100644 --- a/src/main/java/com/botdetector/BotDetectorPlugin.java +++ b/src/main/java/com/botdetector/BotDetectorPlugin.java @@ -81,6 +81,7 @@ import net.runelite.api.MessageNode; import net.runelite.api.Player; import net.runelite.api.WorldType; +import net.runelite.api.clan.ClanRank; import net.runelite.api.coords.WorldPoint; import net.runelite.api.events.ChatMessage; import net.runelite.api.events.CommandExecuted; @@ -89,6 +90,7 @@ import net.runelite.api.events.MenuOpened; import net.runelite.api.events.MenuOptionClicked; import net.runelite.api.events.PlayerSpawned; +import net.runelite.api.events.ScriptPostFired; import net.runelite.api.events.WorldChanged; import net.runelite.api.kit.KitType; import net.runelite.api.widgets.WidgetInfo; @@ -168,6 +170,7 @@ public class BotDetectorPlugin extends Plugin private static final String CLEAR_AUTH_TOKEN_COMMAND = COMMAND_PREFIX + "ClearToken"; private static final String TOGGLE_SHOW_DISCORD_VERIFICATION_ERRORS_COMMAND = COMMAND_PREFIX + "ToggleShowDiscordVerificationErrors"; private static final String TOGGLE_SHOW_DISCORD_VERIFICATION_ERRORS_COMMAND_ALIAS = COMMAND_PREFIX + "ToggleDVE"; + private static final String GET_CLAN_RANK_UPDATES_COMMAND = COMMAND_PREFIX + "GetRankUpdates"; /** Command to method map to be used in {@link #onCommandExecuted(CommandExecuted)}. **/ private final ImmutableMap> commandConsumerMap = @@ -181,6 +184,7 @@ public class BotDetectorPlugin extends Plugin .put(wrap(CLEAR_AUTH_TOKEN_COMMAND), s -> clearAuthTokenCommand()) .put(wrap(TOGGLE_SHOW_DISCORD_VERIFICATION_ERRORS_COMMAND), s -> toggleShowDiscordVerificationErrors()) .put(wrap(TOGGLE_SHOW_DISCORD_VERIFICATION_ERRORS_COMMAND_ALIAS), s -> toggleShowDiscordVerificationErrors()) + .put(wrap(GET_CLAN_RANK_UPDATES_COMMAND), s -> getClanRankUpdatesCommand()) .build(); private static final int MANUAL_FLUSH_COOLDOWN_SECONDS = 60; @@ -225,6 +229,9 @@ public class BotDetectorPlugin extends Plugin @Inject private BotDetectorClient detectorClient; + @Inject + private BotDetectorClanHighlighter clanHighlighter; + private BotDetectorPanel panel; private NavigationButton navButton; @@ -365,6 +372,8 @@ protected void startUp() chatCommandManager.registerCommand(VERIFY_DISCORD_COMMAND, this::verifyDiscord); chatCommandManager.registerCommand(STATS_CHAT_COMMAND, this::statsChatCommand); + + clanHighlighter.startUp(); } @Override @@ -395,6 +404,8 @@ protected void shutDown() chatCommandManager.unregisterCommand(VERIFY_DISCORD_COMMAND); chatCommandManager.unregisterCommand(STATS_CHAT_COMMAND); + + clanHighlighter.shutDown(); } /** @@ -1044,6 +1055,17 @@ private void onWorldChanged(WorldChanged event) processCurrentWorld(); } + @Subscribe + private void onScriptPostFired(ScriptPostFired event) + { + // If clan names list updates (4253) + // or clan rank selection pops up (4316) + if (event.getScriptId() == 4253 || event.getScriptId() == 4316) + { + clanHighlighter.updateHighlight(); + } + } + /** * Opens the plugin panel and sends over {@code playerName} to {@link BotDetectorPanel#predictPlayer(String)} for prediction. * @param playerName The player name to predict. @@ -1339,6 +1361,55 @@ private void toggleShowDiscordVerificationErrors() } } + /** + * Gets the current clan members and their ranks, sends them over to {@link BotDetectorClient#requestClanRankUpdates(String, Map)} + * to get players that need their ranks updated and then highlights them on the clan members interface using {@link #clanHighlighter}. + */ + private void getClanRankUpdatesCommand() + { + if (!authToken.getTokenType().getPermissions().contains(AuthTokenPermission.GET_CLAN_RANK_UPDATES)) + { + sendChatStatusMessage("The currently set auth token does not permit this command.", true); + return; + } + + Map ranks = clanHighlighter.getClanMemberRanks(); + int numMembers = ranks != null ? ranks.size() : 0; + if (numMembers == 0) + { + sendChatStatusMessage("Could not get members/rank list from clan settings.", true); + return; + } + + sendChatStatusMessage("Checking rank updates for " + numMembers + " clan members. Please wait, this may take some time.", true); + + detectorClient.requestClanRankUpdates(authToken.getToken(), ranks).whenComplete((newRanks, ex) -> + { + if (ex == null) + { + int numUpdates = newRanks != null ? newRanks.size() : 0; + if (numUpdates == 0) + { + sendChatStatusMessage("No clan ranks to update.", true); + clientThread.invokeLater(() -> clanHighlighter.setHighlight(null)); + } + else + { + sendChatStatusMessage("Received " + numUpdates + " clan rank updates from the API.", true); + clientThread.invokeLater(() -> clanHighlighter.setHighlight(newRanks)); + } + } + else if (ex instanceof UnauthorizedTokenException) + { + sendChatStatusMessage("Invalid token for getting clan rank updates.", true); + } + else + { + sendChatStatusMessage("Error getting clan rank updates from the API.", true); + } + }); + } + //endregion diff --git a/src/main/java/com/botdetector/http/BotDetectorClient.java b/src/main/java/com/botdetector/http/BotDetectorClient.java index 945b6947..dc4208f6 100644 --- a/src/main/java/com/botdetector/http/BotDetectorClient.java +++ b/src/main/java/com/botdetector/http/BotDetectorClient.java @@ -25,6 +25,8 @@ */ package com.botdetector.http; +import com.botdetector.BotDetectorPlugin; +import com.botdetector.model.CaseInsensitiveString; import com.botdetector.BotDetectorPlugin; import com.botdetector.model.FeedbackPredictionLabel; import com.botdetector.model.PlayerSighting; @@ -33,6 +35,7 @@ import com.botdetector.model.Prediction; import com.google.common.base.Strings; import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; import com.google.gson.Gson; import com.google.gson.JsonDeserializationContext; import com.google.gson.JsonDeserializer; @@ -61,6 +64,7 @@ import lombok.Setter; import lombok.Value; import lombok.extern.slf4j.Slf4j; +import net.runelite.api.clan.ClanRank; import okhttp3.Call; import okhttp3.Callback; import okhttp3.HttpUrl; @@ -91,14 +95,23 @@ private enum ApiPath PLAYER_STATS("stats/contributions/"), PREDICTION("site/prediction/"), FEEDBACK("plugin/predictionfeedback/"), - VERIFY_DISCORD("site/discord_user/") + VERIFY_DISCORD("site/discord_user/"), + CLAN_RANK_UPDATES("plugin/clan/rank-update/") ; final String path; } + /** + * Runelite's okHttpClient with a connect/read timeout of 30 seconds each and no pinging. + */ public OkHttpClient okHttpClient; + /** + * Same as {@link #okHttpClient}, but with a read timeout of 120 seconds. + */ + public OkHttpClient longTimeoutHttpClient; + @Inject private Gson gson; @@ -138,6 +151,10 @@ public BotDetectorClient(OkHttpClient rlClient) return chain.proceed(headerRequest); }) .build(); + + longTimeoutHttpClient = okHttpClient.newBuilder() + .readTimeout(120, TimeUnit.SECONDS) + .build(); } /** @@ -428,6 +445,78 @@ public void onResponse(Call call, Response response) return future; } + /** + * Tokenized API route to request a collection of clan ranks to be changed for the given players. + * @param token The auth token to use. + * @param currentRanks A map of player names and their current clan rank. + * @return A map of player names and the clan rank they should be, not necessarily including names with unchanged ranks. + */ + public CompletableFuture> requestClanRankUpdates( + String token, Map currentRanks) + { + Collection memRanks = currentRanks.entrySet().stream() + .map(cr -> new MemberClanRank(cr.getKey().getStr(), cr.getValue())) + .collect(Collectors.toList()); + + Request request = new Request.Builder() + .url(getUrl(ApiPath.CLAN_RANK_UPDATES).newBuilder() + .addPathSegment(token) + .build()) + .post(RequestBody.create(JSON, gson.toJson(memRanks))) + .build(); + + CompletableFuture> future = new CompletableFuture<>(); + longTimeoutHttpClient.newCall(request).enqueue(new Callback() + { + @Override + public void onFailure(Call call, IOException e) + { + log.warn("Error getting clan rank updates", e); + future.completeExceptionally(e); + } + + @Override + public void onResponse(Call call, Response response) + { + try + { + if (response.code() == 401) + { + throw new UnauthorizedTokenException("Invalid or unauthorized token for operation"); + } + + Collection returned = processResponse(gson, response, + new TypeToken>() + { + }.getType()); + + if (returned == null) + { + future.complete(null); + } + else + { + future.complete(returned.stream().collect( + ImmutableMap.toImmutableMap( + m -> BotDetectorPlugin.normalizeAndWrapPlayerName(m.getMemberName()), + MemberClanRank::getMemberRank))); + } + } + catch (UnauthorizedTokenException | IOException e) + { + log.warn("Error getting clan rank updates", e); + future.completeExceptionally(e); + } + finally + { + response.close(); + } + } + }); + + return future; + } + /** * Processes the body of the given response and parses out the contained JSON object. * @param gson The {@link Gson} instance to use for parsing the JSON object in the {@code response}. @@ -533,6 +622,15 @@ private static class PredictionFeedback String feedbackText; } + @Value + private static class MemberClanRank + { + @SerializedName("player") + String memberName; + @SerializedName("rank") + ClanRank memberRank; + } + /** * Wrapper around the {@link PlayerSighting}'s json serializer. * Adds the reporter name as an element on the same level as the {@link PlayerSighting}'s fields. diff --git a/src/main/java/com/botdetector/model/AuthTokenPermission.java b/src/main/java/com/botdetector/model/AuthTokenPermission.java index 49bd22a5..6a878f31 100644 --- a/src/main/java/com/botdetector/model/AuthTokenPermission.java +++ b/src/main/java/com/botdetector/model/AuthTokenPermission.java @@ -31,5 +31,7 @@ public enum AuthTokenPermission { /** Allows verification of RSN/Discord pairs in-game **/ - VERIFY_DISCORD + VERIFY_DISCORD, + /** Allows checking the clan roster for rank updates **/ + GET_CLAN_RANK_UPDATES } diff --git a/src/main/java/com/botdetector/model/AuthTokenType.java b/src/main/java/com/botdetector/model/AuthTokenType.java index 18cc9dfa..27055519 100644 --- a/src/main/java/com/botdetector/model/AuthTokenType.java +++ b/src/main/java/com/botdetector/model/AuthTokenType.java @@ -26,33 +26,45 @@ package com.botdetector.model; import com.google.common.collect.ImmutableSet; -import java.util.Arrays; import lombok.Getter; -import lombok.RequiredArgsConstructor; import static com.botdetector.model.AuthTokenPermission.*; @Getter -@RequiredArgsConstructor public enum AuthTokenType { /** * No permissions */ - NONE(ImmutableSet.of()), + NONE(), /** * All permissions */ - DEV(Arrays.stream(AuthTokenPermission.values()).collect(ImmutableSet.toImmutableSet())), + DEV(AuthTokenPermission.values()), + + /** + * Can perform discord verification and retrieve clan rank updates + */ + MOD(VERIFY_DISCORD, GET_CLAN_RANK_UPDATES), /** * Can perform discord verification */ - MOD(ImmutableSet.of(VERIFY_DISCORD)) + DISCORD(VERIFY_DISCORD), + + /** + * Can retrieve clan rank updates + */ + CLAN(GET_CLAN_RANK_UPDATES) ; private final ImmutableSet permissions; + AuthTokenType(AuthTokenPermission... permissions) + { + this.permissions = ImmutableSet.copyOf(permissions); + } + /** * Parses the token type from the given {@code prefix}. * @param prefix The prefix to parse.