Repository: pajlads/DinkPlugin
Pull request #312 (2023-09-10 08:06:10)

feat: support wildcard in loot item filters
Follow-up from https://github.com/pajlads/DinkPlugin/pull/310#pullrequestreview-1618701724



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0725100..438d2a3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,7 @@
 ## Unreleased
 
+- Minor: Support wildcards in loot item name filters. (#312)
+
 ## 1.6.3
 
 - Minor: Add item name allowlist and denylist for loot notifications. (#310)
diff --git a/src/main/java/dinkplugin/DinkPluginConfig.java b/src/main/java/dinkplugin/DinkPluginConfig.java
index f4f30ac..6ed2d12 100644
--- a/src/main/java/dinkplugin/DinkPluginConfig.java
+++ b/src/main/java/dinkplugin/DinkPluginConfig.java
@@ -768,7 +768,7 @@ public interface DinkPluginConfig extends Config {
         keyName = "lootItemAllowlist",
         name = "Item Allowlist",
         description = "Always fire notifications for these items, despite value settings.<br/>" +
-            "Place one item name per line (case-insensitive)",
+            "Place one item name per line (case-insensitive; asterisks are wildcards)",
         position = 37,
         section = lootSection
     )
@@ -780,7 +780,7 @@ public interface DinkPluginConfig extends Config {
         keyName = "lootItemDenylist",
         name = "Item Denylist",
         description = "Never fire notifications for these items, despite value settings.<br/>" +
-            "Place one item name per line (case-insensitive)",
+            "Place one item name per line (case-insensitive; asterisks are wildcards)",
         position = 38,
         section = lootSection
     )
diff --git a/src/main/java/dinkplugin/notifiers/LootNotifier.java b/src/main/java/dinkplugin/notifiers/LootNotifier.java
index 8f8ee75..216005c 100644
--- a/src/main/java/dinkplugin/notifiers/LootNotifier.java
+++ b/src/main/java/dinkplugin/notifiers/LootNotifier.java
@@ -43,10 +43,11 @@ import javax.inject.Singleton;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
-import java.util.HashSet;
 import java.util.List;
-import java.util.Set;
+import java.util.concurrent.CopyOnWriteArrayList;
 import java.util.concurrent.TimeUnit;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
 
 @Slf4j
 @Singleton
@@ -66,8 +67,8 @@ public class LootNotifier extends BaseNotifier {
     @Inject
     private ConfigManager configManager;
 
-    private final Set<String> itemLowerNameAllowlist = new HashSet<>();
-    private final Set<String> itemLowerNameDenylist = new HashSet<>();
+    private final Collection<Pattern> itemNameAllowlist = new CopyOnWriteArrayList<>();
+    private final Collection<Pattern> itemNameDenylist = new CopyOnWriteArrayList<>();
 
     private final Cache<String, Integer> killCounts = CacheBuilder.newBuilder()
         .expireAfterAccess(10, TimeUnit.MINUTES)
@@ -88,39 +89,37 @@ public class LootNotifier extends BaseNotifier {
     }
 
     public void init() {
-        synchronized (itemLowerNameAllowlist) {
-            itemLowerNameAllowlist.clear();
+        itemNameAllowlist.clear();
+        itemNameAllowlist.addAll(
             ConfigUtil.readDelimited(config.lootItemAllowlist())
-                .map(String::toLowerCase)
-                .forEach(itemLowerNameAllowlist::add);
-        }
+                .map(Utils::regexify)
+                .collect(Collectors.toList())
+        );
 
-        synchronized (itemLowerNameDenylist) {
-            itemLowerNameDenylist.clear();
+        itemNameDenylist.clear();
+        itemNameDenylist.addAll(
             ConfigUtil.readDelimited(config.lootItemDenylist())
-                .map(String::toLowerCase)
-                .forEach(itemLowerNameDenylist::add);
-        }
+                .map(Utils::regexify)
+                .collect(Collectors.toList())
+        );
     }
 
     public void onConfigChanged(String key, String value) {
-        Collection<String> itemNames;
+        Collection<Pattern> itemNames;
         if ("lootItemAllowlist".equals(key)) {
-            itemNames = itemLowerNameAllowlist;
+            itemNames = itemNameAllowlist;
         } else if ("lootItemDenylist".equals(key)) {
-            itemNames = itemLowerNameDenylist;
+            itemNames = itemNameDenylist;
         } else {
             return;
         }
 
-        synchronized (itemNames) {
-            itemNames.clear();
+        itemNames.clear();
+        itemNames.addAll(
             ConfigUtil.readDelimited(value)
-                .map(String::toLowerCase)
-                .forEach(itemNames::add);
-        }
-
-        log.debug("{} is now {}", key, itemNames);
+                .map(Utils::regexify)
+                .collect(Collectors.toList())
+        );
     }
 
     public void onNpcLootReceived(NpcLootReceived event) {
@@ -214,9 +213,8 @@ public class LootNotifier extends BaseNotifier {
         for (ItemStack item : reduced) {
             SerializedItemStack stack = ItemUtils.stackFromItem(itemManager, item.getId(), item.getQuantity());
             long totalPrice = stack.getTotalPrice();
-            String lowerName = stack.getName().toLowerCase();
-            boolean worthy = totalPrice >= minValue || itemLowerNameAllowlist.contains(lowerName);
-            if (worthy && !itemLowerNameDenylist.contains(lowerName)) {
+            boolean worthy = totalPrice >= minValue || matches(itemNameAllowlist, stack.getName());
+            if (worthy && !matches(itemNameDenylist, stack.getName())) {
                 sendMessage = true;
                 lootMessage.component(ItemUtils.templateStack(stack, true));
                 if (icons) embeds.add(Embed.ofImage(ItemUtils.getItemImageUrl(item.getId())));
@@ -305,4 +303,12 @@ public class LootNotifier extends BaseNotifier {
         return widget != null && widget.getItemId() >= 0;
     }
 
+    private static boolean matches(Collection<Pattern> regexps, String input) {
+        for (Pattern regex : regexps) {
+            if (regex.matcher(input).find())
+                return true;
+        }
+        return false;
+    }
+
 }
diff --git a/src/main/java/dinkplugin/util/Utils.java b/src/main/java/dinkplugin/util/Utils.java
index bb07d80..b656244 100644
--- a/src/main/java/dinkplugin/util/Utils.java
+++ b/src/main/java/dinkplugin/util/Utils.java
@@ -2,6 +2,7 @@ package dinkplugin.util;
 
 import com.google.gson.Gson;
 import com.google.gson.reflect.TypeToken;
+import dinkplugin.DinkPluginConfig;
 import dinkplugin.domain.AccountType;
 import lombok.experimental.UtilityClass;
 import lombok.extern.slf4j.Slf4j;
@@ -35,6 +36,7 @@ import java.io.Reader;
 import java.util.Arrays;
 import java.util.concurrent.CompletableFuture;
 import java.util.function.Function;
+import java.util.regex.Pattern;
 
 @Slf4j
 @UtilityClass
@@ -82,6 +84,41 @@ public class Utils {
         return Character.toUpperCase(text.charAt(0)) + text.substring(1).toLowerCase();
     }
 
+    /**
+     * Converts simple patterns (asterisk is the only special character) into regexps.
+     *
+     * @param pattern a simple pattern (asterisks are wildcards, and the rest is a string literal)
+     * @return a compiled regular expression associated with the simple pattern
+     * @see DinkPluginConfig#lootItemAllowlist()
+     * @see DinkPluginConfig#lootItemDenylist()
+     */
+    public Pattern regexify(@NotNull String pattern) {
+        final int len = pattern.length();
+        final StringBuilder sb = new StringBuilder(len + 2 + 4);
+        int startIndex = 0;
+
+        if (!pattern.startsWith("*")) {
+            sb.append('^');
+        } else {
+            startIndex++;
+        }
+
+        int i;
+        while ((i = pattern.indexOf('*', startIndex)) >= 0) {
+            String section = pattern.substring(startIndex, i);
+            sb.append(Pattern.quote(section));
+            sb.append(".*");
+            startIndex = i + 1;
+        }
+
+        if (startIndex < len) {
+            sb.append(Pattern.quote(pattern.substring(startIndex)));
+            sb.append('$');
+        }
+
+        return Pattern.compile(sb.toString(), Pattern.CASE_INSENSITIVE);
+    }
+
     /**
      * @param a some string
      * @param b another string


Test patch:
diff --git a/src/test/java/dinkplugin/notifiers/LootNotifierTest.java b/src/test/java/dinkplugin/notifiers/LootNotifierTest.java
index c475ec9..5870793 100644
--- a/src/test/java/dinkplugin/notifiers/LootNotifierTest.java
+++ b/src/test/java/dinkplugin/notifiers/LootNotifierTest.java
@@ -134,6 +134,34 @@ class LootNotifierTest extends MockedNotifierTest {
         );
     }
 
+    @Test
+    void testNotifyAllowlistWildcard() {
+        // prepare mocks
+        Player player = mock(Player.class);
+        when(player.getName()).thenReturn(LOOTED_NAME);
+
+        // fire event
+        notifier.onConfigChanged("lootItemAllowlist", "salmon\nraw trout\ntun*\npike");
+        PlayerLootReceived event = new PlayerLootReceived(player, Collections.singletonList(new ItemStack(ItemID.TUNA, 1, null)));
+        plugin.onPlayerLootReceived(event);
+
+        // verify notification message
+        verify(messageHandler).createMessage(
+            PRIMARY_WEBHOOK_URL,
+            false,
+            NotificationBody.builder()
+                .text(
+                    Template.builder()
+                        .template(String.format("%s has looted: 1 x {{tuna}} (%d) from %s for %d gp", PLAYER_NAME, TUNA_PRICE, LOOTED_NAME, TUNA_PRICE))
+                        .replacement("{{tuna}}", Replacements.ofWiki("Tuna"))
+                        .build()
+                )
+                .extra(new LootNotificationData(Collections.singletonList(new SerializedItemStack(ItemID.TUNA, 1, TUNA_PRICE, "Tuna")), LOOTED_NAME, LootRecordType.PLAYER, null))
+                .type(NotificationType.LOOT)
+                .build()
+        );
+    }
+
     @Test
     void testIgnoreDenylist() {
         // fire event
@@ -145,6 +173,17 @@ class LootNotifierTest extends MockedNotifierTest {
         verify(messageHandler, never()).createMessage(any(), anyBoolean(), any());
     }
 
+    @Test
+    void testIgnoreDenylistWildcard() {
+        // fire event
+        notifier.onConfigChanged("lootItemDenylist", "Rub*");
+        LootReceived event = new LootReceived(LOOTED_NAME, 99, LootRecordType.PICKPOCKET, Collections.singletonList(new ItemStack(ItemID.RUBY, 1, null)), 1);
+        plugin.onLootReceived(event);
+
+        // ensure no notification
+        verify(messageHandler, never()).createMessage(any(), anyBoolean(), any());
+    }
+
     @Test
     @DisplayName("Ensure LootReceived event for The Whisperer fires a notification - https://github.com/pajlads/DinkPlugin/pull/286")
     void testNotifyWhisperer() {
diff --git a/src/test/java/dinkplugin/util/UtilsTest.java b/src/test/java/dinkplugin/util/UtilsTest.java
index 07f4a6a..889ce1c 100644
--- a/src/test/java/dinkplugin/util/UtilsTest.java
+++ b/src/test/java/dinkplugin/util/UtilsTest.java
@@ -2,7 +2,11 @@ package dinkplugin.util;
 
 import org.junit.jupiter.api.Test;
 
+import java.util.regex.Pattern;
+
 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 UtilsTest {
 
@@ -20,4 +24,92 @@ class UtilsTest {
         assertEquals("Hello…", Utils.truncate("Hello worldly beings", 13));
     }
 
+    @Test
+    void regexify() {
+        Pattern a = Utils.regexify("Hello world!");
+        assertEquals("^\\QHello world!\\E$", a.pattern());
+        assertTrue(a.matcher("Hello world!").find());
+        assertTrue(a.matcher("hello world!").find());
+        assertFalse(a.matcher("Hello world").find());
+        assertFalse(a.matcher("Hello world!!").find());
+        assertFalse(a.matcher("Hi Hello world!").find());
+
+        Pattern b = Utils.regexify("Hello.world!");
+        assertEquals("^\\QHello.world!\\E$", b.pattern());
+        assertTrue(b.matcher("Hello.world!").find());
+        assertFalse(b.matcher("Hello world!").find());
+        assertFalse(b.matcher("Hello.world!!").find());
+
+        Pattern c = Utils.regexify("Hello world!*");
+        assertEquals("^\\QHello world!\\E.*", c.pattern());
+        assertTrue(c.matcher("Hello world!").find());
+        assertTrue(c.matcher("Hello world!~").find());
+        assertFalse(c.matcher("Hi Hello world!").find());
+        assertFalse(c.matcher("Hello world").find());
+
+        Pattern d = Utils.regexify("*Hello world!");
+        assertEquals("\\QHello world!\\E$", d.pattern());
+        assertTrue(d.matcher("Hello world!").find());
+        assertTrue(d.matcher("Hi Hello world!").find());
+        assertFalse(d.matcher("Hello world!!").find());
+        assertFalse(d.matcher("Hello world").find());
+
+        Pattern e = Utils.regexify("*Hello world!*");
+        assertEquals("\\QHello world!\\E.*", e.pattern());
+        assertTrue(e.matcher("Hello world!").find());
+        assertTrue(e.matcher("Hi Hello world!").find());
+        assertTrue(e.matcher("Hello world!!").find());
+        assertTrue(e.matcher("Hi Hello world!!").find());
+        assertFalse(e.matcher("Hi Hello cruel world!!").find());
+
+        Pattern f = Utils.regexify("*Hello*world!*");
+        assertEquals("\\QHello\\E.*\\Qworld!\\E.*", f.pattern());
+        assertTrue(f.matcher("Hello world!").find());
+        assertTrue(f.matcher("Hi Hello world!").find());
+        assertTrue(f.matcher("Hello world!!").find());
+        assertTrue(f.matcher("Hi Hello world!!").find());
+        assertTrue(f.matcher("Hi hello World!!").find());
+        assertTrue(f.matcher("Hi Hello cruel world!!").find());
+
+        Pattern g = Utils.regexify("Membership's price is $12.49");
+        assertTrue(g.matcher("Membership's price is $12.49").find());
+        assertFalse(g.matcher("Membership's price is $12.499").find());
+        assertFalse(g.matcher("Membershipss price is $12.49").find());
+        assertFalse(g.matcher("Membership's price is $12349").find());
+
+        Pattern h = Utils.regexify("Membership's price is $12.49*");
+        assertTrue(h.matcher("Membership's price is $12.49").find());
+        assertTrue(h.matcher("Membership's price is $12.499").find());
+        assertFalse(h.matcher("A Membership's price is $12.49").find());
+
+        Pattern i = Utils.regexify("dragon*");
+        assertTrue(i.matcher("dragon pickaxe").find());
+        assertTrue(i.matcher("dragon claws").find());
+        assertFalse(i.matcher("iron pickaxe").find());
+
+        Pattern j = Utils.regexify("*orb");
+        assertTrue(j.matcher("awakener's orb").find());
+        assertTrue(j.matcher("commorb").find());
+        assertFalse(j.matcher("commorb v2").find());
+
+        Pattern k = Utils.regexify("vorkath's head");
+        assertTrue(k.matcher("vorkath's head").find());
+        assertFalse(k.matcher("vorki").find());
+        assertFalse(k.matcher("iron pickaxe").find());
+
+        Pattern l = Utils.regexify("clue scroll*");
+        assertTrue(l.matcher("clue scroll (elite)").find());
+        assertTrue(l.matcher("clue scroll (beginner)").find());
+        assertTrue(l.matcher("clue scroll (easy)").find());
+        assertFalse(l.matcher("clue bottle (beginner)").find());
+        assertFalse(l.matcher("iron pickaxe").find());
+
+        Pattern m = Utils.regexify("jar of*");
+        assertTrue(m.matcher("jar of dirt").find());
+        assertTrue(m.matcher("jar of smoke").find());
+        assertTrue(m.matcher("jar of dust").find());
+        assertTrue(m.matcher("jar of spirits").find());
+        assertFalse(m.matcher("iron pickaxe").find());
+    }
+
 }