Repository: pajlads/DinkPlugin (java)

Issue:
Group Bank Notifications
Would you consider adding functionality for notifications when group members withdraw and deposit items into a group bank? There is a similar plug-in with very, very basic functionality [here](https://github.com/barthogenes/RuneLite-Discord-GroupBank-Notifications) which is what we are currently using.

Notifications are submitted in plain text to the discord webhook as so:
![image](https://user-images.githubusercontent.com/13660274/235285275-4b98fb98-06bf-4097-9932-4674979ef549.png)

It makes sense in my head to have all these notifications 'centralized' via Dink (easier to handle and sort, hopefully would include dink's wonderful configuration and additional settings), but understand if there is too much overhead or complexity in doing so.

Pull request description:
feat: add group iron shared storage notifier
Closes #223

![image](https://user-images.githubusercontent.com/8106344/235778749-e88c33f8-d685-4819-8430-02f6a226fbc8.png)


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2b4e10c..d55f0c2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,6 @@
 ## Unreleased
 
+- Major: Add notifications for depositing or withdrawing items from Group Ironman Shared Storage. (#225)
 - Minor: Include discord user profile in notification metadata. (#226)
 - Minor: Add setting to allow death notifications during safe activities. (#227)
 - Minor: Allow player name in Discord notifications to link to CollectionLog.net profile. (#224)
diff --git a/README.md b/README.md
index 1961852..73af31e 100644
--- a/README.md
+++ b/README.md
@@ -22,6 +22,7 @@ Have a suggestion (e.g., new notifier, additional data), bug report (as rare as
 - [Speedrunning](#speedrunning): Send a webhook message upon completing a quest speedrun (with special configuration for personal best times)
 - [BA Gambles](#ba-gambles): Sends a webhook message upon receiving high level gambles from Barbarian Assault
 - [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)
 
 ## Other Setup
 
@@ -601,6 +602,52 @@ Note: `world` and `location` are _not_ sent if the user has disabled the "Includ
 
 </details>
 
+### Group Storage:
+
+`%DEPOSITED%` will be replaced with the list of deposited items
+
+`%WITHDRAWN%` will be replaced with the list of withdrawn items
+
+<details>
+  <summary>JSON for GIM Bank Notifications:</summary>
+
+```json5
+{
+  "content": "%USERNAME% has deposited: %DEPOSITED% | %USERNAME% has withdrawn: %WITHDRAWN%",
+  "type": "GROUP_STORAGE",
+  "playerName": "%USERNAME%",
+  "accountType": "HARDCORE_GROUP_IRONMAN",
+  "extra": {
+    "groupName": "group name",
+    "deposits": [
+      {
+        "id": 315,
+        "name": "Shrimps",
+        "quantity": 2,
+        "priceEach": 56
+      },
+      {
+        "id": 1205,
+        "name": "Bronze dagger",
+        "quantity": 1,
+        "priceEach": 53
+      }
+    ],
+    "withdrawals": [
+      {
+        "id": 1265,
+        "name": "Bronze pickaxe",
+        "quantity": 1,
+        "priceEach": 22
+      }
+    ],
+    "netValue": 143
+  }
+}
+```
+
+</details>
+
 ## Credits
 
 This plugin uses code from [Universal Discord Notifier](https://github.com/MidgetJake/UniversalDiscordNotifier).
diff --git a/src/main/java/dinkplugin/DinkPlugin.java b/src/main/java/dinkplugin/DinkPlugin.java
index 4348ccd..d8a2b0e 100644
--- a/src/main/java/dinkplugin/DinkPlugin.java
+++ b/src/main/java/dinkplugin/DinkPlugin.java
@@ -7,6 +7,7 @@ import dinkplugin.notifiers.CombatTaskNotifier;
 import dinkplugin.notifiers.DeathNotifier;
 import dinkplugin.notifiers.DiaryNotifier;
 import dinkplugin.notifiers.GambleNotifier;
+import dinkplugin.notifiers.GroupStorageNotifier;
 import dinkplugin.notifiers.KillCountNotifier;
 import dinkplugin.notifiers.LevelNotifier;
 import dinkplugin.notifiers.LootNotifier;
@@ -28,6 +29,7 @@ import net.runelite.api.events.InteractingChanged;
 import net.runelite.api.events.StatChanged;
 import net.runelite.api.events.UsernameChanged;
 import net.runelite.api.events.VarbitChanged;
+import net.runelite.api.events.WidgetClosed;
 import net.runelite.api.events.WidgetLoaded;
 import net.runelite.client.RuneLite;
 import net.runelite.client.chat.ChatMessageManager;
@@ -74,6 +76,7 @@ public class DinkPlugin extends Plugin {
     private @Inject DiaryNotifier diaryNotifier;
     private @Inject GambleNotifier gambleNotifier;
     private @Inject PlayerKillNotifier pkNotifier;
+    private @Inject GroupStorageNotifier groupStorageNotifier;
 
     @Override
     protected void startUp() {
@@ -96,6 +99,7 @@ public class DinkPlugin extends Plugin {
         levelNotifier.reset();
         slayerNotifier.reset();
         killCountNotifier.reset();
+        groupStorageNotifier.reset();
     }
 
     @Provides
@@ -221,6 +225,12 @@ public class DinkPlugin extends Plugin {
         clueNotifier.onWidgetLoaded(event);
         speedrunNotifier.onWidgetLoaded(event);
         lootNotifier.onWidgetLoaded(event);
+        groupStorageNotifier.onWidgetLoad(event);
+    }
+
+    @Subscribe
+    public void onWidgetClosed(WidgetClosed event) {
+        groupStorageNotifier.onWidgetClose(event);
     }
 
     public void addChatSuccess(String message) {
diff --git a/src/main/java/dinkplugin/DinkPluginConfig.java b/src/main/java/dinkplugin/DinkPluginConfig.java
index 6c5e419..832f363 100644
--- a/src/main/java/dinkplugin/DinkPluginConfig.java
+++ b/src/main/java/dinkplugin/DinkPluginConfig.java
@@ -134,6 +134,14 @@ public interface DinkPluginConfig extends Config {
     )
     String pkSection = "Player Kills";
 
+    @ConfigSection(
+        name = "Group Storage",
+        description = "Settings for notifying when you deposit or withdraw items from group ironman shared storage",
+        position = 140,
+        closedByDefault = true
+    )
+    String groupStorageSection = "Group Storage";
+
     @ConfigSection(
         name = "Advanced",
         description = "Do not modify without fully understanding these settings",
@@ -434,6 +442,17 @@ public interface DinkPluginConfig extends Config {
         return "";
     }
 
+    @ConfigItem(
+        keyName = "groupStorageWebhook",
+        name = "Group Storage Webhook Override",
+        description = "If non-empty, Group Storage messages are sent to this URL, instead of the primary URL",
+        position = -5,
+        section = webhookSection
+    )
+    default String groupStorageWebhook() {
+        return "";
+    }
+
     @ConfigItem(
         keyName = "collectionLogEnabled",
         name = "Enable collection log",
@@ -1331,4 +1350,51 @@ public interface DinkPluginConfig extends Config {
         return "%USERNAME% has PK'd %TARGET%";
     }
 
+    @ConfigItem(
+        keyName = "groupStorageEnabled",
+        name = "Enable Transactions",
+        description = "Enable notifications upon group storage transactions",
+        position = 140,
+        section = groupStorageSection
+    )
+    default boolean notifyGroupStorage() {
+        return false;
+    }
+
+    @ConfigItem(
+        keyName = "groupStorageSendImage",
+        name = "Send Image",
+        description = "Send image with the notification",
+        position = 141,
+        section = groupStorageSection
+    )
+    default boolean groupStorageSendImage() {
+        return true;
+    }
+
+    @ConfigItem(
+        keyName = "groupStorageMinValue",
+        name = "Min Value",
+        description = "The minimum value of the deposits or withdrawals to send a notification",
+        position = 142,
+        section = groupStorageSection
+    )
+    default int groupStorageMinValue() {
+        return 0;
+    }
+
+    @ConfigItem(
+        keyName = "groupStorageNotifyMessage",
+        name = "Notification Message",
+        description = "The message to be sent through the webhook.<br/>" +
+            "Use %USERNAME% to insert your username<br/>" +
+            "Use %DEPOSITED% to insert the list of deposited items<br/>" +
+            "Use %WITHDRAWN% to insert the list of withdrawn items",
+        position = 143,
+        section = groupStorageSection
+    )
+    default String groupStorageNotifyMessage() {
+        return "%USERNAME% has deposited:\n%DEPOSITED%\n\n%USERNAME% has withdrawn:\n%WITHDRAWN%";
+    }
+
 }
diff --git a/src/main/java/dinkplugin/message/NotificationType.java b/src/main/java/dinkplugin/message/NotificationType.java
index ca4acac..ab25516 100644
--- a/src/main/java/dinkplugin/message/NotificationType.java
+++ b/src/main/java/dinkplugin/message/NotificationType.java
@@ -21,7 +21,8 @@ public enum NotificationType {
     COMBAT_ACHIEVEMENT("Combat Achievement", "combatTaskImage.png", WIKI_IMG_BASE_URL + "Combat_achievements.png"),
     ACHIEVEMENT_DIARY("Achievement Diary", "achievementDiaryImage.png", WIKI_IMG_BASE_URL + "Achievement_Diaries_icon.png"),
     BARBARIAN_ASSAULT_GAMBLE("Barbarian Assault Gamble", "baGambleImage.png", WIKI_IMG_BASE_URL + "Barbarian_Assault_logo.jpg"),
-    PLAYER_KILL("Player Kill", "playerKillImage.png", WIKI_IMG_BASE_URL + "Skull_(status)_icon.png");
+    PLAYER_KILL("Player Kill", "playerKillImage.png", WIKI_IMG_BASE_URL + "Skull_(status)_icon.png"),
+    GROUP_STORAGE("Group Shared Storage", "groupStorage.png", WIKI_IMG_BASE_URL + "Coins_10000.png");
 
     private final String title;
 
diff --git a/src/main/java/dinkplugin/notifiers/GroupStorageNotifier.java b/src/main/java/dinkplugin/notifiers/GroupStorageNotifier.java
new file mode 100644
index 0000000..c19d45c
--- /dev/null
+++ b/src/main/java/dinkplugin/notifiers/GroupStorageNotifier.java
@@ -0,0 +1,264 @@
+package dinkplugin.notifiers;
+
+import dinkplugin.message.Field;
+import dinkplugin.message.NotificationBody;
+import dinkplugin.message.NotificationType;
+import dinkplugin.notifiers.data.GroupStorageNotificationData;
+import dinkplugin.notifiers.data.SerializedItemStack;
+import dinkplugin.util.ItemUtils;
+import net.runelite.api.InventoryID;
+import net.runelite.api.Item;
+import net.runelite.api.ItemContainer;
+import net.runelite.api.ItemID;
+import net.runelite.api.clan.ClanChannel;
+import net.runelite.api.clan.ClanID;
+import net.runelite.api.events.WidgetClosed;
+import net.runelite.api.events.WidgetLoaded;
+import net.runelite.api.widgets.Widget;
+import net.runelite.api.widgets.WidgetID;
+import net.runelite.client.callback.ClientThread;
+import net.runelite.client.game.ItemManager;
+import org.apache.commons.lang3.StringUtils;
+import org.jetbrains.annotations.VisibleForTesting;
+
+import javax.inject.Inject;
+import javax.inject.Singleton;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.function.BiFunction;
+import java.util.function.BinaryOperator;
+import java.util.stream.Collectors;
+
+/**
+ * Tracks when items are deposited or withdrawn to GIM shared storage.
+ * <p>
+ * This is achieved by comparing snapshots of the player's inventory
+ * when the storage is opened and when the transaction is saved.
+ * When the difference between these two snapshots is non-empty,
+ * we fire a notification (given the configured min value is satisfied).
+ */
+@Singleton
+public class GroupStorageNotifier extends BaseNotifier {
+
+    /**
+     * The Group ID corresponding to {@link net.runelite.api.widgets.WidgetInfo#GROUP_STORAGE_ITEM_CONTAINER}
+     * for tracking when GIM shared storage is opened.
+     */
+    static final @VisibleForTesting int GROUP_STORAGE_WIDGET_GROUP = WidgetID.GROUP_STORAGE_GROUP_ID;
+
+    /**
+     * The ID of the widget that appears over the chat box
+     * when the storage transaction is saved/committed.
+     */
+    static final @VisibleForTesting int GROUP_STORAGE_SAVING_WIDGET_ID = 293;
+
+    /**
+     * The message to indicate that a list of deposits or withdrawals is empty.
+     */
+    static final @VisibleForTesting String EMPTY_TRANSACTION = "N/A";
+
+    /**
+     * Adds two integers, but yields null if the sum is zero
+     * (which removes the entry via Map#merge in {@link GroupStorageNotifier#computeDifference(Map, Map)}).
+     */
+    private static final BinaryOperator<Integer> SUM;
+
+    @Inject
+    private ClientThread clientThread;
+
+    @Inject
+    private ItemManager itemManager;
+
+    /**
+     * Items in the player's inventory when the group storage was opened.
+     * Entries map item id to total quantity (across stacks).
+     */
+    private Map<Integer, Integer> initialInventory = Collections.emptyMap();
+
+    @Override
+    public boolean isEnabled() {
+        return config.notifyGroupStorage() && super.isEnabled();
+    }
+
+    @Override
+    protected String getWebhookUrl() {
+        return config.groupStorageWebhook();
+    }
+
+    public void reset() {
+        clientThread.invoke(() -> initialInventory = Collections.emptyMap());
+    }
+
+    public void onWidgetLoad(WidgetLoaded event) {
+        if (event.getGroupId() != GROUP_STORAGE_WIDGET_GROUP)
+            return;
+
+        clientThread.invokeLater(() -> {
+            ItemContainer inv = getInventory();
+            if (inv == null)
+                return false;
+
+            initialInventory = reduce(inv.getItems());
+            return true;
+        });
+    }
+
+    public void onWidgetClose(WidgetClosed event) {
+        if (event.getGroupId() != GROUP_STORAGE_SAVING_WIDGET_ID)
+            return;
+
+        Widget widget = client.getWidget(GROUP_STORAGE_SAVING_WIDGET_ID, 1);
+        if (widget == null)
+            return;
+
+        if (isEnabled() && StringUtils.containsIgnoreCase(widget.getText(), "Saving")) {
+            ItemContainer inv = getInventory();
+            if (inv != null) {
+                Map<Integer, Integer> updatedInventory = reduce(inv.getItems());
+                Map<Integer, Integer> delta = computeDifference(initialInventory, updatedInventory);
+                if (!delta.isEmpty()) {
+                    handleNotify(delta);
+                }
+            }
+        }
+
+        this.reset();
+    }
+
+    private void handleNotify(Map<Integer, Integer> inventoryChanges) {
+        // Calculate transaction information
+        List<SerializedItemStack> deposits = new ArrayList<>();
+        List<SerializedItemStack> withdrawals = new ArrayList<>();
+        long debits = 0, credits = 0;
+        for (Map.Entry<Integer, Integer> entry : inventoryChanges.entrySet()) {
+            int diff = entry.getValue(); // positive=withdraw, negative=deposit
+            SerializedItemStack item = ItemUtils.stackFromItem(itemManager, entry.getKey(), Math.abs(diff));
+            long stackPrice = item.getTotalPrice();
+            if (diff < 0) {
+                deposits.add(item);
+                debits += stackPrice;
+            } else {
+                withdrawals.add(item);
+                credits += stackPrice;
+            }
+        }
+        long netValue = debits - credits;
+
+        // Ensure transaction is large enough to be logged
+        if (debits < config.groupStorageMinValue() && credits < config.groupStorageMinValue())
+            return;
+
+        // Sort lists so more valuable item transactions are at the top
+        Comparator<SerializedItemStack> valuable = Comparator.comparingLong(SerializedItemStack::getTotalPrice).reversed();
+        deposits.sort(valuable);
+        withdrawals.sort(valuable);
+
+        // Convert lists to strings
+        BiFunction<Collection<SerializedItemStack>, String, String> formatItems = (items, linePrefix) -> {
+            if (items.isEmpty()) return EMPTY_TRANSACTION;
+            return items.stream()
+                .map(ItemUtils::formatStack)
+                .collect(Collectors.joining('\n' + linePrefix, linePrefix, ""));
+        };
+        String depositString = formatItems.apply(deposits, "+ ");
+        String withdrawalString = formatItems.apply(withdrawals, "- ");
+
+        // Build content
+        String playerName = client.getLocalPlayer().getName();
+        String content = StringUtils.replaceEach(config.groupStorageNotifyMessage(),
+            new String[] { "%USERNAME%", "%DEPOSITED%", "%WITHDRAWN%" },
+            new String[] { playerName, depositString, withdrawalString }
+        );
+        String formattedText = config.discordRichEmbeds() ? Field.formatBlock("diff", content) : content;
+
+        // Populate metadata
+        GroupStorageNotificationData extra = new GroupStorageNotificationData(
+            deposits,
+            withdrawals,
+            netValue,
+            getGroupName()
+        );
+
+        // Fire notification (delayed by a tick for screenshotHideChat reliability)
+        clientThread.invokeAtTickEnd(() -> createMessage(config.groupStorageSendImage(),
+            NotificationBody.builder()
+                .type(NotificationType.GROUP_STORAGE)
+                .text(formattedText)
+                .playerName(playerName)
+                .extra(extra)
+                .build())
+        );
+    }
+
+    /**
+     * @return the name of the ironman group
+     */
+    private String getGroupName() {
+        ClanChannel channel = client.getClanChannel(ClanID.GROUP_IRONMAN);
+        return channel != null ? channel.getName() : null;
+    }
+
+    /**
+     * Upon opening the group's shared storage, the inventory is replaced
+     * by a temporary container ({@link InventoryID#GROUP_STORAGE_INV}),
+     * which reflects intermediate changes before the transaction is committed.
+     * We prefer reading from this "fake" inventory when available.
+     * On storage open, it is a server-validated copy of the local inventory.
+     * On save, it has modifications that may not yet be reflected in the real inventory.
+     *
+     * @return the player's inventory
+     */
+    private ItemContainer getInventory() {
+        ItemContainer inv = client.getItemContainer(InventoryID.GROUP_STORAGE_INV);
+        return inv != null ? inv : client.getItemContainer(InventoryID.INVENTORY);
+    }
+
+    /**
+     * @param items array of items (e.g., in the player's inventory)
+     * @return mappings of item id to total quantity of the item (across stacks)
+     */
+    private Map<Integer, Integer> reduce(Item[] items) {
+        return Arrays.stream(items)
+            .filter(Objects::nonNull)
+            .filter(item -> item.getId() >= 0)
+            .filter(item -> item.getQuantity() > 0)
+            .collect(Collectors.toMap(this::getItemId, Item::getQuantity, Integer::sum));
+    }
+
+    /**
+     * @param item the item whose ID to query
+     * @return the canonical item ID
+     */
+    private int getItemId(Item item) {
+        int id = item.getId();
+        if (id == ItemID.COINS || id == ItemID.COINS_8890 || id == ItemID.COINS_6964)
+            return ItemID.COINS_995; // use single ID for all coins
+        return itemManager.canonicalize(id); // un-noted, un-placeholdered, un-worn
+    }
+
+    /**
+     * @param before the reduced item mappings when the group storage was first opened
+     * @param after  the reduced item mappings after the save operation
+     * @param <K>    generic key to merge the maps (always Integer in this notifier)
+     * @return mappings of item id to change in quantity, excluding items with no change
+     */
+    private static <K> Map<K, Integer> computeDifference(Map<K, Integer> before, Map<K, Integer> after) {
+        Map<K, Integer> delta = new HashMap<>(after);
+        before.forEach((id, quantity) -> delta.merge(id, -quantity, SUM));
+        return delta;
+    }
+
+    static {
+        SUM = (a, b) -> {
+            int sum = a + b;
+            return sum != 0 ? sum : null;
+        };
+    }
+}
diff --git a/src/main/java/dinkplugin/notifiers/data/GroupStorageNotificationData.java b/src/main/java/dinkplugin/notifiers/data/GroupStorageNotificationData.java
new file mode 100644
index 0000000..691a9aa
--- /dev/null
+++ b/src/main/java/dinkplugin/notifiers/data/GroupStorageNotificationData.java
@@ -0,0 +1,29 @@
+package dinkplugin.notifiers.data;
+
+import dinkplugin.message.Field;
+import dinkplugin.util.ItemUtils;
+import lombok.EqualsAndHashCode;
+import lombok.Value;
+import org.apache.commons.lang3.StringUtils;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+
+@Value
+@EqualsAndHashCode(callSuper = false)
+public class GroupStorageNotificationData extends NotificationData {
+    Collection<SerializedItemStack> deposits;
+    Collection<SerializedItemStack> withdrawals;
+    long netValue;
+    String groupName;
+
+    @Override
+    public List<Field> getFields() {
+        List<Field> fields = new ArrayList<>(2);
+        if (StringUtils.isNotBlank(groupName))
+            fields.add(new Field("Group", Field.formatBlock(null, groupName)));
+        fields.add(new Field("Net Value (GE)", ItemUtils.formatGold(netValue)));
+        return fields;
+    }
+}


Test patch:
diff --git a/src/test/java/dinkplugin/notifiers/GroupStorageNotifierTest.java b/src/test/java/dinkplugin/notifiers/GroupStorageNotifierTest.java
new file mode 100644
index 0000000..9dbf40c
--- /dev/null
+++ b/src/test/java/dinkplugin/notifiers/GroupStorageNotifierTest.java
@@ -0,0 +1,333 @@
+package dinkplugin.notifiers;
+
+import com.google.inject.testing.fieldbinder.Bind;
+import dinkplugin.message.NotificationBody;
+import dinkplugin.message.NotificationType;
+import dinkplugin.notifiers.data.GroupStorageNotificationData;
+import dinkplugin.notifiers.data.SerializedItemStack;
+import net.runelite.api.InventoryID;
+import net.runelite.api.Item;
+import net.runelite.api.ItemContainer;
+import net.runelite.api.ItemID;
+import net.runelite.api.clan.ClanChannel;
+import net.runelite.api.clan.ClanID;
+import net.runelite.api.events.WidgetClosed;
+import net.runelite.api.events.WidgetLoaded;
+import net.runelite.api.vars.AccountType;
+import net.runelite.api.widgets.Widget;
+import net.runelite.api.widgets.WidgetModalMode;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+
+import java.util.Arrays;
+import java.util.Collections;
+
+import static dinkplugin.notifiers.GroupStorageNotifier.EMPTY_TRANSACTION;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.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;
+
+class GroupStorageNotifierTest extends MockedNotifierTest {
+
+    private static final String GROUP_NAME = "Dink QA";
+    private static final int OPAL_PRICE = 600;
+    private static final int RUBY_PRICE = 900;
+    private static final int TUNA_PRICE = 100;
+    private static final WidgetLoaded LOAD_EVENT;
+    private static final WidgetClosed CLOSE_EVENT;
+
+    @Bind
+    @InjectMocks
+    GroupStorageNotifier notifier;
+
+    @Override
+    @BeforeEach
+    protected void setUp() {
+        super.setUp();
+
+        // init config mocks
+        when(config.discordRichEmbeds()).thenReturn(true);
+        when(config.notifyGroupStorage()).thenReturn(true);
+        when(config.groupStorageSendImage()).thenReturn(false);
+        when(config.groupStorageNotifyMessage())
+            .thenReturn("%USERNAME% has deposited:\n%DEPOSITED%\n\n%USERNAME% has withdrawn:\n%WITHDRAWN%");
+
+        // init item mocks
+        mockItem(ItemID.COINS_995, 1, "Coins");
+        mockItem(ItemID.OPAL, OPAL_PRICE, "Opal");
+        mockItem(ItemID.RUBY, RUBY_PRICE, "Ruby");
+        mockItem(ItemID.TUNA, TUNA_PRICE, "Tuna");
+        mockItem(ItemID.TUNA_26149, TUNA_PRICE, "Tuna");
+        when(itemManager.canonicalize(ItemID.TUNA_26149)).thenReturn(ItemID.TUNA);
+
+        // init group mock
+        when(client.getAccountType()).thenReturn(AccountType.HARDCORE_GROUP_IRONMAN);
+        ClanChannel channel = mock(ClanChannel.class);
+        when(channel.getName()).thenReturn(GROUP_NAME);
+        when(client.getClanChannel(ClanID.GROUP_IRONMAN)).thenReturn(channel);
+    }
+
+    @Test
+    void testNotify() {
+        // mock initial inventory state
+        Item[] initialItems = { new Item(ItemID.RUBY, 1), new Item(ItemID.TUNA, 1) };
+        mockContainer(initialItems);
+        notifier.onWidgetLoad(LOAD_EVENT);
+
+        // mock updated inventory
+        Item[] updatedItems = { new Item(ItemID.TUNA, 1), new Item(ItemID.OPAL, 1) };
+        mockContainer(updatedItems);
+
+        mockSaveWidget();
+        notifier.onWidgetClose(CLOSE_EVENT);
+
+        // verify notification message
+        GroupStorageNotificationData extra = new GroupStorageNotificationData(
+            Collections.singletonList(new SerializedItemStack(ItemID.RUBY, 1, RUBY_PRICE, "Ruby")),
+            Collections.singletonList(new SerializedItemStack(ItemID.OPAL, 1, OPAL_PRICE, "Opal")),
+            RUBY_PRICE - OPAL_PRICE,
+            GROUP_NAME
+        );
+
+        verifyNotification(extra, "+ 1 x Ruby (" + RUBY_PRICE + ")", "- 1 x Opal (" + OPAL_PRICE + ")");
+    }
+
+    @Test
+    void testNotifyCoins() {
+        // mock initial inventory state
+        Item[] initialItems = { new Item(ItemID.RUBY, 1), new Item(ItemID.COINS_6964, 100) };
+        mockContainer(initialItems);
+        notifier.onWidgetLoad(LOAD_EVENT);
+
+        // mock updated inventory
+        Item[] updatedItems = { new Item(ItemID.RUBY, 1), new Item(ItemID.COINS_8890, 1000) };
+        mockContainer(updatedItems);
+
+        mockSaveWidget();
+        notifier.onWidgetClose(CLOSE_EVENT);
+
+        // verify notification message
+        GroupStorageNotificationData extra = new GroupStorageNotificationData(
+            Collections.emptyList(),
+            Collections.singletonList(new SerializedItemStack(ItemID.COINS_995, 900, 1, "Coins")),
+            -900,
+            GROUP_NAME
+        );
+
+        verifyNotification(extra, EMPTY_TRANSACTION, "- 900 x Coins (900)");
+    }
+
+    @Test
+    void testNotifyStackable() {
+        // mock initial inventory state
+        Item[] initialItems = { new Item(ItemID.RUBY, 2), new Item(ItemID.COINS_6964, 100), new Item(ItemID.COINS_995, 150) };
+        mockContainer(initialItems);
+        notifier.onWidgetLoad(LOAD_EVENT);
+
+        // mock updated inventory
+        Item[] updatedItems = { new Item(ItemID.RUBY, 1), new Item(ItemID.RUBY, 1), new Item(ItemID.COINS_8890, 1000), new Item(ItemID.COINS, 50) };
+        mockContainer(updatedItems);
+
+        mockSaveWidget();
+        notifier.onWidgetClose(CLOSE_EVENT);
+
+        // verify notification message
+        GroupStorageNotificationData extra = new GroupStorageNotificationData(
+            Collections.emptyList(),
+            Collections.singletonList(new SerializedItemStack(ItemID.COINS_995, 800, 1, "Coins")),
+            -800,
+            GROUP_NAME
+        );
+
+        verifyNotification(extra, EMPTY_TRANSACTION, "- 800 x Coins (800)");
+    }
+
+    @Test
+    void testNotifyMultipleDebit() {
+        // mock initial inventory state
+        Item[] initialItems = { new Item(ItemID.RUBY, 1), new Item(ItemID.OPAL, 1) };
+        mockContainer(initialItems);
+        notifier.onWidgetLoad(LOAD_EVENT);
+
+        // mock updated inventory
+        mockContainer(new Item[0]);
+
+        mockSaveWidget();
+        notifier.onWidgetClose(CLOSE_EVENT);
+
+        // verify notification message
+        GroupStorageNotificationData extra = new GroupStorageNotificationData(
+            Arrays.asList(new SerializedItemStack(ItemID.RUBY, 1, RUBY_PRICE, "Ruby"), new SerializedItemStack(ItemID.OPAL, 1, OPAL_PRICE, "Opal")),
+            Collections.emptyList(),
+            RUBY_PRICE + OPAL_PRICE,
+            GROUP_NAME
+        );
+
+        verifyNotification(extra, "+ 1 x Ruby (" + RUBY_PRICE + ")\n+ 1 x Opal (" + OPAL_PRICE + ")", EMPTY_TRANSACTION);
+    }
+
+    @Test
+    void testNotifyMultipleCredit() {
+        // mock initial inventory state
+        mockContainer(new Item[0]);
+        notifier.onWidgetLoad(LOAD_EVENT);
+
+        // mock updated inventory
+        Item[] updatedItems = { new Item(ItemID.RUBY, 1), new Item(ItemID.OPAL, 1) };
+        mockContainer(updatedItems);
+
+        mockSaveWidget();
+        notifier.onWidgetClose(CLOSE_EVENT);
+
+        // verify notification message
+        GroupStorageNotificationData extra = new GroupStorageNotificationData(
+            Collections.emptyList(),
+            Arrays.asList(new SerializedItemStack(ItemID.RUBY, 1, RUBY_PRICE, "Ruby"), new SerializedItemStack(ItemID.OPAL, 1, OPAL_PRICE, "Opal")),
+            -(RUBY_PRICE + OPAL_PRICE),
+            GROUP_NAME
+        );
+
+        verifyNotification(extra, EMPTY_TRANSACTION, "- 1 x Ruby (" + RUBY_PRICE + ")\n- 1 x Opal (" + OPAL_PRICE + ")");
+    }
+
+    @Test
+    void testNotifyNoted() {
+        // mock initial inventory state
+        Item[] initialItems = { new Item(ItemID.TUNA, 2) };
+        mockContainer(initialItems);
+        notifier.onWidgetLoad(LOAD_EVENT);
+
+        // mock updated inventory
+        Item[] updatedItems = { new Item(ItemID.TUNA_26149, 3) };
+        mockContainer(updatedItems);
+
+        mockSaveWidget();
+        notifier.onWidgetClose(CLOSE_EVENT);
+
+        // verify notification message
+        GroupStorageNotificationData extra = new GroupStorageNotificationData(
+            Collections.emptyList(),
+            Collections.singletonList(new SerializedItemStack(ItemID.TUNA, 1, TUNA_PRICE, "Tuna")),
+            -TUNA_PRICE,
+            GROUP_NAME
+        );
+
+        verifyNotification(extra, EMPTY_TRANSACTION, "- 1 x Tuna (" + TUNA_PRICE + ")");
+    }
+
+    @Test
+    void testIgnoreNoted() {
+        // mock initial inventory state
+        Item[] initialItems = { new Item(ItemID.TUNA, 2) };
+        mockContainer(initialItems);
+        notifier.onWidgetLoad(LOAD_EVENT);
+
+        // mock updated inventory
+        Item[] updatedItems = { new Item(ItemID.TUNA_26149, 2) };
+        mockContainer(updatedItems);
+
+        mockSaveWidget();
+        notifier.onWidgetClose(CLOSE_EVENT);
+
+        // ensure no notification
+        verify(messageHandler, never()).createMessage(any(), anyBoolean(), any());
+    }
+
+    @Test
+    void testIgnoreValue() {
+        // update config mocks
+        when(config.groupStorageMinValue()).thenReturn(RUBY_PRICE + 1);
+
+        // mock initial inventory state
+        Item[] initialItems = { new Item(ItemID.RUBY, 1), new Item(ItemID.TUNA, 1) };
+        mockContainer(initialItems);
+        notifier.onWidgetLoad(LOAD_EVENT);
+
+        // mock updated inventory
+        Item[] updatedItems = { new Item(ItemID.TUNA, 1), new Item(ItemID.OPAL, 1) };
+        mockContainer(updatedItems);
+
+        mockSaveWidget();
+        notifier.onWidgetClose(CLOSE_EVENT);
+
+        // ensure no notification
+        verify(messageHandler, never()).createMessage(any(), anyBoolean(), any());
+    }
+
+    @Test
+    void testIgnoreZero() {
+        // mock initial inventory state
+        Item[] initialItems = { new Item(ItemID.RUBY, 1), new Item(ItemID.TUNA, 1) };
+        mockContainer(initialItems);
+        notifier.onWidgetLoad(LOAD_EVENT);
+
+        // mock updated inventory
+        Item[] updatedItems = { new Item(ItemID.TUNA, 1), new Item(ItemID.RUBY, 1) };
+        mockContainer(updatedItems);
+
+        mockSaveWidget();
+        notifier.onWidgetClose(CLOSE_EVENT);
+
+        // ensure no notification
+        verify(messageHandler, never()).createMessage(any(), anyBoolean(), any());
+    }
+
+    @Test
+    void testIgnore() {
+        // update mocks
+        when(config.notifyGroupStorage()).thenReturn(false);
+
+        // mock initial inventory state
+        Item[] initialItems = { new Item(ItemID.RUBY, 1), new Item(ItemID.TUNA, 1) };
+        mockContainer(initialItems);
+        notifier.onWidgetLoad(LOAD_EVENT);
+
+        // mock updated inventory
+        Item[] updatedItems = { new Item(ItemID.TUNA, 1), new Item(ItemID.OPAL, 1) };
+        mockContainer(updatedItems);
+        notifier.onWidgetClose(CLOSE_EVENT);
+
+        // ensure no notification
+        verify(messageHandler, never()).createMessage(any(), anyBoolean(), any());
+    }
+
+    private void verifyNotification(GroupStorageNotificationData extra, String deposited, String withdrawn) {
+        String text = String.format(
+            "```diff\n%s has deposited:\n%s\n\n%s has withdrawn:\n%s\n```",
+            PLAYER_NAME, deposited, PLAYER_NAME, withdrawn
+        );
+
+        verify(messageHandler).createMessage(
+            PRIMARY_WEBHOOK_URL,
+            false,
+            NotificationBody.builder()
+                .text(text)
+                .extra(extra)
+                .type(NotificationType.GROUP_STORAGE)
+                .playerName(PLAYER_NAME)
+                .build()
+        );
+    }
+
+    private void mockContainer(Item[] items) {
+        ItemContainer container = mock(ItemContainer.class);
+        when(container.getItems()).thenReturn(items);
+        when(client.getItemContainer(InventoryID.GROUP_STORAGE_INV)).thenReturn(container);
+    }
+
+    private void mockSaveWidget() {
+        Widget widget = mock(Widget.class);
+        when(client.getWidget(GroupStorageNotifier.GROUP_STORAGE_SAVING_WIDGET_ID, 1)).thenReturn(widget);
+        when(widget.getText()).thenReturn("Saving...");
+    }
+
+    static {
+        LOAD_EVENT = new WidgetLoaded();
+        LOAD_EVENT.setGroupId(GroupStorageNotifier.GROUP_STORAGE_WIDGET_GROUP);
+        CLOSE_EVENT = new WidgetClosed(GroupStorageNotifier.GROUP_STORAGE_SAVING_WIDGET_ID, WidgetModalMode.MODAL_NOCLICKTHROUGH, true);
+    }
+}
diff --git a/src/test/java/dinkplugin/notifiers/MockedNotifierTest.java b/src/test/java/dinkplugin/notifiers/MockedNotifierTest.java
index 6a1b893..87fa8e6 100644
--- a/src/test/java/dinkplugin/notifiers/MockedNotifierTest.java
+++ b/src/test/java/dinkplugin/notifiers/MockedNotifierTest.java
@@ -131,6 +131,7 @@ abstract class MockedNotifierTest extends MockedTestBase {
         ItemComposition item = mock(ItemComposition.class);
         when(item.getName()).thenReturn(name);
         when(itemManager.getItemComposition(id)).thenReturn(item);
+        when(itemManager.canonicalize(id)).thenReturn(id);
     }
 
 }