Repository: pajlads/DinkPlugin
Pull request #36 (2022-10-23 07:39:22)

refactor: move logic from plugin to notifier
* Move classes to subpackages
* Use getters instead of public fields
* Remove notifier-specific logic from main plugin class (halves the LOC count)
* Miscellaneous other items


pajlada: Tested levelup, loot, clue, and quest notifier and they all work as expected
pajlada: Slayer works
pajlada: Speedruns work
pajlada: collection log works
pajlada: Deaths work
pajlada: only untested thing is pet, but that code path looks sane. merging this in

Patch:
diff --git a/src/main/java/dinkplugin/ClueNotifier.java b/src/main/java/dinkplugin/ClueNotifier.java
deleted file mode 100644
index 536075e..0000000
--- a/src/main/java/dinkplugin/ClueNotifier.java
+++ /dev/null
@@ -1,73 +0,0 @@
-package dinkplugin;
-
-import lombok.Getter;
-import net.runelite.api.ItemComposition;
-import net.runelite.client.util.QuantityFormatter;
-
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-
-public class ClueNotifier extends BaseNotifier {
-    @Getter
-    private final Map<Integer, Integer> clueItems = new HashMap<>();
-
-    private NotificationBody<ClueNotificationData> messageBody;
-
-    public ClueNotifier(DinkPlugin plugin) {
-        super(plugin);
-    }
-
-    public void handleNotify(String numberCompleted, String clueType) {
-        if (plugin.isIgnoredWorld()) return;
-        messageBody = new NotificationBody<>();
-
-        StringBuilder lootMessage = new StringBuilder();
-        long totalPrice = 0;
-        List<SerializedItemStack> itemStacks = new ArrayList<>();
-
-        for (Integer itemId : clueItems.keySet()) {
-            if (lootMessage.length() > 0) {
-                lootMessage.append("\n");
-            }
-            int quantity = clueItems.get(itemId);
-            int price = plugin.itemManager.getItemPrice(itemId);
-            totalPrice += (long) price * quantity;
-            lootMessage.append(getItem(itemId, clueItems.get(itemId)));
-
-            ItemComposition itemComposition = plugin.itemManager.getItemComposition(itemId);
-            itemStacks.add(new SerializedItemStack(itemId, quantity, price, itemComposition.getName()));
-        }
-
-        if (totalPrice < plugin.config.clueMinValue()) {
-            return;
-        }
-
-        String notifyMessage = plugin.config.clueNotifyMessage()
-            .replaceAll("%USERNAME%", Utils.getPlayerName())
-            .replaceAll("%CLUE%", clueType)
-            .replaceAll("%COUNT%", numberCompleted)
-            .replaceAll("%TOTAL_VALUE%", QuantityFormatter.quantityToStackSize(totalPrice))
-            .replaceAll("%LOOT%", lootMessage.toString());
-        messageBody.setContent(notifyMessage);
-        ClueNotificationData extra = new ClueNotificationData();
-        extra.setClueType(clueType);
-        extra.setNumberCompleted(Integer.parseInt(numberCompleted));
-        extra.setItems(itemStacks);
-        messageBody.setExtra(extra);
-        messageBody.setType(NotificationType.CLUE);
-        plugin.messageHandler.createMessage(plugin.config.clueSendImage(), messageBody);
-    }
-
-    public String getItem(int itemId, int quantity) {
-        int price = plugin.itemManager.getItemPrice(itemId);
-        long totalPrice = (long) price * quantity;
-        ItemComposition itemComposition = plugin.itemManager.getItemComposition(itemId);
-
-        if (plugin.config.clueShowItems()) {
-            messageBody.getEmbeds().add(new NotificationBody.Embed(new NotificationBody.UrlEmbed(Utils.getItemImageUrl(itemId))));
-        }
-        return String.format("%s x %s (%s)", quantity, itemComposition.getName(), QuantityFormatter.quantityToStackSize(totalPrice));
-    }
-}
diff --git a/src/main/java/dinkplugin/CollectionNotifier.java b/src/main/java/dinkplugin/CollectionNotifier.java
deleted file mode 100644
index 84e5f7a..0000000
--- a/src/main/java/dinkplugin/CollectionNotifier.java
+++ /dev/null
@@ -1,25 +0,0 @@
-package dinkplugin;
-
-import javax.inject.Inject;
-
-public class CollectionNotifier extends BaseNotifier {
-
-    @Inject
-    public CollectionNotifier(DinkPlugin plugin) {
-        super(plugin);
-    }
-
-    public void handleNotify(String itemName) {
-        if (plugin.isIgnoredWorld()) return;
-        String notifyMessage = plugin.config.collectionNotifyMessage()
-            .replaceAll("%USERNAME%", Utils.getPlayerName())
-            .replaceAll("%ITEM%", itemName);
-        NotificationBody<CollectionNotificationData> b = new NotificationBody<>();
-        b.setContent(notifyMessage);
-        CollectionNotificationData extra = new CollectionNotificationData();
-        extra.setItemName(itemName);
-        b.setExtra(extra);
-        b.setType(NotificationType.COLLECTION);
-        plugin.messageHandler.createMessage(plugin.config.collectionSendImage(), b);
-    }
-}
diff --git a/src/main/java/dinkplugin/DeathNotifier.java b/src/main/java/dinkplugin/DeathNotifier.java
deleted file mode 100644
index 3ca9c68..0000000
--- a/src/main/java/dinkplugin/DeathNotifier.java
+++ /dev/null
@@ -1,22 +0,0 @@
-package dinkplugin;
-
-import javax.inject.Inject;
-
-public class DeathNotifier extends BaseNotifier {
-
-    @Inject
-    public DeathNotifier(DinkPlugin plugin) {
-        super(plugin);
-    }
-
-    @Override
-    public void handleNotify() {
-        if (plugin.isIgnoredWorld()) return;
-        String notifyMessage = plugin.config.deathNotifyMessage()
-            .replaceAll("%USERNAME%", Utils.getPlayerName());
-        NotificationBody<Object> b = new NotificationBody<>();
-        b.setContent(notifyMessage);
-        b.setType(NotificationType.DEATH);
-        plugin.messageHandler.createMessage(plugin.config.deathSendImage(), b);
-    }
-}
diff --git a/src/main/java/dinkplugin/DinkPlugin.java b/src/main/java/dinkplugin/DinkPlugin.java
index 07808b0..c1cccfe 100644
--- a/src/main/java/dinkplugin/DinkPlugin.java
+++ b/src/main/java/dinkplugin/DinkPlugin.java
@@ -1,41 +1,41 @@
 package dinkplugin;
 
 import com.google.inject.Provides;
+import dinkplugin.message.DiscordMessageHandler;
+import dinkplugin.notifiers.ClueNotifier;
+import dinkplugin.notifiers.CollectionNotifier;
+import dinkplugin.notifiers.DeathNotifier;
+import dinkplugin.notifiers.LevelNotifier;
+import dinkplugin.notifiers.LootNotifier;
+import dinkplugin.notifiers.PetNotifier;
+import dinkplugin.notifiers.QuestNotifier;
+import dinkplugin.notifiers.SlayerNotifier;
+import dinkplugin.notifiers.SpeedrunNotifier;
+import lombok.Getter;
 import lombok.extern.slf4j.Slf4j;
 import net.runelite.api.ChatMessageType;
 import net.runelite.api.Client;
-import net.runelite.api.GameState;
-import net.runelite.api.NPC;
-import net.runelite.api.*;
-import net.runelite.api.events.*;
-import net.runelite.api.widgets.Widget;
-import net.runelite.api.widgets.WidgetID;
-import net.runelite.api.widgets.WidgetInfo;
+import net.runelite.api.events.ActorDeath;
+import net.runelite.api.events.ChatMessage;
+import net.runelite.api.events.GameStateChanged;
+import net.runelite.api.events.GameTick;
+import net.runelite.api.events.StatChanged;
+import net.runelite.api.events.UsernameChanged;
+import net.runelite.api.events.WidgetLoaded;
 import net.runelite.client.config.ConfigManager;
 import net.runelite.client.eventbus.Subscribe;
-import net.runelite.client.events.NotificationFired;
 import net.runelite.client.events.NpcLootReceived;
 import net.runelite.client.events.PlayerLootReceived;
 import net.runelite.client.game.ItemManager;
-import net.runelite.client.game.ItemStack;
 import net.runelite.client.game.WorldService;
 import net.runelite.client.plugins.Plugin;
 import net.runelite.client.plugins.PluginDescriptor;
 import net.runelite.client.plugins.loottracker.LootReceived;
 import net.runelite.client.ui.DrawManager;
 import net.runelite.client.util.Text;
-import net.runelite.http.api.loottracker.LootRecordType;
 import okhttp3.OkHttpClient;
 
 import javax.inject.Inject;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.EnumSet;
-import java.util.Set;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-
-import static net.runelite.api.widgets.WidgetID.QUEST_COMPLETED_GROUP_ID;
 
 @Slf4j
 @PluginDescriptor(
@@ -45,19 +45,26 @@ import static net.runelite.api.widgets.WidgetID.QUEST_COMPLETED_GROUP_ID;
 )
 public class DinkPlugin extends Plugin {
     @Inject
+    @Getter
     private Client client;
     @Inject
-    public OkHttpClient httpClient;
-
+    @Getter
+    private OkHttpClient httpClient;
     @Inject
-    public DrawManager drawManager;
+    @Getter
+    private DinkPluginConfig config;
     @Inject
-    public DinkPluginConfig config;
-
+    @Getter
+    private DrawManager drawManager;
     @Inject
-    public ItemManager itemManager;
+    @Getter
+    private ItemManager itemManager;
+    @Inject
+    @Getter
+    private WorldService worldService;
 
-    public final DiscordMessageHandler messageHandler = new DiscordMessageHandler(this);
+    @Getter
+    private final DiscordMessageHandler messageHandler = new DiscordMessageHandler(this);
     private final CollectionNotifier collectionNotifier = new CollectionNotifier(this);
     private final PetNotifier petNotifier = new PetNotifier(this);
     private final LevelNotifier levelNotifier = new LevelNotifier(this);
@@ -68,31 +75,14 @@ public class DinkPlugin extends Plugin {
     private final ClueNotifier clueNotifier = new ClueNotifier(this);
     private final SpeedrunNotifier speedrunNotifier = new SpeedrunNotifier(this);
 
-    private static final Pattern CLUE_SCROLL_REGEX = Pattern.compile("You have completed (?<scrollCount>\\d+) (?<scrollType>\\w+) Treasure Trails\\.");
-    public static final Pattern SLAYER_TASK_REGEX = Pattern.compile("You have completed your task! You killed (?<task>[\\d,]+ [^.]+)\\..*");
-    private static final Pattern SLAYER_COMPLETE_REGEX = Pattern.compile("You've completed (?:at least )?(?<taskCount>[\\d,]+) (?:Wilderness )?tasks?(?: and received (?<points>\\d+) points, giving you a total of [\\d,]+|\\.You'll be eligible to earn reward points if you complete tasks from a more advanced Slayer Master\\.| and reached the maximum amount of Slayer points \\((?<points2>[\\d,]+)\\))?");
-
-    public static final Pattern COLLECTION_LOG_REGEX = Pattern.compile("New item added to your collection log: (?<itemName>(.*))");
-    public static final Pattern PET_REGEX = Pattern.compile("You (?:have a funny feeling like you|feel something weird sneaking).*");
-
-    private static final Set<WorldType> IGNORED_WORLDS = EnumSet.of(WorldType.PVP_ARENA, WorldType.QUEST_SPEEDRUNNING, WorldType.NOSAVE_MODE, WorldType.TOURNAMENT_WORLD);
-
-    private final boolean questCompleted = false;
-    private boolean clueCompleted = false;
-    private String clueCount = "";
-    private String clueType = "";
-
-    @Inject
-    private WorldService worldService;
-
     @Override
-    protected void startUp() throws Exception {
-        Utils.client = client;
+    protected void startUp() {
+        Utils.setClient(client);
         log.info("Started up Dink");
     }
 
     @Override
-    protected void shutDown() throws Exception {
+    protected void shutDown() {
         log.info("Shutting down Dink");
     }
 
@@ -101,11 +91,6 @@ public class DinkPlugin extends Plugin {
         return configManager.getConfig(DinkPluginConfig.class);
     }
 
-    @Subscribe
-    public void onNotificationFired(NotificationFired notif) {
-
-    }
-
     @Subscribe
     public void onUsernameChanged(UsernameChanged usernameChanged) {
         levelNotifier.reset();
@@ -113,14 +98,12 @@ public class DinkPlugin extends Plugin {
 
     @Subscribe
     public void onGameStateChanged(GameStateChanged gameStateChanged) {
-        if (gameStateChanged.getGameState().equals(GameState.LOGIN_SCREEN)) {
-            levelNotifier.reset();
-        }
+        levelNotifier.onGameStateChanged(gameStateChanged);
     }
 
     @Subscribe
     public void onStatChanged(StatChanged statChange) {
-        levelNotifier.handleLevelUp(statChange.getSkill().getName(), statChange.getLevel(), statChange.getXp());
+        levelNotifier.onStatChanged(statChange);
     }
 
     @Subscribe
@@ -133,174 +116,42 @@ public class DinkPlugin extends Plugin {
         ChatMessageType msgType = message.getType();
         String chatMessage = Text.removeTags(message.getMessage());
 
-        if (msgType.equals(ChatMessageType.GAMEMESSAGE)) {
-            Matcher collectionMatcher = COLLECTION_LOG_REGEX.matcher(chatMessage);
-            if (config.notifyCollectionLog() && collectionMatcher.find()) {
-                collectionNotifier.handleNotify(collectionMatcher.group("itemName"));
-                return;
-            }
-
-            if (config.notifyPet() && PET_REGEX.matcher(chatMessage).matches()) {
-                petNotifier.handleNotify();
-                return;
-            }
-
-            if (config.notifySlayer()
-                && (chatMessage.contains("Slayer master")
-                || chatMessage.contains("Slayer Master")
-                || chatMessage.contains("completed your task!")
-            )) {
-                Matcher taskMatcher = SLAYER_TASK_REGEX.matcher(chatMessage);
-                Matcher pointsMatcher = SLAYER_COMPLETE_REGEX.matcher(chatMessage);
-
-                if (taskMatcher.find()) {
-                    String slayerTask = taskMatcher.group("task");
-                    slayerNotifier.setSlayerTask(slayerTask);
-                    slayerNotifier.handleNotify();
-                }
-
-                if (pointsMatcher.find()) {
-                    String slayerPoints = pointsMatcher.group("points");
-                    String slayerTasksCompleted = pointsMatcher.group("taskCount");
-
-                    if (slayerPoints == null) {
-                        slayerPoints = pointsMatcher.group("points2");
-                    }
-
-                    // 3 different cases of seeing points, so in our worst case it's 0
-                    if (slayerPoints == null) {
-                        slayerPoints = "0";
-                    }
-                    slayerNotifier.setSlayerPoints(slayerPoints);
-                    slayerNotifier.setSlayerCompleted(slayerTasksCompleted);
-
-                    slayerNotifier.handleNotify();
-                }
-            }
-
-            if (config.notifyClue()) {
-                Matcher clueMatcher = CLUE_SCROLL_REGEX.matcher(chatMessage);
-                if (clueMatcher.find()) {
-                    String numberCompleted = clueMatcher.group("scrollCount");
-                    String scrollType = clueMatcher.group("scrollType");
-
-                    if (clueCompleted) {
-                        clueNotifier.handleNotify(numberCompleted, scrollType);
-                        clueCompleted = false;
-                    } else {
-                        clueType = scrollType;
-                        clueCount = numberCompleted;
-                        clueCompleted = true;
-                    }
-                }
-            }
+        if (msgType == ChatMessageType.GAMEMESSAGE) {
+            collectionNotifier.onChatMessage(chatMessage);
+            petNotifier.onChatMessage(chatMessage);
+            slayerNotifier.onChatMessage(chatMessage);
+            clueNotifier.onChatMessage(chatMessage);
         }
     }
 
     @Subscribe
     public void onActorDeath(ActorDeath actor) {
-        if (config.notifyDeath() && client.getLocalPlayer() == actor.getActor()) {
-            deathNotifier.handleNotify();
-        }
+        deathNotifier.onActorDeath(actor);
     }
 
     @Subscribe
     public void onNpcLootReceived(NpcLootReceived npcLootReceived) {
-        if (!config.notifyLoot()) {
-            return;
-        }
-
-        NPC npc = npcLootReceived.getNpc();
-        Collection<ItemStack> items = npcLootReceived.getItems();
-
-        lootNotifier.handleNotify(items, npc.getName());
+        lootNotifier.onNpcLootReceived(npcLootReceived);
     }
 
     @Subscribe
     public void onPlayerLootReceived(PlayerLootReceived playerLootReceived) {
-        Collection<ItemStack> items = playerLootReceived.getItems();
-        lootNotifier.handleNotify(items, playerLootReceived.getPlayer().getName());
+        lootNotifier.onPlayerLootReceived(playerLootReceived);
     }
 
     @Subscribe
     public void onLootReceived(LootReceived lootReceived) {
-        if (!config.notifyLoot()) return;
-
-        // only consider non-NPC and non-PK loot
-        if (lootReceived.getType() == LootRecordType.EVENT || lootReceived.getType() == LootRecordType.PICKPOCKET) {
-            lootNotifier.handleNotify(lootReceived.getItems(), lootReceived.getName());
-        }
+        lootNotifier.onLootReceived(lootReceived);
     }
 
     @Subscribe
     public void onWidgetLoaded(WidgetLoaded event) {
-        int groupId = event.getGroupId();
-
-        if (groupId == QUEST_COMPLETED_GROUP_ID) {
-
-            if (config.notifyQuest()) {
-                Widget quest = client.getWidget(WidgetInfo.QUEST_COMPLETED_NAME_TEXT);
-
-                if (quest != null) {
-                    String questWidget = quest.getText();
-                    questNotifier.handleNotify(questWidget);
-                }
-            }
-        }
-
-        if (groupId == WidgetID.CLUE_SCROLL_REWARD_GROUP_ID) {
-            Widget clue = client.getWidget(WidgetInfo.CLUE_SCROLL_REWARD_ITEM_CONTAINER);
-            if (clue != null) {
-                clueNotifier.getClueItems().clear();
-                Widget[] children = clue.getChildren();
-
-                if (children == null) {
-                    return;
-                }
-
-                for (Widget child : children) {
-                    if (child == null) {
-                        continue;
-                    }
-
-                    int quantity = child.getItemQuantity();
-                    int itemId = child.getItemId();
-
-                    if (itemId > -1 && quantity > 0) {
-                        clueNotifier.getClueItems().put(itemId, quantity);
-                    }
-                }
-
-                if (clueCompleted) {
-                    clueNotifier.handleNotify(clueCount, clueType);
-                    clueCompleted = false;
-                } else {
-                    clueCompleted = true;
-                }
-            }
-        }
-
-        final int SPEEDRUN_COMPLETED_GROUP_ID = 781;
-        final int SPEEDRUN_COMPLETED_QUEST_NAME_CHILD_ID = 4;
-        final int SPEEDRUN_COMPLETED_DURATION_CHILD_ID = 10;
-        final int SPEEDRUN_COMPLETED_PB_CHILD_ID = 12;
-        if (config.notifySpeedrun() && groupId == SPEEDRUN_COMPLETED_GROUP_ID) {
-            Widget questName = client.getWidget(SPEEDRUN_COMPLETED_GROUP_ID, SPEEDRUN_COMPLETED_QUEST_NAME_CHILD_ID);
-            Widget duration = client.getWidget(SPEEDRUN_COMPLETED_GROUP_ID, SPEEDRUN_COMPLETED_DURATION_CHILD_ID);
-            Widget personalBest = client.getWidget(SPEEDRUN_COMPLETED_GROUP_ID, SPEEDRUN_COMPLETED_PB_CHILD_ID);
-            if (questName == null || duration == null || personalBest == null) {
-                log.error("Found speedrun finished widget (group id {}) but it is missing something, questName={}, duration={}, pb={}", SPEEDRUN_COMPLETED_GROUP_ID, questName, duration, personalBest);
-            }
-//            log.info("quest name is {}, duration: {}, pb: {}", questName.getText(), duration.getText(), personalBest.getText());
-            this.speedrunNotifier.attemptNotify(Utils.parseQuestWidget(questName.getText()), duration.getText(), personalBest.getText());
-        }
-    }
-
-    public static boolean _isIgnoredWorld(Set<WorldType> worldType) {
-        return !Collections.disjoint(IGNORED_WORLDS, worldType);
+        questNotifier.onWidgetLoaded(event);
+        clueNotifier.onWidgetLoaded(event);
+        speedrunNotifier.onWidgetLoaded(event);
     }
 
     public boolean isIgnoredWorld() {
-        return _isIgnoredWorld(client.getWorldType().clone());
+        return Utils.isIgnoredWorld(client.getWorldType());
     }
 }
diff --git a/src/main/java/dinkplugin/PetNotifier.java b/src/main/java/dinkplugin/PetNotifier.java
deleted file mode 100644
index bfa90fd..0000000
--- a/src/main/java/dinkplugin/PetNotifier.java
+++ /dev/null
@@ -1,21 +0,0 @@
-package dinkplugin;
-
-import javax.inject.Inject;
-
-public class PetNotifier extends BaseNotifier {
-
-    @Inject
-    public PetNotifier(DinkPlugin plugin) {
-        super(plugin);
-    }
-
-    @Override
-    public void handleNotify() {
-        String notifyMessage = plugin.config.petNotifyMessage()
-            .replaceAll("%USERNAME%", Utils.getPlayerName());
-        NotificationBody<Object> body = new NotificationBody<>();
-        body.setContent(notifyMessage);
-        body.setType(NotificationType.PET);
-        plugin.messageHandler.createMessage(plugin.config.petSendImage(), body);
-    }
-}
diff --git a/src/main/java/dinkplugin/QuestNotifier.java b/src/main/java/dinkplugin/QuestNotifier.java
deleted file mode 100644
index 925368f..0000000
--- a/src/main/java/dinkplugin/QuestNotifier.java
+++ /dev/null
@@ -1,22 +0,0 @@
-package dinkplugin;
-
-public class QuestNotifier extends BaseNotifier {
-
-    public QuestNotifier(DinkPlugin plugin) {
-        super(plugin);
-    }
-
-    public void handleNotify(String questText) {
-        if (plugin.isIgnoredWorld()) return;
-        String notifyMessage = plugin.config.questNotifyMessage()
-            .replaceAll("%USERNAME%", Utils.getPlayerName())
-            .replaceAll("%QUEST%", Utils.parseQuestWidget(questText));
-        NotificationBody<QuestNotificationData> body = new NotificationBody<>();
-        body.setContent(notifyMessage);
-        QuestNotificationData extra = new QuestNotificationData();
-        extra.setQuestName(Utils.parseQuestWidget(questText));
-        body.setExtra(extra);
-        body.setType(NotificationType.QUEST);
-        plugin.messageHandler.createMessage(plugin.config.questSendImage(), body);
-    }
-}
diff --git a/src/main/java/dinkplugin/SerializedItemStack.java b/src/main/java/dinkplugin/SerializedItemStack.java
deleted file mode 100644
index 45b7f8c..0000000
--- a/src/main/java/dinkplugin/SerializedItemStack.java
+++ /dev/null
@@ -1,11 +0,0 @@
-package dinkplugin;
-
-import lombok.Builder;
-
-@Builder
-class SerializedItemStack {
-    private int id;
-    private int quantity;
-    private int priceEach;
-    private String name;
-}
diff --git a/src/main/java/dinkplugin/SlayerNotifier.java b/src/main/java/dinkplugin/SlayerNotifier.java
deleted file mode 100644
index 4763af4..0000000
--- a/src/main/java/dinkplugin/SlayerNotifier.java
+++ /dev/null
@@ -1,47 +0,0 @@
-package dinkplugin;
-
-import lombok.Getter;
-import lombok.Setter;
-
-import javax.inject.Inject;
-
-@Getter
-@Setter
-public class SlayerNotifier extends BaseNotifier {
-    private String slayerTask = "";
-    private String slayerPoints = "";
-    private String slayerCompleted = "";
-
-    @Inject
-    public SlayerNotifier(DinkPlugin plugin) {
-        super(plugin);
-    }
-
-    @Override
-    public void handleNotify() {
-        if (plugin.isIgnoredWorld()) return;
-        // Little jank, but it's a bit cleaner than having bools and checking in the main plugin class
-        if (slayerPoints.isEmpty() || slayerTask.isEmpty() || slayerCompleted.isEmpty()) {
-            return;
-        }
-
-        String notifyMessage = plugin.config.slayerNotifyMessage()
-            .replaceAll("%USERNAME%", Utils.getPlayerName())
-            .replaceAll("%TASK%", slayerTask)
-            .replaceAll("%TASKCOUNT%", slayerCompleted)
-            .replaceAll("%POINTS%", slayerPoints);
-        NotificationBody<SlayerNotificationData> body = new NotificationBody<>();
-        SlayerNotificationData extra = new SlayerNotificationData();
-        extra.setSlayerPoints(slayerPoints);
-        extra.setSlayerCompleted(slayerCompleted);
-        extra.setSlayerTask(slayerTask);
-        body.setExtra(extra);
-        body.setContent(notifyMessage);
-        body.setType(NotificationType.SLAYER);
-        plugin.messageHandler.createMessage(plugin.config.slayerSendImage(), body);
-
-        slayerTask = "";
-        slayerPoints = "";
-        slayerCompleted = "";
-    }
-}
diff --git a/src/main/java/dinkplugin/SpeedrunNotifier.java b/src/main/java/dinkplugin/SpeedrunNotifier.java
deleted file mode 100644
index 0de3535..0000000
--- a/src/main/java/dinkplugin/SpeedrunNotifier.java
+++ /dev/null
@@ -1,52 +0,0 @@
-package dinkplugin;
-
-import java.time.Duration;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-
-public class SpeedrunNotifier extends BaseNotifier {
-    public SpeedrunNotifier(DinkPlugin plugin) {
-        super(plugin);
-    }
-
-    public void attemptNotify(String questName, String duration, String pb) {
-        Duration bestTime = this.parseTime(pb);
-        Duration currentTime = this.parseTime(duration);
-        boolean isPb = bestTime.compareTo(currentTime) >= 0;
-        if (!isPb && plugin.config.speedrunPBOnly()) {
-            return;
-        }
-        // pb or notifying on non-pb; take the right string and format placeholders
-        String notifyMessage = plugin.config.speedrunMessage();
-        if (isPb) {
-            notifyMessage = plugin.config.speedrunPBMessage();
-        }
-
-        notifyMessage = notifyMessage
-            .replaceAll("%USERNAME%", Utils.getPlayerName())
-            .replaceAll("%QUEST%", questName)
-            .replaceAll("%TIME%", duration)
-            .replaceAll("%BEST%", pb);
-        NotificationBody<SpeedrunNotificationData> body = new NotificationBody<>();
-        body.setContent(notifyMessage);
-        SpeedrunNotificationData extra = new SpeedrunNotificationData();
-        extra.setQuestName(questName);
-        extra.setPersonalBest(bestTime.toString());
-        extra.setCurrentTime(currentTime.toString());
-        body.setExtra(extra);
-        body.setType(NotificationType.SPEEDRUN);
-        plugin.messageHandler.createMessage(plugin.config.speedrunSendImage(), body);
-    }
-
-    private static final Pattern TIME_PATTERN = Pattern.compile("(?<minutes>\\d+):(?<seconds>\\d{2})\\.(?<fractional>\\d{2})");
-
-    public Duration parseTime(String in) {
-        Matcher m = TIME_PATTERN.matcher(in);
-        if (!m.find()) return Duration.ofMillis(0);
-        int minutes = Integer.parseInt(m.group("minutes"));
-        int seconds = Integer.parseInt(m.group("seconds"));
-        int fractional = Integer.parseInt(m.group("fractional"));
-        // 0.001
-        return Duration.ofMillis(10L * (100L * (60L * minutes + seconds) + fractional));
-    }
-}
diff --git a/src/main/java/dinkplugin/Utils.java b/src/main/java/dinkplugin/Utils.java
index 3a5739c..6b27602 100644
--- a/src/main/java/dinkplugin/Utils.java
+++ b/src/main/java/dinkplugin/Utils.java
@@ -1,7 +1,9 @@
 package dinkplugin;
 
 import com.google.common.collect.ImmutableList;
+import lombok.Setter;
 import net.runelite.api.Client;
+import net.runelite.api.WorldType;
 import net.runelite.client.game.ItemStack;
 
 import javax.imageio.ImageIO;
@@ -10,12 +12,23 @@ import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collection;
+import java.util.Collections;
+import java.util.EnumSet;
 import java.util.List;
+import java.util.Set;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
 public class Utils {
-    public static Client client;
+
+    private static final Set<WorldType> IGNORED_WORLDS = EnumSet.of(WorldType.PVP_ARENA, WorldType.QUEST_SPEEDRUNNING, WorldType.NOSAVE_MODE, WorldType.TOURNAMENT_WORLD);
+
+    @Setter
+    private static Client client;
+
+    public static boolean isIgnoredWorld(Set<WorldType> worldType) {
+        return !Collections.disjoint(IGNORED_WORLDS, worldType);
+    }
 
     public static String getPlayerName() {
         return client.getLocalPlayer().getName();
diff --git a/src/main/java/dinkplugin/DiscordMessageHandler.java b/src/main/java/dinkplugin/message/DiscordMessageHandler.java
similarity index 74%
rename from src/main/java/dinkplugin/DiscordMessageHandler.java
rename to src/main/java/dinkplugin/message/DiscordMessageHandler.java
index 3c5897a..dfe2e64 100644
--- a/src/main/java/dinkplugin/DiscordMessageHandler.java
+++ b/src/main/java/dinkplugin/message/DiscordMessageHandler.java
@@ -1,15 +1,25 @@
-package dinkplugin;
+package dinkplugin.message;
 
 import com.google.common.base.Strings;
+import dinkplugin.DinkPlugin;
+import dinkplugin.Utils;
 import lombok.NonNull;
 import lombok.extern.slf4j.Slf4j;
-import okhttp3.*;
+import okhttp3.Call;
+import okhttp3.Callback;
+import okhttp3.HttpUrl;
+import okhttp3.MediaType;
+import okhttp3.MultipartBody;
+import okhttp3.Request;
+import okhttp3.RequestBody;
+import okhttp3.Response;
+import org.jetbrains.annotations.NotNull;
 
 import javax.inject.Inject;
 import java.awt.image.BufferedImage;
 import java.io.IOException;
 import java.util.ArrayList;
-import java.util.Objects;
+import java.util.List;
 
 import static net.runelite.http.api.RuneLiteAPI.GSON;
 
@@ -24,14 +34,14 @@ public class DiscordMessageHandler {
 
     public <T> void createMessage(boolean sendImage, @NonNull NotificationBody<T> mBody) {
         mBody.setPlayerName(Utils.getPlayerName());
-        String webhookUrl = plugin.config.discordWebhook();
+        String webhookUrl = plugin.getConfig().discordWebhook();
         if (Strings.isNullOrEmpty(webhookUrl)) {
             return;
         }
-        ArrayList<HttpUrl> urlList = new ArrayList<>();
+        List<HttpUrl> urlList = new ArrayList<>();
         String[] strList = webhookUrl.split("\n");
         for (String urlString : strList) {
-            if (Objects.equals(urlString, "")) {
+            if (urlString.isEmpty()) {
                 continue;
             }
             urlList.add(HttpUrl.parse(urlString));
@@ -42,7 +52,7 @@ public class DiscordMessageHandler {
             .addFormDataPart("payload_json", GSON.toJson(mBody));
 
         if (sendImage) {
-            plugin.drawManager.requestNextFrameListener(image -> {
+            plugin.getDrawManager().requestNextFrameListener(image -> {
                 BufferedImage bufferedImage = (BufferedImage) image;
                 byte[] imageBytes;
                 try {
@@ -71,7 +81,7 @@ public class DiscordMessageHandler {
         sendToMultiple(urlList, reqBodyBuilder);
     }
 
-    private void sendToMultiple(ArrayList<HttpUrl> urls, MultipartBody.Builder requestBody) {
+    private void sendToMultiple(List<HttpUrl> urls, MultipartBody.Builder requestBody) {
         for (HttpUrl url : urls) {
             sendMessage(url, requestBody);
         }
@@ -83,14 +93,14 @@ public class DiscordMessageHandler {
             .url(url)
             .post(body)
             .build();
-        plugin.httpClient.newCall(request).enqueue(new Callback() {
+        plugin.getHttpClient().newCall(request).enqueue(new Callback() {
             @Override
-            public void onFailure(Call call, IOException e) {
+            public void onFailure(@NotNull Call call, @NotNull IOException e) {
                 log.warn("There was an error sending the webhook message", e);
             }
 
             @Override
-            public void onResponse(Call call, Response response) throws IOException {
+            public void onResponse(@NotNull Call call, @NotNull Response response) {
                 response.close();
             }
         });
diff --git a/src/main/java/dinkplugin/NotificationBody.java b/src/main/java/dinkplugin/message/NotificationBody.java
similarity index 62%
rename from src/main/java/dinkplugin/NotificationBody.java
rename to src/main/java/dinkplugin/message/NotificationBody.java
index 4717e1c..5329a4f 100644
--- a/src/main/java/dinkplugin/NotificationBody.java
+++ b/src/main/java/dinkplugin/message/NotificationBody.java
@@ -1,6 +1,7 @@
-package dinkplugin;
+package dinkplugin.message;
 
 import lombok.Data;
+import lombok.Value;
 
 import java.util.ArrayList;
 import java.util.List;
@@ -14,13 +15,13 @@ public class NotificationBody<T> {
     private String content;
     private List<Embed> embeds = new ArrayList<>();
 
-    @Data
-    static class Embed {
-        final UrlEmbed image;
+    @Value
+    public static class Embed {
+        UrlEmbed image;
     }
 
-    @Data
-    static class UrlEmbed {
-        final String url;
+    @Value
+    public static class UrlEmbed {
+        String url;
     }
 }
diff --git a/src/main/java/dinkplugin/NotificationType.java b/src/main/java/dinkplugin/message/NotificationType.java
similarity index 83%
rename from src/main/java/dinkplugin/NotificationType.java
rename to src/main/java/dinkplugin/message/NotificationType.java
index 6674800..7e52513 100644
--- a/src/main/java/dinkplugin/NotificationType.java
+++ b/src/main/java/dinkplugin/message/NotificationType.java
@@ -1,4 +1,4 @@
-package dinkplugin;
+package dinkplugin.message;
 
 public enum NotificationType {
     CLUE,
diff --git a/src/main/java/dinkplugin/BaseNotifier.java b/src/main/java/dinkplugin/notifiers/BaseNotifier.java
similarity index 52%
rename from src/main/java/dinkplugin/BaseNotifier.java
rename to src/main/java/dinkplugin/notifiers/BaseNotifier.java
index eb69a24..777dea7 100644
--- a/src/main/java/dinkplugin/BaseNotifier.java
+++ b/src/main/java/dinkplugin/notifiers/BaseNotifier.java
@@ -1,18 +1,24 @@
-package dinkplugin;
+package dinkplugin.notifiers;
+
+import dinkplugin.DinkPlugin;
+import dinkplugin.message.DiscordMessageHandler;
+import dinkplugin.message.NotificationBody;
 
 import javax.inject.Inject;
 
 public class BaseNotifier {
     protected final DinkPlugin plugin;
+    protected final DiscordMessageHandler messageHandler;
 
     @Inject
     public BaseNotifier(DinkPlugin plugin) {
         this.plugin = plugin;
+        this.messageHandler = plugin.getMessageHandler();
     }
 
     public void handleNotify() {
         NotificationBody<Object> b = new NotificationBody<>();
         b.setContent("This is a base notification");
-        plugin.messageHandler.createMessage(false, b);
+        messageHandler.createMessage(false, b);
     }
 }
diff --git a/src/main/java/dinkplugin/notifiers/ClueNotifier.java b/src/main/java/dinkplugin/notifiers/ClueNotifier.java
new file mode 100644
index 0000000..6ea389f
--- /dev/null
+++ b/src/main/java/dinkplugin/notifiers/ClueNotifier.java
@@ -0,0 +1,141 @@
+package dinkplugin.notifiers;
+
+import dinkplugin.DinkPlugin;
+import dinkplugin.message.NotificationBody;
+import dinkplugin.message.NotificationType;
+import dinkplugin.Utils;
+import dinkplugin.notifiers.data.ClueNotificationData;
+import dinkplugin.notifiers.data.SerializedItemStack;
+import lombok.Getter;
+import net.runelite.api.ItemComposition;
+import net.runelite.api.events.WidgetLoaded;
+import net.runelite.api.widgets.Widget;
+import net.runelite.api.widgets.WidgetID;
+import net.runelite.api.widgets.WidgetInfo;
+import net.runelite.client.util.QuantityFormatter;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+public class ClueNotifier extends BaseNotifier {
+    private static final Pattern CLUE_SCROLL_REGEX = Pattern.compile("You have completed (?<scrollCount>\\d+) (?<scrollType>\\w+) Treasure Trails\\.");
+
+    @Getter
+    private final Map<Integer, Integer> clueItems = new HashMap<>();
+    private boolean clueCompleted = false;
+    private String clueCount = "";
+    private String clueType = "";
+
+    public ClueNotifier(DinkPlugin plugin) {
+        super(plugin);
+    }
+
+    public void onChatMessage(String chatMessage) {
+        if (plugin.getConfig().notifyClue()) {
+            Matcher clueMatcher = CLUE_SCROLL_REGEX.matcher(chatMessage);
+            if (clueMatcher.find()) {
+                String numberCompleted = clueMatcher.group("scrollCount");
+                String scrollType = clueMatcher.group("scrollType");
+
+                if (clueCompleted) {
+                    this.handleNotify(numberCompleted, scrollType);
+                    clueCompleted = false;
+                } else {
+                    clueType = scrollType;
+                    clueCount = numberCompleted;
+                    clueCompleted = true;
+                }
+            }
+        }
+    }
+
+    public void onWidgetLoaded(WidgetLoaded event) {
+        if (event.getGroupId() == WidgetID.CLUE_SCROLL_REWARD_GROUP_ID) {
+            Widget clue = plugin.getClient().getWidget(WidgetInfo.CLUE_SCROLL_REWARD_ITEM_CONTAINER);
+            if (clue != null) {
+                clueItems.clear();
+                Widget[] children = clue.getChildren();
+
+                if (children == null) {
+                    return;
+                }
+
+                for (Widget child : children) {
+                    if (child == null) {
+                        continue;
+                    }
+
+                    int quantity = child.getItemQuantity();
+                    int itemId = child.getItemId();
+
+                    if (itemId > -1 && quantity > 0) {
+                        clueItems.put(itemId, quantity);
+                    }
+                }
+
+                if (clueCompleted) {
+                    this.handleNotify(clueCount, clueType);
+                    clueCompleted = false;
+                } else {
+                    clueCompleted = true;
+                }
+            }
+        }
+    }
+
+    private void handleNotify(String numberCompleted, String clueType) {
+        if (plugin.isIgnoredWorld()) return;
+        NotificationBody<ClueNotificationData> messageBody = new NotificationBody<>();
+
+        StringBuilder lootMessage = new StringBuilder();
+        long totalPrice = 0;
+        List<SerializedItemStack> itemStacks = new ArrayList<>();
+
+        for (Integer itemId : clueItems.keySet()) {
+            if (lootMessage.length() > 0) {
+                lootMessage.append("\n");
+            }
+            int quantity = clueItems.get(itemId);
+            int price = plugin.getItemManager().getItemPrice(itemId);
+            totalPrice += (long) price * quantity;
+            lootMessage.append(getItem(itemId, clueItems.get(itemId), messageBody));
+
+            ItemComposition itemComposition = plugin.getItemManager().getItemComposition(itemId);
+            itemStacks.add(new SerializedItemStack(itemId, quantity, price, itemComposition.getName()));
+        }
+
+        if (totalPrice < plugin.getConfig().clueMinValue()) {
+            return;
+        }
+
+        String notifyMessage = plugin.getConfig().clueNotifyMessage()
+            .replaceAll("%USERNAME%", Utils.getPlayerName())
+            .replaceAll("%CLUE%", clueType)
+            .replaceAll("%COUNT%", numberCompleted)
+            .replaceAll("%TOTAL_VALUE%", QuantityFormatter.quantityToStackSize(totalPrice))
+            .replaceAll("%LOOT%", lootMessage.toString());
+        messageBody.setContent(notifyMessage);
+        ClueNotificationData extra = new ClueNotificationData();
+        extra.setClueType(clueType);
+        extra.setNumberCompleted(Integer.parseInt(numberCompleted));
+        extra.setItems(itemStacks);
+        messageBody.setExtra(extra);
+        messageBody.setType(NotificationType.CLUE);
+        messageHandler.createMessage(plugin.getConfig().clueSendImage(), messageBody);
+    }
+
+    private String getItem(int itemId, int quantity, NotificationBody<ClueNotificationData> messageBody) {
+        int price = plugin.getItemManager().getItemPrice(itemId);
+        long totalPrice = (long) price * quantity;
+        ItemComposition itemComposition = plugin.getItemManager().getItemComposition(itemId);
+
+        if (plugin.getConfig().clueShowItems()) {
+            messageBody.getEmbeds().add(new NotificationBody.Embed(new NotificationBody.UrlEmbed(Utils.getItemImageUrl(itemId))));
+        }
+        return String.format("%s x %s (%s)", quantity, itemComposition.getName(), QuantityFormatter.quantityToStackSize(totalPrice));
+    }
+}
diff --git a/src/main/java/dinkplugin/notifiers/CollectionNotifier.java b/src/main/java/dinkplugin/notifiers/CollectionNotifier.java
new file mode 100644
index 0000000..a83caff
--- /dev/null
+++ b/src/main/java/dinkplugin/notifiers/CollectionNotifier.java
@@ -0,0 +1,43 @@
+package dinkplugin.notifiers;
+
+import dinkplugin.DinkPlugin;
+import dinkplugin.message.NotificationBody;
+import dinkplugin.message.NotificationType;
+import dinkplugin.Utils;
+import dinkplugin.notifiers.data.CollectionNotificationData;
+
+import javax.inject.Inject;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+public class CollectionNotifier extends BaseNotifier {
+    static final Pattern COLLECTION_LOG_REGEX = Pattern.compile("New item added to your collection log: (?<itemName>(.*))");
+
+    @Inject
+    public CollectionNotifier(DinkPlugin plugin) {
+        super(plugin);
+    }
+
+    public void onChatMessage(String chatMessage) {
+        if (!plugin.getConfig().notifyCollectionLog()) return;
+
+        Matcher collectionMatcher = COLLECTION_LOG_REGEX.matcher(chatMessage);
+        if (collectionMatcher.find()) {
+            this.handleNotify(collectionMatcher.group("itemName"));
+        }
+    }
+
+    private void handleNotify(String itemName) {
+        if (plugin.isIgnoredWorld()) return;
+        String notifyMessage = plugin.getConfig().collectionNotifyMessage()
+            .replaceAll("%USERNAME%", Utils.getPlayerName())
+            .replaceAll("%ITEM%", itemName);
+        NotificationBody<CollectionNotificationData> b = new NotificationBody<>();
+        b.setContent(notifyMessage);
+        CollectionNotificationData extra = new CollectionNotificationData();
+        extra.setItemName(itemName);
+        b.setExtra(extra);
+        b.setType(NotificationType.COLLECTION);
+        messageHandler.createMessage(plugin.getConfig().collectionSendImage(), b);
+    }
+}
diff --git a/src/main/java/dinkplugin/notifiers/DeathNotifier.java b/src/main/java/dinkplugin/notifiers/DeathNotifier.java
new file mode 100644
index 0000000..2899fdb
--- /dev/null
+++ b/src/main/java/dinkplugin/notifiers/DeathNotifier.java
@@ -0,0 +1,34 @@
+package dinkplugin.notifiers;
+
+import dinkplugin.DinkPlugin;
+import dinkplugin.message.NotificationBody;
+import dinkplugin.message.NotificationType;
+import dinkplugin.Utils;
+import net.runelite.api.events.ActorDeath;
+
+import javax.inject.Inject;
+
+public class DeathNotifier extends BaseNotifier {
+
+    @Inject
+    public DeathNotifier(DinkPlugin plugin) {
+        super(plugin);
+    }
+
+    @Override
+    public void handleNotify() {
+        if (plugin.isIgnoredWorld()) return;
+        String notifyMessage = plugin.getConfig().deathNotifyMessage()
+            .replaceAll("%USERNAME%", Utils.getPlayerName());
+        NotificationBody<Object> b = new NotificationBody<>();
+        b.setContent(notifyMessage);
+        b.setType(NotificationType.DEATH);
+        messageHandler.createMessage(plugin.getConfig().deathSendImage(), b);
+    }
+
+    public void onActorDeath(ActorDeath actor) {
+        if (plugin.getConfig().notifyDeath() && plugin.getClient().getLocalPlayer() == actor.getActor()) {
+            this.handleNotify();
+        }
+    }
+}
diff --git a/src/main/java/dinkplugin/LevelNotifier.java b/src/main/java/dinkplugin/notifiers/LevelNotifier.java
similarity index 70%
rename from src/main/java/dinkplugin/LevelNotifier.java
rename to src/main/java/dinkplugin/notifiers/LevelNotifier.java
index 918f63d..78625b0 100644
--- a/src/main/java/dinkplugin/LevelNotifier.java
+++ b/src/main/java/dinkplugin/notifiers/LevelNotifier.java
@@ -1,7 +1,15 @@
-package dinkplugin;
+package dinkplugin.notifiers;
 
+import dinkplugin.DinkPlugin;
+import dinkplugin.message.NotificationBody;
+import dinkplugin.message.NotificationType;
+import dinkplugin.Utils;
+import dinkplugin.notifiers.data.LevelNotificationData;
 import lombok.extern.slf4j.Slf4j;
 import net.runelite.api.Experience;
+import net.runelite.api.GameState;
+import net.runelite.api.events.GameStateChanged;
+import net.runelite.api.events.StatChanged;
 
 import javax.inject.Inject;
 import java.util.ArrayList;
@@ -28,9 +36,10 @@ public class LevelNotifier extends BaseNotifier {
     }
 
     private boolean checkLevelInterval(int level) {
-        return plugin.config.levelInterval() <= 1
+        int interval = plugin.getConfig().levelInterval();
+        return interval <= 1
             || level == 99
-            || level % plugin.config.levelInterval() == 0;
+            || level % interval == 0;
     }
 
     public void onTick() {
@@ -46,7 +55,17 @@ public class LevelNotifier extends BaseNotifier {
         }
     }
 
-    public void attemptNotify() {
+    public void onStatChanged(StatChanged statChange) {
+        this.handleLevelUp(statChange.getSkill().getName(), statChange.getLevel(), statChange.getXp());
+    }
+
+    public void onGameStateChanged(GameStateChanged gameStateChanged) {
+        if (gameStateChanged.getGameState() == GameState.LOGIN_SCREEN) {
+            this.reset();
+        }
+    }
+
+    private void attemptNotify() {
         sendMessage = false;
         StringBuilder skillMessage = new StringBuilder();
         int index = 0;
@@ -65,7 +84,7 @@ public class LevelNotifier extends BaseNotifier {
 
         String skillString = skillMessage.toString();
         levelledSkills.clear();
-        String fullNotification = plugin.config.levelNotifyMessage()
+        String fullNotification = plugin.getConfig().levelNotifyMessage()
             .replaceAll("%USERNAME%", Utils.getPlayerName())
             .replaceAll("%SKILL%", skillString);
         NotificationBody<LevelNotificationData> body = new NotificationBody<>();
@@ -77,7 +96,7 @@ public class LevelNotifier extends BaseNotifier {
 
         body.setExtra(extra);
         body.setType(NotificationType.LEVEL);
-        plugin.messageHandler.createMessage(plugin.config.levelSendImage(), body);
+        messageHandler.createMessage(plugin.getConfig().levelSendImage(), body);
     }
 
     public void handleLevelUp(String skill, int level, int xp) {
@@ -85,7 +104,7 @@ public class LevelNotifier extends BaseNotifier {
 
         int virtualLevel = level < 99 ? level : Experience.getLevelForXp(xp); // avoid log(n) query when not needed
         Integer previousLevel = currentLevels.put(skill, virtualLevel);
-        if (plugin.config.notifyLevel() && checkLevelInterval(virtualLevel) && previousLevel != null) {
+        if (plugin.getConfig().notifyLevel() && checkLevelInterval(virtualLevel) && previousLevel != null) {
             if (virtualLevel > previousLevel) {
                 levelledSkills.add(skill);
                 sendMessage = true;
diff --git a/src/main/java/dinkplugin/LootNotifier.java b/src/main/java/dinkplugin/notifiers/LootNotifier.java
similarity index 55%
rename from src/main/java/dinkplugin/LootNotifier.java
rename to src/main/java/dinkplugin/notifiers/LootNotifier.java
index cbebf01..0cafd53 100644
--- a/src/main/java/dinkplugin/LootNotifier.java
+++ b/src/main/java/dinkplugin/notifiers/LootNotifier.java
@@ -1,44 +1,73 @@
-package dinkplugin;
+package dinkplugin.notifiers;
 
+import dinkplugin.DinkPlugin;
+import dinkplugin.message.NotificationBody;
+import dinkplugin.message.NotificationType;
+import dinkplugin.Utils;
+import dinkplugin.notifiers.data.LootNotificationData;
+import dinkplugin.notifiers.data.SerializedItemStack;
 import net.runelite.api.ItemComposition;
+import net.runelite.client.events.NpcLootReceived;
+import net.runelite.client.events.PlayerLootReceived;
 import net.runelite.client.game.ItemStack;
+import net.runelite.client.plugins.loottracker.LootReceived;
 import net.runelite.client.util.QuantityFormatter;
+import net.runelite.http.api.loottracker.LootRecordType;
 
 import javax.inject.Inject;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
 
-
 public class LootNotifier extends BaseNotifier {
-    private boolean sendMessage = false;
 
     @Inject
     public LootNotifier(DinkPlugin plugin) {
         super(plugin);
     }
 
-    public void handleNotify(Collection<ItemStack> items, String dropper) {
+    public void onNpcLootReceived(NpcLootReceived event) {
+        if (plugin.getConfig().notifyLoot()) {
+            this.handleNotify(event.getItems(), event.getNpc().getName());
+        }
+    }
+
+    public void onPlayerLootReceived(PlayerLootReceived event) {
+        this.handleNotify(event.getItems(), event.getPlayer().getName());
+    }
+
+    public void onLootReceived(LootReceived lootReceived) {
+        if (!plugin.getConfig().notifyLoot()) return;
+
+        // only consider non-NPC and non-PK loot
+        if (lootReceived.getType() == LootRecordType.EVENT || lootReceived.getType() == LootRecordType.PICKPOCKET) {
+            this.handleNotify(lootReceived.getItems(), lootReceived.getName());
+        }
+    }
+
+    private void handleNotify(Collection<ItemStack> items, String dropper) {
         if (plugin.isIgnoredWorld()) return;
+
+        boolean sendMessage = false;
         NotificationBody<LootNotificationData> messageBody = new NotificationBody<>();
         StringBuilder lootMessage = new StringBuilder();
-        int minValue = plugin.config.minLootValue();
+        int minValue = plugin.getConfig().minLootValue();
         long totalStackValue = 0;
         List<SerializedItemStack> serializedItems = new ArrayList<>();
         for (ItemStack item : Utils.reduceItemStack(items)) {
             int itemId = item.getId();
             int quantity = item.getQuantity();
-            int price = plugin.itemManager.getItemPrice(itemId);
+            int price = plugin.getItemManager().getItemPrice(itemId);
             long totalPrice = (long) price * quantity;
 
-            ItemComposition itemComposition = plugin.itemManager.getItemComposition(itemId);
+            ItemComposition itemComposition = plugin.getItemManager().getItemComposition(itemId);
             if (totalPrice >= minValue) {
                 if (totalStackValue != 0) {
                     lootMessage.append("\n");
                 }
                 sendMessage = true;
                 lootMessage.append(String.format("%s x %s (%s)", quantity, itemComposition.getName(), QuantityFormatter.quantityToStackSize(totalPrice)));
-                if (plugin.config.lootIcons()) {
+                if (plugin.getConfig().lootIcons()) {
                     messageBody.getEmbeds().add(new NotificationBody.Embed(new NotificationBody.UrlEmbed(Utils.getItemImageUrl(itemId))));
                 }
             }
@@ -48,9 +77,8 @@ public class LootNotifier extends BaseNotifier {
         }
 
         if (sendMessage) {
-            sendMessage = false;
             String lootString = lootMessage.toString();
-            String notifyMessage = plugin.config.lootNotifyMessage()
+            String notifyMessage = plugin.getConfig().lootNotifyMessage()
                 .replaceAll("%USERNAME%", Utils.getPlayerName())
                 .replaceAll("%LOOT%", lootString)
                 .replaceAll("%TOTAL_VALUE%", QuantityFormatter.quantityToStackSize(totalStackValue))
@@ -61,7 +89,7 @@ public class LootNotifier extends BaseNotifier {
             extra.setSource(dropper);
             messageBody.setExtra(extra);
             messageBody.setType(NotificationType.LOOT);
-            plugin.messageHandler.createMessage(plugin.config.lootSendImage(), messageBody);
+            messageHandler.createMessage(plugin.getConfig().lootSendImage(), messageBody);
         }
     }
 }
diff --git a/src/main/java/dinkplugin/notifiers/PetNotifier.java b/src/main/java/dinkplugin/notifiers/PetNotifier.java
new file mode 100644
index 0000000..2b6e7e3
--- /dev/null
+++ b/src/main/java/dinkplugin/notifiers/PetNotifier.java
@@ -0,0 +1,34 @@
+package dinkplugin.notifiers;
+
+import dinkplugin.DinkPlugin;
+import dinkplugin.message.NotificationBody;
+import dinkplugin.message.NotificationType;
+import dinkplugin.Utils;
+
+import javax.inject.Inject;
+import java.util.regex.Pattern;
+
+public class PetNotifier extends BaseNotifier {
+    static final Pattern PET_REGEX = Pattern.compile("You (?:have a funny feeling like you|feel something weird sneaking).*");
+
+    @Inject
+    public PetNotifier(DinkPlugin plugin) {
+        super(plugin);
+    }
+
+    @Override
+    public void handleNotify() {
+        String notifyMessage = plugin.getConfig().petNotifyMessage()
+            .replaceAll("%USERNAME%", Utils.getPlayerName());
+        NotificationBody<Object> body = new NotificationBody<>();
+        body.setContent(notifyMessage);
+        body.setType(NotificationType.PET);
+        messageHandler.createMessage(plugin.getConfig().petSendImage(), body);
+    }
+
+    public void onChatMessage(String chatMessage) {
+        if (plugin.getConfig().notifyPet() && PET_REGEX.matcher(chatMessage).matches()) {
+            this.handleNotify();
+        }
+    }
+}
diff --git a/src/main/java/dinkplugin/notifiers/QuestNotifier.java b/src/main/java/dinkplugin/notifiers/QuestNotifier.java
new file mode 100644
index 0000000..641b65c
--- /dev/null
+++ b/src/main/java/dinkplugin/notifiers/QuestNotifier.java
@@ -0,0 +1,43 @@
+package dinkplugin.notifiers;
+
+import dinkplugin.DinkPlugin;
+import dinkplugin.message.NotificationBody;
+import dinkplugin.message.NotificationType;
+import dinkplugin.Utils;
+import dinkplugin.notifiers.data.QuestNotificationData;
+import net.runelite.api.events.WidgetLoaded;
+import net.runelite.api.widgets.Widget;
+import net.runelite.api.widgets.WidgetInfo;
+
+import static net.runelite.api.widgets.WidgetID.QUEST_COMPLETED_GROUP_ID;
+
+public class QuestNotifier extends BaseNotifier {
+
+    public QuestNotifier(DinkPlugin plugin) {
+        super(plugin);
+    }
+
+    public void onWidgetLoaded(WidgetLoaded event) {
+        if (event.getGroupId() == QUEST_COMPLETED_GROUP_ID && plugin.getConfig().notifyQuest()) {
+            Widget quest = plugin.getClient().getWidget(WidgetInfo.QUEST_COMPLETED_NAME_TEXT);
+            if (quest != null) {
+                String questWidget = quest.getText();
+                this.handleNotify(questWidget);
+            }
+        }
+    }
+
+    private void handleNotify(String questText) {
+        if (plugin.isIgnoredWorld()) return;
+        String notifyMessage = plugin.getConfig().questNotifyMessage()
+            .replaceAll("%USERNAME%", Utils.getPlayerName())
+            .replaceAll("%QUEST%", Utils.parseQuestWidget(questText));
+        NotificationBody<QuestNotificationData> body = new NotificationBody<>();
+        body.setContent(notifyMessage);
+        QuestNotificationData extra = new QuestNotificationData();
+        extra.setQuestName(Utils.parseQuestWidget(questText));
+        body.setExtra(extra);
+        body.setType(NotificationType.QUEST);
+        messageHandler.createMessage(plugin.getConfig().questSendImage(), body);
+    }
+}
diff --git a/src/main/java/dinkplugin/notifiers/SlayerNotifier.java b/src/main/java/dinkplugin/notifiers/SlayerNotifier.java
new file mode 100644
index 0000000..f23d700
--- /dev/null
+++ b/src/main/java/dinkplugin/notifiers/SlayerNotifier.java
@@ -0,0 +1,88 @@
+package dinkplugin.notifiers;
+
+import dinkplugin.DinkPlugin;
+import dinkplugin.message.NotificationBody;
+import dinkplugin.message.NotificationType;
+import dinkplugin.Utils;
+import dinkplugin.notifiers.data.SlayerNotificationData;
+
+import javax.inject.Inject;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+public class SlayerNotifier extends BaseNotifier {
+    public static final Pattern SLAYER_TASK_REGEX = Pattern.compile("You have completed your task! You killed (?<task>[\\d,]+ [^.]+)\\..*");
+    private static final Pattern SLAYER_COMPLETE_REGEX = Pattern.compile("You've completed (?:at least )?(?<taskCount>[\\d,]+) (?:Wilderness )?tasks?(?: and received (?<points>\\d+) points, giving you a total of [\\d,]+|\\.You'll be eligible to earn reward points if you complete tasks from a more advanced Slayer Master\\.| and reached the maximum amount of Slayer points \\((?<points2>[\\d,]+)\\))?");
+
+    private String slayerTask = "";
+    private String slayerPoints = "";
+    private String slayerCompleted = "";
+
+    @Inject
+    public SlayerNotifier(DinkPlugin plugin) {
+        super(plugin);
+    }
+
+    @Override
+    public void handleNotify() {
+        if (plugin.isIgnoredWorld()) return;
+        // Little jank, but it's a bit cleaner than having bools and checking in the main plugin class
+        if (slayerPoints.isEmpty() || slayerTask.isEmpty() || slayerCompleted.isEmpty()) {
+            return;
+        }
+
+        String notifyMessage = plugin.getConfig().slayerNotifyMessage()
+            .replaceAll("%USERNAME%", Utils.getPlayerName())
+            .replaceAll("%TASK%", slayerTask)
+            .replaceAll("%TASKCOUNT%", slayerCompleted)
+            .replaceAll("%POINTS%", slayerPoints);
+        NotificationBody<SlayerNotificationData> body = new NotificationBody<>();
+        SlayerNotificationData extra = new SlayerNotificationData();
+        extra.setSlayerPoints(slayerPoints);
+        extra.setSlayerCompleted(slayerCompleted);
+        extra.setSlayerTask(slayerTask);
+        body.setExtra(extra);
+        body.setContent(notifyMessage);
+        body.setType(NotificationType.SLAYER);
+        messageHandler.createMessage(plugin.getConfig().slayerSendImage(), body);
+
+        slayerTask = "";
+        slayerPoints = "";
+        slayerCompleted = "";
+    }
+
+    public void onChatMessage(String chatMessage) {
+
+        if (plugin.getConfig().notifySlayer()
+            && (chatMessage.contains("Slayer master")
+            || chatMessage.contains("Slayer Master")
+            || chatMessage.contains("completed your task!")
+        )) {
+            Matcher taskMatcher = SLAYER_TASK_REGEX.matcher(chatMessage);
+            Matcher pointsMatcher = SLAYER_COMPLETE_REGEX.matcher(chatMessage);
+
+            if (taskMatcher.find()) {
+                this.slayerTask = taskMatcher.group("task");
+                this.handleNotify();
+            }
+
+            if (pointsMatcher.find()) {
+                String slayerPoints = pointsMatcher.group("points");
+                String slayerTasksCompleted = pointsMatcher.group("taskCount");
+
+                if (slayerPoints == null) {
+                    slayerPoints = pointsMatcher.group("points2");
+                }
+
+                // 3 different cases of seeing points, so in our worst case it's 0
+                if (slayerPoints == null) {
+                    slayerPoints = "0";
+                }
+                this.slayerPoints = slayerPoints;
+                this.slayerCompleted = slayerTasksCompleted;
+
+                this.handleNotify();
+            }
+        }
+    }
+}
diff --git a/src/main/java/dinkplugin/notifiers/SpeedrunNotifier.java b/src/main/java/dinkplugin/notifiers/SpeedrunNotifier.java
new file mode 100644
index 0000000..0222536
--- /dev/null
+++ b/src/main/java/dinkplugin/notifiers/SpeedrunNotifier.java
@@ -0,0 +1,82 @@
+package dinkplugin.notifiers;
+
+import dinkplugin.DinkPlugin;
+import dinkplugin.message.NotificationBody;
+import dinkplugin.message.NotificationType;
+import dinkplugin.Utils;
+import dinkplugin.notifiers.data.SpeedrunNotificationData;
+import lombok.extern.slf4j.Slf4j;
+import net.runelite.api.Client;
+import net.runelite.api.events.WidgetLoaded;
+import net.runelite.api.widgets.Widget;
+
+import java.time.Duration;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+@Slf4j
+public class SpeedrunNotifier extends BaseNotifier {
+    private static final int SPEEDRUN_COMPLETED_GROUP_ID = 781;
+    private static final int SPEEDRUN_COMPLETED_QUEST_NAME_CHILD_ID = 4;
+    private static final int SPEEDRUN_COMPLETED_DURATION_CHILD_ID = 10;
+    private static final int SPEEDRUN_COMPLETED_PB_CHILD_ID = 12;
+
+    public SpeedrunNotifier(DinkPlugin plugin) {
+        super(plugin);
+    }
+
+    public void onWidgetLoaded(WidgetLoaded event) {
+        if (event.getGroupId() == SPEEDRUN_COMPLETED_GROUP_ID && plugin.getConfig().notifySpeedrun()) {
+            Client client = plugin.getClient();
+            Widget questName = client.getWidget(SPEEDRUN_COMPLETED_GROUP_ID, SPEEDRUN_COMPLETED_QUEST_NAME_CHILD_ID);
+            Widget duration = client.getWidget(SPEEDRUN_COMPLETED_GROUP_ID, SPEEDRUN_COMPLETED_DURATION_CHILD_ID);
+            Widget personalBest = client.getWidget(SPEEDRUN_COMPLETED_GROUP_ID, SPEEDRUN_COMPLETED_PB_CHILD_ID);
+            if (questName != null && duration != null && personalBest != null) {
+                this.attemptNotify(Utils.parseQuestWidget(questName.getText()), duration.getText(), personalBest.getText());
+            } else {
+                log.error("Found speedrun finished widget (group id {}) but it is missing something, questName={}, duration={}, pb={}", SPEEDRUN_COMPLETED_GROUP_ID, questName, duration, personalBest);
+            }
+        }
+    }
+
+    private void attemptNotify(String questName, String duration, String pb) {
+        Duration bestTime = this.parseTime(pb);
+        Duration currentTime = this.parseTime(duration);
+        boolean isPb = bestTime.compareTo(currentTime) >= 0;
+        if (!isPb && plugin.getConfig().speedrunPBOnly()) {
+            return;
+        }
+        // pb or notifying on non-pb; take the right string and format placeholders
+        String notifyMessage = plugin.getConfig().speedrunMessage();
+        if (isPb) {
+            notifyMessage = plugin.getConfig().speedrunPBMessage();
+        }
+
+        notifyMessage = notifyMessage
+            .replaceAll("%USERNAME%", Utils.getPlayerName())
+            .replaceAll("%QUEST%", questName)
+            .replaceAll("%TIME%", duration)
+            .replaceAll("%BEST%", pb);
+        NotificationBody<SpeedrunNotificationData> body = new NotificationBody<>();
+        body.setContent(notifyMessage);
+        SpeedrunNotificationData extra = new SpeedrunNotificationData();
+        extra.setQuestName(questName);
+        extra.setPersonalBest(bestTime.toString());
+        extra.setCurrentTime(currentTime.toString());
+        body.setExtra(extra);
+        body.setType(NotificationType.SPEEDRUN);
+        messageHandler.createMessage(plugin.getConfig().speedrunSendImage(), body);
+    }
+
+    private static final Pattern TIME_PATTERN = Pattern.compile("(?<minutes>\\d+):(?<seconds>\\d{2})\\.(?<fractional>\\d{2})");
+
+    public Duration parseTime(String in) {
+        Matcher m = TIME_PATTERN.matcher(in);
+        if (!m.find()) return Duration.ofMillis(0);
+        int minutes = Integer.parseInt(m.group("minutes"));
+        int seconds = Integer.parseInt(m.group("seconds"));
+        int fractional = Integer.parseInt(m.group("fractional"));
+        // 0.001
+        return Duration.ofMillis(10L * (100L * (60L * minutes + seconds) + fractional));
+    }
+}
diff --git a/src/main/java/dinkplugin/ClueNotificationData.java b/src/main/java/dinkplugin/notifiers/data/ClueNotificationData.java
similarity index 84%
rename from src/main/java/dinkplugin/ClueNotificationData.java
rename to src/main/java/dinkplugin/notifiers/data/ClueNotificationData.java
index 908581f..51e049b 100644
--- a/src/main/java/dinkplugin/ClueNotificationData.java
+++ b/src/main/java/dinkplugin/notifiers/data/ClueNotificationData.java
@@ -1,4 +1,4 @@
-package dinkplugin;
+package dinkplugin.notifiers.data;
 
 import lombok.Data;
 
diff --git a/src/main/java/dinkplugin/CollectionNotificationData.java b/src/main/java/dinkplugin/notifiers/data/CollectionNotificationData.java
similarity index 74%
rename from src/main/java/dinkplugin/CollectionNotificationData.java
rename to src/main/java/dinkplugin/notifiers/data/CollectionNotificationData.java
index 493ad37..7700834 100644
--- a/src/main/java/dinkplugin/CollectionNotificationData.java
+++ b/src/main/java/dinkplugin/notifiers/data/CollectionNotificationData.java
@@ -1,4 +1,4 @@
-package dinkplugin;
+package dinkplugin.notifiers.data;
 
 import lombok.Data;
 
diff --git a/src/main/java/dinkplugin/LevelNotificationData.java b/src/main/java/dinkplugin/notifiers/data/LevelNotificationData.java
similarity index 87%
rename from src/main/java/dinkplugin/LevelNotificationData.java
rename to src/main/java/dinkplugin/notifiers/data/LevelNotificationData.java
index 601b080..22e0a87 100644
--- a/src/main/java/dinkplugin/LevelNotificationData.java
+++ b/src/main/java/dinkplugin/notifiers/data/LevelNotificationData.java
@@ -1,4 +1,4 @@
-package dinkplugin;
+package dinkplugin.notifiers.data;
 
 import lombok.Data;
 
diff --git a/src/main/java/dinkplugin/LootNotificationData.java b/src/main/java/dinkplugin/notifiers/data/LootNotificationData.java
similarity index 85%
rename from src/main/java/dinkplugin/LootNotificationData.java
rename to src/main/java/dinkplugin/notifiers/data/LootNotificationData.java
index fc02a1f..cb3baf5 100644
--- a/src/main/java/dinkplugin/LootNotificationData.java
+++ b/src/main/java/dinkplugin/notifiers/data/LootNotificationData.java
@@ -1,4 +1,4 @@
-package dinkplugin;
+package dinkplugin.notifiers.data;
 
 import lombok.Data;
 
diff --git a/src/main/java/dinkplugin/QuestNotificationData.java b/src/main/java/dinkplugin/notifiers/data/QuestNotificationData.java
similarity index 73%
rename from src/main/java/dinkplugin/QuestNotificationData.java
rename to src/main/java/dinkplugin/notifiers/data/QuestNotificationData.java
index aef38b8..5d1b5d1 100644
--- a/src/main/java/dinkplugin/QuestNotificationData.java
+++ b/src/main/java/dinkplugin/notifiers/data/QuestNotificationData.java
@@ -1,4 +1,4 @@
-package dinkplugin;
+package dinkplugin.notifiers.data;
 
 import lombok.Data;
 
diff --git a/src/main/java/dinkplugin/notifiers/data/SerializedItemStack.java b/src/main/java/dinkplugin/notifiers/data/SerializedItemStack.java
new file mode 100644
index 0000000..22b9064
--- /dev/null
+++ b/src/main/java/dinkplugin/notifiers/data/SerializedItemStack.java
@@ -0,0 +1,15 @@
+package dinkplugin.notifiers.data;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+
+@Data
+@Builder
+@AllArgsConstructor
+public class SerializedItemStack {
+    private int id;
+    private int quantity;
+    private int priceEach;
+    private String name;
+}
diff --git a/src/main/java/dinkplugin/SlayerNotificationData.java b/src/main/java/dinkplugin/notifiers/data/SlayerNotificationData.java
similarity index 82%
rename from src/main/java/dinkplugin/SlayerNotificationData.java
rename to src/main/java/dinkplugin/notifiers/data/SlayerNotificationData.java
index d3ecc78..ed30997 100644
--- a/src/main/java/dinkplugin/SlayerNotificationData.java
+++ b/src/main/java/dinkplugin/notifiers/data/SlayerNotificationData.java
@@ -1,4 +1,4 @@
-package dinkplugin;
+package dinkplugin.notifiers.data;
 
 import lombok.Data;
 
diff --git a/src/main/java/dinkplugin/SpeedrunNotificationData.java b/src/main/java/dinkplugin/notifiers/data/SpeedrunNotificationData.java
similarity index 61%
rename from src/main/java/dinkplugin/SpeedrunNotificationData.java
rename to src/main/java/dinkplugin/notifiers/data/SpeedrunNotificationData.java
index c256742..bb589b1 100644
--- a/src/main/java/dinkplugin/SpeedrunNotificationData.java
+++ b/src/main/java/dinkplugin/notifiers/data/SpeedrunNotificationData.java
@@ -1,8 +1,10 @@
-package dinkplugin;
+package dinkplugin.notifiers.data;
 
 import lombok.Data;
+import lombok.EqualsAndHashCode;
 
 @Data
+@EqualsAndHashCode(callSuper = true)
 public class SpeedrunNotificationData extends QuestNotificationData {
     private String personalBest;
     private String currentTime;


Test patch:
diff --git a/src/test/java/dinkplugin/DinkTest.java b/src/test/java/dinkplugin/DinkTest.java
index fb17065..bbc39f2 100644
--- a/src/test/java/dinkplugin/DinkTest.java
+++ b/src/test/java/dinkplugin/DinkTest.java
@@ -1,10 +1,10 @@
 package dinkplugin;
 
-import dinkplugin.DinkPlugin;
 import net.runelite.client.RuneLite;
 import net.runelite.client.externalplugins.ExternalPluginManager;
 
 public class DinkTest {
+    @SuppressWarnings("unchecked")
     public static void main(String[] args) throws Exception {
         ExternalPluginManager.loadBuiltin(DinkPlugin.class);
         RuneLite.main(args);
diff --git a/src/test/java/dinkplugin/IgnoredWorldTest.kt b/src/test/java/dinkplugin/IgnoredWorldTest.kt
index 4cb88ff..0a20052 100644
--- a/src/test/java/dinkplugin/IgnoredWorldTest.kt
+++ b/src/test/java/dinkplugin/IgnoredWorldTest.kt
@@ -6,7 +6,6 @@ import org.junit.jupiter.params.ParameterizedTest
 import org.junit.jupiter.params.provider.Arguments
 import org.junit.jupiter.params.provider.ArgumentsProvider
 import org.junit.jupiter.params.provider.ArgumentsSource
-import org.junit.jupiter.params.provider.ValueSource
 import java.util.stream.Stream
 import java.util.EnumSet;
 import net.runelite.api.WorldType;
@@ -15,13 +14,13 @@ class IgnoredWorldTest {
     @ParameterizedTest(name = "World should not be ignored {0}")
     @ArgumentsSource(NonIgnoredWorldTypeProvider::class)
     fun `World should not be ignored`(worldType: Set<WorldType>) {
-        assertFalse(DinkPlugin._isIgnoredWorld(worldType))
+        assertFalse(Utils.isIgnoredWorld(worldType))
     }
 
     private class NonIgnoredWorldTypeProvider : ArgumentsProvider {
         override fun provideArguments(context: ExtensionContext?): Stream<out Arguments> = Stream.of(
             Arguments.of(EnumSet.of(WorldType.MEMBERS)),
-            Arguments.of(emptySet<WorldType>()),
+            Arguments.of(EnumSet.noneOf(WorldType::class.java)),
             Arguments.of(EnumSet.of(WorldType.SKILL_TOTAL)),
             Arguments.of(EnumSet.of(WorldType.FRESH_START_WORLD)),
         )
@@ -30,7 +29,7 @@ class IgnoredWorldTest {
     @ParameterizedTest(name = "World should be ignored {0}")
     @ArgumentsSource(IgnoredWorldTypeProvider::class)
     fun `World should be ignored`(worldType: Set<WorldType>) {
-        assertTrue(DinkPlugin._isIgnoredWorld(worldType))
+        assertTrue(Utils.isIgnoredWorld(worldType))
     }
 
     private class IgnoredWorldTypeProvider : ArgumentsProvider {
diff --git a/src/test/java/dinkplugin/MatchersTest.kt b/src/test/java/dinkplugin/notifiers/MatchersTest.kt
similarity index 90%
rename from src/test/java/dinkplugin/MatchersTest.kt
rename to src/test/java/dinkplugin/notifiers/MatchersTest.kt
index d87ebff..d3fde59 100644
--- a/src/test/java/dinkplugin/MatchersTest.kt
+++ b/src/test/java/dinkplugin/notifiers/MatchersTest.kt
@@ -1,4 +1,4 @@
-package dinkplugin
+package dinkplugin.notifiers
 
 import org.junit.jupiter.api.Assertions.*
 import org.junit.jupiter.api.extension.ExtensionContext
@@ -13,7 +13,7 @@ class MatchersTest {
     @ParameterizedTest(name = "Slayer task completion message should trigger {0}")
     @ArgumentsSource(SlayerTaskProvider::class)
     fun `Slayer task completion regex finds match`(message: String, task: String) {
-        val matcher = DinkPlugin.SLAYER_TASK_REGEX.matcher(message)
+        val matcher = SlayerNotifier.SLAYER_TASK_REGEX.matcher(message)
         assertTrue(matcher.find())
         assertEquals(task, matcher.group("task"))
     }
@@ -27,7 +27,7 @@ class MatchersTest {
         ]
     )
     fun `Slayer task completion regex does not match`(message: String) {
-        val matcher = DinkPlugin.SLAYER_TASK_REGEX.matcher(message)
+        val matcher = SlayerNotifier.SLAYER_TASK_REGEX.matcher(message)
         assertFalse(matcher.find())
     }
 
@@ -54,7 +54,7 @@ class MatchersTest {
     @ParameterizedTest(name = "Collection log message should trigger {0}")
     @ArgumentsSource(CollectionLogProvider::class)
     fun `Collection log regex finds match`(message: String, item: String) {
-        val matcher = DinkPlugin.COLLECTION_LOG_REGEX.matcher(message)
+        val matcher = CollectionNotifier.COLLECTION_LOG_REGEX.matcher(message)
         assertTrue(matcher.find())
         assertEquals(item, matcher.group("itemName"))
     }
@@ -66,7 +66,7 @@ class MatchersTest {
         ]
     )
     fun `Collection log regex does not match`(message: String) {
-        val matcher = DinkPlugin.COLLECTION_LOG_REGEX.matcher(message)
+        val matcher = CollectionNotifier.COLLECTION_LOG_REGEX.matcher(message)
         assertFalse(matcher.find())
     }
 
@@ -96,7 +96,7 @@ class MatchersTest {
         ]
     )
     fun `Pet regex finds match`(message: String) {
-        val matcher = DinkPlugin.PET_REGEX.matcher(message)
+        val matcher = PetNotifier.PET_REGEX.matcher(message)
         assertTrue(matcher.find())
     }
 
@@ -108,7 +108,7 @@ class MatchersTest {
         ]
     )
     fun `Pet regex does not match`(message: String) {
-        val matcher = DinkPlugin.PET_REGEX.matcher(message)
+        val matcher = PetNotifier.PET_REGEX.matcher(message)
         assertFalse(matcher.find())
     }