Repository: pajlads/DinkPlugin (java)

Issue:
Support for combat tasks completion
Seeing as collection log slots are already supported, it would be nice to include combat tasks completion as another category to support. 

Possibly including a filter for whichever tier and/or type the user would like to trigger the notification with?

Pull request description:
feat: add combat achievement notifier
Closes #60


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c079049..0326c99 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,6 @@
 # Unreleased
 
+- Major: Add combat achievement notifications. (#63)
 - Major: Add additional information for PvP deaths. (#55)
 - Major: Add boss kill count notifications. (#50)
 - Minor: Add config to ignore slayer tasks with low point values. (#61)
diff --git a/README.md b/README.md
index f6f6521..72d80ed 100644
--- a/README.md
+++ b/README.md
@@ -221,6 +221,23 @@ JSON:
 }
 ```
 
+#### Combat Achievements:
+
+`%TIER%` will be replaced with the combat achievement tier (e.g., Easy, Hard, Grandmaster)
+
+`%TASK` will be replaced with the name of the combat task (e.g., Peach Conjurer)
+
+```json5
+{
+  "content": "%USERNAME% has completed %TIER% combat task: %TASK%",
+  "extra": {
+    "tier": "GRANDMASTER",
+    "task": "Peach Conjurer"
+  },
+  "type": "COMBAT_ACHIEVEMENT"
+}
+```
+
 ## 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 5d5dbe1..266a5be 100644
--- a/src/main/java/dinkplugin/DinkPlugin.java
+++ b/src/main/java/dinkplugin/DinkPlugin.java
@@ -4,6 +4,7 @@ import com.google.inject.Provides;
 import dinkplugin.message.DiscordMessageHandler;
 import dinkplugin.notifiers.ClueNotifier;
 import dinkplugin.notifiers.CollectionNotifier;
+import dinkplugin.notifiers.CombatTaskNotifier;
 import dinkplugin.notifiers.DeathNotifier;
 import dinkplugin.notifiers.KillCountNotifier;
 import dinkplugin.notifiers.LevelNotifier;
@@ -72,6 +73,7 @@ public class DinkPlugin extends Plugin {
     private final ClueNotifier clueNotifier = new ClueNotifier(this);
     private final SpeedrunNotifier speedrunNotifier = new SpeedrunNotifier(this);
     private final KillCountNotifier killCountNotifier = new KillCountNotifier(this);
+    private final CombatTaskNotifier combatTaskNotifier = new CombatTaskNotifier(this);
 
     @Override
     protected void startUp() {
@@ -119,6 +121,7 @@ public class DinkPlugin extends Plugin {
             slayerNotifier.onChatMessage(chatMessage);
             clueNotifier.onChatMessage(chatMessage);
             killCountNotifier.onGameMessage(chatMessage);
+            combatTaskNotifier.onGameMessage(chatMessage);
         }
     }
 
diff --git a/src/main/java/dinkplugin/DinkPluginConfig.java b/src/main/java/dinkplugin/DinkPluginConfig.java
index 0b9d70a..0cfab59 100644
--- a/src/main/java/dinkplugin/DinkPluginConfig.java
+++ b/src/main/java/dinkplugin/DinkPluginConfig.java
@@ -1,5 +1,6 @@
 package dinkplugin;
 
+import dinkplugin.domain.CombatAchievementTier;
 import net.runelite.client.config.Config;
 import net.runelite.client.config.ConfigGroup;
 import net.runelite.client.config.ConfigItem;
@@ -77,6 +78,13 @@ public interface DinkPluginConfig extends Config {
     )
     String killCountSection = "Kill Count";
 
+    @ConfigSection(
+        name = "Combat Tasks",
+        description = "Settings for notifying when you complete a combat achievement",
+        position = 43
+    )
+    String combatTaskSection = "Combat Tasks";
+
     @ConfigItem(
         keyName = "discordWebhook",
         name = "Discord Webhook",
@@ -549,4 +557,48 @@ public interface DinkPluginConfig extends Config {
         return "%USERNAME% has defeated %BOSS% with a completion count of %COUNT%";
     }
 
+    @ConfigItem(
+        keyName = "combatTaskEnabled",
+        name = "Enable Combat Tasks",
+        description = "Enable notifications for combat achievements",
+        position = 43,
+        section = combatTaskSection
+    )
+    default boolean notifyCombatTask() {
+        return false;
+    }
+
+    @ConfigItem(
+        keyName = "combatTaskSendImage",
+        name = "Send Image",
+        description = "Send image with the notification",
+        position = 44,
+        section = combatTaskSection
+    )
+    default boolean combatTaskSendImage() {
+        return false;
+    }
+
+    @ConfigItem(
+        keyName = "combatTaskMinTier",
+        name = "Min Tier",
+        description = "Minimum combat achievement tier to warrant a notification",
+        position = 45,
+        section = combatTaskSection
+    )
+    default CombatAchievementTier minCombatAchievementTier() {
+        return CombatAchievementTier.EASY;
+    }
+
+    @ConfigItem(
+        keyName = "combatTaskMessage",
+        name = "Notification Message",
+        description = "The message to be sent to the webhook. Use %USERNAME% to insert your username, %TIER% to insert the task tier, %TASK% to insert the task name",
+        position = 46,
+        section = combatTaskSection
+    )
+    default String combatTaskMessage() {
+        return "%USERNAME% has completed %TIER% combat task: %TASK%";
+    }
+
 }
diff --git a/src/main/java/dinkplugin/domain/CombatAchievementTier.java b/src/main/java/dinkplugin/domain/CombatAchievementTier.java
new file mode 100644
index 0000000..23d056c
--- /dev/null
+++ b/src/main/java/dinkplugin/domain/CombatAchievementTier.java
@@ -0,0 +1,27 @@
+package dinkplugin.domain;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+public enum CombatAchievementTier {
+    EASY,
+    MEDIUM,
+    HARD,
+    ELITE,
+    MASTER,
+    GRANDMASTER;
+
+    private final String displayName = this.name().charAt(0) + this.name().substring(1).toLowerCase();
+
+    @Override
+    public String toString() {
+        return this.displayName;
+    }
+
+    public static final Map<String, CombatAchievementTier> TIER_BY_LOWER_NAME = Collections.unmodifiableMap(
+        Arrays.stream(values()).collect(Collectors.toMap(t -> t.name().toLowerCase(), Function.identity()))
+    );
+}
diff --git a/src/main/java/dinkplugin/message/NotificationType.java b/src/main/java/dinkplugin/message/NotificationType.java
index b7ecfa5..701a6e3 100644
--- a/src/main/java/dinkplugin/message/NotificationType.java
+++ b/src/main/java/dinkplugin/message/NotificationType.java
@@ -10,5 +10,6 @@ public enum NotificationType {
     QUEST,
     SLAYER,
     SPEEDRUN,
-    KILL_COUNT
+    KILL_COUNT,
+    COMBAT_ACHIEVEMENT
 }
diff --git a/src/main/java/dinkplugin/notifiers/CombatTaskNotifier.java b/src/main/java/dinkplugin/notifiers/CombatTaskNotifier.java
new file mode 100644
index 0000000..608c85d
--- /dev/null
+++ b/src/main/java/dinkplugin/notifiers/CombatTaskNotifier.java
@@ -0,0 +1,62 @@
+package dinkplugin.notifiers;
+
+import dinkplugin.DinkPlugin;
+import dinkplugin.DinkPluginConfig;
+import dinkplugin.Utils;
+import dinkplugin.domain.CombatAchievementTier;
+import dinkplugin.message.NotificationBody;
+import dinkplugin.message.NotificationType;
+import dinkplugin.notifiers.data.CombatAchievementData;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.lang3.tuple.Pair;
+import org.jetbrains.annotations.VisibleForTesting;
+
+import java.util.Optional;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+public class CombatTaskNotifier extends BaseNotifier {
+    private static final Pattern ACHIEVEMENT_PATTERN = Pattern.compile("Congratulations, you've completed an? (?<tier>\\w+) combat task: (?<task>.+)\\.");
+
+    public CombatTaskNotifier(DinkPlugin plugin) {
+        super(plugin);
+    }
+
+    @Override
+    public boolean isEnabled() {
+        return plugin.getConfig().notifyCombatTask() && super.isEnabled();
+    }
+
+    public void onGameMessage(String message) {
+        if (isEnabled())
+            parse(message).ifPresent(pair -> handle(pair.getLeft(), pair.getRight()));
+    }
+
+    private void handle(CombatAchievementTier tier, String task) {
+        if (tier.ordinal() < plugin.getConfig().minCombatAchievementTier().ordinal())
+            return;
+
+        String player = Utils.getPlayerName(plugin.getClient());
+        String message = StringUtils.replaceEach(
+            plugin.getConfig().combatTaskMessage(),
+            new String[] { "%USERNAME%", "%TIER%", "%TASK%" },
+            new String[] { player, tier.toString(), task }
+        );
+
+        createMessage(DinkPluginConfig::combatTaskSendImage, NotificationBody.<CombatAchievementData>builder()
+            .type(NotificationType.COMBAT_ACHIEVEMENT)
+            .content(message)
+            .playerName(player)
+            .extra(new CombatAchievementData(tier, task))
+            .build());
+    }
+
+    @VisibleForTesting
+    static Optional<Pair<CombatAchievementTier, String>> parse(String message) {
+        Matcher matcher = ACHIEVEMENT_PATTERN.matcher(message);
+        if (!matcher.find()) return Optional.empty();
+        return Optional.of(matcher.group("tier"))
+            .map(CombatAchievementTier.TIER_BY_LOWER_NAME::get)
+            .map(tier -> Pair.of(tier, matcher.group("task")));
+    }
+}
diff --git a/src/main/java/dinkplugin/notifiers/data/CombatAchievementData.java b/src/main/java/dinkplugin/notifiers/data/CombatAchievementData.java
new file mode 100644
index 0000000..89ef60f
--- /dev/null
+++ b/src/main/java/dinkplugin/notifiers/data/CombatAchievementData.java
@@ -0,0 +1,10 @@
+package dinkplugin.notifiers.data;
+
+import dinkplugin.domain.CombatAchievementTier;
+import lombok.Value;
+
+@Value
+public class CombatAchievementData {
+    CombatAchievementTier tier;
+    String task;
+}


Test patch:
diff --git a/src/test/java/dinkplugin/notifiers/CombatTaskNotifierTest.java b/src/test/java/dinkplugin/notifiers/CombatTaskNotifierTest.java
new file mode 100644
index 0000000..4bdc790
--- /dev/null
+++ b/src/test/java/dinkplugin/notifiers/CombatTaskNotifierTest.java
@@ -0,0 +1,77 @@
+package dinkplugin.notifiers;
+
+import dinkplugin.domain.CombatAchievementTier;
+import org.apache.commons.lang3.tuple.Pair;
+import org.junit.jupiter.api.extension.ExtensionContext;
+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 static dinkplugin.domain.CombatAchievementTier.*;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+
+class CombatTaskNotifierTest {
+
+    @ParameterizedTest(name = "Combat Achievement regex should match: {0}")
+    @ArgumentsSource(AchievementProvider.class)
+    void shouldParse(String message, Pair<CombatAchievementTier, String> expected) {
+        assertEquals(expected, CombatTaskNotifier.parse(message).orElse(null));
+    }
+
+    @ParameterizedTest(name = "Combat Achievement regex should not match: {0}")
+    @ValueSource(
+        strings = {
+            "Forsen: forsen",
+            "Your heriboar harvest count is: 69.",
+            "Your King Black Dragon kill count is: 581.",
+            "You have completed your task! You killed 31 TzKal-Zuk. You gained 75 xp.",
+            "Congratulations, you just advanced a Strength level.",
+            "Congratulations, you've completed a gachi combat task: Swordfight with the homies."
+        }
+    )
+    void shouldNotParse(String message) {
+        assertFalse(CombatTaskNotifier.parse(message).isPresent());
+    }
+
+    private static class AchievementProvider implements ArgumentsProvider {
+        @Override
+        public Stream<? extends Arguments> provideArguments(ExtensionContext context) {
+            return Stream.of(
+                Arguments.of(
+                    "Congratulations, you've completed an easy combat task: Defence? What Defence?.",
+                    Pair.of(EASY, "Defence? What Defence?")
+                ),
+                Arguments.of(
+                    "Congratulations, you've completed an easy combat task: A Slow Death.",
+                    Pair.of(EASY, "A Slow Death")
+                ),
+                Arguments.of(
+                    "Congratulations, you've completed a medium combat task: Fire in the Hole!.",
+                    Pair.of(MEDIUM, "Fire in the Hole!")
+                ),
+                Arguments.of(
+                    "Congratulations, you've completed a medium combat task: I'd rather Be Illiterate.",
+                    Pair.of(MEDIUM, "I'd rather Be Illiterate")
+                ),
+                Arguments.of(
+                    "Congratulations, you've completed a hard combat task: Whack-a-Mole.",
+                    Pair.of(HARD, "Whack-a-Mole")
+                ),
+                Arguments.of(
+                    "Congratulations, you've completed a master combat task: Nibblers, Begone!.",
+                    Pair.of(MASTER, "Nibblers, Begone!")
+                ),
+                Arguments.of(
+                    "Congratulations, you've completed a grandmaster combat task: Inferno Grandmaster.",
+                    Pair.of(GRANDMASTER, "Inferno Grandmaster")
+                )
+            );
+        }
+    }
+
+}