Repository: pajlads/DinkPlugin (java)

Issue:
Feature request: Make generic achievement metadata more accesible
I am currently working on making an OSRS 2011-style adventurer's log (see pic). It is using Dink as it's engine for pushing log data (admittedly not the intended usecase). I chose Dink because it is avaiable in both HDOS as well as Runelite.

![firefox_F7NBRXQF7b](https://github.com/MidgetJake/UniversalDiscordNotifier/assets/18210607/7a8182a2-007d-4f57-9967-7835b9b12249)

repo: https://github.com/J3sven/OSRS-alog

One issue I am facing with Dink right now is that the (combat) achievement, Quest point and Collectionlog counters do no update until a relevant event has occured. A method of accessing this data more readily would be much appreciated, especially considering accounts with any of these stats maxed would never see an update until their cap is raised.

Some suggestions:

- Simply include the data in every webhook request (perhaps a bit overkill)
-  A generic log in/log out notification that lists the user's stats and achievement data. This could be something desirable for general users too as a reference of what progress they made that day.

Pull request description:
feat: add login notification for custom webhook handlers
Closes #320 

Only notifies upon login (can be expanded in future PRs)


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 897df1c..860071b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,6 @@
 ## Unreleased
 
+- Minor: Send character information on login to custom handlers via advanced config. (#321)
 - Minor: Add key in the Speedrun notifier extra object for whether the run is a personal best or not. (#329)
 - Minor: Indicate in pet notification metadata when a pet was previously owned but lost. (#314)
 - Minor: Support wildcards in loot item name filters. (#312)
diff --git a/README.md b/README.md
index f5a8304..71f06d5 100644
--- a/README.md
+++ b/README.md
@@ -61,6 +61,7 @@ Some notifiers require in-game settings to be configured to send chat messages u
 - Discord rich embed footers can be customized with user-specified text and image url
 - When network issues occur, Dink can make repeated attempts to send the webhook (with exponential backoff)
 - Notifications can be sent to [Discord Forum Channels](https://support.discord.com/hc/en-us/articles/6208479917079-Forum-Channels-FAQ); append `?forum` to the end of the webhook url to create a new thread per message or use `?thread_id=123456` to post to an existing forum thread (be sure to change `123456` with the actual thread ID)
+- Character [metadata](#metadata) can be sent to custom webhook handlers on login for tracking relevant statistics.
 
 ## Chat Commands
 
@@ -746,6 +747,90 @@ See [javadocs](https://static.runelite.net/api/runelite-api/net/runelite/api/Gra
 
 </details>
 
+### Metadata:
+
+On login, Dink can submit a character summary containing data that spans multiple notifiers to a custom webhook handler (configurable in the `Advanced` section). This login notification is delayed by at least 5 seconds in order to gather all of the relevant data. However, `collectionLog` data can be missing if the user does not have the Character Summary tab selected (since the client otherwise is not sent that data).
+
+<details>
+  <summary>JSON for Login Notifications:</summary>
+
+```json5
+{
+  "content": "%USERNAME% logged into World %WORLD%",
+  "type": "LOGIN",
+  "playerName": "%USERNAME%",
+  "accountType": "NORMAL",
+  "clanName": "Dink QA",
+  "extra": {
+    "world": 338,
+    "collectionLog": {
+      "completed": 651,
+      "total": 1477
+    },
+    "combatAchievementPoints": {
+      "completed": 503,
+      "total": 2005
+    },
+    "achievementDiary": {
+      "completed": 42,
+      "total": 48
+    },
+    "achievementDiaryTasks": {
+      "completed": 477,
+      "total": 492
+    },
+    "barbarianAssault": {
+      "highGambleCount": 0
+    },
+    "skills": {
+      "totalExperience": 337810454,
+      "totalLevel": 2164,
+      "levels": {
+        "Hunter": 90,
+        "Thieving": 86,
+        "Runecraft": 86,
+        "Construction": 86,
+        "Cooking": 103,
+        "Magic": 106,
+        "Fletching": 99,
+        "Herblore": 91,
+        "Firemaking": 100,
+        "Attack": 107,
+        "Fishing": 92,
+        "Crafting": 96,
+        "Hitpoints": 111,
+        "Ranged": 110,
+        "Mining": 88,
+        "Smithing": 91,
+        "Agility": 82,
+        "Woodcutting": 96,
+        "Slayer": 104,
+        "Defence": 103,
+        "Strength": 104,
+        "Prayer": 91,
+        "Farming": 100
+      }
+    },
+    "questCount": {
+      "completed": 156,
+      "total": 158
+    },
+    "questPoints": {
+      "completed": 296,
+      "total": 300
+    },
+    "slayer": {
+      "points": 2204,
+      "streak": 1074
+    }
+  }
+}
+```
+
+Note: `clanName` requires `Advanced > Send Clan Name` to be enabled (default: on). The `groupIronClanName` and `discordUser` fields also have similar toggles in the Advanced config section.
+
+</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 c054c53..36237d7 100644
--- a/src/main/java/dinkplugin/DinkPlugin.java
+++ b/src/main/java/dinkplugin/DinkPlugin.java
@@ -11,6 +11,7 @@ import dinkplugin.notifiers.GrandExchangeNotifier;
 import dinkplugin.notifiers.GroupStorageNotifier;
 import dinkplugin.notifiers.KillCountNotifier;
 import dinkplugin.notifiers.LevelNotifier;
+import dinkplugin.notifiers.MetaNotifier;
 import dinkplugin.notifiers.LootNotifier;
 import dinkplugin.notifiers.PetNotifier;
 import dinkplugin.notifiers.PlayerKillNotifier;
@@ -81,6 +82,7 @@ public class DinkPlugin extends Plugin {
     private @Inject PlayerKillNotifier pkNotifier;
     private @Inject GroupStorageNotifier groupStorageNotifier;
     private @Inject GrandExchangeNotifier grandExchangeNotifier;
+    private @Inject MetaNotifier metaNotifier;
 
     @Override
     protected void startUp() {
@@ -146,6 +148,7 @@ public class DinkPlugin extends Plugin {
         levelNotifier.onGameStateChanged(gameStateChanged);
         diaryNotifier.onGameState(gameStateChanged);
         grandExchangeNotifier.onGameStateChange(gameStateChanged);
+        metaNotifier.onGameState(gameStateChanged);
     }
 
     @Subscribe
@@ -166,6 +169,7 @@ public class DinkPlugin extends Plugin {
         killCountNotifier.onTick();
         pkNotifier.onTick();
         grandExchangeNotifier.onTick();
+        metaNotifier.onTick();
     }
 
     @Subscribe
diff --git a/src/main/java/dinkplugin/DinkPluginConfig.java b/src/main/java/dinkplugin/DinkPluginConfig.java
index 6ed2d12..ae6c581 100644
--- a/src/main/java/dinkplugin/DinkPluginConfig.java
+++ b/src/main/java/dinkplugin/DinkPluginConfig.java
@@ -335,6 +335,19 @@ public interface DinkPluginConfig extends Config {
         return "[%TYPE%] %MESSAGE%";
     }
 
+    @ConfigItem(
+        keyName = "metadataWebhook",
+        name = "Custom Metadata Handler",
+        description = "Webhook URL for custom handlers to receive regular information about the player.<br/>" +
+            "Not recommended for use with Discord webhooks, as it could cause spam.<br/>" +
+            "You can target multiple webhooks by specifying their URLs on separate lines",
+        position = 1014,
+        section = advancedSection
+    )
+    default String metadataWebhook() {
+        return "";
+    }
+
     @ConfigItem(
         keyName = "discordWebhook", // do not rename; would break old configs
         name = "Primary Webhook URLs",
diff --git a/src/main/java/dinkplugin/SettingsManager.java b/src/main/java/dinkplugin/SettingsManager.java
index c52172a..4ca9842 100644
--- a/src/main/java/dinkplugin/SettingsManager.java
+++ b/src/main/java/dinkplugin/SettingsManager.java
@@ -114,6 +114,7 @@ public class SettingsManager {
         webhookConfigKeys = ImmutableSet.<String>builder()
             .add("discordWebhook") // DinkPluginConfig#primaryWebhook
             .addAll(keysBySection.getOrDefault(DinkPluginConfig.webhookSection.toLowerCase().replace(" ", ""), Collections.emptySet()))
+            .add("metadataWebhook") // MetaNotifier's configuration is in the Advanced section
             .build();
     }
 
diff --git a/src/main/java/dinkplugin/message/DiscordMessageHandler.java b/src/main/java/dinkplugin/message/DiscordMessageHandler.java
index 604fb73..e6d079e 100644
--- a/src/main/java/dinkplugin/message/DiscordMessageHandler.java
+++ b/src/main/java/dinkplugin/message/DiscordMessageHandler.java
@@ -49,6 +49,7 @@ import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.CompletionException;
 import java.util.concurrent.ScheduledExecutorService;
@@ -99,6 +100,7 @@ public class DiscordMessageHandler {
         Collection<HttpUrl> urlList = Arrays.stream(StringUtils.split(webhookUrl, '\n'))
             .filter(StringUtils::isNotBlank)
             .map(HttpUrl::parse)
+            .filter(Objects::nonNull)
             .collect(Collectors.toList());
         if (urlList.isEmpty()) return;
 
diff --git a/src/main/java/dinkplugin/message/NotificationType.java b/src/main/java/dinkplugin/message/NotificationType.java
index 502d6b2..dfd949a 100644
--- a/src/main/java/dinkplugin/message/NotificationType.java
+++ b/src/main/java/dinkplugin/message/NotificationType.java
@@ -23,7 +23,8 @@ public enum NotificationType {
     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"),
     GROUP_STORAGE("Group Shared Storage", "groupStorage.png", WIKI_IMG_BASE_URL + "Coins_10000.png"),
-    GRAND_EXCHANGE("Grand Exchange", "grandExchange.png", WIKI_IMG_BASE_URL + "Grand_Exchange_icon.png");
+    GRAND_EXCHANGE("Grand Exchange", "grandExchange.png", WIKI_IMG_BASE_URL + "Grand_Exchange_icon.png"),
+    LOGIN("Player Login", "login.png", WIKI_IMG_BASE_URL + "Prop_sword.png");
 
     private final String title;
 
@@ -36,4 +37,5 @@ public enum NotificationType {
      * Link to the notifier icon
      */
     private final String thumbnail;
+
 }
diff --git a/src/main/java/dinkplugin/notifiers/CollectionNotifier.java b/src/main/java/dinkplugin/notifiers/CollectionNotifier.java
index 479415a..cca81f0 100644
--- a/src/main/java/dinkplugin/notifiers/CollectionNotifier.java
+++ b/src/main/java/dinkplugin/notifiers/CollectionNotifier.java
@@ -19,7 +19,6 @@ import net.runelite.api.events.GameStateChanged;
 import net.runelite.api.events.VarbitChanged;
 import net.runelite.client.callback.ClientThread;
 import net.runelite.client.game.ItemManager;
-import org.jetbrains.annotations.VisibleForTesting;
 
 import javax.inject.Inject;
 import java.util.concurrent.atomic.AtomicBoolean;
@@ -36,8 +35,7 @@ public class CollectionNotifier extends BaseNotifier {
     /*
      * https://github.com/Joshua-F/cs2-scripts/blob/master/scripts/%5Bclientscript,collection_init_frame%5D.cs2#L3
      */
-    @VisibleForTesting
-    static final @Varp int COMPLETED_VARP = 2943, TOTAL_VARP = 2944;
+    public static final @Varp int COMPLETED_VARP = 2943, TOTAL_VARP = 2944;
 
     /**
      * The number of completed entries in the collection log, as implied by {@link #COMPLETED_VARP}.
diff --git a/src/main/java/dinkplugin/notifiers/CombatTaskNotifier.java b/src/main/java/dinkplugin/notifiers/CombatTaskNotifier.java
index 32cd787..b0186b1 100644
--- a/src/main/java/dinkplugin/notifiers/CombatTaskNotifier.java
+++ b/src/main/java/dinkplugin/notifiers/CombatTaskNotifier.java
@@ -33,9 +33,11 @@ public class CombatTaskNotifier extends BaseNotifier {
      * @see <a href="https://github.com/Joshua-F/cs2-scripts/blob/master/scripts/%5Bproc,ca_tasks_progress_bar%5D.cs2#L58">CS2 Reference</a>
      */
     @Varbit
-    @VisibleForTesting
     public static final int TOTAL_POINTS_ID = 14815;
 
+    @Varbit
+    public static final int GRANDMASTER_TOTAL_POINTS_ID = 14814;
+
     /**
      * @see <a href="https://github.com/Joshua-F/cs2-scripts/blob/master/scripts/%5Bproc,ca_tasks_progress_bar%5D.cs2#L6-L11">CS2 Reference</a>
      */
@@ -150,7 +152,7 @@ public class CombatTaskNotifier extends BaseNotifier {
             .put(CombatAchievementTier.HARD, 10661) // 304 = 115 + 63 * 3
             .put(CombatAchievementTier.ELITE, 14812) // 820 = 304 + 129 * 4
             .put(CombatAchievementTier.MASTER, 14813) // 1465 = 820 + 129 * 5
-            .put(CombatAchievementTier.GRANDMASTER, 14814) // 2005 = 1465 + 90 * 6
+            .put(CombatAchievementTier.GRANDMASTER, GRANDMASTER_TOTAL_POINTS_ID) // 2005 = 1465 + 90 * 6
             .build();
     }
 }
diff --git a/src/main/java/dinkplugin/notifiers/DiaryNotifier.java b/src/main/java/dinkplugin/notifiers/DiaryNotifier.java
index ed5e56f..9af80bd 100644
--- a/src/main/java/dinkplugin/notifiers/DiaryNotifier.java
+++ b/src/main/java/dinkplugin/notifiers/DiaryNotifier.java
@@ -26,6 +26,17 @@ import static dinkplugin.domain.AchievementDiary.DIARIES;
 @Slf4j
 @Singleton
 public class DiaryNotifier extends BaseNotifier {
+
+    /**
+     * @see <a href="https://github.com/Joshua-F/cs2-scripts/blob/master/scripts/%5Bproc,script3971%5D.cs2">CS2 Reference</a>
+     */
+    static final int COMPLETED_TASKS_SCRIPT_ID = 3971;
+
+    /**
+     * @see <a href="https://github.com/Joshua-F/cs2-scripts/blob/master/scripts/%5Bproc,script3980%5D.cs2">CS2 Reference</a>
+     */
+    static final int TOTAL_TASKS_SCRIPT_ID = 3980;
+
     private static final Pattern COMPLETION_REGEX = Pattern.compile("Congratulations! You have completed all of the (?<difficulty>.+) tasks in the (?<area>.+) area");
     private final Map<Integer, Integer> diaryCompletionById = new ConcurrentHashMap<>();
     private final AtomicInteger initDelayTicks = new AtomicInteger();
@@ -196,7 +207,7 @@ public class DiaryNotifier extends BaseNotifier {
         log.debug("Finished initializing current diary completions: {} out of {}", getTotalCompleted(), diaryCompletionById.size());
     }
 
-    private static boolean isComplete(int id, int value) {
+    public static boolean isComplete(int id, int value) {
         if (id == 3578 || id == 3599 || id == 3611) {
             // Karamja special case (except Elite): 0 = not started, 1 = started, 2 = completed tasks
             return value > 1;
diff --git a/src/main/java/dinkplugin/notifiers/MetaNotifier.java b/src/main/java/dinkplugin/notifiers/MetaNotifier.java
new file mode 100644
index 0000000..6d51781
--- /dev/null
+++ b/src/main/java/dinkplugin/notifiers/MetaNotifier.java
@@ -0,0 +1,133 @@
+package dinkplugin.notifiers;
+
+import dinkplugin.domain.AchievementDiary;
+import dinkplugin.message.NotificationBody;
+import dinkplugin.message.NotificationType;
+import dinkplugin.message.templating.Replacements;
+import dinkplugin.message.templating.Template;
+import dinkplugin.notifiers.data.LoginNotificationData;
+import dinkplugin.notifiers.data.Progress;
+import dinkplugin.util.Utils;
+import net.runelite.api.Experience;
+import net.runelite.api.GameState;
+import net.runelite.api.Skill;
+import net.runelite.api.VarPlayer;
+import net.runelite.api.Varbits;
+import net.runelite.api.events.GameStateChanged;
+import net.runelite.client.callback.ClientThread;
+import org.apache.commons.lang3.StringUtils;
+import org.jetbrains.annotations.VisibleForTesting;
+
+import javax.inject.Inject;
+import javax.inject.Singleton;
+import java.util.Arrays;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.stream.Collectors;
+
+@Singleton
+public class MetaNotifier extends BaseNotifier {
+    static final @VisibleForTesting int INIT_TICKS = 10; // 6 seconds after login
+
+    private final AtomicInteger loginTicks = new AtomicInteger(-1);
+    private final AtomicReference<GameState> gameState = new AtomicReference<>();
+
+    @Inject
+    private ClientThread clientThread;
+
+    @Override
+    public boolean isEnabled() {
+        return StringUtils.isNotBlank(config.metadataWebhook()) && super.isEnabled();
+    }
+
+    @Override
+    protected String getWebhookUrl() {
+        return config.metadataWebhook();
+    }
+
+    public void onGameState(GameStateChanged event) {
+        GameState newState = event.getGameState();
+        if (newState == GameState.LOADING) {
+            // ignore this intermediate state
+            return;
+        }
+        GameState oldState = gameState.getAndSet(newState);
+        if (oldState == GameState.LOGGING_IN && newState == GameState.LOGGED_IN) {
+            loginTicks.set(INIT_TICKS);
+        }
+    }
+
+    public void onTick() {
+        if (loginTicks.getAndUpdate(i -> Math.max(-1, i - 1)) == 0 && isEnabled()) {
+            clientThread.invokeLater(this::notifyLogin); // just 20ms later to be able to run client scripts cleanly
+        }
+    }
+
+    private void notifyLogin() {
+        // Gather data points
+        int world = client.getWorld();
+
+        int collectionCompleted = client.getVarpValue(CollectionNotifier.COMPLETED_VARP);
+        int collectionTotal = client.getVarpValue(CollectionNotifier.TOTAL_VARP);
+
+        int combatAchievementPoints = client.getVarbitValue(CombatTaskNotifier.TOTAL_POINTS_ID);
+        int combatAchievementPointsTotal = client.getVarbitValue(CombatTaskNotifier.GRANDMASTER_TOTAL_POINTS_ID);
+
+        int diaryCompleted = AchievementDiary.DIARIES.keySet()
+            .stream()
+            .mapToInt(id -> DiaryNotifier.isComplete(id, client.getVarbitValue(id)) ? 1 : 0)
+            .sum();
+        int diaryTotal = AchievementDiary.DIARIES.size();
+        client.runScript(DiaryNotifier.COMPLETED_TASKS_SCRIPT_ID);
+        int diaryTaskCompleted = client.getIntStack()[0];
+        client.runScript(DiaryNotifier.TOTAL_TASKS_SCRIPT_ID);
+        int diaryTaskTotal = client.getIntStack()[0];
+
+        int gambleCount = client.getVarbitValue(Varbits.BA_GC);
+
+        long experienceTotal = client.getOverallExperience();
+        int levelTotal = client.getTotalLevel();
+        Map<String, Integer> skillLevels = Arrays.stream(Skill.values()).collect(Collectors.toMap(Skill::getName, skill -> {
+            int lvl = client.getRealSkillLevel(skill);
+            return lvl < 99 ? lvl : Experience.getLevelForXp(client.getSkillExperience(skill));
+        }));
+
+        int questsCompleted = client.getVarbitValue(QuestNotifier.COMPLETED_ID);
+        int questsTotal = client.getVarbitValue(QuestNotifier.TOTAL_ID);
+        int questPoints = client.getVarpValue(VarPlayer.QUEST_POINTS);
+        int questPointsTotal = client.getVarbitValue(QuestNotifier.QP_TOTAL_ID);
+
+        int slayerPoints = client.getVarbitValue(Varbits.SLAYER_POINTS);
+        int slayerStreak = client.getVarbitValue(Varbits.SLAYER_TASK_STREAK);
+
+        // Fire notification
+        String playerName = Utils.getPlayerName(client);
+        Template message = Template.builder()
+            .replacementBoundary("%")
+            .template("%USERNAME% logged into World %WORLD%")
+            .replacement("%USERNAME%", Replacements.ofText(playerName))
+            .replacement("%WORLD%", Replacements.ofText(String.valueOf(world)))
+            .build();
+        LoginNotificationData extra = new LoginNotificationData(
+            world,
+            Progress.of(collectionCompleted, collectionTotal),
+            Progress.of(combatAchievementPoints, combatAchievementPointsTotal),
+            Progress.of(diaryCompleted, diaryTotal),
+            Progress.of(diaryTaskCompleted, diaryTaskTotal),
+            new LoginNotificationData.BarbarianAssault(gambleCount),
+            new LoginNotificationData.SkillData(experienceTotal, levelTotal, skillLevels),
+            Progress.of(questsCompleted, questsTotal),
+            Progress.of(questPoints, questPointsTotal),
+            new LoginNotificationData.SlayerData(slayerPoints, slayerStreak)
+        );
+        createMessage(false, NotificationBody.builder()
+            .type(NotificationType.LOGIN)
+            .text(message)
+            .extra(extra)
+            .playerName(playerName)
+            .build()
+        );
+    }
+
+}
diff --git a/src/main/java/dinkplugin/notifiers/QuestNotifier.java b/src/main/java/dinkplugin/notifiers/QuestNotifier.java
index 804d1f9..43a4a30 100644
--- a/src/main/java/dinkplugin/notifiers/QuestNotifier.java
+++ b/src/main/java/dinkplugin/notifiers/QuestNotifier.java
@@ -25,8 +25,7 @@ public class QuestNotifier extends BaseNotifier {
      * https://github.com/Joshua-F/cs2-scripts/blob/master/scripts/%5Bproc,questlist_completed%5D.cs2#L5
      */
     @Varbit
-    @VisibleForTesting
-    static final int COMPLETED_ID = 6347, TOTAL_ID = 11877;
+    public static final int COMPLETED_ID = 6347, TOTAL_ID = 11877;
 
     /*
      * https://github.com/Joshua-F/cs2-scripts/blob/master/scripts/%5Bproc,questlist_qp%5D.cs2#L5
diff --git a/src/main/java/dinkplugin/notifiers/data/LoginNotificationData.java b/src/main/java/dinkplugin/notifiers/data/LoginNotificationData.java
new file mode 100644
index 0000000..7ad9ed8
--- /dev/null
+++ b/src/main/java/dinkplugin/notifiers/data/LoginNotificationData.java
@@ -0,0 +1,42 @@
+package dinkplugin.notifiers.data;
+
+import lombok.EqualsAndHashCode;
+import lombok.Value;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.Map;
+
+@Value
+@EqualsAndHashCode(callSuper = false)
+public class LoginNotificationData extends NotificationData {
+    int world;
+
+    @Nullable // requires QUEST_TAB==0; i.e., character summary is selected
+    Progress collectionLog;
+    Progress combatAchievementPoints;
+    Progress achievementDiary;
+    Progress achievementDiaryTasks;
+    BarbarianAssault barbarianAssault;
+    SkillData skills;
+    Progress questCount;
+    Progress questPoints;
+    SlayerData slayer;
+
+    @Value
+    public static class SkillData {
+        long totalExperience;
+        int totalLevel;
+        Map<String, Integer> levels;
+    }
+
+    @Value
+    public static class BarbarianAssault {
+        int highGambleCount;
+    }
+
+    @Value
+    public static class SlayerData {
+        int points;
+        int streak;
+    }
+}
diff --git a/src/main/java/dinkplugin/notifiers/data/Progress.java b/src/main/java/dinkplugin/notifiers/data/Progress.java
new file mode 100644
index 0000000..81feba8
--- /dev/null
+++ b/src/main/java/dinkplugin/notifiers/data/Progress.java
@@ -0,0 +1,14 @@
+package dinkplugin.notifiers.data;
+
+import lombok.Value;
+
+@Value
+public class Progress {
+    int completed;
+    int total;
+
+    public static Progress of(int completed, int total) {
+        if (total <= 0 || completed < 0) return null;
+        return new Progress(completed, total);
+    }
+}


Test patch:
diff --git a/src/test/java/dinkplugin/notifiers/MetaNotifierTest.java b/src/test/java/dinkplugin/notifiers/MetaNotifierTest.java
new file mode 100644
index 0000000..9631215
--- /dev/null
+++ b/src/test/java/dinkplugin/notifiers/MetaNotifierTest.java
@@ -0,0 +1,171 @@
+package dinkplugin.notifiers;
+
+import com.google.inject.testing.fieldbinder.Bind;
+import dinkplugin.message.NotificationBody;
+import dinkplugin.message.NotificationType;
+import dinkplugin.notifiers.data.LoginNotificationData;
+import dinkplugin.notifiers.data.Progress;
+import net.runelite.api.Experience;
+import net.runelite.api.GameState;
+import net.runelite.api.Skill;
+import net.runelite.api.VarPlayer;
+import net.runelite.api.Varbits;
+import net.runelite.api.events.GameStateChanged;
+import org.apache.commons.lang3.StringUtils;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+
+import java.util.Arrays;
+import java.util.Map;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class MetaNotifierTest extends MockedNotifierTest {
+
+    @Bind
+    @InjectMocks
+    MetaNotifier notifier;
+
+    int world = 420;
+    int level = 50;
+    long xp = Experience.getXpForLevel(level);
+    int skillCount = Skill.values().length;
+    String url = StringUtils.isNotBlank(PRIMARY_WEBHOOK_URL) ? PRIMARY_WEBHOOK_URL : "https://example.com";
+
+    @Override
+    @BeforeEach
+    protected void setUp() {
+        super.setUp();
+
+        // update config mocks
+        when(config.metadataWebhook()).thenReturn(url);
+
+        // update client mocks
+        when(client.getWorld()).thenReturn(world);
+
+        when(client.getVarpValue(CollectionNotifier.COMPLETED_VARP)).thenReturn(1312);
+        when(client.getVarpValue(CollectionNotifier.TOTAL_VARP)).thenReturn(1477);
+
+        when(client.getVarbitValue(CombatTaskNotifier.TOTAL_POINTS_ID)).thenReturn(1984);
+        when(client.getVarbitValue(CombatTaskNotifier.GRANDMASTER_TOTAL_POINTS_ID)).thenReturn(2005);
+
+        when(client.getVarbitValue(Varbits.DIARY_FALADOR_EASY)).thenReturn(1);
+        when(client.getVarbitValue(Varbits.DIARY_VARROCK_EASY)).thenReturn(1);
+        when(client.getVarbitValue(Varbits.DIARY_WILDERNESS_EASY)).thenReturn(1);
+
+        when(client.getVarbitValue(Varbits.BA_GC)).thenReturn(666);
+
+        when(client.getRealSkillLevel(any())).thenReturn(level);
+        when(client.getTotalLevel()).thenReturn(skillCount * level);
+        when(client.getOverallExperience()).thenReturn(skillCount * xp);
+
+        when(client.getVarbitValue(QuestNotifier.COMPLETED_ID)).thenReturn(21);
+        when(client.getVarbitValue(QuestNotifier.TOTAL_ID)).thenReturn(158);
+        when(client.getVarpValue(VarPlayer.QUEST_POINTS)).thenReturn(43);
+        when(client.getVarbitValue(QuestNotifier.QP_TOTAL_ID)).thenReturn(300);
+
+        when(client.getVarbitValue(Varbits.SLAYER_POINTS)).thenReturn(2484);
+        when(client.getVarbitValue(Varbits.SLAYER_TASK_STREAK)).thenReturn(300);
+
+        // too lazy to mock script results, just return zero so excluded from serialization in tests
+        when(client.getIntStack()).thenReturn(new int[1]);
+    }
+
+    @Test
+    void testNotify() {
+        // fire event
+        notifier.onGameState(event(GameState.LOGGING_IN));
+        notifier.onGameState(event(GameState.LOADING));
+        notifier.onGameState(event(GameState.LOGGED_IN));
+        IntStream.rangeClosed(0, MetaNotifier.INIT_TICKS).forEach(i -> notifier.onTick());
+
+        // verify handled
+        Map<String, Integer> levels = Arrays.stream(Skill.values())
+            .collect(Collectors.toMap(Skill::getName, s -> level));
+        LoginNotificationData extra = new LoginNotificationData(world,
+            Progress.of(1312, 1477),
+            Progress.of(1984, 2005),
+            Progress.of(3, 48),
+            null,
+            new LoginNotificationData.BarbarianAssault(666),
+            new LoginNotificationData.SkillData(xp * skillCount, level * skillCount, levels),
+            Progress.of(21, 158), Progress.of(43, 300),
+            new LoginNotificationData.SlayerData(2484, 300)
+        );
+        verify(messageHandler).createMessage(
+            url,
+            false,
+            NotificationBody.builder()
+                .extra(extra)
+                .text(buildTemplate(PLAYER_NAME + " logged into World " + world))
+                .type(NotificationType.LOGIN)
+                .playerName(PLAYER_NAME)
+                .build()
+        );
+    }
+
+    @Test
+    void testNotifyWithoutCollection() {
+        // update client mock
+        when(client.getVarpValue(CollectionNotifier.COMPLETED_VARP)).thenReturn(0);
+        when(client.getVarpValue(CollectionNotifier.TOTAL_VARP)).thenReturn(0);
+
+        // fire events
+        notifier.onGameState(event(GameState.LOGGING_IN));
+        notifier.onGameState(event(GameState.LOADING));
+        notifier.onGameState(event(GameState.LOGGED_IN));
+        IntStream.rangeClosed(0, MetaNotifier.INIT_TICKS).forEach(i -> notifier.onTick());
+
+        // verify handled
+        Map<String, Integer> levels = Arrays.stream(Skill.values())
+            .collect(Collectors.toMap(Skill::getName, s -> level));
+        LoginNotificationData extra = new LoginNotificationData(world,
+            null, // collection log data should not be present
+            Progress.of(1984, 2005),
+            Progress.of(3, 48),
+            null,
+            new LoginNotificationData.BarbarianAssault(666),
+            new LoginNotificationData.SkillData(xp * skillCount, level * skillCount, levels),
+            Progress.of(21, 158), Progress.of(43, 300),
+            new LoginNotificationData.SlayerData(2484, 300)
+        );
+        verify(messageHandler).createMessage(
+            url,
+            false,
+            NotificationBody.builder()
+                .extra(extra)
+                .text(buildTemplate(PLAYER_NAME + " logged into World " + world))
+                .type(NotificationType.LOGIN)
+                .playerName(PLAYER_NAME)
+                .build()
+        );
+    }
+
+    @Test
+    void testDisabled() {
+        // update config mock
+        when(config.metadataWebhook()).thenReturn("");
+
+        // fire event
+        GameStateChanged event = new GameStateChanged();
+        event.setGameState(GameState.LOGGED_IN);
+        notifier.onGameState(event);
+        IntStream.rangeClosed(0, MetaNotifier.INIT_TICKS).forEach(i -> notifier.onTick());
+
+        // ensure no notification
+        verify(messageHandler, never()).createMessage(any(), anyBoolean(), any());
+    }
+
+    private static GameStateChanged event(GameState state) {
+        GameStateChanged event = new GameStateChanged();
+        event.setGameState(state);
+        return event;
+    }
+}