Repository: pajlads/DinkPlugin
Pull request #361 (2023-11-11 11:08:27)

feat: add notifier to capture trades with other players
Requested on discord; screenshots completed trades

![image](https://github.com/pajlads/DinkPlugin/assets/8106344/825c0564-8e54-4673-af17-f33921b1fb80)



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ab0ea08..c74e7b6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,6 @@
 ## Unreleased
 
+- Major: Add notifier to capture trades with other players. (#361)
 - Minor: Add loot notifier setting that redirects pk loot to the pk notifier override url. (#353)
 - Minor: Allow customization of which safe deaths can trigger notifications. (#363, #384)
 - Bugfix: Avoid erroneous level notification on login when initial data is delayed. (#383)
diff --git a/README.md b/README.md
index 28575bb..4500329 100644
--- a/README.md
+++ b/README.md
@@ -35,6 +35,7 @@ To use this plugin, a webhook URL is required; you can obtain one from Discord w
 - [Player Kills](#player-kills): Sends a webhook message upon killing another player (while hitsplats are still visible)
 - [Group Storage](#group-storage): Sends a webhook message upon Group Ironman Shared Bank transactions (i.e., depositing or withdrawing items)
 - [Grand Exchange](#grand-exchange): Sends a webhook message upon buying or selling items on the GE (with customizable value threshold)
+- [Trades](#trades): Sends a webhook message upon completing a trade with another player (with customizable item value threshold)
 - [Leagues](#leagues): Sends a webhook message upon completing a Leagues IV task or unlocking a region/relic
 
 ## Other Setup
@@ -760,6 +761,49 @@ See [javadocs](https://static.runelite.net/api/runelite-api/net/runelite/api/Gra
 
 </details>
 
+### Trades:
+
+`%COUNTERPARTY%` will be replaced with the name of the other user involved in the trade
+
+`%GROSS_VALUE%` will be replaced with the sum of item values offered by both parties in the transaction.
+
+`%NET_VALUE%` will be replaced with the value of the received items _minus_ the value of the given items.
+
+<details>
+  <summary>JSON for Player Trade Notifications:</summary>
+
+```json5
+{
+  "content": "%USERNAME% traded with %COUNTERPARTY%",
+  "type": "TRADE",
+  "playerName": "%USERNAME%",
+  "accountType": "NORMAL",
+  "extra": {
+    "counterparty": "%COUNTERPARTY%",
+    "receivedItems": [
+      {
+        "id": 314,
+        "quantity": 100,
+        "priceEach": 2,
+        "name": "Feather"
+      }
+    ],
+    "givenItems": [
+      {
+        "id": 2,
+        "quantity": 3,
+        "priceEach": 150,
+        "name": "Cannonball"
+      }
+    ],
+    "receivedValue": 200,
+    "givenValue": 450
+  }
+}
+```
+
+</details>
+
 ### Leagues:
 
 Leagues notifications include: region unlocked, relic unlocked, and task completed (with customizable difficulty threshold).
diff --git a/src/main/java/dinkplugin/DinkPlugin.java b/src/main/java/dinkplugin/DinkPlugin.java
index 6427135..720c379 100644
--- a/src/main/java/dinkplugin/DinkPlugin.java
+++ b/src/main/java/dinkplugin/DinkPlugin.java
@@ -12,13 +12,14 @@ import dinkplugin.notifiers.GroupStorageNotifier;
 import dinkplugin.notifiers.KillCountNotifier;
 import dinkplugin.notifiers.LeaguesNotifier;
 import dinkplugin.notifiers.LevelNotifier;
-import dinkplugin.notifiers.MetaNotifier;
 import dinkplugin.notifiers.LootNotifier;
+import dinkplugin.notifiers.MetaNotifier;
 import dinkplugin.notifiers.PetNotifier;
 import dinkplugin.notifiers.PlayerKillNotifier;
 import dinkplugin.notifiers.QuestNotifier;
 import dinkplugin.notifiers.SlayerNotifier;
 import dinkplugin.notifiers.SpeedrunNotifier;
+import dinkplugin.notifiers.TradeNotifier;
 import dinkplugin.util.Utils;
 import lombok.extern.slf4j.Slf4j;
 import net.runelite.api.ChatMessageType;
@@ -87,6 +88,7 @@ public class DinkPlugin extends Plugin {
     private @Inject GrandExchangeNotifier grandExchangeNotifier;
     private @Inject LeaguesNotifier leaguesNotifier;
     private @Inject MetaNotifier metaNotifier;
+    private @Inject TradeNotifier tradeNotifier;
 
     private final AtomicReference<GameState> gameState = new AtomicReference<>();
 
@@ -114,6 +116,7 @@ public class DinkPlugin extends Plugin {
         killCountNotifier.reset();
         groupStorageNotifier.reset();
         speedrunNotifier.reset();
+        tradeNotifier.reset();
     }
 
     @Provides
@@ -222,6 +225,10 @@ public class DinkPlugin extends Plugin {
                 gambleNotifier.onMesBoxNotification(chatMessage);
                 break;
 
+            case TRADE:
+                tradeNotifier.onTradeMessage(chatMessage);
+                break;
+
             default:
                 // do nothing
                 break;
@@ -282,11 +289,13 @@ public class DinkPlugin extends Plugin {
         speedrunNotifier.onWidgetLoaded(event);
         groupStorageNotifier.onWidgetLoad(event);
         killCountNotifier.onWidget(event);
+        tradeNotifier.onWidgetLoad(event);
     }
 
     @Subscribe
     public void onWidgetClosed(WidgetClosed event) {
         groupStorageNotifier.onWidgetClose(event);
+        tradeNotifier.onWidgetClose(event);
     }
 
     public void addChatSuccess(String message) {
diff --git a/src/main/java/dinkplugin/DinkPluginConfig.java b/src/main/java/dinkplugin/DinkPluginConfig.java
index 72964e3..5dd85cf 100644
--- a/src/main/java/dinkplugin/DinkPluginConfig.java
+++ b/src/main/java/dinkplugin/DinkPluginConfig.java
@@ -156,6 +156,14 @@ public interface DinkPluginConfig extends Config {
     )
     String grandExchangeSection = "Grand Exchange";
 
+    @ConfigSection(
+        name = "Player Trades",
+        description = "Settings for notifying when you trade with another player",
+        position = 160,
+        closedByDefault = true
+    )
+    String tradeSection = "Player Trades";
+
     @ConfigSection(
         name = "Leagues",
         description = "Settings for notifying when you complete league tasks, unlock areas, and redeem relics",
@@ -560,6 +568,17 @@ public interface DinkPluginConfig extends Config {
         return "";
     }
 
+    @ConfigItem(
+        keyName = "tradeWebhook",
+        name = "Trade Webhook Override",
+        description = "If non-empty, Trading messages are sent to this URL, instead of the primary URL",
+        position = -3,
+        section = webhookSection
+    )
+    default String tradeWebhook() {
+        return "";
+    }
+
     @ConfigItem(
         keyName = "leaguesWebhook",
         name = "Leagues Webhook Override",
@@ -1711,6 +1730,54 @@ public interface DinkPluginConfig extends Config {
         return "%USERNAME% %TYPE% %ITEM% on the GE";
     }
 
+    @ConfigItem(
+        keyName = "notifyTrades",
+        name = "Enable Trades",
+        description = "Enable notifications upon completed player trades",
+        position = 160,
+        section = tradeSection
+    )
+    default boolean notifyTrades() {
+        return false;
+    }
+
+    @ConfigItem(
+        keyName = "tradeSendImage",
+        name = "Send Image",
+        description = "Send image with the notification",
+        position = 161,
+        section = tradeSection
+    )
+    default boolean tradeSendImage() {
+        return true;
+    }
+
+    @ConfigItem(
+        keyName = "tradeMinValue",
+        name = "Min Value",
+        description = "The minimum total value of the traded items to send a notification",
+        position = 162,
+        section = tradeSection
+    )
+    default int tradeMinValue() {
+        return 0;
+    }
+
+    @ConfigItem(
+        keyName = "tradeNotifyMessage",
+        name = "Notification Message",
+        description = "The message to be sent through the webhook.<br/>" +
+            "Use %USERNAME% to insert your username<br/>" +
+            "Use %COUNTERPARTY% to insert the name of the other player<br/>" +
+            "Use %IN_VALUE% to insert the value of the items received from the counterparty<br/>" +
+            "Use %OUT_VALUE% to insert the value of the items given to the counterparty",
+        position = 163,
+        section = tradeSection
+    )
+    default String tradeNotifyMessage() {
+        return "%USERNAME% traded with %COUNTERPARTY%";
+    }
+
     @ConfigItem(
         keyName = "notifyLeagues",
         name = "Enable Leagues",
diff --git a/src/main/java/dinkplugin/message/DiscordMessageHandler.java b/src/main/java/dinkplugin/message/DiscordMessageHandler.java
index 93ec5f5..ce7deda 100644
--- a/src/main/java/dinkplugin/message/DiscordMessageHandler.java
+++ b/src/main/java/dinkplugin/message/DiscordMessageHandler.java
@@ -18,7 +18,6 @@ import net.runelite.api.clan.ClanChannel;
 import net.runelite.api.clan.ClanID;
 import net.runelite.api.widgets.ComponentID;
 import net.runelite.api.widgets.InterfaceID;
-import net.runelite.api.widgets.Widget;
 import net.runelite.client.callback.ClientThread;
 import net.runelite.client.discord.DiscordService;
 import net.runelite.client.ui.DrawManager;
@@ -63,6 +62,8 @@ import java.util.stream.Collectors;
 @Slf4j
 @Singleton
 public class DiscordMessageHandler {
+    public static final @Component int PRIVATE_CHAT_WIDGET = Utils.packWidget(InterfaceID.PRIVATE_CHAT, 0);
+
     private final Gson gson;
     private final Client client;
     private final DrawManager drawManager;
@@ -110,10 +111,11 @@ public class DiscordMessageHandler {
         NotificationBody<?> mBody = enrichBody(inputBody, sendImage);
         if (sendImage) {
             // optionally hide chat for privacy in screenshot
-            boolean chatHidden = hideWidget(config.screenshotHideChat(), client, ComponentID.CHATBOX_FRAME);
-            boolean whispersHidden = hideWidget(config.screenshotHideChat(), client, Utils.packWidget(InterfaceID.PRIVATE_CHAT, 0));
+            boolean alreadyCaptured = mBody.getScreenshotOverride() != null;
+            boolean chatHidden = Utils.hideWidget(!alreadyCaptured && config.screenshotHideChat(), client, ComponentID.CHATBOX_FRAME);
+            boolean whispersHidden = Utils.hideWidget(!alreadyCaptured && config.screenshotHideChat(), client, PRIVATE_CHAT_WIDGET);
 
-            captureScreenshot(config.screenshotScale() / 100.0, chatHidden, whispersHidden)
+            captureScreenshot(config.screenshotScale() / 100.0, chatHidden, whispersHidden, mBody.getScreenshotOverride())
                 .thenApply(image ->
                     RequestBody.create(MediaType.parse("image/" + image.getKey()), image.getValue())
                 )
@@ -281,20 +283,25 @@ public class DiscordMessageHandler {
      * @param scalePercent {@link DinkPluginConfig#screenshotScale()} divided by 100.0
      * @param chatHidden Whether the chat widget should be unhidden
      * @param whispersHidden Whether the whispers widget should be unhidden
+     * @param screenshotOverride an optional image to use instead of grabbing a frame from {@link DrawManager}
      * @return future of the image byte array by the image format name
      * @apiNote scalePercent should be in (0, 1]
      * @implNote the image format is either "png" (lossless) or "jpeg" (lossy), both of which can be used in MIME type
      */
-    private CompletableFuture<Map.Entry<String, byte[]>> captureScreenshot(double scalePercent, boolean chatHidden, boolean whispersHidden) {
+    private CompletableFuture<Map.Entry<String, byte[]>> captureScreenshot(double scalePercent, boolean chatHidden, boolean whispersHidden, @Nullable Image screenshotOverride) {
         CompletableFuture<Image> future = new CompletableFuture<>();
-        drawManager.requestNextFrameListener(img -> {
-            // unhide any widgets we hid (scheduled for client thread)
-            unhideWidget(chatHidden, client, clientThread, ComponentID.CHATBOX_FRAME);
-            unhideWidget(whispersHidden, client, clientThread, Utils.packWidget(InterfaceID.PRIVATE_CHAT, 0));
+        if (screenshotOverride != null) {
+            executor.execute(() -> future.complete(screenshotOverride));
+        } else {
+            drawManager.requestNextFrameListener(img -> {
+                // unhide any widgets we hid (scheduled for client thread)
+                Utils.unhideWidget(chatHidden, client, clientThread, ComponentID.CHATBOX_FRAME);
+                Utils.unhideWidget(whispersHidden, client, clientThread, Utils.packWidget(InterfaceID.PRIVATE_CHAT, 0));
 
-            // resolve future on separate thread
-            executor.execute(() -> future.complete(img));
-        });
+                // resolve future on separate thread
+                executor.execute(() -> future.complete(img));
+            });
+        }
         return future.thenApply(ImageUtil::bufferedImageFromImage)
             .thenApply(input -> Utils.rescale(input, scalePercent))
             .thenApply(image -> {
@@ -362,26 +369,4 @@ public class DiscordMessageHandler {
         return embeds;
     }
 
-    private static boolean hideWidget(boolean shouldHide, Client client, @Component int info) {
-        if (!shouldHide)
-            return false;
-
-        Widget widget = client.getWidget(info);
-        if (widget == null || widget.isHidden())
-            return false;
-
-        widget.setHidden(true);
-        return true;
-    }
-
-    private static void unhideWidget(boolean shouldUnhide, Client client, ClientThread clientThread, @Component int info) {
-        if (!shouldUnhide)
-            return;
-
-        clientThread.invoke(() -> {
-            Widget widget = client.getWidget(info);
-            if (widget != null)
-                widget.setHidden(false);
-        });
-    }
 }
diff --git a/src/main/java/dinkplugin/message/NotificationBody.java b/src/main/java/dinkplugin/message/NotificationBody.java
index c061c4e..c2f7485 100644
--- a/src/main/java/dinkplugin/message/NotificationBody.java
+++ b/src/main/java/dinkplugin/message/NotificationBody.java
@@ -14,6 +14,7 @@ import lombok.With;
 import org.jetbrains.annotations.NotNull;
 import org.jetbrains.annotations.Nullable;
 
+import java.awt.Image;
 import java.util.LinkedList;
 import java.util.List;
 
@@ -86,4 +87,11 @@ public class NotificationBody<T extends NotificationData> {
     @SerializedName("thread_name")
     String threadName;
 
+    /**
+     * An optional screenshot to use, instead of capturing one from {@link DiscordMessageHandler}
+     */
+    @Nullable
+    @EqualsAndHashCode.Exclude
+    transient Image screenshotOverride;
+
 }
diff --git a/src/main/java/dinkplugin/message/NotificationType.java b/src/main/java/dinkplugin/message/NotificationType.java
index 6731936..74020fa 100644
--- a/src/main/java/dinkplugin/message/NotificationType.java
+++ b/src/main/java/dinkplugin/message/NotificationType.java
@@ -27,7 +27,8 @@ public enum NotificationType {
     LEAGUES_AREA("Area Unlocked", "leaguesArea.png", WIKI_IMG_BASE_URL + "Trailblazer_Reloaded_League_-_%3F_Relic.png"),
     LEAGUES_RELIC("Relic Chosen", "leaguesRelic.png", WIKI_IMG_BASE_URL + "Trailblazer_Reloaded_League_-_relics_icon.png"),
     LEAGUES_TASK("Task Completed", "leaguesTask.png", WIKI_IMG_BASE_URL + "Trailblazer_Reloaded_League_icon.png"),
-    LOGIN("Player Login", "login.png", WIKI_IMG_BASE_URL + "Prop_sword.png");
+    LOGIN("Player Login", "login.png", WIKI_IMG_BASE_URL + "Prop_sword.png"),
+    TRADE("Player Trade", "trade.png", WIKI_IMG_BASE_URL + "Inventory.png");
 
     private final String title;
 
diff --git a/src/main/java/dinkplugin/notifiers/TradeNotifier.java b/src/main/java/dinkplugin/notifiers/TradeNotifier.java
new file mode 100644
index 0000000..4da1dfa
--- /dev/null
+++ b/src/main/java/dinkplugin/notifiers/TradeNotifier.java
@@ -0,0 +1,161 @@
+package dinkplugin.notifiers;
+
+import dinkplugin.message.NotificationBody;
+import dinkplugin.message.NotificationType;
+import dinkplugin.message.templating.Replacements;
+import dinkplugin.message.templating.Template;
+import dinkplugin.notifiers.data.SerializedItemStack;
+import dinkplugin.notifiers.data.TradeNotificationData;
+import dinkplugin.util.ItemUtils;
+import dinkplugin.util.Utils;
+import lombok.extern.slf4j.Slf4j;
+import net.runelite.api.InventoryID;
+import net.runelite.api.Item;
+import net.runelite.api.ItemContainer;
+import net.runelite.api.annotations.Interface;
+import net.runelite.api.annotations.VarCStr;
+import net.runelite.api.events.WidgetClosed;
+import net.runelite.api.events.WidgetLoaded;
+import net.runelite.api.widgets.ComponentID;
+import net.runelite.client.callback.ClientThread;
+import net.runelite.client.game.ItemManager;
+import net.runelite.client.ui.DrawManager;
+import net.runelite.client.util.QuantityFormatter;
+import org.jetbrains.annotations.VisibleForTesting;
+
+import javax.inject.Inject;
+import javax.inject.Singleton;
+import java.awt.Image;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static dinkplugin.message.DiscordMessageHandler.PRIVATE_CHAT_WIDGET;
+
+@Slf4j
+@Singleton
+public class TradeNotifier extends BaseNotifier {
+    @VisibleForTesting
+    public static final String TRADE_ACCEPTED_MESSAGE = "Accepted trade.";
+    /**
+     * @see <a href="https://github.com/Joshua-F/cs2-scripts/blob/master/scripts/%5Bclientscript%2Ctrade_partner_set%5D.cs2#L3">CS2 Reference</a>
+     */
+    @VisibleForTesting
+    public static final @VarCStr int TRADE_COUNTERPARTY_VAR = 357;
+    private static final @Interface int TRADE_CONFIRMATION_GROUP = 334;
+
+    @Inject
+    private ClientThread clientThread;
+
+    @Inject
+    private DrawManager drawManager;
+
+    @Inject
+    private ItemManager itemManager;
+
+    private final AtomicReference<Image> image = new AtomicReference<>();
+
+    @Override
+    public boolean isEnabled() {
+        return config.notifyTrades() && super.isEnabled();
+    }
+
+    @Override
+    protected String getWebhookUrl() {
+        return config.tradeWebhook();
+    }
+
+    public void reset() {
+        image.lazySet(null);
+    }
+
+    public void onTradeMessage(String message) {
+        if (!TRADE_ACCEPTED_MESSAGE.equals(message) || !isEnabled()) {
+            this.reset();
+            return;
+        }
+
+        ItemContainer tradeInv = client.getItemContainer(InventoryID.TRADE);
+        ItemContainer otherInv = client.getItemContainer(InventoryID.TRADEOTHER);
+        if (tradeInv == null && otherInv == null) {
+            log.debug("Could not find traded items!");
+            this.reset();
+            return;
+        }
+        Item[] trade = tradeInv != null ? tradeInv.getItems() : new Item[0];
+        Item[] other = otherInv != null ? otherInv.getItems() : new Item[0];
+        long receiveValue = getTotalValue(other);
+        long giveValue = getTotalValue(trade);
+        if (receiveValue + giveValue < config.tradeMinValue()) {
+            this.reset();
+            return;
+        }
+        List<SerializedItemStack> received = getItems(other);
+        List<SerializedItemStack> disbursed = getItems(trade);
+
+        String localPlayer = client.getLocalPlayer().getName();
+        String counterparty = Utils.sanitize(client.getVarcStrValue(TRADE_COUNTERPARTY_VAR));
+        Template content = Template.builder()
+            .template(config.tradeNotifyMessage())
+            .replacementBoundary("%")
+            .replacement("%USERNAME%", Replacements.ofText(localPlayer))
+            .replacement("%COUNTERPARTY%", Replacements.ofLink(counterparty, config.playerLookupService().getPlayerUrl(counterparty)))
+            .replacement("%IN_VALUE%", Replacements.ofText(QuantityFormatter.quantityToStackSize(receiveValue)))
+            .replacement("%OUT_VALUE%", Replacements.ofText(QuantityFormatter.quantityToStackSize(giveValue)))
+            .build();
+
+        createMessage(config.tradeSendImage(), NotificationBody.builder()
+            .text(content)
+            .extra(new TradeNotificationData(counterparty, received, disbursed, receiveValue, giveValue))
+            .playerName(localPlayer)
+            .screenshotOverride(image.get())
+            .type(NotificationType.TRADE)
+            .build()
+        );
+
+        this.reset();
+    }
+
+    public void onWidgetLoad(WidgetLoaded event) {
+        if (event.getGroupId() == TRADE_CONFIRMATION_GROUP) {
+            boolean chatHidden = Utils.hideWidget(config.screenshotHideChat(), client, ComponentID.CHATBOX_FRAME);
+            boolean whispersHidden = Utils.hideWidget(config.screenshotHideChat(), client, PRIVATE_CHAT_WIDGET);
+            drawManager.requestNextFrameListener(frame -> {
+                image.set(frame);
+                Utils.unhideWidget(chatHidden, client, clientThread, ComponentID.CHATBOX_FRAME);
+                Utils.unhideWidget(whispersHidden, client, clientThread, PRIVATE_CHAT_WIDGET);
+            });
+        }
+    }
+
+    public void onWidgetClose(WidgetClosed event) {
+        // relevant when local player declines a trade since no chat message occurs
+        if (event.getGroupId() == TRADE_CONFIRMATION_GROUP) {
+            clientThread.invokeAtTickEnd(this::reset);
+        }
+    }
+
+    private long getTotalValue(Item[] items) {
+        long v = 0;
+        for (Item item : items) {
+            v += ItemUtils.getPrice(itemManager, item.getId()) * item.getQuantity();
+        }
+        return v;
+    }
+
+    private List<SerializedItemStack> getItems(Item[] items) {
+        if (items.length == 0) {
+            return Collections.emptyList();
+        }
+        Map<Integer, Integer> quantityById = new HashMap<>(items.length * 4 / 3);
+        for (Item item : items) {
+            quantityById.merge(item.getId(), item.getQuantity(), Integer::sum);
+        }
+        List<SerializedItemStack> stacks = new ArrayList<>(quantityById.size());
+        quantityById.forEach((id, quantity) -> stacks.add(ItemUtils.stackFromItem(itemManager, id, quantity)));
+        return stacks;
+    }
+}
diff --git a/src/main/java/dinkplugin/notifiers/data/TradeNotificationData.java b/src/main/java/dinkplugin/notifiers/data/TradeNotificationData.java
new file mode 100644
index 0000000..7f7b29c
--- /dev/null
+++ b/src/main/java/dinkplugin/notifiers/data/TradeNotificationData.java
@@ -0,0 +1,27 @@
+package dinkplugin.notifiers.data;
+
+import dinkplugin.message.Field;
+import dinkplugin.util.ItemUtils;
+import lombok.EqualsAndHashCode;
+import lombok.Value;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@Value
+@EqualsAndHashCode(callSuper = false)
+public class TradeNotificationData extends NotificationData {
+    String counterparty;
+    List<SerializedItemStack> receivedItems;
+    List<SerializedItemStack> givenItems;
+    long receivedValue;
+    long givenValue;
+
+    @Override
+    public List<Field> getFields() {
+        List<Field> fields = new ArrayList<>(2);
+        fields.add(new Field("Received Value", ItemUtils.formatGold(receivedValue)));
+        fields.add(new Field("Given Value", ItemUtils.formatGold(givenValue)));
+        return fields;
+    }
+}
diff --git a/src/main/java/dinkplugin/util/Utils.java b/src/main/java/dinkplugin/util/Utils.java
index 1d4ee0c..77cc8a1 100644
--- a/src/main/java/dinkplugin/util/Utils.java
+++ b/src/main/java/dinkplugin/util/Utils.java
@@ -11,6 +11,8 @@ import net.runelite.api.Client;
 import net.runelite.api.Varbits;
 import net.runelite.api.annotations.Component;
 import net.runelite.api.annotations.Interface;
+import net.runelite.api.widgets.Widget;
+import net.runelite.client.callback.ClientThread;
 import net.runelite.client.util.ColorUtil;
 import net.runelite.client.util.Text;
 import okhttp3.Call;
@@ -203,6 +205,29 @@ public class Utils {
         return groupId << 16 | childId;
     }
 
+    public static boolean hideWidget(boolean shouldHide, Client client, @Component int info) {
+        if (!shouldHide)
+            return false;
+
+        Widget widget = client.getWidget(info);
+        if (widget == null || widget.isHidden())
+            return false;
+
+        widget.setHidden(true);
+        return true;
+    }
+
+    public static void unhideWidget(boolean shouldUnhide, Client client, ClientThread clientThread, @Component int info) {
+        if (!shouldUnhide)
+            return;
+
+        clientThread.invoke(() -> {
+            Widget widget = client.getWidget(info);
+            if (widget != null)
+                widget.setHidden(false);
+        });
+    }
+
     public BufferedImage rescale(BufferedImage input, double percent) {
         if (percent + Math.ulp(1.0) >= 1.0)
             return input;


Test patch:
diff --git a/src/test/java/dinkplugin/notifiers/TradeNotifierTest.java b/src/test/java/dinkplugin/notifiers/TradeNotifierTest.java
new file mode 100644
index 0000000..e6f5ac6
--- /dev/null
+++ b/src/test/java/dinkplugin/notifiers/TradeNotifierTest.java
@@ -0,0 +1,140 @@
+package dinkplugin.notifiers;
+
+import com.google.inject.testing.fieldbinder.Bind;
+import dinkplugin.message.NotificationBody;
+import dinkplugin.message.NotificationType;
+import dinkplugin.message.templating.Replacements;
+import dinkplugin.message.templating.Template;
+import dinkplugin.notifiers.data.SerializedItemStack;
+import dinkplugin.notifiers.data.TradeNotificationData;
+import net.runelite.api.InventoryID;
+import net.runelite.api.Item;
+import net.runelite.api.ItemContainer;
+import net.runelite.api.ItemID;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+
+import java.util.List;
+
+import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.anyBoolean;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+public class TradeNotifierTest extends MockedNotifierTest {
+    private static final String COUNTERPARTY = "Billy";
+
+    private static final int OPAL_PRICE = 600;
+    private static final int RUBY_PRICE = 900;
+
+    @Bind
+    @InjectMocks
+    TradeNotifier notifier;
+
+    @Override
+    @BeforeEach
+    protected void setUp() {
+        super.setUp();
+
+        // init config mocks
+        when(config.notifyTrades()).thenReturn(true);
+        when(config.tradeMinValue()).thenReturn(2000);
+        when(config.tradeNotifyMessage()).thenReturn("%USERNAME% traded with %COUNTERPARTY%");
+
+        // init item mocks
+        mockItem(ItemID.OPAL, OPAL_PRICE, "Opal");
+        mockItem(ItemID.RUBY, RUBY_PRICE, "Ruby");
+    }
+
+    @Test
+    void testNotify() {
+        // update mocks
+        when(client.getVarcStrValue(TradeNotifier.TRADE_COUNTERPARTY_VAR)).thenReturn(COUNTERPARTY);
+
+        ItemContainer tradeContainer = mock(ItemContainer.class);
+        Item[] tradeItems = {new Item(ItemID.OPAL, 2)};
+        when(tradeContainer.getItems()).thenReturn(tradeItems);
+        when(client.getItemContainer(InventoryID.TRADE)).thenReturn(tradeContainer);
+
+        ItemContainer otherContainer = mock(ItemContainer.class);
+        Item[] otherItems = {new Item(ItemID.RUBY, 1)};
+        when(otherContainer.getItems()).thenReturn(otherItems);
+        when(client.getItemContainer(InventoryID.TRADEOTHER)).thenReturn(otherContainer);
+
+        // fire event
+        notifier.onTradeMessage(TradeNotifier.TRADE_ACCEPTED_MESSAGE);
+
+        // verify handled
+        List<SerializedItemStack> received = List.of(
+            new SerializedItemStack(ItemID.RUBY, 1, RUBY_PRICE, "Ruby")
+        );
+        List<SerializedItemStack> discarded = List.of(
+            new SerializedItemStack(ItemID.OPAL, 2, OPAL_PRICE, "Opal")
+        );
+        verify(messageHandler).createMessage(
+            PRIMARY_WEBHOOK_URL,
+            false,
+            NotificationBody.builder()
+                .text(
+                    Template.builder()
+                        .template("%USERNAME% traded with %COUNTERPARTY%")
+                        .replacement("%USERNAME%", Replacements.ofText(PLAYER_NAME))
+                        .replacement("%COUNTERPARTY%", Replacements.ofLink(COUNTERPARTY, config.playerLookupService().getPlayerUrl(COUNTERPARTY)))
+                        .build()
+                )
+                .extra(new TradeNotificationData(COUNTERPARTY, received, discarded, RUBY_PRICE, 2 * OPAL_PRICE))
+                .type(NotificationType.TRADE)
+                .playerName(PLAYER_NAME)
+                .build()
+        );
+    }
+
+    @Test
+    void testIgnoreValue() {
+        // update mocks
+        when(client.getVarcStrValue(TradeNotifier.TRADE_COUNTERPARTY_VAR)).thenReturn(COUNTERPARTY);
+
+        ItemContainer tradeContainer = mock(ItemContainer.class);
+        Item[] tradeItems = {new Item(ItemID.OPAL, 1)};
+        when(tradeContainer.getItems()).thenReturn(tradeItems);
+        when(client.getItemContainer(InventoryID.TRADE)).thenReturn(tradeContainer);
+
+        ItemContainer otherContainer = mock(ItemContainer.class);
+        Item[] otherItems = {new Item(ItemID.RUBY, 1)};
+        when(otherContainer.getItems()).thenReturn(otherItems);
+        when(client.getItemContainer(InventoryID.TRADEOTHER)).thenReturn(otherContainer);
+
+        // fire event
+        notifier.onTradeMessage(TradeNotifier.TRADE_ACCEPTED_MESSAGE);
+
+        // ensure no notification
+        verify(messageHandler, never()).createMessage(any(), anyBoolean(), any());
+    }
+
+    @Test
+    void testDisabled() {
+        // update mocks
+        when(config.notifyTrades()).thenReturn(false);
+        when(client.getVarcStrValue(TradeNotifier.TRADE_COUNTERPARTY_VAR)).thenReturn(COUNTERPARTY);
+
+        ItemContainer tradeContainer = mock(ItemContainer.class);
+        Item[] tradeItems = {new Item(ItemID.OPAL, 2)};
+        when(tradeContainer.getItems()).thenReturn(tradeItems);
+        when(client.getItemContainer(InventoryID.TRADE)).thenReturn(tradeContainer);
+
+        ItemContainer otherContainer = mock(ItemContainer.class);
+        Item[] otherItems = {new Item(ItemID.RUBY, 1)};
+        when(otherContainer.getItems()).thenReturn(otherItems);
+        when(client.getItemContainer(InventoryID.TRADEOTHER)).thenReturn(otherContainer);
+
+        // fire event
+        notifier.onTradeMessage(TradeNotifier.TRADE_ACCEPTED_MESSAGE);
+
+        // ensure no notification
+        verify(messageHandler, never()).createMessage(any(), anyBoolean(), any());
+    }
+
+}