Repository: pajlads/DinkPlugin (java)

Issue:
Support for Diary completions
![image](https://user-images.githubusercontent.com/41973452/201446382-813d3285-b3e2-4d11-99d2-2028f92f9b49.png)

varbits are our friend
https://static.runelite.net/api/runelite-api/constant-values.html#net.runelite.api.Varbits.DIARY_ARDOUGNE_EASY

Pull request description:
feat: add achievement diary notifier
Closes #66

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f8b847f..1dddae1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,6 @@
 # Unreleased
 
+- Major: Add achievement diary notifications. (#67)
 - Major: Add combat achievement notifications. (#63)
 - Major: Add additional information for PvP deaths. (#55)
 - Major: Add boss kill count notifications. (#50)
diff --git a/README.md b/README.md
index 72d80ed..e640de0 100644
--- a/README.md
+++ b/README.md
@@ -238,6 +238,26 @@ JSON:
 }
 ```
 
+#### Achievement Diary:
+
+`%AREA%` will be replaced with the geographic area of the achievement diary tasks (e.g., Varrock)
+
+`%DIFFICULTY%` will be replaced with the level of the achievement diary (e.g., Elite)
+
+`%TOTAL%` will be replaced with the total number of achievement diaries completed across all locations and difficulties
+
+```json5
+{
+  "content": "%USERNAME% has completed the %DIFFICULTY% %AREA% Achievement Diary, for a total of %TOTAL% diaries completed",
+  "extra": {
+    "area": "Varrock",
+    "difficulty": "ELITE",
+    "total": 36
+  },
+  "type": "ACHIEVEMENT_DIARY"
+}
+```
+
 ## Credits
 
 This plugin uses code from [Universal Discord Notifier](https://github.com/MidgetJake/UniversalDiscordNotifier).
diff --git a/src/main/java/dinkplugin/DinkPlugin.java b/src/main/java/dinkplugin/DinkPlugin.java
index 266a5be..b8d428b 100644
--- a/src/main/java/dinkplugin/DinkPlugin.java
+++ b/src/main/java/dinkplugin/DinkPlugin.java
@@ -6,6 +6,7 @@ import dinkplugin.notifiers.ClueNotifier;
 import dinkplugin.notifiers.CollectionNotifier;
 import dinkplugin.notifiers.CombatTaskNotifier;
 import dinkplugin.notifiers.DeathNotifier;
+import dinkplugin.notifiers.DiaryNotifier;
 import dinkplugin.notifiers.KillCountNotifier;
 import dinkplugin.notifiers.LevelNotifier;
 import dinkplugin.notifiers.LootNotifier;
@@ -23,6 +24,7 @@ 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.VarbitChanged;
 import net.runelite.api.events.WidgetLoaded;
 import net.runelite.client.config.ConfigManager;
 import net.runelite.client.eventbus.Subscribe;
@@ -74,6 +76,7 @@ public class DinkPlugin extends Plugin {
     private final SpeedrunNotifier speedrunNotifier = new SpeedrunNotifier(this);
     private final KillCountNotifier killCountNotifier = new KillCountNotifier(this);
     private final CombatTaskNotifier combatTaskNotifier = new CombatTaskNotifier(this);
+    private final DiaryNotifier diaryNotifier = new DiaryNotifier(this);
 
     @Override
     protected void startUp() {
@@ -98,6 +101,7 @@ public class DinkPlugin extends Plugin {
     @Subscribe
     public void onGameStateChanged(GameStateChanged gameStateChanged) {
         levelNotifier.onGameStateChanged(gameStateChanged);
+        diaryNotifier.onGameState(gameStateChanged);
     }
 
     @Subscribe
@@ -108,6 +112,7 @@ public class DinkPlugin extends Plugin {
     @Subscribe
     public void onGameTick(GameTick event) {
         levelNotifier.onTick();
+        diaryNotifier.onTick();
     }
 
     @Subscribe
@@ -145,6 +150,11 @@ public class DinkPlugin extends Plugin {
         lootNotifier.onLootReceived(lootReceived);
     }
 
+    @Subscribe
+    public void onVarbitChanged(VarbitChanged event) {
+        diaryNotifier.onVarbitChanged(event);
+    }
+
     @Subscribe
     public void onWidgetLoaded(WidgetLoaded event) {
         questNotifier.onWidgetLoaded(event);
diff --git a/src/main/java/dinkplugin/DinkPluginConfig.java b/src/main/java/dinkplugin/DinkPluginConfig.java
index d229cbe..9bebfcb 100644
--- a/src/main/java/dinkplugin/DinkPluginConfig.java
+++ b/src/main/java/dinkplugin/DinkPluginConfig.java
@@ -1,5 +1,6 @@
 package dinkplugin;
 
+import dinkplugin.domain.AchievementDiaries;
 import dinkplugin.domain.CombatAchievementTier;
 import net.runelite.client.config.Config;
 import net.runelite.client.config.ConfigGroup;
@@ -85,6 +86,13 @@ public interface DinkPluginConfig extends Config {
     )
     String combatTaskSection = "Combat Tasks";
 
+    @ConfigSection(
+        name = "Achievement Diary",
+        description = "Settings for notifying when you complete an Achievement Diary",
+        position = 47
+    )
+    String diarySection = "Achievement Diary";
+
     @ConfigItem(
         keyName = "discordWebhook",
         name = "Discord Webhook",
@@ -602,4 +610,48 @@ public interface DinkPluginConfig extends Config {
         return "%USERNAME% has completed %TIER% combat task: %TASK%";
     }
 
+    @ConfigItem(
+        keyName = "diaryEnabled",
+        name = "Enable Diary",
+        description = "Enable notifications for achievement diary completions",
+        position = 47,
+        section = diarySection
+    )
+    default boolean notifyAchievementDiary() {
+        return false;
+    }
+
+    @ConfigItem(
+        keyName = "diarySendImage",
+        name = "Send Image",
+        description = "Send image with the notification",
+        position = 48,
+        section = diarySection
+    )
+    default boolean diarySendImage() {
+        return false;
+    }
+
+    @ConfigItem(
+        keyName = "diaryMinDifficulty",
+        name = "Min Difficulty",
+        description = "Minimum achievement diary difficulty to warrant a notification",
+        position = 49,
+        section = diarySection
+    )
+    default AchievementDiaries.Difficulty minDiaryDifficulty() {
+        return AchievementDiaries.Difficulty.EASY;
+    }
+
+    @ConfigItem(
+        keyName = "diaryMessage",
+        name = "Notification Message",
+        description = "The message to be sent to the webhook. Use %USERNAME% to insert your username, %DIFFICULTY% to insert the diary difficulty, %AREA% to insert the diary area, %TOTAL% to insert the total diaries completed",
+        position = 50,
+        section = diarySection
+    )
+    default String diaryNotifyMessage() {
+        return "%USERNAME% has completed the %DIFFICULTY% %AREA% Achievement Diary, for a total of %TOTAL% diaries completed";
+    }
+
 }
diff --git a/src/main/java/dinkplugin/domain/AchievementDiaries.java b/src/main/java/dinkplugin/domain/AchievementDiaries.java
new file mode 100644
index 0000000..150fced
--- /dev/null
+++ b/src/main/java/dinkplugin/domain/AchievementDiaries.java
@@ -0,0 +1,84 @@
+package dinkplugin.domain;
+
+import lombok.Getter;
+import lombok.Value;
+import lombok.extern.slf4j.Slf4j;
+import net.runelite.api.Varbits;
+import net.runelite.api.annotations.Varbit;
+import org.apache.commons.lang3.StringUtils;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Modifier;
+import java.util.Arrays;
+import java.util.Map;
+import java.util.Objects;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+@Slf4j
+public enum AchievementDiaries {
+
+    INSTANCE;
+
+    @Getter
+    private final Map<Integer, Diary> diaries;
+
+    AchievementDiaries() {
+        this.diaries = Arrays.stream(Varbits.class.getDeclaredFields())
+            .filter(field -> Modifier.isPublic(field.getModifiers()))
+            .filter(field -> Modifier.isStatic(field.getModifiers()))
+            .filter(field -> Modifier.isFinal(field.getModifiers()))
+            .filter(field -> field.getType() == int.class)
+            .filter(field -> field.getName().startsWith("DIARY_"))
+            .map(Diary::from)
+            .filter(Objects::nonNull)
+            .collect(Collectors.toMap(Diary::getId, Function.identity()));
+    }
+
+    public enum Difficulty {
+        EASY,
+        MEDIUM,
+        HARD,
+        ELITE;
+
+        private final String displayName = this.name().charAt(0) + this.name().substring(1).toLowerCase();
+
+        @Override
+        public String toString() {
+            return this.displayName;
+        }
+    }
+
+    @Value
+    public static class Diary {
+        @Varbit
+        int id;
+        String area;
+        Difficulty difficulty;
+
+        @Nullable
+        static Diary from(@NotNull Field varbit) {
+            try {
+                int id = varbit.getInt(null);
+
+                String fieldName = varbit.getName();
+                String[] parts = StringUtils.split(fieldName, '_');
+                if (parts.length != 3) return null;
+
+                Difficulty difficulty = Difficulty.valueOf(parts[2]);
+
+                String area = parts[1];
+                area = area.charAt(0) + area.substring(1).toLowerCase();
+                if ("Western".equals(area)) area = "Western Provinces";
+
+                return new Diary(id, area, difficulty);
+            } catch (Exception e) {
+                log.debug("Failed to parse achievement diary: " + varbit.getName(), e);
+                return null;
+            }
+        }
+    }
+
+}
diff --git a/src/main/java/dinkplugin/message/NotificationType.java b/src/main/java/dinkplugin/message/NotificationType.java
index 701a6e3..68c32d9 100644
--- a/src/main/java/dinkplugin/message/NotificationType.java
+++ b/src/main/java/dinkplugin/message/NotificationType.java
@@ -11,5 +11,6 @@ public enum NotificationType {
     SLAYER,
     SPEEDRUN,
     KILL_COUNT,
-    COMBAT_ACHIEVEMENT
+    COMBAT_ACHIEVEMENT,
+    ACHIEVEMENT_DIARY
 }
diff --git a/src/main/java/dinkplugin/notifiers/DiaryNotifier.java b/src/main/java/dinkplugin/notifiers/DiaryNotifier.java
new file mode 100644
index 0000000..e645e4d
--- /dev/null
+++ b/src/main/java/dinkplugin/notifiers/DiaryNotifier.java
@@ -0,0 +1,135 @@
+package dinkplugin.notifiers;
+
+import dinkplugin.DinkPlugin;
+import dinkplugin.DinkPluginConfig;
+import dinkplugin.Utils;
+import dinkplugin.domain.AchievementDiaries;
+import dinkplugin.message.NotificationBody;
+import dinkplugin.message.NotificationType;
+import dinkplugin.notifiers.data.DiaryNotificationData;
+import net.runelite.api.Client;
+import net.runelite.api.GameState;
+import net.runelite.api.events.GameStateChanged;
+import net.runelite.api.events.VarbitChanged;
+import org.apache.commons.lang3.StringUtils;
+
+import javax.inject.Inject;
+import java.util.HashMap;
+import java.util.Map;
+
+public class DiaryNotifier extends BaseNotifier {
+    private static final Map<Integer, AchievementDiaries.Diary> DIARIES = AchievementDiaries.INSTANCE.getDiaries();
+    private final Map<Integer, Integer> diaryCompletionById = new HashMap<>();
+    private int initDelayTicks = 0;
+
+    @Inject
+    public DiaryNotifier(DinkPlugin plugin) {
+        super(plugin);
+    }
+
+    @Override
+    public boolean isEnabled() {
+        return plugin.getConfig().notifyAchievementDiary() && super.isEnabled();
+    }
+
+    public void reset() {
+        this.diaryCompletionById.clear();
+        this.initDelayTicks = 0;
+    }
+
+    public void onGameState(GameStateChanged event) {
+        if (event.getGameState() != GameState.LOGGED_IN)
+            this.reset();
+    }
+
+    public void onTick() {
+        if (plugin.getClient().getGameState() != GameState.LOGGED_IN)
+            return;
+
+        if (initDelayTicks > 0) {
+            initDelayTicks--;
+
+            if (initDelayTicks <= 0)
+                this.initCompleted();
+        } else if (diaryCompletionById.isEmpty() && isEnabled()) {
+            // mark diary completions to be initialized later
+            this.initDelayTicks = 8;
+        }
+    }
+
+    public void onVarbitChanged(VarbitChanged event) {
+        int id = event.getVarbitId();
+        if (id < 0) return;
+        AchievementDiaries.Diary diary = DIARIES.get(id);
+        if (diary == null) return;
+        if (diaryCompletionById.isEmpty()) return;
+        if (!super.isEnabled()) return;
+
+        int value = event.getValue();
+        Integer previous = diaryCompletionById.get(id);
+        if (previous == null || value < previous) {
+            reset();
+        } else if (value > previous) {
+            diaryCompletionById.put(id, value);
+
+            if (value < 2 && (id == 3578 || id == 3599 || id == 3611)) {
+                // Karamja special case: 0 = not started, 1 = started, 2 = completed tasks
+                return;
+            }
+
+            if (checkDifficulty(diary))
+                handle(diary);
+        }
+    }
+
+    private void handle(AchievementDiaries.Diary diary) {
+        int total = getTotalCompleted();
+        String player = Utils.getPlayerName(plugin.getClient());
+        String message = StringUtils.replaceEach(
+            plugin.getConfig().diaryNotifyMessage(),
+            new String[] { "%USERNAME%", "%DIFFICULTY%", "%AREA%", "%TOTAL%" },
+            new String[] { player, diary.getDifficulty().toString(), diary.getArea(), String.valueOf(total) }
+        );
+
+        createMessage(DinkPluginConfig::diarySendImage, NotificationBody.builder()
+            .type(NotificationType.ACHIEVEMENT_DIARY)
+            .content(message)
+            .extra(new DiaryNotificationData(diary.getArea(), diary.getDifficulty(), total))
+            .playerName(player)
+            .build());
+    }
+
+    private boolean checkDifficulty(AchievementDiaries.Diary diary) {
+        DinkPluginConfig config = plugin.getConfig();
+        return config.notifyAchievementDiary() && diary.getDifficulty().ordinal() >= config.minDiaryDifficulty().ordinal();
+    }
+
+    private int getTotalCompleted() {
+        int n = 0;
+
+        for (Map.Entry<Integer, Integer> entry : diaryCompletionById.entrySet()) {
+            int id = entry.getKey();
+            int value = entry.getValue();
+            if (value <= 0) continue;
+
+            if (id != 3578 && id != 3599 && id != 3611) {
+                n++;
+            } else if (value > 1) {
+                n++; // Karamja special case
+            }
+        }
+
+        return n;
+    }
+
+    private void initCompleted() {
+        if (!super.isEnabled()) return;
+        Client client = plugin.getClient();
+        for (Integer id : DIARIES.keySet()) {
+            int value = client.getVarbitValue(id);
+            if (value >= 0) {
+                diaryCompletionById.put(id, value);
+            }
+        }
+    }
+}
diff --git a/src/main/java/dinkplugin/notifiers/data/DiaryNotificationData.java b/src/main/java/dinkplugin/notifiers/data/DiaryNotificationData.java
new file mode 100644
index 0000000..414fe1a
--- /dev/null
+++ b/src/main/java/dinkplugin/notifiers/data/DiaryNotificationData.java
@@ -0,0 +1,11 @@
+package dinkplugin.notifiers.data;
+
+import dinkplugin.domain.AchievementDiaries;
+import lombok.Value;
+
+@Value
+public class DiaryNotificationData {
+    String area;
+    AchievementDiaries.Difficulty difficulty;
+    int total;
+}


Test patch:
diff --git a/src/test/java/dinkplugin/domain/AchievementDiariesTest.java b/src/test/java/dinkplugin/domain/AchievementDiariesTest.java
new file mode 100644
index 0000000..faeac09
--- /dev/null
+++ b/src/test/java/dinkplugin/domain/AchievementDiariesTest.java
@@ -0,0 +1,30 @@
+package dinkplugin.domain;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.Map;
+
+import static net.runelite.api.Varbits.*;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+class AchievementDiariesTest {
+
+    @Test
+    @DisplayName("Test that all Achievement Diaries have been parsed")
+    void testParsingSuccessful() {
+        Map<Integer, AchievementDiaries.Diary> diaries = AchievementDiaries.INSTANCE.getDiaries();
+
+        assertEquals(48, diaries.size());
+        assertEquals(new AchievementDiaries.Diary(DIARY_VARROCK_EASY, "Varrock", AchievementDiaries.Difficulty.EASY), diaries.get(DIARY_VARROCK_EASY));
+        assertEquals(new AchievementDiaries.Diary(DIARY_FALADOR_MEDIUM, "Falador", AchievementDiaries.Difficulty.MEDIUM), diaries.get(DIARY_FALADOR_MEDIUM));
+        assertEquals(new AchievementDiaries.Diary(DIARY_DESERT_HARD, "Desert", AchievementDiaries.Difficulty.HARD), diaries.get(DIARY_DESERT_HARD));
+        assertEquals(new AchievementDiaries.Diary(DIARY_KARAMJA_ELITE, "Karamja", AchievementDiaries.Difficulty.ELITE), diaries.get(DIARY_KARAMJA_ELITE));
+        assertEquals(new AchievementDiaries.Diary(DIARY_KARAMJA_HARD, "Karamja", AchievementDiaries.Difficulty.HARD), diaries.get(DIARY_KARAMJA_HARD));
+        assertNull(diaries.get(3610));
+        assertNull(diaries.get(PRAYER_AUGURY));
+        assertNull(diaries.get(KOUREND_FAVOR_ARCEUUS));
+    }
+
+}