Repository: pajlads/DinkPlugin
Pull request #149 (2023-01-30 02:46:16)

feat: parse pet name from clan message
Include pet name in extra notification data when parseable from accompanying clan notification message.

Related: #20

![image](https://user-images.githubusercontent.com/8106344/215376789-c151e6ea-0272-43ea-953d-a57464ebfdae.png)


pajlada: ![2023-01-30-103401_859x141_scrot](https://user-images.githubusercontent.com/962989/215446271-b3ebfb87-fb0e-429b-ab2b-323b53401a24.png)
`[Log Dog LLC] Clipper has a funny feeling like he's being followed: Little nightmare at 2,678 killcount.`


![2023-01-30-104538_860x191_scrot](https://user-images.githubusercontent.com/962989/215446345-cc802a9b-1e1f-45d4-86cf-8339f766ca40.png)
`[Muu] Majin Muu has a funny feeling like she's being followed: Herbi at 3,054 harvest count.`



![2023-01-30-105325_517x113_scrot](https://user-images.githubusercontent.com/962989/215446402-9663d85a-4442-4f08-8260-45338e06079a.png)
`[Dankermen] pajdank has a funny feeling like he's being followed: Rocky at 5,850,317 XP.`


Text for ants
![2023-01-30-110232_295x98_scrot](https://user-images.githubusercontent.com/962989/215446692-abfe04a4-c659-4b65-a139-f38c6d933a4f.png)
`[Outer Void] Dash Inc feels something weird sneaking into his backpack: Heron at 8,426,283 XP.`

All the above examples I could find online or on my own computer seem to correctly match the regex, could add one or more of these if we want more clan-message-tests
iProdigy: Follow-up PR will also try to get name from collection log message

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 80ac451..e6c5e10 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,6 @@
 ## Unreleased
 
+- Minor: Include pet name in rich embeds and notification metadata if the user has clan notifications on. (#149)
 - Bugfix: Report correct item quantity from unsired loot. (#147)
 
 ## 1.2.2
diff --git a/README.md b/README.md
index 043e014..c1a5336 100644
--- a/README.md
+++ b/README.md
@@ -352,9 +352,14 @@ The examples below omit `embeds` and `playerName` keys because they are always t
 ```json5
 {
   "content": "%USERNAME% has a funny feeling they are being followed",
+  "extra": {
+    "petName": "Ikkle hydra"
+  },
   "type": "PET"
 }
 ```
+
+Note: `petName` is only included if the game sent it to your chat via clan notifications.
 </details>
 
 ### Speedrunning:
diff --git a/src/main/java/dinkplugin/DinkPlugin.java b/src/main/java/dinkplugin/DinkPlugin.java
index 14f35f9..c54e810 100644
--- a/src/main/java/dinkplugin/DinkPlugin.java
+++ b/src/main/java/dinkplugin/DinkPlugin.java
@@ -82,6 +82,7 @@ public class DinkPlugin extends Plugin {
     }
 
     void resetNotifiers() {
+        petNotifier.reset();
         clueNotifier.reset();
         diaryNotifier.reset();
         levelNotifier.reset();
@@ -117,6 +118,7 @@ public class DinkPlugin extends Plugin {
 
     @Subscribe
     public void onGameTick(GameTick event) {
+        petNotifier.onTick();
         clueNotifier.onTick();
         slayerNotifier.onTick();
         levelNotifier.onTick();
@@ -126,20 +128,30 @@ public class DinkPlugin extends Plugin {
 
     @Subscribe
     public void onChatMessage(ChatMessage message) {
-        ChatMessageType msgType = message.getType();
         String chatMessage = Text.removeTags(message.getMessage().replace("<br>", "\n")).replace('\u00A0', ' ').trim();
-
-        if (msgType == ChatMessageType.GAMEMESSAGE) {
-            collectionNotifier.onChatMessage(chatMessage);
-            petNotifier.onChatMessage(chatMessage);
-            slayerNotifier.onChatMessage(chatMessage);
-            clueNotifier.onChatMessage(chatMessage);
-            killCountNotifier.onGameMessage(chatMessage);
-            combatTaskNotifier.onGameMessage(chatMessage);
-        }
-
-        if (msgType == ChatMessageType.FRIENDSCHATNOTIFICATION) {
-            killCountNotifier.onFriendsChatNotification(chatMessage);
+        switch (message.getType()) {
+            case GAMEMESSAGE:
+                collectionNotifier.onChatMessage(chatMessage);
+                petNotifier.onChatMessage(chatMessage);
+                slayerNotifier.onChatMessage(chatMessage);
+                clueNotifier.onChatMessage(chatMessage);
+                killCountNotifier.onGameMessage(chatMessage);
+                combatTaskNotifier.onGameMessage(chatMessage);
+                break;
+
+            case FRIENDSCHATNOTIFICATION:
+                killCountNotifier.onFriendsChatNotification(chatMessage);
+                // intentional fallthrough to clan notifications
+
+            case CLAN_MESSAGE:
+            case CLAN_GUEST_MESSAGE:
+            case CLAN_GIM_MESSAGE:
+                petNotifier.onClanNotification(chatMessage);
+                break;
+
+            default:
+                // do nothing
+                break;
         }
     }
 
diff --git a/src/main/java/dinkplugin/notifiers/PetNotifier.java b/src/main/java/dinkplugin/notifiers/PetNotifier.java
index 5d454e9..e089335 100644
--- a/src/main/java/dinkplugin/notifiers/PetNotifier.java
+++ b/src/main/java/dinkplugin/notifiers/PetNotifier.java
@@ -2,15 +2,34 @@ package dinkplugin.notifiers;
 
 import dinkplugin.message.NotificationBody;
 import dinkplugin.message.NotificationType;
+import dinkplugin.notifiers.data.PetNotificationData;
+import dinkplugin.util.ItemSearcher;
+import dinkplugin.util.ItemUtils;
 import dinkplugin.util.Utils;
+import org.apache.commons.lang3.StringUtils;
 import org.jetbrains.annotations.VisibleForTesting;
 
+import javax.inject.Inject;
+import javax.inject.Singleton;
+import java.util.Optional;
+import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
+@Singleton
 public class PetNotifier extends BaseNotifier {
     @VisibleForTesting
     static final Pattern PET_REGEX = Pattern.compile("You (?:have a funny feeling like you|feel something weird sneaking).*");
 
+    @VisibleForTesting
+    static final Pattern CLAN_REGEX = Pattern.compile("\\b(?<user>[\\w\\s]+) (?:has a funny feeling like .+ followed|feels something weird sneaking into .+ backpack): (?<pet>.+) at\\s");
+
+    private static final String PRIMED_NAME = "";
+
+    @Inject
+    private ItemSearcher itemSearcher;
+
+    private String petName = null;
+
     @Override
     public boolean isEnabled() {
         return config.notifyPet() && super.isEnabled();
@@ -23,17 +42,55 @@ public class PetNotifier extends BaseNotifier {
 
     public void onChatMessage(String chatMessage) {
         if (isEnabled() && PET_REGEX.matcher(chatMessage).matches()) {
-            this.handleNotify();
+            // Prime the notifier to trigger next tick
+            this.petName = PRIMED_NAME;
         }
     }
 
+    public void onClanNotification(String message) {
+        if (!PRIMED_NAME.equals(petName)) {
+            // We have not received the normal message about a pet drop, so this clan message cannot be relevant to us
+            return;
+        }
+
+        Matcher matcher = CLAN_REGEX.matcher(message);
+        if (matcher.find()) {
+            String user = matcher.group("user").trim();
+            if (user.equals(Utils.getPlayerName(client))) {
+                this.petName = matcher.group("pet");
+            }
+        }
+    }
+
+    public void onTick() {
+        if (petName != null)
+            this.handleNotify();
+    }
+
+    public void reset() {
+        this.petName = null;
+    }
+
     private void handleNotify() {
         String notifyMessage = config.petNotifyMessage()
             .replace("%USERNAME%", Utils.getPlayerName(client));
 
+        String thumbnail = Optional.ofNullable(petName)
+            .filter(s -> !s.isEmpty())
+            .map(Utils::ucFirst)
+            .map(itemSearcher::findItemId)
+            .map(ItemUtils::getItemImageUrl)
+            .orElse(null);
+
+        PetNotificationData extra = new PetNotificationData(StringUtils.defaultIfEmpty(petName, null));
+
         createMessage(config.petSendImage(), NotificationBody.builder()
+            .extra(extra)
             .text(notifyMessage)
+            .thumbnailUrl(thumbnail)
             .type(NotificationType.PET)
             .build());
+
+        reset();
     }
 }
diff --git a/src/main/java/dinkplugin/notifiers/data/PetNotificationData.java b/src/main/java/dinkplugin/notifiers/data/PetNotificationData.java
new file mode 100644
index 0000000..80b83b1
--- /dev/null
+++ b/src/main/java/dinkplugin/notifiers/data/PetNotificationData.java
@@ -0,0 +1,24 @@
+package dinkplugin.notifiers.data;
+
+import dinkplugin.message.Field;
+import lombok.EqualsAndHashCode;
+import lombok.Value;
+
+import java.util.Collections;
+import java.util.List;
+
+@Value
+@EqualsAndHashCode(callSuper = false)
+public class PetNotificationData extends NotificationData {
+    String petName;
+
+    @Override
+    public List<Field> getFields() {
+        if (petName == null || petName.isEmpty())
+            return super.getFields();
+
+        return Collections.singletonList(
+            new Field("Name", "```\n" + petName + "\n```")
+        );
+    }
+}
diff --git a/src/main/java/dinkplugin/util/Utils.java b/src/main/java/dinkplugin/util/Utils.java
index d6cefcf..6ee9fe2 100644
--- a/src/main/java/dinkplugin/util/Utils.java
+++ b/src/main/java/dinkplugin/util/Utils.java
@@ -35,6 +35,17 @@ public class Utils {
     public final Color PINK = ColorUtil.fromHex("#f40098"); // analogous to RED in CIELCh_uv color space
     public final Color RED = ColorUtil.fromHex("#ca2a2d"); // red used in pajaW
 
+    /**
+     * Converts text into "upper case first" form, as is used by OSRS for item names.
+     *
+     * @param text the string to be transformed
+     * @return the text with only the first character capitalized
+     */
+    public String ucFirst(@NotNull String text) {
+        if (text.length() < 2) return text.toUpperCase();
+        return Character.toUpperCase(text.charAt(0)) + text.substring(1).toLowerCase();
+    }
+
     public boolean isSettingsOpen(@NotNull Client client) {
         Widget widget = client.getWidget(WidgetInfo.SETTINGS_INIT);
         return widget != null && !widget.isSelfHidden();


Test patch:
diff --git a/src/test/java/dinkplugin/notifiers/MatchersTest.java b/src/test/java/dinkplugin/notifiers/MatchersTest.java
index 8c1d5d0..e7f0360 100644
--- a/src/test/java/dinkplugin/notifiers/MatchersTest.java
+++ b/src/test/java/dinkplugin/notifiers/MatchersTest.java
@@ -100,31 +100,6 @@ class MatchersTest {
         }
     }
 
-    @ParameterizedTest(name = "Pet message should trigger {0}")
-    @ValueSource(
-        strings = {
-            "You have a funny feeling like you're being followed.",
-            "You have a funny feeling like you would have been followed...",
-            "You feel something weird sneaking into your backpack."
-        }
-    )
-    void petRegexFindsMatch(String message) {
-        Matcher matcher = PetNotifier.PET_REGEX.matcher(message);
-        assertTrue(matcher.find());
-    }
-
-    @ParameterizedTest(name = "Pet message should not trigger {0}")
-    @ValueSource(
-        strings = {
-            "Forsen: forsen",
-            "You feel like you forgot to turn the stove off"
-        }
-    )
-    void petRegexDoesNotMatch(String message) {
-        Matcher matcher = PetNotifier.PET_REGEX.matcher(message);
-        assertFalse(matcher.find());
-    }
-
     @ParameterizedTest(name = "Kill count message should trigger on: {0}")
     @ArgumentsSource(KillCountProvider.class)
     void killCountRegexFindsMatch(String message, Pair<String, Integer> expected) {
diff --git a/src/test/java/dinkplugin/notifiers/PetMatcherTest.java b/src/test/java/dinkplugin/notifiers/PetMatcherTest.java
new file mode 100644
index 0000000..f995777
--- /dev/null
+++ b/src/test/java/dinkplugin/notifiers/PetMatcherTest.java
@@ -0,0 +1,77 @@
+package dinkplugin.notifiers;
+
+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.regex.Matcher;
+import java.util.stream.Stream;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class PetMatcherTest {
+
+    @ParameterizedTest(name = "Pet message should trigger {0}")
+    @ValueSource(
+        strings = {
+            "You have a funny feeling like you're being followed.",
+            "You have a funny feeling like you would have been followed...",
+            "You feel something weird sneaking into your backpack."
+        }
+    )
+    void petRegexFindsMatch(String message) {
+        Matcher matcher = PetNotifier.PET_REGEX.matcher(message);
+        assertTrue(matcher.find());
+    }
+
+    @ParameterizedTest(name = "Pet message should not trigger {0}")
+    @ValueSource(
+        strings = {
+            "Forsen: forsen",
+            "You feel like you forgot to turn the stove off"
+        }
+    )
+    void petRegexDoesNotMatch(String message) {
+        Matcher matcher = PetNotifier.PET_REGEX.matcher(message);
+        assertFalse(matcher.find());
+    }
+
+    @ParameterizedTest(name = "Pet name should be parsed from: {0}")
+    @ArgumentsSource(ClanPetProvider.class)
+    void petNameMatches(String message, String user, String pet) {
+        Matcher matcher = PetNotifier.CLAN_REGEX.matcher(message);
+        assertTrue(matcher.find());
+        assertEquals(user, matcher.group("user"));
+        assertEquals(pet, matcher.group("pet"));
+    }
+
+    @ParameterizedTest(name = "Pet name should be not parsed from {0}")
+    @ValueSource(
+        strings = {
+            "Forsen: forsen",
+            "You feel like you forgot to turn the stove off"
+        }
+    )
+    void petNameDoesNotMatch(String message) {
+        Matcher matcher = PetNotifier.CLAN_REGEX.matcher(message);
+        assertFalse(matcher.find());
+    }
+
+    private static class ClanPetProvider implements ArgumentsProvider {
+        @Override
+        public Stream<? extends Arguments> provideArguments(ExtensionContext context) {
+            return Stream.of(
+                Arguments.of("[Log Dog LLC] Clipper has a funny feeling like he's being followed: Little nightmare at 2,678 killcount.", "Clipper", "Little nightmare"),
+                Arguments.of("[Muu] Majin Muu has a funny feeling like she's being followed: Herbi at 3,054 harvest count.", "Majin Muu", "Herbi"),
+                Arguments.of("[Dankermen] pajdank has a funny feeling like he's being followed: Rocky at 5,850,317 XP.", "pajdank", "Rocky"),
+                Arguments.of("[Outer Void] Dash Inc feels something weird sneaking into his backpack: Heron at 8,426,283 XP.", "Dash Inc", "Heron")
+            );
+        }
+    }
+
+}
diff --git a/src/test/java/dinkplugin/notifiers/PetNotifierTest.java b/src/test/java/dinkplugin/notifiers/PetNotifierTest.java
index 60e3f7b..933cdd3 100644
--- a/src/test/java/dinkplugin/notifiers/PetNotifierTest.java
+++ b/src/test/java/dinkplugin/notifiers/PetNotifierTest.java
@@ -3,9 +3,14 @@ package dinkplugin.notifiers;
 import com.google.inject.testing.fieldbinder.Bind;
 import dinkplugin.message.NotificationBody;
 import dinkplugin.message.NotificationType;
+import dinkplugin.notifiers.data.PetNotificationData;
+import dinkplugin.util.ItemSearcher;
+import dinkplugin.util.ItemUtils;
+import net.runelite.api.ItemID;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.mockito.InjectMocks;
+import org.mockito.Mock;
 
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyBoolean;
@@ -19,6 +24,10 @@ class PetNotifierTest extends MockedNotifierTest {
     @InjectMocks
     PetNotifier notifier;
 
+    @Bind
+    @Mock
+    ItemSearcher itemSearcher;
+
     @Override
     @BeforeEach
     protected void setUp() {
@@ -34,13 +43,47 @@ class PetNotifierTest extends MockedNotifierTest {
     void testNotify() {
         // send fake message
         notifier.onChatMessage("You feel something weird sneaking into your backpack.");
+        notifier.onTick();
+
+        // verify handled
+        verify(messageHandler).createMessage(
+            PRIMARY_WEBHOOK_URL,
+            false,
+            NotificationBody.builder()
+                .extra(new PetNotificationData(null))
+                .text(PLAYER_NAME + " got a pet")
+                .type(NotificationType.PET)
+                .build()
+        );
+    }
+
+    @Test
+    void testNotifyClan() {
+        String petName = "TzRek-Jad";
+        int itemId = ItemID.TZREKJAD;
+
+        // prepare mocks
+        when(itemSearcher.findItemId("Tzrek-jad")).thenReturn(itemId);
+
+        // send fake message
+        notifier.onChatMessage("You have a funny feeling like you're being followed.");
+        notifier.onClanNotification(
+            String.format(
+                "[ClanName] %s has a funny feeling like he would have been followed: %s at 50 killcount",
+                PLAYER_NAME,
+                petName
+            )
+        );
+        notifier.onTick();
 
         // verify handled
         verify(messageHandler).createMessage(
             PRIMARY_WEBHOOK_URL,
             false,
             NotificationBody.builder()
+                .extra(new PetNotificationData(petName))
                 .text(PLAYER_NAME + " got a pet")
+                .thumbnailUrl(ItemUtils.getItemImageUrl(itemId))
                 .type(NotificationType.PET)
                 .build()
         );
@@ -53,12 +96,14 @@ class PetNotifierTest extends MockedNotifierTest {
 
         // send fake message
         notifier.onChatMessage("You feel something weird sneaking into your backpack.");
+        notifier.onTick();
 
         // verify handled at override url
         verify(messageHandler).createMessage(
             "https://example.com",
             false,
             NotificationBody.builder()
+                .extra(new PetNotificationData(null))
                 .text(PLAYER_NAME + " got a pet")
                 .type(NotificationType.PET)
                 .build()
@@ -69,6 +114,7 @@ class PetNotifierTest extends MockedNotifierTest {
     void testIgnore() {
         // send non-pet message
         notifier.onChatMessage("You feel Forsen's warmth behind you.");
+        notifier.onTick();
 
         // ensure no notification occurred
         verify(messageHandler, never()).createMessage(any(), anyBoolean(), any());
@@ -81,6 +127,7 @@ class PetNotifierTest extends MockedNotifierTest {
 
         // send fake message
         notifier.onChatMessage("You feel something weird sneaking into your backpack.");
+        notifier.onTick();
 
         // ensure no notification occurred
         verify(messageHandler, never()).createMessage(any(), anyBoolean(), any());
@@ -94,12 +141,14 @@ class PetNotifierTest extends MockedNotifierTest {
 
         // send fake message
         notifier.onChatMessage("You feel something weird sneaking into your backpack.");
+        notifier.onTick();
 
         // verify handled
         verify(messageHandler).createMessage(
             PRIMARY_WEBHOOK_URL,
             false,
             NotificationBody.builder()
+                .extra(new PetNotificationData(null))
                 .text(PLAYER_NAME + " got a pet")
                 .type(NotificationType.PET)
                 .build()
@@ -114,6 +163,7 @@ class PetNotifierTest extends MockedNotifierTest {
 
         // send fake message
         notifier.onChatMessage("You feel something weird sneaking into your backpack.");
+        notifier.onTick();
 
         // ensure no notification occurred
         verify(messageHandler, never()).createMessage(any(), anyBoolean(), any());