Repository: pajlads/DinkPlugin (java)

Issue:
Add rsn whitelist option
Would appreciate a way to only trigger notifications on specific accounts, to avoid alts sending unwanted notifications.

Pull request description:
feat: add ignored player names configuration
Closes #111


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1b13596..2be754f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,7 @@
 ## Unreleased
 
 - Major: Use Discord rich embed format for webhooks. This is technically a possible breaking change for users that target non-Discord webhook servers; these users can revert to the old notification format by disabling the `Use Rich Embeds` setting in the `Advanced` section. (#110)
+- Minor: Add configurable player name ignore list for notifications. (#114)
 - Dev: Allow unit tests to post to an actual webhook server. (#110)
 
 ## 1.1.3
diff --git a/src/main/java/dinkplugin/DinkPlugin.java b/src/main/java/dinkplugin/DinkPlugin.java
index b10cb83..14f35f9 100644
--- a/src/main/java/dinkplugin/DinkPlugin.java
+++ b/src/main/java/dinkplugin/DinkPlugin.java
@@ -53,7 +53,7 @@ public class DinkPlugin extends Plugin {
 
     private @Inject ChatMessageManager chatManager;
 
-    private @Inject SettingsValidator validator;
+    private @Inject SettingsManager settingsManager;
 
     private @Inject CollectionNotifier collectionNotifier;
     private @Inject PetNotifier petNotifier;
@@ -71,12 +71,17 @@ public class DinkPlugin extends Plugin {
     @Override
     protected void startUp() {
         log.info("Started up Dink");
+        settingsManager.init();
         levelNotifier.initLevels();
     }
 
     @Override
     protected void shutDown() {
         log.info("Shutting down Dink");
+        this.resetNotifiers();
+    }
+
+    void resetNotifiers() {
         clueNotifier.reset();
         diaryNotifier.reset();
         levelNotifier.reset();
@@ -91,7 +96,7 @@ public class DinkPlugin extends Plugin {
 
     @Subscribe
     public void onConfigChanged(ConfigChanged event) {
-        validator.onConfigChanged(event);
+        settingsManager.onConfigChanged(event);
     }
 
     @Subscribe
@@ -165,7 +170,7 @@ public class DinkPlugin extends Plugin {
 
     @Subscribe
     public void onVarbitChanged(VarbitChanged event) {
-        validator.onVarbitChanged(event);
+        settingsManager.onVarbitChanged(event);
         diaryNotifier.onVarbitChanged(event);
     }
 
diff --git a/src/main/java/dinkplugin/DinkPluginConfig.java b/src/main/java/dinkplugin/DinkPluginConfig.java
index fada0ef..7a534da 100644
--- a/src/main/java/dinkplugin/DinkPluginConfig.java
+++ b/src/main/java/dinkplugin/DinkPluginConfig.java
@@ -176,6 +176,17 @@ public interface DinkPluginConfig extends Config {
         return "https://github.com/pajlads/DinkPlugin/raw/master/icon.png";
     }
 
+    @ConfigItem(
+        keyName = "ignoredNames",
+        name = "Ignored RSNs",
+        description = "Prevent notifications from the following player names (One name per line)",
+        position = 1006,
+        section = advancedSection
+    )
+    default String ignoredNames() {
+        return "";
+    }
+
     @ConfigItem(
         keyName = "discordWebhook", // do not rename; would break old configs
         name = "Primary Webhook URLs",
diff --git a/src/main/java/dinkplugin/SettingsManager.java b/src/main/java/dinkplugin/SettingsManager.java
new file mode 100644
index 0000000..a8c6fc5
--- /dev/null
+++ b/src/main/java/dinkplugin/SettingsManager.java
@@ -0,0 +1,136 @@
+package dinkplugin;
+
+import dinkplugin.notifiers.CollectionNotifier;
+import dinkplugin.notifiers.CombatTaskNotifier;
+import dinkplugin.util.Utils;
+import lombok.RequiredArgsConstructor;
+import lombok.Synchronized;
+import lombok.extern.slf4j.Slf4j;
+import net.runelite.api.Client;
+import net.runelite.api.GameState;
+import net.runelite.api.Varbits;
+import net.runelite.api.events.VarbitChanged;
+import net.runelite.client.callback.ClientThread;
+import net.runelite.client.events.ConfigChanged;
+import org.apache.commons.lang3.StringUtils;
+import org.jetbrains.annotations.VisibleForTesting;
+
+import javax.inject.Inject;
+import javax.inject.Singleton;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.regex.Pattern;
+import java.util.stream.Stream;
+
+@Slf4j
+@Singleton
+@RequiredArgsConstructor(onConstructor_ = { @Inject })
+public class SettingsManager {
+    private static final String CONFIG_GROUP = "dinkplugin";
+    private static final Pattern DELIM = Pattern.compile("[,;\\n]");
+
+    private final Collection<String> ignoredNames = new HashSet<>();
+
+    private final Client client;
+    private final ClientThread clientThread;
+    private final DinkPlugin plugin;
+    private final DinkPluginConfig config;
+
+    /**
+     * Check whether a username is not on the configured ignore list.
+     *
+     * @param name the local player's name
+     * @return whether notifications for this player should be sent
+     */
+    @Synchronized
+    public boolean isNamePermitted(String name) {
+        return name != null && !ignoredNames.contains(name.toLowerCase());
+    }
+
+    @VisibleForTesting
+    public void init() {
+        setIgnoredNames(config.ignoredNames());
+    }
+
+    void onConfigChanged(ConfigChanged event) {
+        if (!CONFIG_GROUP.equals(event.getGroup())) {
+            return;
+        }
+
+        String key = event.getKey();
+        String value = event.getNewValue();
+
+        if ("ignoredNames".equals(key)) {
+            setIgnoredNames(value);
+            return;
+        }
+
+        if (client.getGameState() == GameState.LOGGED_IN) {
+            if ("combatTaskEnabled".equals(key) && "true".equals(value)) {
+                clientThread.invokeLater(() -> {
+                    if (isRepeatPopupInvalid(client.getVarbitValue(CombatTaskNotifier.COMBAT_TASK_REPEAT_POPUP))) {
+                        plugin.addChatWarning(CombatTaskNotifier.REPEAT_WARNING);
+                    }
+                });
+                return;
+            }
+
+            if ("collectionLogEnabled".equals(key) && "true".equals(value)) {
+                clientThread.invokeLater(() -> {
+                    if (isCollectionLogInvalid(client.getVarbitValue(Varbits.COLLECTION_LOG_NOTIFICATION))) {
+                        plugin.addChatWarning(CollectionNotifier.ADDITION_WARNING);
+                    }
+                });
+            }
+        }
+    }
+
+    void onVarbitChanged(VarbitChanged event) {
+        int value = event.getValue();
+
+        if (event.getVarbitId() == CombatTaskNotifier.COMBAT_TASK_REPEAT_POPUP && isRepeatPopupInvalid(value) && config.notifyCombatTask()) {
+            warnForGameSetting(CombatTaskNotifier.REPEAT_WARNING);
+        }
+
+        if (event.getVarbitId() == Varbits.COLLECTION_LOG_NOTIFICATION && isCollectionLogInvalid(value) && config.notifyCollectionLog()) {
+            warnForGameSetting(CollectionNotifier.ADDITION_WARNING);
+        }
+    }
+
+    private void warnForGameSetting(String message) {
+        if (Utils.isSettingsOpen(client)) {
+            plugin.addChatWarning(message);
+        } else {
+            log.warn(message);
+        }
+    }
+
+    private Stream<String> readDelimited(String value) {
+        if (value == null) return Stream.empty();
+        return DELIM.splitAsStream(value)
+            .map(String::trim)
+            .filter(StringUtils::isNotEmpty);
+    }
+
+    @Synchronized
+    private void setIgnoredNames(String configValue) {
+        ignoredNames.clear();
+        readDelimited(configValue)
+            .map(String::toLowerCase)
+            .forEach(ignoredNames::add);
+        log.debug("Updated RSN Deny List to: {}", ignoredNames);
+
+        if (plugin != null)
+            plugin.resetNotifiers();
+    }
+
+    private static boolean isCollectionLogInvalid(int varbitValue) {
+        // we require chat notification for collection log notifier
+        return varbitValue != 1 && varbitValue != 3;
+    }
+
+    private static boolean isRepeatPopupInvalid(int varbitValue) {
+        // we discourage repeat notifications for combat task notifier if unintentional
+        return varbitValue > 0;
+    }
+}
diff --git a/src/main/java/dinkplugin/SettingsValidator.java b/src/main/java/dinkplugin/SettingsValidator.java
deleted file mode 100644
index 9b9c7f5..0000000
--- a/src/main/java/dinkplugin/SettingsValidator.java
+++ /dev/null
@@ -1,82 +0,0 @@
-package dinkplugin;
-
-import dinkplugin.notifiers.CollectionNotifier;
-import dinkplugin.notifiers.CombatTaskNotifier;
-import dinkplugin.util.Utils;
-import lombok.RequiredArgsConstructor;
-import lombok.extern.slf4j.Slf4j;
-import net.runelite.api.Client;
-import net.runelite.api.GameState;
-import net.runelite.api.Varbits;
-import net.runelite.api.events.VarbitChanged;
-import net.runelite.client.callback.ClientThread;
-import net.runelite.client.events.ConfigChanged;
-
-import javax.inject.Inject;
-
-@Slf4j
-@RequiredArgsConstructor(onConstructor_ = { @Inject })
-class SettingsValidator {
-    private static final String CONFIG_GROUP = "dinkplugin";
-
-    private final Client client;
-    private final ClientThread clientThread;
-    private final DinkPlugin plugin;
-    private final DinkPluginConfig config;
-
-    void onConfigChanged(ConfigChanged event) {
-        if (!CONFIG_GROUP.equals(event.getGroup()) || client.getGameState() != GameState.LOGGED_IN) {
-            return;
-        }
-
-        String key = event.getKey();
-        String value = event.getNewValue();
-
-        if ("combatTaskEnabled".equals(key) && "true".equals(value)) {
-            clientThread.invokeLater(() -> {
-                if (isRepeatPopupInvalid(client.getVarbitValue(CombatTaskNotifier.COMBAT_TASK_REPEAT_POPUP))) {
-                    plugin.addChatWarning(CombatTaskNotifier.REPEAT_WARNING);
-                }
-            });
-            return;
-        }
-
-        if ("collectionLogEnabled".equals(key) && "true".equals(value)) {
-            clientThread.invokeLater(() -> {
-                if (isCollectionLogInvalid(client.getVarbitValue(Varbits.COLLECTION_LOG_NOTIFICATION))) {
-                    plugin.addChatWarning(CollectionNotifier.ADDITION_WARNING);
-                }
-            });
-        }
-    }
-
-    void onVarbitChanged(VarbitChanged event) {
-        int value = event.getValue();
-
-        if (event.getVarbitId() == CombatTaskNotifier.COMBAT_TASK_REPEAT_POPUP && isRepeatPopupInvalid(value) && config.notifyCombatTask()) {
-            warnForGameSetting(CombatTaskNotifier.REPEAT_WARNING);
-        }
-
-        if (event.getVarbitId() == Varbits.COLLECTION_LOG_NOTIFICATION && isCollectionLogInvalid(value) && config.notifyCollectionLog()) {
-            warnForGameSetting(CollectionNotifier.ADDITION_WARNING);
-        }
-    }
-
-    private void warnForGameSetting(String message) {
-        if (Utils.isSettingsOpen(client)) {
-            plugin.addChatWarning(message);
-        } else {
-            log.warn(message);
-        }
-    }
-
-    private static boolean isCollectionLogInvalid(int varbitValue) {
-        // we require chat notification for collection log notifier
-        return varbitValue != 1 && varbitValue != 3;
-    }
-
-    private static boolean isRepeatPopupInvalid(int varbitValue) {
-        // we discourage repeat notifications for combat task notifier if unintentional
-        return varbitValue > 0;
-    }
-}
diff --git a/src/main/java/dinkplugin/notifiers/BaseNotifier.java b/src/main/java/dinkplugin/notifiers/BaseNotifier.java
index 5f80d69..77a5cb9 100644
--- a/src/main/java/dinkplugin/notifiers/BaseNotifier.java
+++ b/src/main/java/dinkplugin/notifiers/BaseNotifier.java
@@ -1,6 +1,7 @@
 package dinkplugin.notifiers;
 
 import dinkplugin.DinkPluginConfig;
+import dinkplugin.SettingsManager;
 import dinkplugin.message.DiscordMessageHandler;
 import dinkplugin.message.NotificationBody;
 import dinkplugin.util.WorldUtils;
@@ -14,6 +15,9 @@ public abstract class BaseNotifier {
     @Inject
     protected DinkPluginConfig config;
 
+    @Inject
+    protected SettingsManager settingsManager;
+
     @Inject
     protected Client client;
 
@@ -21,7 +25,7 @@ public abstract class BaseNotifier {
     private DiscordMessageHandler messageHandler;
 
     public boolean isEnabled() {
-        return !WorldUtils.isIgnoredWorld(client.getWorldType());
+        return !WorldUtils.isIgnoredWorld(client.getWorldType()) && settingsManager.isNamePermitted(client.getLocalPlayer().getName());
     }
 
     protected abstract String getWebhookUrl();
diff --git a/src/main/java/dinkplugin/notifiers/SpeedrunNotifier.java b/src/main/java/dinkplugin/notifiers/SpeedrunNotifier.java
index 293d815..e812087 100644
--- a/src/main/java/dinkplugin/notifiers/SpeedrunNotifier.java
+++ b/src/main/java/dinkplugin/notifiers/SpeedrunNotifier.java
@@ -23,7 +23,8 @@ public class SpeedrunNotifier extends BaseNotifier {
 
     @Override
     public boolean isEnabled() {
-        return config.notifySpeedrun(); // intentionally doesn't call super
+        // intentionally doesn't call super as WorldUtils.isIgnoredWorld includes speedrunning
+        return config.notifySpeedrun() && settingsManager.isNamePermitted(client.getLocalPlayer().getName());
     }
 
     @Override


Test patch:
diff --git a/src/test/java/dinkplugin/notifiers/MockedNotifierTest.java b/src/test/java/dinkplugin/notifiers/MockedNotifierTest.java
index c216d29..fea5031 100644
--- a/src/test/java/dinkplugin/notifiers/MockedNotifierTest.java
+++ b/src/test/java/dinkplugin/notifiers/MockedNotifierTest.java
@@ -4,6 +4,7 @@ import com.google.gson.Gson;
 import com.google.inject.testing.fieldbinder.Bind;
 import dinkplugin.DinkPluginConfig;
 import dinkplugin.MockedTestBase;
+import dinkplugin.SettingsManager;
 import dinkplugin.message.DiscordMessageHandler;
 import dinkplugin.util.TestImageUtil;
 import net.runelite.api.Client;
@@ -55,6 +56,9 @@ abstract class MockedNotifierTest extends MockedTestBase {
     @Bind
     protected ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor();
 
+    @Bind
+    protected SettingsManager settingsManager = Mockito.spy(new SettingsManager(client, null, null, config));
+
     @Bind
     protected DiscordMessageHandler messageHandler = Mockito.spy(new DiscordMessageHandler(gson, client, drawManager, httpClient, config, executor));
 
diff --git a/src/test/java/dinkplugin/notifiers/PetNotifierTest.java b/src/test/java/dinkplugin/notifiers/PetNotifierTest.java
index 91dbd5c..e6a5a31 100644
--- a/src/test/java/dinkplugin/notifiers/PetNotifierTest.java
+++ b/src/test/java/dinkplugin/notifiers/PetNotifierTest.java
@@ -84,4 +84,37 @@ class PetNotifierTest extends MockedNotifierTest {
         verify(messageHandler, never()).createMessage(any(), anyBoolean(), any());
     }
 
+    @Test
+    void testNotifyIrrelevantNameIgnore() {
+        // ignore notifications for an irrelevant player
+        when(config.ignoredNames()).thenReturn("xqc");
+        settingsManager.init();
+
+        // send fake message
+        notifier.onChatMessage("You feel something weird sneaking into your backpack.");
+
+        // verify handled
+        verify(messageHandler).createMessage(
+            PRIMARY_WEBHOOK_URL,
+            false,
+            NotificationBody.builder()
+                .text(PLAYER_NAME + " got a pet")
+                .type(NotificationType.PET)
+                .build()
+        );
+    }
+
+    @Test
+    void testIgnoredPlayerName() {
+        // ignore notifications for our player name
+        when(config.ignoredNames()).thenReturn(PLAYER_NAME);
+        settingsManager.init();
+
+        // send fake message
+        notifier.onChatMessage("You feel something weird sneaking into your backpack.");
+
+        // ensure no notification occurred
+        verify(messageHandler, never()).createMessage(any(), anyBoolean(), any());
+    }
+
 }
diff --git a/src/test/java/dinkplugin/notifiers/SpeedrunNotifierTest.java b/src/test/java/dinkplugin/notifiers/SpeedrunNotifierTest.java
index 7b4ee2e..8e53e13 100644
--- a/src/test/java/dinkplugin/notifiers/SpeedrunNotifierTest.java
+++ b/src/test/java/dinkplugin/notifiers/SpeedrunNotifierTest.java
@@ -107,6 +107,51 @@ class SpeedrunNotifierTest extends MockedNotifierTest {
         verify(messageHandler, never()).createMessage(any(), anyBoolean(), any());
     }
 
+    @Test
+    void testNotifyDenyList() {
+        // configure irrelevant deny list
+        when(config.ignoredNames()).thenReturn("pajlads");
+        settingsManager.init();
+
+        // mock widget
+        String latest = "1:15.30";
+        Widget time = mock(Widget.class);
+        when(client.getWidget(SPEEDRUN_COMPLETED_GROUP_ID, SPEEDRUN_COMPLETED_DURATION_CHILD_ID)).thenReturn(time);
+        when(time.getText()).thenReturn(latest);
+
+        // fire fake event
+        notifier.onWidgetLoaded(event());
+
+        // check notification message
+        verify(messageHandler).createMessage(
+            PRIMARY_WEBHOOK_URL,
+            false,
+            NotificationBody.builder()
+                .text(String.format("%s has beat their PB of %s with a time of %s", PLAYER_NAME, QUEST_NAME, latest))
+                .extra(new SpeedrunNotificationData(QUEST_NAME, Duration.ofMinutes(1).plusSeconds(30).plusMillis(250).toString(), Duration.ofMinutes(1).plusSeconds(15).plusMillis(300).toString()))
+                .type(NotificationType.SPEEDRUN)
+                .build()
+        );
+    }
+
+    @Test
+    void testIgnoreDenyList() {
+        // configure deny list
+        when(config.ignoredNames()).thenReturn(PLAYER_NAME);
+        settingsManager.init();
+
+        // mock widget
+        Widget time = mock(Widget.class);
+        when(client.getWidget(SPEEDRUN_COMPLETED_GROUP_ID, SPEEDRUN_COMPLETED_DURATION_CHILD_ID)).thenReturn(time);
+        when(time.getText()).thenReturn("1:15.30");
+
+        // fire fake event
+        notifier.onWidgetLoaded(event());
+
+        // ensure no notification
+        verify(messageHandler, never()).createMessage(any(), anyBoolean(), any());
+    }
+
     private WidgetLoaded event() {
         WidgetLoaded event = new WidgetLoaded();
         event.setGroupId(SPEEDRUN_COMPLETED_GROUP_ID);