diff --git a/src/main/java/com/botdetector/http/BotDetectorClient.java b/src/main/java/com/botdetector/http/BotDetectorClient.java index ed00e175..b8204478 100644 --- a/src/main/java/com/botdetector/http/BotDetectorClient.java +++ b/src/main/java/com/botdetector/http/BotDetectorClient.java @@ -50,8 +50,10 @@ import com.google.inject.Singleton; import java.io.IOException; import java.lang.reflect.Type; +import java.time.Duration; import java.time.Instant; import java.util.Collection; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; @@ -87,17 +89,19 @@ public class BotDetectorClient System.getProperty("BotDetectorAPIPath", "https://api.prd.osrsbotdetector.com")); private static final Supplier CURRENT_EPOCH_SUPPLIER = () -> String.valueOf(Instant.now().getEpochSecond()); + private static final long LABELS_CACHE_SECONDS = 60 * 60; // One hour + @Getter @AllArgsConstructor private enum ApiPath { - DETECTION("v1/report"), - PLAYER_STATS_PASSIVE("v1/report/count"), - PLAYER_STATS_MANUAL("v1/report/manual/count"), - PLAYER_STATS_FEEDBACK("v1/feedback/count"), - PREDICTION("v1/prediction"), - FEEDBACK("v1/feedback/"), - VERIFY_DISCORD("site/discord_user/") + DETECTION("v2/report"), + PLAYER_STATS_REPORTS("v2/player/report/score"), + PLAYER_STATS_FEEDBACK("v2/player/feedback/score"), + PREDICTION("v2/player/prediction"), + FEEDBACK("v2/feedback"), + LABELS("v2/labels"), + VERIFY_DISCORD("site/discord_user") ; final String path; @@ -115,6 +119,9 @@ private enum ApiPath private final Supplier pluginVersionSupplier = () -> (pluginVersion != null && !pluginVersion.isEmpty()) ? pluginVersion : API_VERSION_FALLBACK_WORD; + private Collection cachedLabels = null; + private Instant lastTimeCachedLabels = Instant.MIN; + /** * Constructs a base URL for the given {@code path}. * @param path The path to get the base URL for. @@ -298,7 +305,7 @@ public void onResponse(Call call, Response response) } /** - * Sends a feedback to the API for the given prediction. + * Sends a feedback to the API for the given prediction. If a feedback is duplicated, the future will return false. * @param pred The prediction object to give a feedback for. * @param uploaderName The user's player name (See {@link BotDetectorPlugin#getUploaderName()}). * @param proposedLabel The user's proposed label and feedback. @@ -335,12 +342,21 @@ public void onResponse(Call call, Response response) { try { + boolean duplicated = false; + if (!response.isSuccessful()) { - throw getIOException(response); + IOException ioe = getIOException(response); + // If the error is because of being a duplicate record, do not throw + // Instead return false and let the caller handle it + if (!ioe.getMessage().contains("duplicate_record")) + { + throw ioe; + } + duplicated = true; } - future.complete(true); + future.complete(!duplicated); } catch (IOException e) { @@ -376,21 +392,21 @@ public CompletableFuture requestPrediction(String playerName) */ public CompletableFuture requestPrediction(String playerName, boolean receiveBreakdownOnSpecialCases) { - Request request = new Request.Builder() + Request request_pred = new Request.Builder() .url(getUrl(ApiPath.PREDICTION).newBuilder() .addQueryParameter("name", playerName) .addQueryParameter("breakdown", Boolean.toString(receiveBreakdownOnSpecialCases)) .build()) .build(); - CompletableFuture future = new CompletableFuture<>(); - okHttpClient.newCall(request).enqueue(new Callback() + CompletableFuture predFuture = new CompletableFuture<>(); + okHttpClient.newCall(request_pred).enqueue(new Callback() { @Override public void onFailure(Call call, IOException e) { log.warn("Error obtaining player prediction data", e); - future.completeExceptionally(e); + predFuture.completeExceptionally(e); } @Override @@ -398,12 +414,20 @@ public void onResponse(Call call, Response response) { try { - future.complete(processResponse(gson, response, Prediction.class)); + Collection preds = processResponse(gson, response, new TypeToken>() + { + }.getType()); + if (preds != null) + { + predFuture.complete(preds.stream().findFirst().orElse(null)); + return; + } + predFuture.complete(null); } catch (IOException e) { log.warn("Error obtaining player prediction data", e); - future.completeExceptionally(e); + predFuture.completeExceptionally(e); } finally { @@ -412,7 +436,110 @@ public void onResponse(Call call, Response response) } }); - return future; + CompletableFuture> labelsFuture = new CompletableFuture<>(); + + Instant now = Instant.now(); + if (Duration.between(lastTimeCachedLabels, now).getSeconds() <= LABELS_CACHE_SECONDS) + { + labelsFuture.complete(cachedLabels); + } + else + { + Request request_labels = new Request.Builder() + .url(getUrl(ApiPath.LABELS).newBuilder() + .build()) + .build(); + + okHttpClient.newCall(request_labels).enqueue(new Callback() + { + @Override + public void onFailure(Call call, IOException e) + { + log.warn("Error obtaining labels data", e); + labelsFuture.completeExceptionally(e); + } + + @Override + public void onResponse(Call call, Response response) + { + try + { + Collection labels = processResponse(gson, response, new TypeToken>() + { + }.getType()); + if (labels != null) + { + cachedLabels = labels; + lastTimeCachedLabels = now; + } + labelsFuture.complete(labels); + } + catch (IOException e) + { + log.warn("Error obtaining player labels data", e); + labelsFuture.completeExceptionally(e); + } + finally + { + response.close(); + } + } + }); + } + + CompletableFuture finalFuture = new CompletableFuture<>(); + + // Doing this so we log only the first future failing, not all 2 within the callback. + CompletableFuture.allOf(predFuture, labelsFuture).whenComplete((v, e) -> + { + if (e != null) + { + // allOf will send a CompletionException when one of the futures fail, just get the cause. + log.warn("Error obtaining player prediction data", e.getCause()); + finalFuture.completeExceptionally(e.getCause()); + return; + } + + Prediction pred = predFuture.join(); + if (pred == null) + { + finalFuture.complete(null); + return; + } + + Collection labels = labelsFuture.join(); + + // Re-add predictions as lowercase + Map newBreakdown = new HashMap<>(); + if (pred.getPredictionBreakdown() != null) + { + for (Map.Entry entry : pred.getPredictionBreakdown().entrySet()) { + newBreakdown.put(entry.getKey().toLowerCase(), entry.getValue()); + } + } + + // Add labels that may not be in the breakdown + if (labels != null) + { + for (LabelAPIItem label : labels) + { + newBreakdown.putIfAbsent(label.getLabel().toLowerCase(), 0.0); + } + } + + // Build a new copy of the prediction object with normalized labels + finalFuture.complete( + Prediction.builder() + .playerName(pred.getPlayerName()) + .playerId(pred.getPlayerId()) + .confidence(pred.getConfidence()) + .predictionBreakdown(newBreakdown) + .predictionLabel(pred.getPredictionLabel().toLowerCase()) + .build() + ); + }); + + return finalFuture; } /** @@ -422,18 +549,8 @@ public void onResponse(Call call, Response response) */ public CompletableFuture> requestPlayerStats(String playerName) { - Gson bdGson = gson.newBuilder() - .registerTypeAdapter(boolean.class, new BooleanToZeroOneConverter()) - .create(); - - Request requestP = new Request.Builder() - .url(getUrl(ApiPath.PLAYER_STATS_PASSIVE).newBuilder() - .addQueryParameter("name", playerName) - .build()) - .build(); - - Request requestM = new Request.Builder() - .url(getUrl(ApiPath.PLAYER_STATS_MANUAL).newBuilder() + Request requestR = new Request.Builder() + .url(getUrl(ApiPath.PLAYER_STATS_REPORTS).newBuilder() .addQueryParameter("name", playerName) .build()) .build(); @@ -444,18 +561,16 @@ public CompletableFuture> requestPlayerStats(S .build()) .build(); - CompletableFuture> passiveFuture = new CompletableFuture<>(); - CompletableFuture> manualFuture = new CompletableFuture<>(); + CompletableFuture> reportsFuture = new CompletableFuture<>(); CompletableFuture> feedbackFuture = new CompletableFuture<>(); - okHttpClient.newCall(requestP).enqueue(new PlayerStatsCallback(passiveFuture, bdGson)); - okHttpClient.newCall(requestM).enqueue(new PlayerStatsCallback(manualFuture, bdGson)); - okHttpClient.newCall(requestF).enqueue(new PlayerStatsCallback(feedbackFuture, bdGson)); + okHttpClient.newCall(requestR).enqueue(new PlayerStatsCallback(reportsFuture, gson)); + okHttpClient.newCall(requestF).enqueue(new PlayerStatsCallback(feedbackFuture, gson)); CompletableFuture> finalFuture = new CompletableFuture<>(); - // Doing this so we log only the first future failing, not all 3 within the callback. - CompletableFuture.allOf(passiveFuture, manualFuture, feedbackFuture).whenComplete((v, e) -> + // Doing this so we log only the first future failing, not all 2 within the callback. + CompletableFuture.allOf(reportsFuture, feedbackFuture).whenComplete((v, e) -> { if (e != null) { @@ -465,8 +580,7 @@ public CompletableFuture> requestPlayerStats(S } else { - finalFuture.complete(processPlayerStats( - passiveFuture.join(), manualFuture.join(), feedbackFuture.join())); + finalFuture.complete(processPlayerStats(reportsFuture.join(), feedbackFuture.join())); } }); @@ -592,20 +706,21 @@ private IOException getIOException(Response response) /** * Collects the given {@link PlayerStatsAPIItem} into a combined map that the plugin expects. - * @param passive The passive usage stats from the API. - * @param manual The manual flagging stats from the API. + * @param reports The reports usage stats from the API. * @param feedback The feedback stats from the API. * @return The combined processed map expected by the plugin. */ - private Map processPlayerStats(Collection passive, Collection manual, Collection feedback) + private Map processPlayerStats(Collection reports, Collection feedback) { - if (passive == null || manual == null || feedback == null) + if (reports == null || feedback == null) { return null; } - PlayerStats passiveStats = countStats(passive, false); - PlayerStats manualStats = countStats(manual, true); + PlayerStats passiveStats = countStats(reports.stream().filter( + r -> r.getManual() != null && !r.getManual()).collect(Collectors.toList()), false); + PlayerStats manualStats = countStats(reports.stream().filter( + r -> r.getManual() != null && r.getManual()).collect(Collectors.toList()), true); PlayerStats feedbackStats = countStats(feedback, false); PlayerStats totalStats = PlayerStats.builder() @@ -623,7 +738,7 @@ private Map processPlayerStats(Collection()); c.gridy++; c.gridx = 0; - c.weightx = 2.0 / 3; + c.weightx = 1; c.gridwidth = 2; panel.add(feedbackLabelComboBox, c); @@ -794,7 +794,7 @@ private JPanel predictionFeedbackPanel() feedbackSendButton.addActionListener(l -> sendFeedbackToClient((FeedbackPredictionLabel)feedbackLabelComboBox.getSelectedItem())); feedbackSendButton.setFocusable(false); c.gridx = 2; - c.weightx = 1.0 / 3; + c.weightx = 0; c.gridwidth = 1; panel.add(feedbackSendButton, c); @@ -1358,19 +1358,32 @@ private void sendFeedbackToClient(FeedbackPredictionLabel proposedLabel) feedbackHeaderLabel.setIcon(Icons.LOADING_SPINNER); feedbackHeaderLabel.setToolTipText(null); detectorClient.sendFeedback(lastPrediction, lastPredictionUploaderName, proposedLabel, feedbackText) - .whenComplete((b, ex) -> + .whenComplete((successful, ex) -> { boolean stillSame = lastPrediction != null && wrappedName.equals(normalizeAndWrapPlayerName(lastPrediction.getPlayerName())); String message; - if (ex == null && b) + if (ex == null) { - message = "Thank you for your prediction feedback for '%s'!"; - if (stillSame) + if (successful) { - feedbackHeaderLabel.setIcon(null); - feedbackHeaderLabel.setToolTipText(null); + message = "Thank you for your prediction feedback for '%s'!"; + if (stillSame) + { + feedbackHeaderLabel.setIcon(null); + feedbackHeaderLabel.setToolTipText(null); + } + } + // Failure is due to duplicate record on server side + else + { + message = "Sorry, but your feedback for '%s' was rejected as it already exists on the server."; + if (stillSame) + { + feedbackHeaderLabel.setIcon(Icons.WARNING_ICON); + feedbackHeaderLabel.setToolTipText("The server rejected your feedback as it already exists for this player"); + } } } else @@ -1382,7 +1395,7 @@ private void sendFeedbackToClient(FeedbackPredictionLabel proposedLabel) { resetFeedbackPanel(false); feedbackHeaderLabel.setIcon(Icons.ERROR_ICON); - feedbackHeaderLabel.setToolTipText(ex != null ? ex.getMessage() : "Unknown error"); + feedbackHeaderLabel.setToolTipText(ex.getMessage()); } }