Repository: pajlads/DinkPlugin
Pull request #106 (2022-12-24 08:25:35)

feat: track best fight duration in kill count notifier
* Allow kill count notification on personal best time (even if interval was not met)
* Utilize separate message template for best time notification
* Add latest fight duration to `extra` json data


Felanbird: Crash is resolved, general functionality of the notifier still works as expected, will think about what approach to take to test the actual personalBest changes.

```
{
   "type":"KILL_COUNT",
   "extra":{
      "boss":"Vorkath",
      "count":1375,
      "gameMessage":"Your Vorkath kill count is: 1375.",
      "time":"PT2M22S",
      "isPersonalBest":false
   },
   "content":"felanbird1 has defeated Vorkath with a completion count of 1375",
   "playerName":"felanbird1",
   "embeds":[
      
   ]
}
```
iProdigy: > If easily done I'd prefer the milliseconds be cut

I agree the duration formatting could be nicer. As for the milliseconds, can you try testing with: 
![image](https://user-images.githubusercontent.com/8106344/209478119-df438bcc-eb35-4aa8-8161-860f0c21d2d0.png)

It claims tenth of a second, but I recall seeing examples with hundredths (but maybe they were all 0's in the hundredths place)... also we could skip milliseconds altogether if the precise varbit is not enabled
pajlada: I think the millisecond option is fairly common, I see the .60's/.40's fairly often on streams
Felanbird: > I think the millisecond option is fairly common, I see the .60's/.40's fairly often on streams

Yeah he was correct, toggling that setting gives me `.xx` kill times on Alch hydra
pajlada: It's only the nearest tenth of a second, so maybe we can remove the 3rd decimal
pajlada: I'm happy with these changes, good to merge

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a03359a..3c22d05 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,6 @@
 ## Unreleased
 
+- Minor: Add personal best time message to kill count notifier. (#106)
 - Minor: Add better descriptions for `Min Value` settings. (#100)
 - Minor: Add death notifier setting to disable kept item embeds. (#99)
 - Minor: Add clue and loot notifier setting to skip screenshots for low item values. (#98)
diff --git a/src/main/java/dinkplugin/DinkPlugin.java b/src/main/java/dinkplugin/DinkPlugin.java
index 2ae96ae..d43988e 100644
--- a/src/main/java/dinkplugin/DinkPlugin.java
+++ b/src/main/java/dinkplugin/DinkPlugin.java
@@ -80,6 +80,7 @@ public class DinkPlugin extends Plugin {
         diaryNotifier.reset();
         levelNotifier.reset();
         slayerNotifier.reset();
+        killCountNotifier.reset();
     }
 
     @Provides
@@ -114,12 +115,13 @@ public class DinkPlugin extends Plugin {
         slayerNotifier.onTick();
         levelNotifier.onTick();
         diaryNotifier.onTick();
+        killCountNotifier.onTick();
     }
 
     @Subscribe
     public void onChatMessage(ChatMessage message) {
         ChatMessageType msgType = message.getType();
-        String chatMessage = Text.removeTags(message.getMessage());
+        String chatMessage = Text.removeTags(message.getMessage().replace("<br>", "\n")).replace('\u00A0', ' ').trim();
 
         if (msgType == ChatMessageType.GAMEMESSAGE) {
             collectionNotifier.onChatMessage(chatMessage);
@@ -129,6 +131,10 @@ public class DinkPlugin extends Plugin {
             killCountNotifier.onGameMessage(chatMessage);
             combatTaskNotifier.onGameMessage(chatMessage);
         }
+
+        if (msgType == ChatMessageType.FRIENDSCHATNOTIFICATION) {
+            killCountNotifier.onFriendsChatNotification(chatMessage);
+        }
     }
 
     @Subscribe
diff --git a/src/main/java/dinkplugin/DinkPluginConfig.java b/src/main/java/dinkplugin/DinkPluginConfig.java
index 52908b7..37e29ff 100644
--- a/src/main/java/dinkplugin/DinkPluginConfig.java
+++ b/src/main/java/dinkplugin/DinkPluginConfig.java
@@ -782,11 +782,22 @@ public interface DinkPluginConfig extends Config {
         return true;
     }
 
+    @ConfigItem(
+        keyName = "killCountPB",
+        name = "Personal Best",
+        description = "Notify on achieving a new personal best time",
+        position = 93,
+        section = killCountSection
+    )
+    default boolean killCountNotifyBestTime() {
+        return true;
+    }
+
     @ConfigItem(
         keyName = "killCountInterval",
         name = "Kill Count Interval",
         description = "Interval between when a notification should be sent",
-        position = 93,
+        position = 94,
         section = killCountSection
     )
     default int killCountInterval() {
@@ -797,13 +808,24 @@ public interface DinkPluginConfig extends Config {
         keyName = "killCountMessage",
         name = "Notification Message",
         description = "The message to be sent to the webhook. Use %USERNAME% to insert your username, %BOSS% to insert the NPC name, %COUNT% to insert the kill count",
-        position = 94,
+        position = 95,
         section = killCountSection
     )
     default String killCountMessage() {
         return "%USERNAME% has defeated %BOSS% with a completion count of %COUNT%";
     }
 
+    @ConfigItem(
+        keyName = "killCountBestTimeMessage",
+        name = "PB Notification Message",
+        description = "The message to be sent to the webhook upon a personal best time. Use %USERNAME% to insert your username, %BOSS% to insert the NPC name, %COUNT% to insert the kill count, %TIME% to insert the completion time",
+        position = 96,
+        section = killCountSection
+    )
+    default String killCountBestTimeMessage() {
+        return "%USERNAME% has defeated %BOSS% with a new personal best time of %TIME% and a completion count of %COUNT%";
+    }
+
     @ConfigItem(
         keyName = "combatTaskEnabled",
         name = "Enable Combat Tasks",
diff --git a/src/main/java/dinkplugin/Utils.java b/src/main/java/dinkplugin/Utils.java
index 028f2c7..72554e7 100644
--- a/src/main/java/dinkplugin/Utils.java
+++ b/src/main/java/dinkplugin/Utils.java
@@ -18,7 +18,10 @@ import net.runelite.client.game.ItemStack;
 import net.runelite.client.util.ColorUtil;
 import okhttp3.MediaType;
 import okhttp3.MultipartBody;
+import org.apache.commons.lang3.ObjectUtils;
+import org.apache.commons.lang3.StringUtils;
 import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
 import org.jetbrains.annotations.VisibleForTesting;
 
 import javax.imageio.ImageIO;
@@ -26,6 +29,9 @@ import java.awt.Color;
 import java.awt.image.BufferedImage;
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
+import java.time.Duration;
+import java.time.LocalTime;
+import java.time.temporal.Temporal;
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
@@ -42,6 +48,10 @@ import java.util.regex.Pattern;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
 
+import static java.time.temporal.ChronoField.HOUR_OF_DAY;
+import static java.time.temporal.ChronoField.MILLI_OF_SECOND;
+import static java.time.temporal.ChronoField.MINUTE_OF_HOUR;
+import static java.time.temporal.ChronoField.SECOND_OF_MINUTE;
 import static net.runelite.api.ItemID.*;
 
 public class Utils {
@@ -49,6 +59,8 @@ public class Utils {
     public static final Color PINK = ColorUtil.fromHex("#f40098"); // analogous to RED in CIELCh_uv color space
     public static final Color RED = ColorUtil.fromHex("#ca2a2d"); // red used in pajaW
 
+    private static final Pattern TIME_PATTERN = Pattern.compile("\\b(?:(?<hours>\\d+):)?(?<minutes>\\d+):(?<seconds>\\d{2})(?:\\.(?<fractional>\\d{2}))?\\b");
+
     private static final Set<WorldType> IGNORED_WORLDS = EnumSet.of(WorldType.PVP_ARENA, WorldType.QUEST_SPEEDRUNNING, WorldType.NOSAVE_MODE, WorldType.TOURNAMENT_WORLD);
 
     private static final Set<Integer> LMS_REGIONS = ImmutableSet.of(13658, 13659, 13660, 13914, 13915, 13916, 13918, 13919, 13920, 14174, 14175, 14176, 14430, 14431, 14432);
@@ -66,6 +78,9 @@ public class Utils {
     @Varbit
     private static final int CASTLE_WARS_Y_OFFSET = 157;
 
+    @VisibleForTesting
+    public static final @Varbit int ENABLE_PRECISE_TIMING = 11866;
+
     private static final Set<Integer> NEVER_KEPT_ITEMS = ImmutableSet.of(
         CLUE_BOX, LOOTING_BAG,
         AMULET_OF_THE_DAMNED, AMULET_OF_THE_DAMNED_FULL,
@@ -204,6 +219,53 @@ public class Utils {
         });
     }
 
+    public static boolean isPreciseTiming(Client client) {
+        return client.getVarbitValue(ENABLE_PRECISE_TIMING) > 0;
+    }
+
+    @NotNull
+    public static Duration parseTime(String in) {
+        Matcher m = TIME_PATTERN.matcher(in);
+        if (!m.find()) return Duration.ZERO;
+
+        int minutes = Integer.parseInt(m.group("minutes"));
+        int seconds = Integer.parseInt(m.group("seconds"));
+
+        Duration d = Duration.ofMinutes(minutes).plusSeconds(seconds);
+
+        String hours = m.group("hours");
+        if (hours != null) {
+            d = d.plusHours(Integer.parseInt(hours));
+        }
+
+        String fractional = m.group("fractional");
+        if (fractional != null) {
+            // osrs sends 2 digits, but this is robust to changes
+            String f = fractional.length() < 3 ? StringUtils.rightPad(fractional, 3, '0') : fractional.substring(0, 3);
+            d = d.plusMillis(Integer.parseInt(f));
+        }
+
+        return d;
+    }
+
+    @NotNull
+    public static String format(@Nullable Duration duration, boolean precise) {
+        Temporal time = ObjectUtils.defaultIfNull(duration, Duration.ZERO).addTo(LocalTime.of(0, 0));
+        StringBuilder sb = new StringBuilder();
+
+        int h = time.get(HOUR_OF_DAY);
+        if (h > 0)
+            sb.append(String.format("%02d", h)).append(':');
+
+        sb.append(String.format("%02d", time.get(MINUTE_OF_HOUR))).append(':');
+        sb.append(String.format("%02d", time.get(SECOND_OF_MINUTE)));
+
+        if (precise)
+            sb.append('.').append(String.format("%02d", time.get(MILLI_OF_SECOND) / 10));
+
+        return sb.toString();
+    }
+
     // Credit to: https://github.com/oliverpatrick/Enhanced-Discord-Notifications/blob/master/src/main/java/com/enhanceddiscordnotifications/EnhancedDiscordNotificationsPlugin.java
     // This method existed and seemed fairly solid.
 
diff --git a/src/main/java/dinkplugin/notifiers/KillCountNotifier.java b/src/main/java/dinkplugin/notifiers/KillCountNotifier.java
index f06b533..7b3afe3 100644
--- a/src/main/java/dinkplugin/notifiers/KillCountNotifier.java
+++ b/src/main/java/dinkplugin/notifiers/KillCountNotifier.java
@@ -11,6 +11,8 @@ import org.apache.commons.lang3.tuple.Pair;
 import org.jetbrains.annotations.Nullable;
 import org.jetbrains.annotations.VisibleForTesting;
 
+import javax.inject.Singleton;
+import java.time.Duration;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.Objects;
@@ -18,10 +20,16 @@ import java.util.Optional;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
+import static org.apache.commons.lang3.ObjectUtils.defaultIfNull;
+
 @Slf4j
+@Singleton
 public class KillCountNotifier extends BaseNotifier {
     private static final Pattern PRIMARY_REGEX = Pattern.compile("Your (?<key>.+)\\s(?<type>kill|chest|completion)\\s?count is: (?<value>\\d+)\\b");
     private static final Pattern SECONDARY_REGEX = Pattern.compile("Your (?:completed|subdued) (?<key>.+) count is: (?<value>\\d+)\\b");
+    private static final Pattern TIME_REGEX = Pattern.compile("(?:Duration|time|Subdued in):? (?<time>[\\d:]+(.\\d+)?)\\.?", Pattern.CASE_INSENSITIVE);
+
+    private BossNotificationData data;
 
     @Override
     public boolean isEnabled() {
@@ -33,27 +41,53 @@ public class KillCountNotifier extends BaseNotifier {
         return config.killCountWebhook();
     }
 
+    public void reset() {
+        this.data = null;
+    }
+
     public void onGameMessage(String message) {
         if (isEnabled())
-            parse(message).ifPresent(pair -> handleKill(pair.getLeft(), pair.getRight(), message));
+            parse(message).ifPresent(this::updateData);
     }
 
-    private void handleKill(String boss, int killCount, String rawMessage) {
-        if (!checkKillInterval(killCount)) return;
+    public void onFriendsChatNotification(String message) {
+        // For CoX, Jagex sends duration via FRIENDSCHATNOTIFICATION
+        if (message.startsWith("Congratulations - your raid is complete!"))
+            this.onGameMessage(message);
+    }
+
+    public void onTick() {
+        if (data != null) {
+            // all data must be sent on the same tick to be included
+            handleKill(data);
+            reset();
+        }
+    }
+
+    private void handleKill(BossNotificationData data) {
+        // ensure data is present
+        if (data.getBoss() == null || data.getCount() == null)
+            return;
+
+        // ensure interval met or pb, depending on config
+        if (!checkKillInterval(data.getCount(), data.isPersonalBest()))
+            return;
 
         // Assemble content
+        boolean isPb = data.isPersonalBest() == Boolean.TRUE;
         String player = Utils.getPlayerName(client);
+        String time = Utils.format(data.getTime(), Utils.isPreciseTiming(client));
         String content = StringUtils.replaceEach(
-            config.killCountMessage(),
-            new String[] { "%USERNAME%", "%BOSS%", "%COUNT%" },
-            new String[] { player, boss, String.valueOf(killCount) }
+            isPb ? config.killCountBestTimeMessage() : config.killCountMessage(),
+            new String[] { "%USERNAME%", "%BOSS%", "%COUNT%", "%TIME%" },
+            new String[] { player, data.getBoss(), String.valueOf(data.getCount()), time }
         );
 
         // Prepare body
         NotificationBody.NotificationBodyBuilder<BossNotificationData> body =
             NotificationBody.<BossNotificationData>builder()
                 .content(content)
-                .extra(new BossNotificationData(boss, killCount, rawMessage))
+                .extra(data)
                 .playerName(player)
                 .screenshotFile("killCountImage.png")
                 .type(NotificationType.KILL_COUNT);
@@ -63,7 +97,7 @@ public class KillCountNotifier extends BaseNotifier {
         if (!screenshot)
             Arrays.stream(client.getCachedNPCs())
                 .filter(Objects::nonNull)
-                .filter(npc -> boss.equalsIgnoreCase(npc.getName()))
+                .filter(npc -> data.getBoss().equalsIgnoreCase(npc.getName()))
                 .findAny()
                 .map(NPC::getId)
                 .map(Utils::getNpcImageUrl)
@@ -76,7 +110,10 @@ public class KillCountNotifier extends BaseNotifier {
         createMessage(screenshot, body.build());
     }
 
-    private boolean checkKillInterval(int killCount) {
+    private boolean checkKillInterval(int killCount, @Nullable Boolean pb) {
+        if (pb == Boolean.TRUE && config.killCountNotifyBestTime())
+            return true;
+
         if (killCount == 1 && config.killCountNotifyInitial())
             return true;
 
@@ -84,8 +121,42 @@ public class KillCountNotifier extends BaseNotifier {
         return interval <= 1 || killCount % interval == 0;
     }
 
+    private void updateData(BossNotificationData updated) {
+        if (data == null) {
+            this.data = updated;
+        } else {
+            // Boss data and timing are sent in separate messages
+            // where the order of the messages differs depending on the boss.
+            // Here, we update data without setting any not-null values back to null.
+            this.data = new BossNotificationData(
+                defaultIfNull(updated.getBoss(), data.getBoss()),
+                defaultIfNull(updated.getCount(), data.getCount()),
+                defaultIfNull(updated.getGameMessage(), data.getGameMessage()),
+                defaultIfNull(updated.getTime(), data.getTime()),
+                defaultIfNull(updated.isPersonalBest(), data.isPersonalBest())
+            );
+        }
+    }
+
+    private static Optional<BossNotificationData> parse(String message) {
+        Optional<Pair<String, Integer>> boss = parseBoss(message);
+        if (boss.isPresent())
+            return boss.map(pair -> new BossNotificationData(pair.getLeft(), pair.getRight(), message, null, null));
+        return parseTime(message).map(t -> new BossNotificationData(null, null, null, t.getLeft(), t.getRight()));
+    }
+
+    private static Optional<Pair<Duration, Boolean>> parseTime(String message) {
+        Matcher matcher = TIME_REGEX.matcher(message);
+        if (matcher.find()) {
+            Duration duration = Utils.parseTime(matcher.group("time"));
+            boolean pb = message.toLowerCase().contains("(new personal best)");
+            return Optional.of(Pair.of(duration, pb));
+        }
+        return Optional.empty();
+    }
+
     @VisibleForTesting
-    static Optional<Pair<String, Integer>> parse(String message) {
+    static Optional<Pair<String, Integer>> parseBoss(String message) {
         Matcher primary = PRIMARY_REGEX.matcher(message);
         Matcher secondary; // lazy init
         if (primary.find()) {
@@ -101,6 +172,7 @@ public class KillCountNotifier extends BaseNotifier {
     }
 
     private static Optional<Pair<String, Integer>> result(String boss, String count) {
+        // safely transform (String, String) => (String, Int)
         try {
             return Optional.ofNullable(boss).map(k -> Pair.of(boss, Integer.parseInt(count)));
         } catch (NumberFormatException e) {
diff --git a/src/main/java/dinkplugin/notifiers/SpeedrunNotifier.java b/src/main/java/dinkplugin/notifiers/SpeedrunNotifier.java
index 9187601..5b47e94 100644
--- a/src/main/java/dinkplugin/notifiers/SpeedrunNotifier.java
+++ b/src/main/java/dinkplugin/notifiers/SpeedrunNotifier.java
@@ -11,12 +11,9 @@ import org.apache.commons.lang3.StringUtils;
 import org.jetbrains.annotations.VisibleForTesting;
 
 import java.time.Duration;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
 
 @Slf4j
 public class SpeedrunNotifier extends BaseNotifier {
-    private static final Pattern TIME_PATTERN = Pattern.compile("(?<minutes>\\d+):(?<seconds>\\d{2})\\.(?<fractional>\\d{2})");
     static final @VisibleForTesting int SPEEDRUN_COMPLETED_GROUP_ID = 781;
     static final @VisibleForTesting int SPEEDRUN_COMPLETED_QUEST_NAME_CHILD_ID = 4;
     static final @VisibleForTesting int SPEEDRUN_COMPLETED_DURATION_CHILD_ID = 10;
@@ -46,8 +43,8 @@ public class SpeedrunNotifier extends BaseNotifier {
     }
 
     private void attemptNotify(String questName, String duration, String pb) {
-        Duration bestTime = parseTime(pb);
-        Duration currentTime = parseTime(duration);
+        Duration bestTime = Utils.parseTime(pb);
+        Duration currentTime = Utils.parseTime(duration);
         boolean isPb = bestTime.compareTo(currentTime) >= 0;
         if (!isPb && config.speedrunPBOnly()) {
             return;
@@ -67,14 +64,4 @@ public class SpeedrunNotifier extends BaseNotifier {
             .type(NotificationType.SPEEDRUN)
             .build());
     }
-
-    private static 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/notifiers/data/BossNotificationData.java b/src/main/java/dinkplugin/notifiers/data/BossNotificationData.java
index 450f9be..b9b2a00 100644
--- a/src/main/java/dinkplugin/notifiers/data/BossNotificationData.java
+++ b/src/main/java/dinkplugin/notifiers/data/BossNotificationData.java
@@ -1,10 +1,19 @@
 package dinkplugin.notifiers.data;
 
+import com.google.gson.annotations.JsonAdapter;
+import dinkplugin.util.DurationAdapter;
 import lombok.Value;
+import lombok.experimental.Accessors;
+
+import java.time.Duration;
 
 @Value
 public class BossNotificationData {
     String boss;
     Integer count;
     String gameMessage;
+    @JsonAdapter(DurationAdapter.class)
+    Duration time;
+    @Accessors(fluent = true)
+    Boolean isPersonalBest;
 }
diff --git a/src/main/java/dinkplugin/util/DurationAdapter.java b/src/main/java/dinkplugin/util/DurationAdapter.java
new file mode 100644
index 0000000..9602a13
--- /dev/null
+++ b/src/main/java/dinkplugin/util/DurationAdapter.java
@@ -0,0 +1,36 @@
+package dinkplugin.util;
+
+import com.google.gson.TypeAdapter;
+import com.google.gson.stream.JsonReader;
+import com.google.gson.stream.JsonWriter;
+
+import java.io.IOException;
+import java.time.Duration;
+import java.time.format.DateTimeParseException;
+
+/**
+ * Serializes and deserializes {@link Duration} instances
+ * from their equivalent ISO-8601 string representation.
+ * <p>
+ * This adapter exists because GSON does not ship with
+ * a module for the Java 8 time API.
+ *
+ * @see <a href="https://github.com/google/gson/issues/1059">GSON Issue</a>
+ */
+public class DurationAdapter extends TypeAdapter<Duration> {
+    @Override
+    public void write(JsonWriter out, Duration duration) throws IOException {
+        out.value(duration != null ? duration.toString() : null);
+    }
+
+    @Override
+    public Duration read(JsonReader in) throws IOException {
+        if (in.hasNext()) {
+            try {
+                return Duration.parse(in.nextString());
+            } catch (DateTimeParseException ignored) {
+            }
+        }
+        return null;
+    }
+}


Test patch:
diff --git a/src/test/java/dinkplugin/notifiers/KillCountNotifierTest.java b/src/test/java/dinkplugin/notifiers/KillCountNotifierTest.java
index 12f2545..018a296 100644
--- a/src/test/java/dinkplugin/notifiers/KillCountNotifierTest.java
+++ b/src/test/java/dinkplugin/notifiers/KillCountNotifierTest.java
@@ -1,12 +1,17 @@
 package dinkplugin.notifiers;
 
+import dinkplugin.Utils;
 import dinkplugin.message.NotificationBody;
 import dinkplugin.message.NotificationType;
 import dinkplugin.notifiers.data.BossNotificationData;
+import net.runelite.http.api.RuneLiteAPI;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.mockito.InjectMocks;
 
+import java.time.Duration;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyBoolean;
 import static org.mockito.Mockito.never;
@@ -25,8 +30,13 @@ class KillCountNotifierTest extends MockedNotifierTest {
 
         // init config mocks
         when(config.notifyKillCount()).thenReturn(true);
+        when(config.killCountNotifyBestTime()).thenReturn(true);
         when(config.killCountSendImage()).thenReturn(true);
         when(config.killCountMessage()).thenReturn("%USERNAME% has defeated %BOSS% with a completion count of %COUNT%");
+        when(config.killCountBestTimeMessage()).thenReturn("%USERNAME% has defeated %BOSS% with a new personal best time of %TIME% and a completion count of %COUNT%");
+
+        // init client mocks
+        when(client.getVarbitValue(Utils.ENABLE_PRECISE_TIMING)).thenReturn(1);
     }
 
     @Test
@@ -38,18 +48,23 @@ class KillCountNotifierTest extends MockedNotifierTest {
         // fire event
         String gameMessage = "Your King Black Dragon kill count is: 420.";
         notifier.onGameMessage(gameMessage);
+        notifier.onTick();
 
         // check notification
+        NotificationBody<BossNotificationData> body = NotificationBody.<BossNotificationData>builder()
+            .content(PLAYER_NAME + " has defeated King Black Dragon with a completion count of 420")
+            .extra(new BossNotificationData("King Black Dragon", 420, gameMessage, null, null))
+            .playerName(PLAYER_NAME)
+            .type(NotificationType.KILL_COUNT)
+            .build();
+
         verify(messageHandler).createMessage(
             PRIMARY_WEBHOOK_URL,
             true,
-            NotificationBody.builder()
-                .content(PLAYER_NAME + " has defeated King Black Dragon with a completion count of 420")
-                .extra(new BossNotificationData("King Black Dragon", 420, gameMessage))
-                .playerName(PLAYER_NAME)
-                .type(NotificationType.KILL_COUNT)
-                .build()
+            body
         );
+
+        assertDoesNotThrow(() -> RuneLiteAPI.GSON.toJson(body));
     }
 
     @Test
@@ -61,6 +76,7 @@ class KillCountNotifierTest extends MockedNotifierTest {
         // fire event
         String gameMessage = "Your King Black Dragon kill count is: 1.";
         notifier.onGameMessage(gameMessage);
+        notifier.onTick();
 
         // check notification
         verify(messageHandler).createMessage(
@@ -68,7 +84,7 @@ class KillCountNotifierTest extends MockedNotifierTest {
             true,
             NotificationBody.builder()
                 .content(PLAYER_NAME + " has defeated King Black Dragon with a completion count of 1")
-                .extra(new BossNotificationData("King Black Dragon", 1, gameMessage))
+                .extra(new BossNotificationData("King Black Dragon", 1, gameMessage, null, null))
                 .playerName(PLAYER_NAME)
                 .type(NotificationType.KILL_COUNT)
                 .build()
@@ -84,6 +100,7 @@ class KillCountNotifierTest extends MockedNotifierTest {
         // fire event
         String gameMessage = "Your King Black Dragon kill count is: 1.";
         notifier.onGameMessage(gameMessage);
+        notifier.onTick();
 
         // ensure no message
         verify(messageHandler, never()).createMessage(any(), anyBoolean(), any());
@@ -99,6 +116,308 @@ class KillCountNotifierTest extends MockedNotifierTest {
         // fire event
         String gameMessage = "Your King Black Dragon kill count is: 1.";
         notifier.onGameMessage(gameMessage);
+        notifier.onTick();
+
+        // ensure no message
+        verify(messageHandler, never()).createMessage(any(), anyBoolean(), any());
+    }
+
+    @Test
+    void testNotifyPb() {
+        // more config
+        when(config.killCountInterval()).thenReturn(99);
+
+        // fire events
+        String gameMessage = "Your Zulrah kill count is: 12.";
+        notifier.onGameMessage(gameMessage);
+        notifier.onGameMessage("Fight duration: 0:56.50 (new personal best).");
+        notifier.onTick();
+
+        // check notification
+        NotificationBody<BossNotificationData> body = NotificationBody.<BossNotificationData>builder()
+            .content(PLAYER_NAME + " has defeated Zulrah with a new personal best time of 00:56.50 and a completion count of 12")
+            .extra(new BossNotificationData("Zulrah", 12, gameMessage, Duration.ofSeconds(56).plusMillis(500), true))
+            .playerName(PLAYER_NAME)
+            .type(NotificationType.KILL_COUNT)
+            .build();
+
+        verify(messageHandler).createMessage(
+            PRIMARY_WEBHOOK_URL,
+            true,
+            body
+        );
+
+        assertDoesNotThrow(() -> RuneLiteAPI.GSON.toJson(body));
+    }
+
+    @Test
+    void testNotifyPbLong() {
+        // more config
+        when(config.killCountInterval()).thenReturn(99);
+
+        // fire events
+        String gameMessage = "Your Zulrah kill count is: 1.";
+        notifier.onGameMessage(gameMessage);
+        notifier.onGameMessage("Fight duration: 1:00:56.50 (new personal best).");
+        notifier.onTick();
+
+        // check notification
+        verify(messageHandler).createMessage(
+            PRIMARY_WEBHOOK_URL,
+            true,
+            NotificationBody.builder()
+                .content(PLAYER_NAME + " has defeated Zulrah with a new personal best time of 01:00:56.50 and a completion count of 1")
+                .extra(new BossNotificationData("Zulrah", 1, gameMessage, Duration.ofHours(1).plusSeconds(56).plusMillis(500), true))
+                .playerName(PLAYER_NAME)
+                .type(NotificationType.KILL_COUNT)
+                .build()
+        );
+    }
+
+    @Test
+    void testNotifyPbImprecise() {
+        // more mocks
+        when(config.killCountInterval()).thenReturn(99);
+        when(client.getVarbitValue(Utils.ENABLE_PRECISE_TIMING)).thenReturn(0);
+
+        // fire events
+        String gameMessage = "Your Zulrah kill count is: 13.";
+        notifier.onGameMessage(gameMessage);
+        notifier.onGameMessage("Fight duration: 0:56 (new personal best).");
+        notifier.onTick();
+
+        // check notification
+        NotificationBody<BossNotificationData> body = NotificationBody.<BossNotificationData>builder()
+            .content(PLAYER_NAME + " has defeated Zulrah with a new personal best time of 00:56 and a completion count of 13")
+            .extra(new BossNotificationData("Zulrah", 13, gameMessage, Duration.ofSeconds(56), true))
+            .playerName(PLAYER_NAME)
+            .type(NotificationType.KILL_COUNT)
+            .build();
+
+        verify(messageHandler).createMessage(
+            PRIMARY_WEBHOOK_URL,
+            true,
+            body
+        );
+
+        assertDoesNotThrow(() -> RuneLiteAPI.GSON.toJson(body));
+    }
+
+    @Test
+    void testNotifyChambersPb() {
+        // more config
+        when(config.killCountInterval()).thenReturn(99);
+
+        // fire events
+        notifier.onFriendsChatNotification("Congratulations - your raid is complete!\nTeam size: 24+ players Duration: 36:04.20 (new personal best)");
+        String gameMessage = "Your completed Chambers of Xeric count is: 125.";
+        notifier.onGameMessage(gameMessage);
+        notifier.onTick();
+
+        // check notification
+        verify(messageHandler).createMessage(
+            PRIMARY_WEBHOOK_URL,
+            true,
+            NotificationBody.builder()
+                .content(PLAYER_NAME + " has defeated Chambers of Xeric with a new personal best time of 36:04.20 and a completion count of 125")
+                .extra(new BossNotificationData("Chambers of Xeric", 125, gameMessage, Duration.ofMinutes(36).plusSeconds(4).plusMillis(200), true))
+                .playerName(PLAYER_NAME)
+                .type(NotificationType.KILL_COUNT)
+                .build()
+        );
+    }
+
+    @Test
+    void testNotifyChambersInterval() {
+        // more config
+        when(config.killCountNotifyInitial()).thenReturn(false);
+        when(config.killCountInterval()).thenReturn(25);
+
+        // fire events
+        notifier.onFriendsChatNotification("Congratulations - your raid is complete!\nTeam size: Solo Duration: 46:31.80 Personal best: 40:24.60");
+        String gameMessage = "Your completed Chambers of Xeric count is: 150.";
+        notifier.onGameMessage(gameMessage);
+        notifier.onTick();
+
+        // check notification
+        verify(messageHandler).createMessage(
+            PRIMARY_WEBHOOK_URL,
+            true,
+            NotificationBody.builder()
+                .content(PLAYER_NAME + " has defeated Chambers of Xeric with a completion count of 150")
+                .extra(new BossNotificationData("Chambers of Xeric", 150, gameMessage, Duration.ofMinutes(46).plusSeconds(31).plusMillis(800), false))
+                .playerName(PLAYER_NAME)
+                .type(NotificationType.KILL_COUNT)
+                .build()
+        );
+    }
+
+    @Test
+    void testNotifyGauntletPb() {
+        // more config
+        when(config.killCountInterval()).thenReturn(99);
+
+        // fire events
+        notifier.onGameMessage("Challenge duration: 10:25 (new personal best).");
+        String gameMessage = "Your Gauntlet completion count is: 10.";
+        notifier.onGameMessage(gameMessage);
+        notifier.onTick();
+
+        // check notification
+        verify(messageHandler).createMessage(
+            PRIMARY_WEBHOOK_URL,
+            true,
+            NotificationBody.builder()
+                .content(PLAYER_NAME + " has defeated Crystalline Hunllef with a new personal best time of 10:25.00 and a completion count of 10")
+                .extra(new BossNotificationData("Crystalline Hunllef", 10, gameMessage, Duration.ofMinutes(10).plusSeconds(25), true))
+                .playerName(PLAYER_NAME)
+                .type(NotificationType.KILL_COUNT)
+                .build()
+        );
+    }
+
+    @Test
+    void testNotifyTemporossPb() {
+        // more config
+        when(config.killCountInterval()).thenReturn(99);
+
+        // fire events
+        notifier.onGameMessage("Subdued in 6:30 (new personal best).");
+        String gameMessage = "Your Tempoross kill count is: 69.";
+        notifier.onGameMessage(gameMessage);
+        notifier.onTick();
+
+        // check notification
+        verify(messageHandler).createMessage(
+            PRIMARY_WEBHOOK_URL,
+            true,
+            NotificationBody.builder()
+                .content(PLAYER_NAME + " has defeated Tempoross with a new personal best time of 06:30.00 and a completion count of 69")
+                .extra(new BossNotificationData("Tempoross", 69, gameMessage, Duration.ofMinutes(6).plusSeconds(30), true))
+                .playerName(PLAYER_NAME)
+                .type(NotificationType.KILL_COUNT)
+                .build()
+        );
+    }
+
+    @Test
+    void testNotifyTombsPb() {
+        // more config
+        when(config.killCountInterval()).thenReturn(99);
+
+        // fire events
+        notifier.onGameMessage("Tombs of Amascut: Expert Mode total completion time: 25:00 (new personal best)");
+        String gameMessage = "Your completed Tombs of Amascut: Expert Mode count is: 8.";
+        notifier.onGameMessage(gameMessage);
+        notifier.onTick();
+
+        // check notification
+        verify(messageHandler).createMessage(
+            PRIMARY_WEBHOOK_URL,
+            true,
+            NotificationBody.builder()
+                .content(PLAYER_NAME + " has defeated Tombs of Amascut: Expert Mode with a new personal best time of 25:00.00 and a completion count of 8")
+                .extra(new BossNotificationData("Tombs of Amascut: Expert Mode", 8, gameMessage, Duration.ofMinutes(25), true))
+                .playerName(PLAYER_NAME)
+                .type(NotificationType.KILL_COUNT)
+                .build()
+        );
+    }
+
+    @Test
+    void testNotifyNoPb() {
+        // more config
+        when(config.killCountInterval()).thenReturn(6);
+
+        // fire events
+        String gameMessage = "Your Zulrah kill count is: 12.";
+        notifier.onGameMessage(gameMessage);
+        notifier.onGameMessage("Fight duration: 0:59.30. Personal best: 0:56.50");
+        notifier.onTick();
+
+        // check notification
+        verify(messageHandler).createMessage(
+            PRIMARY_WEBHOOK_URL,
+            true,
+            NotificationBody.builder()
+                .content(PLAYER_NAME + " has defeated Zulrah with a completion count of 12")
+                .extra(new BossNotificationData("Zulrah", 12, gameMessage, Duration.ofSeconds(59).plusMillis(300), false))
+                .playerName(PLAYER_NAME)
+                .type(NotificationType.KILL_COUNT)
+                .build()
+        );
+    }
+
+    @Test
+    void testIgnoreNoPb() {
+        // more config
+        when(config.killCountInterval()).thenReturn(99);
+
+        // fire events
+        String gameMessage = "Your Zulrah kill count is: 12.";
+        notifier.onGameMessage(gameMessage);
+        notifier.onGameMessage("Fight duration: 0:59.30. Personal best: 0:56.50");
+        notifier.onTick();
+
+        // ensure no message
+        verify(messageHandler, never()).createMessage(any(), anyBoolean(), any());
+    }
+
+    @Test
+    void testIgnoreChambersNoPb() {
+        // more config
+        when(config.killCountInterval()).thenReturn(99);
+
+        // fire events
+        notifier.onFriendsChatNotification("Congratulations - your raid is complete!\nTeam size: Solo Duration: 46:31.80 Personal best: 40:24.60");
+        String gameMessage = "Your completed Chambers of Xeric count is: 147.";
+        notifier.onGameMessage(gameMessage);
+        notifier.onTick();
+
+        // ensure no message
+        verify(messageHandler, never()).createMessage(any(), anyBoolean(), any());
+    }
+
+    @Test
+    void testIgnoreTemporossNoPb() {
+        // more config
+        when(config.killCountInterval()).thenReturn(99);
+
+        // fire events
+        notifier.onGameMessage("Subdued in 6:13. Personal best: 5:57");
+        String gameMessage = "Your Tempoross kill count is: 69.";
+        notifier.onGameMessage(gameMessage);
+        notifier.onTick();
+
+        // ensure no message
+        verify(messageHandler, never()).createMessage(any(), anyBoolean(), any());
+    }
+
+    @Test
+    void testIgnoreTombsNoPb() {
+        // more config
+        when(config.killCountInterval()).thenReturn(99);
+
+        // fire events
+        notifier.onGameMessage("Tombs of Amascut total completion time: 29:45.60. Personal best: 25:08.40");
+        String gameMessage = "Your completed Tombs of Amascut count is: 40.";
+        notifier.onGameMessage(gameMessage);
+        notifier.onTick();
+
+        // ensure no message
+        verify(messageHandler, never()).createMessage(any(), anyBoolean(), any());
+    }
+
+    @Test
+    void testIgnoreTheatreNoPb() {
+        // more config
+        when(config.killCountInterval()).thenReturn(99);
+
+        // fire events
+        notifier.onGameMessage("Theatre of Blood total completion time: 23:42.60. Personal best: 20:47.00");
+        String gameMessage = "Your completed Theatre of Blood count is: 17.";
+        notifier.onGameMessage(gameMessage);
+        notifier.onTick();
 
         // ensure no message
         verify(messageHandler, never()).createMessage(any(), anyBoolean(), any());
diff --git a/src/test/java/dinkplugin/notifiers/MatchersTest.java b/src/test/java/dinkplugin/notifiers/MatchersTest.java
index 588e5a0..8c1d5d0 100644
--- a/src/test/java/dinkplugin/notifiers/MatchersTest.java
+++ b/src/test/java/dinkplugin/notifiers/MatchersTest.java
@@ -128,7 +128,7 @@ class MatchersTest {
     @ParameterizedTest(name = "Kill count message should trigger on: {0}")
     @ArgumentsSource(KillCountProvider.class)
     void killCountRegexFindsMatch(String message, Pair<String, Integer> expected) {
-        assertEquals(expected, KillCountNotifier.parse(message).orElse(null));
+        assertEquals(expected, KillCountNotifier.parseBoss(message).orElse(null));
     }
 
     @ParameterizedTest(name = "Kill count message should not trigger on: {0}")
@@ -139,7 +139,7 @@ class MatchersTest {
         }
     )
     void killCountRegexDoesNotMatch(String message) {
-        assertFalse(KillCountNotifier.parse(message).isPresent());
+        assertFalse(KillCountNotifier.parseBoss(message).isPresent());
     }
 
     private static class KillCountProvider implements ArgumentsProvider {