From f8f35a3364604f819af9798387b123bc1a805a43 Mon Sep 17 00:00:00 2001 From: Reece Harris Date: Mon, 16 Feb 2026 23:51:08 +0000 Subject: [PATCH 1/3] feat: add webhook configuration and alerts for buy/sell/dump actions --- .gitignore | 1 + .../config/FlippingCopilotConfig.java | 68 ++- .../controller/FlippingCopilotPlugin.java | 17 + .../controller/SuggestionController.java | 37 ++ .../controller/WebHookController.java | 398 ++++++++++++++++-- .../model/DiscordWebhookBody.java | 28 +- .../flippingcopilot/model/FlipManager.java | 2 +- .../model/FlipPriceGraphControllerTest.java | 6 +- 8 files changed, 518 insertions(+), 39 deletions(-) diff --git a/.gitignore b/.gitignore index 7515a154..49949b52 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ nbactions.xml nb-configuration.xml nbproject/ .DS_Store +bin/ \ No newline at end of file diff --git a/src/main/java/com/flippingcopilot/config/FlippingCopilotConfig.java b/src/main/java/com/flippingcopilot/config/FlippingCopilotConfig.java index 0d2bc8a0..9494161c 100644 --- a/src/main/java/com/flippingcopilot/config/FlippingCopilotConfig.java +++ b/src/main/java/com/flippingcopilot/config/FlippingCopilotConfig.java @@ -277,14 +277,76 @@ default Color chatTextColor() { return new Color(0x0040FF); } + @ConfigSection( + name = "Webhook", + description = "Configure webhook notifications", + position = 5 + ) + String webhookSection = "webhookSection"; + @ConfigItem( keyName = "webhook", name = "Webhook URL", - description = "The Discord Webhook URL for sending display name and profit.", - section = notificationsSection, - position = 5 + description = "The Discord Webhook URL.", + section = webhookSection, + position = 1 ) String webhook(); + @ConfigItem( + keyName = "webhookMentionText", + name = "Mention text", + description = "Text to prepend to alerts, e.g. @YourName or <@1234567890>.", + section = webhookSection, + position = 2 + ) + default String webhookMentionText() { return ""; } + + @ConfigItem( + keyName = "enableWebhookBuyAlerts", + name = "Buy alerts", + description = "Notify on buy alerts.", + section = webhookSection, + position = 3 + ) + default boolean enableWebhookBuyAlerts() { return false; } + + @ConfigItem( + keyName = "enableWebhookSellAlerts", + name = "Sell alerts", + description = "Notify on sell alerts.", + section = webhookSection, + position = 4 + ) + default boolean enableWebhookSellAlerts() { return false; } + + @ConfigItem( + keyName = "enableWebhookDumpAlerts", + name = "Dump alerts", + description = "Notify on dump alerts.", + section = webhookSection, + position = 5 + ) + default boolean enableWebhookDumpAlerts() { return true; } + + @ConfigItem( + keyName = "enableWebhookAbortCollectAlerts", + name = "Abort/Collect alerts", + description = "Notify when you should abort an offer or collect items.", + section = webhookSection, + position = 6 + ) + default boolean enableWebhookAbortCollectAlerts() { return false; } + + @ConfigItem( + keyName = "webhookAlertsOnlyWhenOutOfFocus", + name = "Only alert when unfocused", + description = "Only send Buy/Sell/Dump webhooks when RuneLite is out of focus. Session stats always send.", + section = webhookSection, + position = 7 + ) + default boolean webhookAlertsOnlyWhenOutOfFocus() { return false; } + + } diff --git a/src/main/java/com/flippingcopilot/controller/FlippingCopilotPlugin.java b/src/main/java/com/flippingcopilot/controller/FlippingCopilotPlugin.java index 0d53f982..f040767c 100644 --- a/src/main/java/com/flippingcopilot/controller/FlippingCopilotPlugin.java +++ b/src/main/java/com/flippingcopilot/controller/FlippingCopilotPlugin.java @@ -221,10 +221,27 @@ public void onItemContainerChanged(ItemContainerChanged event) { public void onGameTick(GameTick event) { suggestionController.onGameTick(); offerEventHandler.onGameTick(); + webHookController.setClientHasFocus(isClientFocused()); grandExchangeOpenRS.set(grandExchange.isOpen()); osrsLoginRS.set(osrsLoginRS.get().nexState(client)); } + private boolean isClientFocused() { + try { + java.awt.Canvas canvas = client.getCanvas(); + if (canvas == null) { + return true; + } + java.awt.Window window = SwingUtilities.getWindowAncestor(canvas); + if (window != null) { + return window.isActive(); + } + return canvas.isFocusOwner(); + } catch (Exception e) { + return true; + } + } + @Subscribe public void onBeforeRender(BeforeRender event) { gameUiChangesHandler.onBeforeRender(event); diff --git a/src/main/java/com/flippingcopilot/controller/SuggestionController.java b/src/main/java/com/flippingcopilot/controller/SuggestionController.java index 85a6695c..9135cdc9 100644 --- a/src/main/java/com/flippingcopilot/controller/SuggestionController.java +++ b/src/main/java/com/flippingcopilot/controller/SuggestionController.java @@ -43,6 +43,8 @@ public class SuggestionController { private final GrandExchange grandExchange; private final ScheduledExecutorService executorService; private final ApiRequestHandler apiRequestHandler; + private final WebHookController webHookController; + private final Notifier notifier; private final OfferManager offerManager; private final CopilotLoginManager copilotLoginManager; @@ -199,6 +201,15 @@ private synchronized void handleSuggestionReceived(Suggestion oldSuggestion, Sug if (newSuggestion.isDumpAlert && config.dumpAlertSound()) { client.playSoundEffect(SoundEffectID.GE_ADD_OFFER_DINGALING); } + if (newSuggestion.isDumpAlert) { + // Send short-format webhook for dump alert: Item, Price + try { + webHookController.sendDumpAlert(newSuggestion); + } catch (Exception e) { + log.debug("Error sending dump webhook", e); + } + } + suggestionManager.setSuggestion(newSuggestion); suggestionManager.setSuggestionError(null); suggestionManager.setSuggestionRequestInProgress(false); @@ -206,7 +217,33 @@ private synchronized void handleSuggestionReceived(Suggestion oldSuggestion, Sug accountStatusManager.resetSkipSuggestion(); offerManager.setOfferJustPlaced(false); suggestionPanel.refresh(); + + // Abort/Collect webhook alerts + try { + if (accountStatus.isCollectNeeded(newSuggestion, grandExchange.isSetupOfferOpen())) { + webHookController.sendCollectAlert(); + } + } catch (Exception e) { + log.debug("Error sending collect webhook", e); + } + showNotifications(oldSuggestion, newSuggestion, accountStatus); + // Send webhook alerts for buy/sell suggestions in short format + String type = newSuggestion.getType(); + if ("buy".equals(type)) { + try { + webHookController.sendBuyAlert(newSuggestion); + } catch (Exception e) { log.debug("Error sending buy webhook", e); } + } else if ("sell".equals(type)) { + try { + webHookController.sendSellAlert(newSuggestion); + } catch (Exception e) { log.debug("Error sending sell webhook", e); } + } else if ("abort".equals(type)) { + try { + webHookController.sendAbortAlert(newSuggestion); + } catch (Exception e) { log.debug("Error sending abort webhook", e); } + } + if (!newSuggestion.getType().equals("wait")) { SwingUtilities.invokeLater(() -> flipDialogController.priceGraphPanel.newSuggestedItemId( newSuggestion.getItemId(), diff --git a/src/main/java/com/flippingcopilot/controller/WebHookController.java b/src/main/java/com/flippingcopilot/controller/WebHookController.java index 1f4c782b..832a61d0 100644 --- a/src/main/java/com/flippingcopilot/controller/WebHookController.java +++ b/src/main/java/com/flippingcopilot/controller/WebHookController.java @@ -3,8 +3,10 @@ import com.flippingcopilot.config.FlippingCopilotConfig; import com.flippingcopilot.model.DiscordWebhookBody; import com.flippingcopilot.model.SessionData; +import com.flippingcopilot.model.Suggestion; import com.flippingcopilot.model.Stats; import com.flippingcopilot.ui.UIUtilities; +import com.flippingcopilot.util.ProfitCalculator; import joptsimple.internal.Strings; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -13,9 +15,16 @@ import javax.inject.Inject; import javax.inject.Singleton; import java.io.IOException; +import java.text.NumberFormat; import java.time.Instant; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.Locale; +import java.util.Collections; -import static com.flippingcopilot.util.DateUtil.formatEpoch; import static net.runelite.http.api.RuneLiteAPI.GSON; @Slf4j @@ -23,18 +32,49 @@ @RequiredArgsConstructor(onConstructor_ = @Inject) public class WebHookController { + private static final int COLOR_BUY = 0x43B581; + private static final int COLOR_SELL = 0xF04747; + private static final int COLOR_DUMP = 0xFFA500; + private static final int COLOR_ABORT = 0xF04747; + private static final int COLOR_COLLECT = 0xFAA61A; + private static final int COLOR_SUMMARY = 0x7289DA; + private final FlippingCopilotConfig config; private final OkHttpClient okHttpClient; + private final ProfitCalculator profitCalculator; + + private volatile boolean clientHasFocus = true; + + private final Object alertCacheLock = new Object(); + private final Deque lastAlertKeys = new ArrayDeque<>(4); + + public void setClientHasFocus(boolean focused) { + this.clientHasFocus = focused; + } private void sendWebHook(DiscordWebhookBody discordWebhookBody) { String configURL = config.webhook(); - if (Strings.isNullOrEmpty(configURL)) {return; } + if (Strings.isNullOrEmpty(configURL)) { return; } HttpUrl url = HttpUrl.parse(configURL); if (url == null) { log.warn("bad discord webhook url {}", configURL); return; } + + // Prepend mention to all messages if provided + if (discordWebhookBody.getContent() != null) { + String mention = config.webhookMentionText(); + if (mention != null && !mention.trim().isEmpty()) { + discordWebhookBody.setContent(mention + " " + discordWebhookBody.getContent()); + } + } else { + String mention = config.webhookMentionText(); + if (mention != null && !mention.trim().isEmpty()) { + discordWebhookBody.setContent(mention); + } + } + MultipartBody.Builder requestBodyBuilder = new MultipartBody.Builder() .setType(MultipartBody.FORM) .addFormDataPart("payload_json", GSON.toJson(discordWebhookBody)); @@ -66,32 +106,338 @@ public void onResponse(Call call, Response response) { public void sendMessage(Stats stats, SessionData sd, String displayName, boolean sessionIsFinished) { if (stats.profit != 0 && displayName != null) { - - long seconds = sd.durationMillis / 1000; - String beganAtText = formatEpoch(sd.startTime); - String endedAtText = sessionIsFinished ? formatEpoch(Instant.now().getEpochSecond()) : "n/a"; - String durationText = String.format("%02d:%02d:%02d", seconds / 3600, (seconds % 3600) / 60, seconds % 60); - String profitText = UIUtilities.formatProfit(stats.profit); - String taxText = UIUtilities.formatProfit(stats.taxPaid); - String roiText = String.format("%.3f%%", stats.calculateRoi() * 100); - String cashStackText = UIUtilities.quantityToRSDecimalStack(Math.abs(sd.averageCash), false) + " gp"; - - String template = "%s, your session stats are:\n" + - "```" + - "Session began at: %s\n" + - "Session ended at: %s\n" + - "Active session time: %s\n" + - "Flips made: %d\n" + - "Profit: %s\n" + - "Tax paid: %s\n" + - "Roi: %s\n" + - "Avg wealth: %s\n" + - "```"; - - String discordMessage = String.format(template, displayName, beganAtText, endedAtText, durationText, stats.flipsMade, profitText, taxText, roiText, cashStackText); DiscordWebhookBody discordWebhookBody = new DiscordWebhookBody(); - discordWebhookBody.setContent(discordMessage); + discordWebhookBody.setContent(null); + discordWebhookBody.setTts(false); + + String desc = formatStatsSummary(stats, sd); + discordWebhookBody.setEmbeds(Collections.singletonList(buildEmbedWithAuthor( + "Session Summary", + desc, + COLOR_SUMMARY, + "STATS" + ))); sendWebHook(discordWebhookBody); } } + + public void sendDumpAlert(String itemName, int price) { + if (!config.enableWebhookDumpAlerts()) { return; } + if (config.webhookAlertsOnlyWhenOutOfFocus() && clientHasFocus) { return; } + if (itemName == null || itemName.isBlank() || price <= 0) { return; } + sendItemPriceEmbed("Dump", itemName, price, COLOR_DUMP); + } + + public void sendDumpAlert(Suggestion suggestion) { + if (suggestion == null) { return; } + if (!config.enableWebhookDumpAlerts()) { return; } + if (config.webhookAlertsOnlyWhenOutOfFocus() && clientHasFocus) { return; } + if (suggestion.getName() == null || suggestion.getName().isBlank() || suggestion.getPrice() <= 0) { return; } + if (isDuplicateAlertKey(buildAlertKey("dump", suggestion))) { return; } + sendSuggestionEmbed("Dump", suggestion, COLOR_DUMP); + } + + public void sendBuyAlert(String itemName, int price) { + if (!config.enableWebhookBuyAlerts()) { return; } + if (config.webhookAlertsOnlyWhenOutOfFocus() && clientHasFocus) { return; } + if (itemName == null || itemName.isBlank() || price <= 0) { return; } + sendItemPriceEmbed("Buy", itemName, price, COLOR_BUY); + } + + public void sendBuyAlert(Suggestion suggestion) { + if (suggestion == null) { return; } + if (!config.enableWebhookBuyAlerts()) { return; } + if (config.webhookAlertsOnlyWhenOutOfFocus() && clientHasFocus) { return; } + if (suggestion.getName() == null || suggestion.getName().isBlank() || suggestion.getPrice() <= 0) { return; } + if (isDuplicateAlertKey(buildAlertKey("buy", suggestion))) { return; } + sendSuggestionEmbed("Buy", suggestion, COLOR_BUY); + } + + public void sendSellAlert(String itemName, int price) { + if (!config.enableWebhookSellAlerts()) { return; } + if (config.webhookAlertsOnlyWhenOutOfFocus() && clientHasFocus) { return; } + if (itemName == null || itemName.isBlank() || price <= 0) { return; } + sendItemPriceEmbed("Sell", itemName, price, COLOR_SELL); + } + + public void sendSellAlert(Suggestion suggestion) { + if (suggestion == null) { return; } + if (!config.enableWebhookSellAlerts()) { return; } + if (config.webhookAlertsOnlyWhenOutOfFocus() && clientHasFocus) { return; } + if (suggestion.getName() == null || suggestion.getName().isBlank() || suggestion.getPrice() <= 0) { return; } + if (isDuplicateAlertKey(buildAlertKey("sell", suggestion))) { return; } + sendSuggestionEmbed("Sell", suggestion, COLOR_SELL); + } + + public void sendAbortAlert(Suggestion suggestion) { + if (suggestion == null) { return; } + if (!config.enableWebhookAbortCollectAlerts()) { return; } + if (config.webhookAlertsOnlyWhenOutOfFocus() && clientHasFocus) { return; } + if (suggestion.getName() == null || suggestion.getName().isBlank()) { return; } + if (isDuplicateAlertKey(buildAlertKey("abort", suggestion))) { return; } + + String desc = "Abort offer\n\n```\n\uD83D\uDED1 Abort: " + suggestion.getName() + "\n```"; + sendGenericEmbed("Abort", suggestion.getName(), desc, COLOR_ABORT, buildItemThumbnailUrl(suggestion.getItemId())); + } + + public void sendCollectAlert() { + if (!config.enableWebhookAbortCollectAlerts()) { return; } + if (config.webhookAlertsOnlyWhenOutOfFocus() && clientHasFocus) { return; } + if (isDuplicateAlertKey("collect")) { return; } + + String desc = "Collect items\n\n```\n\uD83D\uDCE5 Collect items from the Grand Exchange\n```"; + sendGenericEmbed("Collect", "Collect items", desc, COLOR_COLLECT, null); + } + + private void sendItemPriceEmbed(String action, String itemName, int price, int color) { + String formattedPrice = formatGp(price); + String desc = formatActionDescription(action, itemName, formattedPrice, price); + DiscordWebhookBody body = new DiscordWebhookBody(); + body.setContent(null); + body.setTts(false); + body.setEmbeds(Collections.singletonList(buildEmbedWithAuthor( + itemName, + desc, + color, + action.toUpperCase(Locale.ENGLISH) + ))); + sendWebHook(body); + } + + private void sendSuggestionEmbed(String action, Suggestion suggestion, int color) { + String formattedPrice = formatGp(suggestion.getPrice()); + + Double expectedProfit = suggestion.getExpectedProfit(); + if (expectedProfit == null && "Sell".equalsIgnoreCase(action)) { + try { + Long estimated = profitCalculator.calculateSuggestionProfit(suggestion); + if (estimated != null) { + expectedProfit = (double) estimated; + } + } catch (Exception ignored) { + // keep webhook resilient; omit profit if cannot be estimated + } + } + + String desc = formatActionDescription( + action, + suggestion.getName(), + formattedPrice, + suggestion.getPrice(), + suggestion.getQuantity(), + expectedProfit, + suggestion.getExpectedDuration() + ); + + String thumbnailUrl = buildItemThumbnailUrl(suggestion.getItemId()); + DiscordWebhookBody body = new DiscordWebhookBody(); + body.setContent(null); + body.setTts(false); + body.setEmbeds(Collections.singletonList(buildEmbedWithAuthor( + suggestion.getName(), + desc, + color, + action.toUpperCase(Locale.ENGLISH), + thumbnailUrl + ))); + sendWebHook(body); + } + + private static DiscordWebhookBody.Embed buildEmbedWithAuthor(String title, String description, int color, String authorName) { + return buildEmbedWithAuthor(title, description, color, authorName, null); + } + + private static DiscordWebhookBody.Embed buildEmbedWithAuthor(String title, String description, int color, String authorName, String thumbnailUrl) { + DiscordWebhookBody.Embed e = new DiscordWebhookBody.Embed(); + e.setTitle(title); + e.setDescription(description); + e.setColor(color); + + DiscordWebhookBody.Author a = new DiscordWebhookBody.Author(); + a.setName(authorName); + e.setAuthor(a); + + if (thumbnailUrl != null && !thumbnailUrl.isBlank()) { + DiscordWebhookBody.Thumbnail t = new DiscordWebhookBody.Thumbnail(); + t.setUrl(thumbnailUrl); + e.setThumbnail(t); + } + return e; + } + + private static String buildItemThumbnailUrl(int itemId) { + if (itemId <= 0) { + return null; + } + // OSRS plugin: use the Old School item database so IDs match. + return "https://secure.runescape.com/m=itemdb_oldschool/obj_sprite.gif?id=" + itemId; + } + + private void sendGenericEmbed(String author, String title, String description, int color, String thumbnailUrl) { + DiscordWebhookBody body = new DiscordWebhookBody(); + body.setContent(null); + body.setTts(false); + body.setEmbeds(Collections.singletonList(buildEmbedWithAuthor( + title, + description, + color, + author.toUpperCase(Locale.ENGLISH), + thumbnailUrl + ))); + sendWebHook(body); + } + + private static String formatGp(long gp) { + return UIUtilities.quantityToRSDecimalStack(gp, false) + " gp"; + } + + private static String formatSignedGp(long gp) { + return UIUtilities.quantityToRSDecimalStack(gp, true) + " gp"; + } + + private static String formatActionDescription(String action, String itemName, String formattedPrice, int price) { + // Keep it lightweight but structured, similar to provided example. + String header = String.format("%s for %s each", action, formattedPrice); + + StringBuilder sb = new StringBuilder(); + sb.append(header); + sb.append("\n\n```"); + if ("Buy".equalsIgnoreCase(action)) { + sb.append("\n⬇️ Insta-Buy: ").append(formattedPrice); + } else if ("Sell".equalsIgnoreCase(action)) { + sb.append("\n⬆️ Insta-Sell: ").append(formattedPrice); + } else { + sb.append("\n⚠️ Dump Price: ").append(formattedPrice); + } + sb.append("\n```"); + return sb.toString(); + } + + private static String formatActionDescription( + String action, + String itemName, + String formattedPrice, + int price, + int quantity, + Double expectedProfit, + Double expectedDurationSeconds + ) { + NumberFormat qtyFormat = NumberFormat.getNumberInstance(Locale.ENGLISH); + String qty = quantity > 0 ? qtyFormat.format(quantity) : null; + + String header; + if (qty != null) { + header = String.format("%s %s for %s each", action, qty, formattedPrice); + } else { + header = String.format("%s for %s each", action, formattedPrice); + } + + StringBuilder sb = new StringBuilder(); + sb.append(header); + sb.append("\n\n```"); + + // Keep the block minimal: just qty + estimates + if (qty != null) { + sb.append("\nQty: ").append(qty); + } + + if (expectedProfit != null) { + long profitGp = Math.round(expectedProfit); + sb.append("\nEst profit: ").append(formatSignedGp(profitGp)); + } + + if (expectedDurationSeconds != null) { + sb.append("\nEst time: ").append(formatDuration(expectedDurationSeconds)); + } + + sb.append("\n```"); + return sb.toString(); + } + + private static String formatDuration(double durationSeconds) { + int totalMinutes = (int) Math.round(durationSeconds / 60.0); + totalMinutes = Math.round(totalMinutes / 5.0f) * 5; + totalMinutes = Math.max(totalMinutes, 5); + + if (totalMinutes < 60) { + return totalMinutes + "min"; + } + + int hours = totalMinutes / 60; + int minutes = totalMinutes % 60; + if (minutes == 0) { + return hours + "h"; + } + return hours + "h " + minutes + "m"; + } + + private static String buildAlertKey(String action, Suggestion suggestion) { + String a = action == null ? "" : action; + int itemId = suggestion == null ? -1 : suggestion.getItemId(); + int price = suggestion == null ? -1 : suggestion.getPrice(); + int qty = suggestion == null ? -1 : suggestion.getQuantity(); + return a + ":" + itemId + ":" + price + ":" + qty; + } + + private boolean isDuplicateAlertKey(String key) { + if (key == null || key.isBlank()) { return false; } + synchronized (alertCacheLock) { + if (lastAlertKeys.contains(key)) { + return true; + } + lastAlertKeys.addFirst(key); + while (lastAlertKeys.size() > 4) { + lastAlertKeys.removeLast(); + } + return false; + } + } + + private static String formatStatsSummary(Stats stats, SessionData sd) { + DateTimeFormatter dtf = DateTimeFormatter.ofPattern("MMM d, yyyy, h:mm:ss a", Locale.ENGLISH); + ZoneId zone = ZoneId.systemDefault(); + + ZonedDateTime began = null; + ZonedDateTime ended = null; + String active = null; + String avgWealth = null; + + if (sd != null) { + began = Instant.ofEpochSecond(sd.startTime).atZone(zone); + long durationSeconds = Math.max(0, sd.durationMillis / 1000); + ended = Instant.ofEpochSecond(sd.startTime + (int) durationSeconds).atZone(zone); + active = formatHhMmSs(durationSeconds); + avgWealth = UIUtilities.quantityToRSDecimalStack(Math.abs(sd.averageCash), false) + " gp"; + } + + String roi = String.format(Locale.ENGLISH, "%.3f%%", stats.calculateRoi() * 100); + + StringBuilder sb = new StringBuilder(); + sb.append("```"); + if (began != null) { + sb.append("\nSession began at: ").append(dtf.format(began)); + } + if (ended != null) { + sb.append("\nSession ended at: ").append(dtf.format(ended)); + } + if (active != null) { + sb.append("\nActive session time: ").append(active); + } + sb.append("\nFlips made: ").append(stats.flipsMade); + sb.append("\nProfit: ").append(UIUtilities.formatProfit(stats.profit)); + sb.append("\nTax paid: ").append(UIUtilities.formatProfit(stats.taxPaid)); + sb.append("\nRoi: ").append(roi); + if (avgWealth != null) { + sb.append("\nAvg wealth: ").append(avgWealth); + } + sb.append("\n```"); + return sb.toString(); + } + + private static String formatHhMmSs(long totalSeconds) { + long h = totalSeconds / 3600; + long m = (totalSeconds % 3600) / 60; + long s = totalSeconds % 60; + return String.format(Locale.ENGLISH, "%02d:%02d:%02d", h, m, s); + } } diff --git a/src/main/java/com/flippingcopilot/model/DiscordWebhookBody.java b/src/main/java/com/flippingcopilot/model/DiscordWebhookBody.java index 92652a98..bbb05c94 100644 --- a/src/main/java/com/flippingcopilot/model/DiscordWebhookBody.java +++ b/src/main/java/com/flippingcopilot/model/DiscordWebhookBody.java @@ -1,19 +1,35 @@ package com.flippingcopilot.model; +import com.google.gson.annotations.SerializedName; import lombok.Data; +import java.util.List; + @Data public class DiscordWebhookBody { private String content; - private Embed embed; + private Boolean tts; + private List embeds; @Data - static class Embed { - final UrlEmbed image; + public static class Embed { + private String title; + private String description; + private Integer color; + private Author author; + private Thumbnail thumbnail; } @Data - static class UrlEmbed { - final String url; + public static class Thumbnail { + private String url; + } + + @Data + public static class Author { + private String name; + + @SerializedName("icon_url") + private String iconUrl; } -} \ No newline at end of file +} diff --git a/src/main/java/com/flippingcopilot/model/FlipManager.java b/src/main/java/com/flippingcopilot/model/FlipManager.java index 40369abb..089b411d 100644 --- a/src/main/java/com/flippingcopilot/model/FlipManager.java +++ b/src/main/java/com/flippingcopilot/model/FlipManager.java @@ -373,7 +373,7 @@ private Function closedTimeCmp(List flips, UUID id, in return (a) -> { // sorts time ascending with id as tie-breaker int c = Integer.compare(flips.get(a).getClosedTime(), time); - return c != 0 ? c : id.compareTo(flips.get(a).getId()); + return c != 0 ? c : flips.get(a).getId().compareTo(id); }; } diff --git a/src/test/java/com/flippingcopilot/model/FlipPriceGraphControllerTest.java b/src/test/java/com/flippingcopilot/model/FlipPriceGraphControllerTest.java index 421a8f57..97539181 100644 --- a/src/test/java/com/flippingcopilot/model/FlipPriceGraphControllerTest.java +++ b/src/test/java/com/flippingcopilot/model/FlipPriceGraphControllerTest.java @@ -34,7 +34,7 @@ public void testOneAccount() { verifyflipManagerStoredOrder(flipManager); - flips.sort(Comparator.comparing(FlipV2::getClosedTime).reversed().thenComparing(FlipV2::getId)); + flips.sort(Comparator.comparing(FlipV2::getClosedTime).reversed().thenComparing(FlipV2::getId, Comparator.reverseOrder())); // create list of test interval start times List testTimes = Stream.generate(()-> randomIntBetween(sixMonthsAgo, now)).limit(100).collect(Collectors.toList()); @@ -80,7 +80,7 @@ public void testMultipleAccounts() { verifyflipManagerStoredOrder(flipManager); - flips.sort(Comparator.comparing(FlipV2::getClosedTime).reversed().thenComparing(Comparator.comparing(FlipV2::getId).reversed())); + flips.sort(Comparator.comparing(FlipV2::getClosedTime).reversed().thenComparing(FlipV2::getId, Comparator.reverseOrder())); // create list of test interval start times List testTimes = Stream.generate(()-> randomIntBetween(sixMonthsAgo, now)).limit(100).collect(Collectors.toList()); @@ -195,4 +195,4 @@ private List generateFlipsBetween(int start, int end, int number, List Date: Mon, 16 Feb 2026 23:58:54 +0000 Subject: [PATCH 2/3] feat: implement window focus listener for webhook gating (move away from game tick to event) --- .../controller/FlippingCopilotPlugin.java | 91 +++++++++++++++---- 1 file changed, 74 insertions(+), 17 deletions(-) diff --git a/src/main/java/com/flippingcopilot/controller/FlippingCopilotPlugin.java b/src/main/java/com/flippingcopilot/controller/FlippingCopilotPlugin.java index f040767c..fad61b47 100644 --- a/src/main/java/com/flippingcopilot/controller/FlippingCopilotPlugin.java +++ b/src/main/java/com/flippingcopilot/controller/FlippingCopilotPlugin.java @@ -30,10 +30,15 @@ import javax.inject.Inject; import javax.swing.*; +import java.awt.Canvas; +import java.awt.Window; +import java.awt.event.WindowAdapter; +import java.awt.event.WindowEvent; import java.awt.image.BufferedImage; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; @Slf4j @@ -134,6 +139,10 @@ public ExecutorService provideExecutorService(@Named("copilotExecutor") Schedule private MainPanel mainPanel; private StatsPanelV2 statsPanel; private NavigationButton navButton; + private volatile boolean focusListenerInstalled = false; + private ScheduledFuture focusListenerInstallTask; + private Window focusWindow; + private WindowAdapter focusListener; @Override protected void startUp() throws Exception { @@ -178,10 +187,75 @@ protected void startUp() throws Exception { } }) , 2000, 1000, TimeUnit.MILLISECONDS); + + installFocusListenerEventually(); + } + + private void installFocusListenerEventually() { + // Window can be null early in startup, so retry a few times. + if (focusListenerInstallTask != null) { + focusListenerInstallTask.cancel(false); + } + focusListenerInstallTask = executorService.scheduleAtFixedRate( + () -> SwingUtilities.invokeLater(this::installFocusListenerIfPossible), + 0, + 1, + TimeUnit.SECONDS + ); + } + + private void installFocusListenerIfPossible() { + if (focusListenerInstalled) { + return; + } + try { + Canvas canvas = client.getCanvas(); + if (canvas == null) { + return; + } + Window window = SwingUtilities.getWindowAncestor(canvas); + if (window == null) { + return; + } + + focusWindow = window; + focusListener = new WindowAdapter() { + @Override + public void windowGainedFocus(WindowEvent e) { + webHookController.setClientHasFocus(true); + } + + @Override + public void windowLostFocus(WindowEvent e) { + webHookController.setClientHasFocus(false); + } + }; + window.addWindowFocusListener(focusListener); + webHookController.setClientHasFocus(window.isActive()); + focusListenerInstalled = true; + + if (focusListenerInstallTask != null) { + focusListenerInstallTask.cancel(false); + focusListenerInstallTask = null; + } + log.debug("installed window focus listener for webhook gating"); + } catch (Exception e) { + log.debug("failed to install window focus listener", e); + } } @Override protected void shutDown() throws Exception { + if (focusListenerInstallTask != null) { + focusListenerInstallTask.cancel(false); + focusListenerInstallTask = null; + } + if (focusWindow != null && focusListener != null) { + try { + focusWindow.removeWindowFocusListener(focusListener); + } catch (Exception ignored) { + } + } offerManager.saveAll(); highlightController.removeAll(); clientThread.invokeLater(() -> slotProfitColorizer.resetAllSlots()); @@ -221,27 +295,10 @@ public void onItemContainerChanged(ItemContainerChanged event) { public void onGameTick(GameTick event) { suggestionController.onGameTick(); offerEventHandler.onGameTick(); - webHookController.setClientHasFocus(isClientFocused()); grandExchangeOpenRS.set(grandExchange.isOpen()); osrsLoginRS.set(osrsLoginRS.get().nexState(client)); } - private boolean isClientFocused() { - try { - java.awt.Canvas canvas = client.getCanvas(); - if (canvas == null) { - return true; - } - java.awt.Window window = SwingUtilities.getWindowAncestor(canvas); - if (window != null) { - return window.isActive(); - } - return canvas.isFocusOwner(); - } catch (Exception e) { - return true; - } - } - @Subscribe public void onBeforeRender(BeforeRender event) { gameUiChangesHandler.onBeforeRender(event); From 169c13034409dd53086c5868e054bf38d60ebed3 Mon Sep 17 00:00:00 2001 From: Reece Harris Date: Tue, 17 Feb 2026 00:26:24 +0000 Subject: [PATCH 3/3] feat: enhance webhook functionality with focus window management and notification gating --- .../controller/FlippingCopilotPlugin.java | 1 + .../controller/SuggestionController.java | 43 ++++++++++-------- .../controller/WebHookController.java | 44 +++++++++++++++---- 3 files changed, 61 insertions(+), 27 deletions(-) diff --git a/src/main/java/com/flippingcopilot/controller/FlippingCopilotPlugin.java b/src/main/java/com/flippingcopilot/controller/FlippingCopilotPlugin.java index fad61b47..11733481 100644 --- a/src/main/java/com/flippingcopilot/controller/FlippingCopilotPlugin.java +++ b/src/main/java/com/flippingcopilot/controller/FlippingCopilotPlugin.java @@ -219,6 +219,7 @@ private void installFocusListenerIfPossible() { } focusWindow = window; + webHookController.setFocusWindow(window); focusListener = new WindowAdapter() { @Override public void windowGainedFocus(WindowEvent e) { diff --git a/src/main/java/com/flippingcopilot/controller/SuggestionController.java b/src/main/java/com/flippingcopilot/controller/SuggestionController.java index 9135cdc9..b90c2618 100644 --- a/src/main/java/com/flippingcopilot/controller/SuggestionController.java +++ b/src/main/java/com/flippingcopilot/controller/SuggestionController.java @@ -58,8 +58,9 @@ public class SuggestionController { private MainPanel mainPanel; private LoginPanel loginPanel; - private CopilotPanel copilotPanel; - private SuggestionPanel suggestionPanel; + private CopilotPanel copilotPanel; + private SuggestionPanel suggestionPanel; + private boolean lastCollectNeeded = false; public void togglePause() { if (pausedManager.isPaused()) { @@ -201,7 +202,9 @@ private synchronized void handleSuggestionReceived(Suggestion oldSuggestion, Sug if (newSuggestion.isDumpAlert && config.dumpAlertSound()) { client.playSoundEffect(SoundEffectID.GE_ADD_OFFER_DINGALING); } - if (newSuggestion.isDumpAlert) { + boolean shouldNotify = shouldNotify(newSuggestion, oldSuggestion); + + if (newSuggestion.isDumpAlert && shouldNotify) { // Send short-format webhook for dump alert: Item, Price try { webHookController.sendDumpAlert(newSuggestion); @@ -218,11 +221,13 @@ private synchronized void handleSuggestionReceived(Suggestion oldSuggestion, Sug offerManager.setOfferJustPlaced(false); suggestionPanel.refresh(); - // Abort/Collect webhook alerts + // Collect webhook alert (edge-triggered to avoid spam) try { - if (accountStatus.isCollectNeeded(newSuggestion, grandExchange.isSetupOfferOpen())) { + boolean collectNeeded = accountStatus.isCollectNeeded(newSuggestion, grandExchange.isSetupOfferOpen()); + if (collectNeeded && !lastCollectNeeded) { webHookController.sendCollectAlert(); } + lastCollectNeeded = collectNeeded; } catch (Exception e) { log.debug("Error sending collect webhook", e); } @@ -230,15 +235,15 @@ private synchronized void handleSuggestionReceived(Suggestion oldSuggestion, Sug showNotifications(oldSuggestion, newSuggestion, accountStatus); // Send webhook alerts for buy/sell suggestions in short format String type = newSuggestion.getType(); - if ("buy".equals(type)) { + if (shouldNotify && "buy".equals(type)) { try { webHookController.sendBuyAlert(newSuggestion); } catch (Exception e) { log.debug("Error sending buy webhook", e); } - } else if ("sell".equals(type)) { + } else if (shouldNotify && "sell".equals(type)) { try { webHookController.sendSellAlert(newSuggestion); } catch (Exception e) { log.debug("Error sending sell webhook", e); } - } else if ("abort".equals(type)) { + } else if (shouldNotify && "abort".equals(type)) { try { webHookController.sendAbortAlert(newSuggestion); } catch (Exception e) { log.debug("Error sending abort webhook", e); } @@ -276,17 +281,17 @@ private PriceLine buildPriceLine(Suggestion suggestion) { return null; } - void showNotifications(Suggestion oldSuggestion, Suggestion newSuggestion, AccountStatus accountStatus) { - if (shouldNotify(newSuggestion, oldSuggestion)) { - String msg = newSuggestion.toMessage(); - if (config.enableTrayNotifications()) { - notifier.notify(msg); - } - if (!copilotPanel.isShowing() && config.enableChatNotifications()) { - showChatNotifications(newSuggestion, accountStatus); - } - } - } + void showNotifications(Suggestion oldSuggestion, Suggestion newSuggestion, AccountStatus accountStatus) { + if (shouldNotify(newSuggestion, oldSuggestion)) { + String msg = newSuggestion.toMessage(); + if (config.enableTrayNotifications()) { + notifier.notify(msg); + } + if (!copilotPanel.isShowing() && config.enableChatNotifications()) { + showChatNotifications(newSuggestion, accountStatus); + } + } + } static boolean shouldNotify(Suggestion newSuggestion, Suggestion oldSuggestion) { if (newSuggestion.getType().equals("wait")) { diff --git a/src/main/java/com/flippingcopilot/controller/WebHookController.java b/src/main/java/com/flippingcopilot/controller/WebHookController.java index 832a61d0..f72ff5bb 100644 --- a/src/main/java/com/flippingcopilot/controller/WebHookController.java +++ b/src/main/java/com/flippingcopilot/controller/WebHookController.java @@ -14,6 +14,8 @@ import javax.inject.Inject; import javax.inject.Singleton; +import java.awt.KeyboardFocusManager; +import java.awt.Window; import java.io.IOException; import java.text.NumberFormat; import java.time.Instant; @@ -44,6 +46,7 @@ public class WebHookController { private final ProfitCalculator profitCalculator; private volatile boolean clientHasFocus = true; + private volatile Window focusWindow; private final Object alertCacheLock = new Object(); private final Deque lastAlertKeys = new ArrayDeque<>(4); @@ -52,6 +55,31 @@ public void setClientHasFocus(boolean focused) { this.clientHasFocus = focused; } + public void setFocusWindow(Window window) { + this.focusWindow = window; + } + + private boolean isClientFocusedForGating() { + // Primary signal is the focus listener updating clientHasFocus. + boolean focused = clientHasFocus; + + // Fallback: re-check actual AWT focus state if we have the window. + // This helps when focus events are missed or the window changes. + Window w = focusWindow; + if (w != null) { + try { + focused = w.isActive() || w.isFocused(); + if (!focused) { + Window active = KeyboardFocusManager.getCurrentKeyboardFocusManager().getActiveWindow(); + focused = (active == w); + } + } catch (Exception ignored) { + // keep resilient; use last known value + } + } + return focused; + } + private void sendWebHook(DiscordWebhookBody discordWebhookBody) { String configURL = config.webhook(); if (Strings.isNullOrEmpty(configURL)) { return; } @@ -123,7 +151,7 @@ public void sendMessage(Stats stats, SessionData sd, String displayName, boolean public void sendDumpAlert(String itemName, int price) { if (!config.enableWebhookDumpAlerts()) { return; } - if (config.webhookAlertsOnlyWhenOutOfFocus() && clientHasFocus) { return; } + if (config.webhookAlertsOnlyWhenOutOfFocus() && isClientFocusedForGating()) { return; } if (itemName == null || itemName.isBlank() || price <= 0) { return; } sendItemPriceEmbed("Dump", itemName, price, COLOR_DUMP); } @@ -131,7 +159,7 @@ public void sendDumpAlert(String itemName, int price) { public void sendDumpAlert(Suggestion suggestion) { if (suggestion == null) { return; } if (!config.enableWebhookDumpAlerts()) { return; } - if (config.webhookAlertsOnlyWhenOutOfFocus() && clientHasFocus) { return; } + if (config.webhookAlertsOnlyWhenOutOfFocus() && isClientFocusedForGating()) { return; } if (suggestion.getName() == null || suggestion.getName().isBlank() || suggestion.getPrice() <= 0) { return; } if (isDuplicateAlertKey(buildAlertKey("dump", suggestion))) { return; } sendSuggestionEmbed("Dump", suggestion, COLOR_DUMP); @@ -139,7 +167,7 @@ public void sendDumpAlert(Suggestion suggestion) { public void sendBuyAlert(String itemName, int price) { if (!config.enableWebhookBuyAlerts()) { return; } - if (config.webhookAlertsOnlyWhenOutOfFocus() && clientHasFocus) { return; } + if (config.webhookAlertsOnlyWhenOutOfFocus() && isClientFocusedForGating()) { return; } if (itemName == null || itemName.isBlank() || price <= 0) { return; } sendItemPriceEmbed("Buy", itemName, price, COLOR_BUY); } @@ -147,7 +175,7 @@ public void sendBuyAlert(String itemName, int price) { public void sendBuyAlert(Suggestion suggestion) { if (suggestion == null) { return; } if (!config.enableWebhookBuyAlerts()) { return; } - if (config.webhookAlertsOnlyWhenOutOfFocus() && clientHasFocus) { return; } + if (config.webhookAlertsOnlyWhenOutOfFocus() && isClientFocusedForGating()) { return; } if (suggestion.getName() == null || suggestion.getName().isBlank() || suggestion.getPrice() <= 0) { return; } if (isDuplicateAlertKey(buildAlertKey("buy", suggestion))) { return; } sendSuggestionEmbed("Buy", suggestion, COLOR_BUY); @@ -155,7 +183,7 @@ public void sendBuyAlert(Suggestion suggestion) { public void sendSellAlert(String itemName, int price) { if (!config.enableWebhookSellAlerts()) { return; } - if (config.webhookAlertsOnlyWhenOutOfFocus() && clientHasFocus) { return; } + if (config.webhookAlertsOnlyWhenOutOfFocus() && isClientFocusedForGating()) { return; } if (itemName == null || itemName.isBlank() || price <= 0) { return; } sendItemPriceEmbed("Sell", itemName, price, COLOR_SELL); } @@ -163,7 +191,7 @@ public void sendSellAlert(String itemName, int price) { public void sendSellAlert(Suggestion suggestion) { if (suggestion == null) { return; } if (!config.enableWebhookSellAlerts()) { return; } - if (config.webhookAlertsOnlyWhenOutOfFocus() && clientHasFocus) { return; } + if (config.webhookAlertsOnlyWhenOutOfFocus() && isClientFocusedForGating()) { return; } if (suggestion.getName() == null || suggestion.getName().isBlank() || suggestion.getPrice() <= 0) { return; } if (isDuplicateAlertKey(buildAlertKey("sell", suggestion))) { return; } sendSuggestionEmbed("Sell", suggestion, COLOR_SELL); @@ -172,7 +200,7 @@ public void sendSellAlert(Suggestion suggestion) { public void sendAbortAlert(Suggestion suggestion) { if (suggestion == null) { return; } if (!config.enableWebhookAbortCollectAlerts()) { return; } - if (config.webhookAlertsOnlyWhenOutOfFocus() && clientHasFocus) { return; } + if (config.webhookAlertsOnlyWhenOutOfFocus() && isClientFocusedForGating()) { return; } if (suggestion.getName() == null || suggestion.getName().isBlank()) { return; } if (isDuplicateAlertKey(buildAlertKey("abort", suggestion))) { return; } @@ -182,7 +210,7 @@ public void sendAbortAlert(Suggestion suggestion) { public void sendCollectAlert() { if (!config.enableWebhookAbortCollectAlerts()) { return; } - if (config.webhookAlertsOnlyWhenOutOfFocus() && clientHasFocus) { return; } + if (config.webhookAlertsOnlyWhenOutOfFocus() && isClientFocusedForGating()) { return; } if (isDuplicateAlertKey("collect")) { return; } String desc = "Collect items\n\n```\n\uD83D\uDCE5 Collect items from the Grand Exchange\n```";