Repository: pajlads/DinkPlugin (java)

Issue:
Feature Request: Notification for Logout Timers
### Checklist

- [X] I've searched the [issues and pull requests](https://github.com/pajlads/Dinkplugin/issues?q=) for similar looking suggestions.
- [X] I've checked the `Unreleased` section of the [changelog](https://github.com/pajlads/DinkPlugin/blob/master/CHANGELOG.md) for newly added features that sound like my suggestion.

### Describe your Suggestion

Add notification timers for logout alerts (4h,6h, etc)

### Reasoning

It'd be a ton of fun in friend groups to see what people are doing when they get nerd logged!

Pull request description:
feat: add notifier for generic chat messages
Closes #349


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4e87cbe..d1369d3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,7 @@
 ## Unreleased
 
+- Major: Add notifier for chat messages that match custom patterns. (#391)
+
 ## 1.9.1
 
 - Minor: Include Quetzin pet name in pet notifications. (#442)
diff --git a/README.md b/README.md
index 83e679b..2797e15 100644
--- a/README.md
+++ b/README.md
@@ -38,6 +38,7 @@ To use this plugin, a webhook URL is required; you can obtain one from Discord w
 - [Grand Exchange](#grand-exchange): Sends a webhook message upon buying or selling items on the GE (with customizable value threshold)
 - [Trades](#trades): Sends a webhook message upon completing a trade with another player (with customizable item value threshold)
 - [Leagues](#leagues): Sends a webhook message upon completing a Leagues IV task or unlocking a region/relic
+- [Chat](#chat): Sends a webhook message upon receiving a chat message that matches a user-specified pattern
 
 ## Other Setup
 
@@ -275,6 +276,14 @@ Leagues notifications include: region unlocked, relic unlocked, and task complet
 
 Each of these events can be independently enabled or disabled in the notifier settings.
 
+### Chat:
+
+The chat notifier enables notifications for messages that are otherwise not covered by our other notifiers.
+
+You can customize the message patterns to your liking (`*` is a wildcard), and specify which types of messages to check (e.g., game, trade, clan notification, user chat).
+
+`%MESSAGE%` will be replaced with the chat message the matched one of the patterns.
+
 ### Metadata:
 
 On login, Dink can submit a character summary containing data that spans multiple notifiers to a custom webhook handler (configurable in the `Advanced` section). This login notification is delayed by at least 5 seconds in order to gather all of the relevant data.
diff --git a/docs/json-examples.md b/docs/json-examples.md
index 4a9b665..1338f0a 100644
--- a/docs/json-examples.md
+++ b/docs/json-examples.md
@@ -652,6 +652,23 @@ Note: Fields like `tasksUntilNextArea`, `pointsUntilNextRelic`, and `pointsUntil
 
 `accountType` is always `IRONMAN` for Leagues, unrelated to what the user is outside of Leagues.
 
+### Chat
+
+JSON for Matching Chat Message Notifications:
+
+```json5
+{
+  "type": "CHAT",
+  "content": "%USERNAME% received a chat message: `You've been playing for a while, consider taking a break from your screen.`",
+  "extra": {
+    "type": "GAMEMESSAGE",
+    "message": "You've been playing for a while, consider taking a break from your screen."
+  }
+}
+```
+
+Note: The possible values for `extra.type` are documented in RuneLite's [javadocs](https://static.runelite.net/api/runelite-api/net/runelite/api/ChatMessageType.html).
+
 ### Metadata
 
 JSON for Login Notifications:
diff --git a/src/main/java/dinkplugin/DinkPlugin.java b/src/main/java/dinkplugin/DinkPlugin.java
index 0160982..fe904cc 100644
--- a/src/main/java/dinkplugin/DinkPlugin.java
+++ b/src/main/java/dinkplugin/DinkPlugin.java
@@ -1,6 +1,7 @@
 package dinkplugin;
 
 import com.google.inject.Provides;
+import dinkplugin.notifiers.ChatNotifier;
 import dinkplugin.notifiers.ClueNotifier;
 import dinkplugin.notifiers.CollectionNotifier;
 import dinkplugin.notifiers.CombatTaskNotifier;
@@ -92,6 +93,7 @@ public class DinkPlugin extends Plugin {
     private @Inject GrandExchangeNotifier grandExchangeNotifier;
     private @Inject MetaNotifier metaNotifier;
     private @Inject TradeNotifier tradeNotifier;
+    private @Inject ChatNotifier chatNotifier;
 
     private final AtomicReference<GameState> gameState = new AtomicReference<>();
 
@@ -102,6 +104,7 @@ public class DinkPlugin extends Plugin {
         versionManager.onStart();
         lootNotifier.init();
         deathNotifier.init();
+        chatNotifier.init();
     }
 
     @Override
@@ -123,6 +126,7 @@ public class DinkPlugin extends Plugin {
         groupStorageNotifier.reset();
         speedrunNotifier.reset();
         tradeNotifier.reset();
+        chatNotifier.reset();
     }
 
     @Provides
@@ -149,6 +153,7 @@ public class DinkPlugin extends Plugin {
         settingsManager.onConfigChanged(event);
         lootNotifier.onConfigChanged(event.getKey(), event.getNewValue());
         deathNotifier.onConfigChanged(event.getKey(), event.getNewValue());
+        chatNotifier.onConfig(event.getKey(), event.getNewValue());
     }
 
     @Subscribe
@@ -204,6 +209,7 @@ public class DinkPlugin extends Plugin {
     @Subscribe(priority = 1) // run before the base loot tracker plugin
     public void onChatMessage(ChatMessage message) {
         String chatMessage = Utils.sanitize(message.getMessage());
+        chatNotifier.onMessage(message.getType(), chatMessage);
         switch (message.getType()) {
             case GAMEMESSAGE:
                 collectionNotifier.onChatMessage(chatMessage);
diff --git a/src/main/java/dinkplugin/DinkPluginConfig.java b/src/main/java/dinkplugin/DinkPluginConfig.java
index 0fd4605..eac84b8 100644
--- a/src/main/java/dinkplugin/DinkPluginConfig.java
+++ b/src/main/java/dinkplugin/DinkPluginConfig.java
@@ -1,12 +1,14 @@
 package dinkplugin;
 
 import dinkplugin.domain.AchievementDiary;
+import dinkplugin.domain.ChatNotificationType;
 import dinkplugin.domain.ClueTier;
 import dinkplugin.domain.CombatAchievementTier;
 import dinkplugin.domain.ExceptionalDeath;
 import dinkplugin.domain.FilterMode;
 import dinkplugin.domain.LeagueTaskDifficulty;
 import dinkplugin.domain.PlayerLookupService;
+import dinkplugin.notifiers.ChatNotifier;
 import net.runelite.client.config.Config;
 import net.runelite.client.config.ConfigGroup;
 import net.runelite.client.config.ConfigItem;
@@ -164,6 +166,14 @@ public interface DinkPluginConfig extends Config {
     )
     String tradeSection = "Player Trades";
 
+    @ConfigSection(
+        name = "Custom Chat Messages",
+        description = "Settings for notifying when you receive specific messages from the game",
+        position = 170,
+        closedByDefault = true
+    )
+    String chatSection = "Custom Chat Messages";
+
     /*
     @ConfigSection(
         name = "Leagues",
@@ -590,6 +600,17 @@ public interface DinkPluginConfig extends Config {
         return "";
     }
 
+    @ConfigItem(
+        keyName = "chatWebhook",
+        name = "Chat Webhook Override",
+        description = "If non-empty, filtered chat messages are sent to this URL, instead of the primary URL",
+        position = -2,
+        section = webhookSection
+    )
+    default String chatWebhook() {
+        return "";
+    }
+
     @ConfigItem(
         keyName = "leaguesWebhook",
         name = "Leagues Webhook Override",
@@ -1820,6 +1841,68 @@ public interface DinkPluginConfig extends Config {
         return "%USERNAME% traded with %COUNTERPARTY%";
     }
 
+    @ConfigItem(
+        keyName = "notifyChat",
+        name = "Enable Chat Notifications",
+        description = "Enable notifications upon receiving specific chat messages",
+        position = 170,
+        section = chatSection
+    )
+    default boolean notifyChat() {
+        return false;
+    }
+
+    @ConfigItem(
+        keyName = "chatSendImage",
+        name = "Send Image",
+        description = "Send image with the notification",
+        position = 171,
+        section = chatSection
+    )
+    default boolean chatSendImage() {
+        return true;
+    }
+
+    @ConfigItem(
+        keyName = "chatMessageTypes",
+        name = "Message Types",
+        description = "The types of chat messages that can trigger notifications",
+        position = 172,
+        section = chatSection
+    )
+    default Set<ChatNotificationType> chatMessageTypes() {
+        return EnumSet.of(ChatNotificationType.GAME);
+    }
+
+    @ConfigItem(
+        keyName = ChatNotifier.PATTERNS_CONFIG_KEY,
+        name = "Message Filters",
+        description = "The chat message patterns that should trigger notifications.<br/>" +
+            "Place one pattern per line (case-insensitive; asterisks are wildcards)",
+        position = 173,
+        section = chatSection
+    )
+    default String chatPatterns() {
+        return "You've unlocked an emote: *\n" +
+            "You've completed the * event*\n" + // for holiday events
+            "You have accepted * into *.\n" + // for clan recruitment
+            "You will be logged out in approximately 30 minutes.*\n" +
+            "You will be logged out in approximately 10 minutes.*\n";
+    }
+
+    @ConfigItem(
+        keyName = "chatNotifyMessage",
+        name = "Notification Message",
+        description = "The message to be sent through the webhook.<br/>" +
+            "Use %USERNAME% to insert your username<br/>" +
+            "Use %MESSAGE% to insert the chat message",
+        position = 174,
+        section = chatSection
+    )
+    default String chatNotifyMessage() {
+        return "%USERNAME% received a chat message:\n\n```\n%MESSAGE%\n```";
+    }
+
     @ConfigItem(
         keyName = "notifyLeagues",
         name = "Enable Leagues",
diff --git a/src/main/java/dinkplugin/VersionManager.java b/src/main/java/dinkplugin/VersionManager.java
index d703408..fdfa193 100644
--- a/src/main/java/dinkplugin/VersionManager.java
+++ b/src/main/java/dinkplugin/VersionManager.java
@@ -126,5 +126,6 @@ public class VersionManager {
 
     static {
         register("1.9.0", "Notifications now report monster drop rarity");
+        register("1.10.0", "Chat messages that match custom patterns can trigger notifications");
     }
 }
diff --git a/src/main/java/dinkplugin/domain/ChatNotificationType.java b/src/main/java/dinkplugin/domain/ChatNotificationType.java
new file mode 100644
index 0000000..ce532c2
--- /dev/null
+++ b/src/main/java/dinkplugin/domain/ChatNotificationType.java
@@ -0,0 +1,44 @@
+package dinkplugin.domain;
+
+import net.runelite.api.ChatMessageType;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+import static net.runelite.api.ChatMessageType.*;
+
+/**
+ * A condensed version of {@link ChatMessageType} for more user-friendly configuration.
+ */
+public enum ChatNotificationType {
+    GAME("Game Engine", GAMEMESSAGE, ENGINE, MESBOX, LOGINLOGOUTNOTIFICATION, PLAYERRELATED),
+    CLAN("Clan Notifications", CLAN_MESSAGE, CLAN_GIM_MESSAGE, CLAN_GUEST_MESSAGE, FRIENDSCHATNOTIFICATION),
+    TRADES("Trades and Duels", TRADE, TRADE_SENT, TRADEREQ, CHALREQ_TRADE, CHALREQ_FRIENDSCHAT, CHALREQ_CLANCHAT),
+    CHAT("User Messages (Discouraged)", PUBLICCHAT, PRIVATECHAT, PRIVATECHATOUT, MODCHAT, MODPRIVATECHAT, FRIENDSCHAT, CLAN_CHAT, CLAN_GUEST_CHAT, AUTOTYPER, MODAUTOTYPER);
+
+    public static final Map<ChatMessageType, ChatNotificationType> MAPPINGS;
+
+    private final String displayName;
+    private final ChatMessageType[] types;
+
+    ChatNotificationType(String displayName, ChatMessageType... types) {
+        this.displayName = displayName;
+        this.types = types;
+    }
+
+    @Override
+    public String toString() {
+        return this.displayName;
+    }
+
+    static {
+        Map<ChatMessageType, ChatNotificationType> map = new HashMap<>();
+        for (ChatNotificationType value : values()) {
+            for (ChatMessageType type : value.types) {
+                map.put(type, value);
+            }
+        }
+        MAPPINGS = Collections.unmodifiableMap(map);
+    }
+}
diff --git a/src/main/java/dinkplugin/message/NotificationType.java b/src/main/java/dinkplugin/message/NotificationType.java
index 74020fa..d438e41 100644
--- a/src/main/java/dinkplugin/message/NotificationType.java
+++ b/src/main/java/dinkplugin/message/NotificationType.java
@@ -28,7 +28,8 @@ public enum NotificationType {
     LEAGUES_RELIC("Relic Chosen", "leaguesRelic.png", WIKI_IMG_BASE_URL + "Trailblazer_Reloaded_League_-_relics_icon.png"),
     LEAGUES_TASK("Task Completed", "leaguesTask.png", WIKI_IMG_BASE_URL + "Trailblazer_Reloaded_League_icon.png"),
     LOGIN("Player Login", "login.png", WIKI_IMG_BASE_URL + "Prop_sword.png"),
-    TRADE("Player Trade", "trade.png", WIKI_IMG_BASE_URL + "Inventory.png");
+    TRADE("Player Trade", "trade.png", WIKI_IMG_BASE_URL + "Inventory.png"),
+    CHAT("Chat Notification", "chat.png", WIKI_IMG_BASE_URL + "Toggle_Chat_effects.png");
 
     private final String title;
 
diff --git a/src/main/java/dinkplugin/notifiers/ChatNotifier.java b/src/main/java/dinkplugin/notifiers/ChatNotifier.java
new file mode 100644
index 0000000..39fea66
--- /dev/null
+++ b/src/main/java/dinkplugin/notifiers/ChatNotifier.java
@@ -0,0 +1,91 @@
+package dinkplugin.notifiers;
+
+import dinkplugin.domain.ChatNotificationType;
+import dinkplugin.message.NotificationBody;
+import dinkplugin.message.NotificationType;
+import dinkplugin.message.templating.Replacements;
+import dinkplugin.message.templating.Template;
+import dinkplugin.notifiers.data.ChatNotificationData;
+import dinkplugin.util.ConfigUtil;
+import dinkplugin.util.Utils;
+import lombok.Synchronized;
+import net.runelite.api.ChatMessageType;
+
+import javax.inject.Singleton;
+import java.util.Collection;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+
+@Singleton
+public class ChatNotifier extends BaseNotifier {
+    public static final String PATTERNS_CONFIG_KEY = "chatPatterns";
+
+    private final Collection<Pattern> regexps = new CopyOnWriteArrayList<>();
+
+    @Override
+    public boolean isEnabled() {
+        return config.notifyChat() && super.isEnabled();
+    }
+
+    @Override
+    protected String getWebhookUrl() {
+        return config.chatWebhook();
+    }
+
+    public void init() {
+        this.loadPatterns(config.chatPatterns());
+    }
+
+    @Synchronized
+    public void reset() {
+        regexps.clear();
+    }
+
+    public void onConfig(String key, String value) {
+        if (PATTERNS_CONFIG_KEY.equals(key)) {
+            this.loadPatterns(value);
+        }
+    }
+
+    public void onMessage(ChatMessageType messageType, String message) {
+        ChatNotificationType type = ChatNotificationType.MAPPINGS.get(messageType);
+        if (type != null && config.chatMessageTypes().contains(type) && isEnabled() && hasMatch(message)) {
+            this.handleNotify(messageType, message);
+        }
+    }
+
+    private void handleNotify(ChatMessageType type, String message) {
+        String playerName = Utils.getPlayerName(client);
+        Template template = Template.builder()
+            .template(config.chatNotifyMessage())
+            .replacementBoundary("%")
+            .replacement("%USERNAME%", Replacements.ofText(playerName))
+            .replacement("%MESSAGE%", Replacements.ofText(message))
+            .build();
+        createMessage(config.chatSendImage(), NotificationBody.builder()
+            .text(template)
+            .type(NotificationType.CHAT)
+            .extra(new ChatNotificationData(type, message))
+            .playerName(playerName)
+            .build());
+    }
+
+    private boolean hasMatch(String chatMessage) {
+        for (Pattern pattern : regexps) {
+            if (pattern.matcher(chatMessage).find())
+                return true;
+        }
+        return false;
+    }
+
+    @Synchronized
+    private void loadPatterns(String configValue) {
+        regexps.clear();
+        regexps.addAll(
+            ConfigUtil.readDelimited(configValue)
+                .map(Utils::regexify)
+                .collect(Collectors.toList())
+        );
+    }
+}
diff --git a/src/main/java/dinkplugin/notifiers/data/ChatNotificationData.java b/src/main/java/dinkplugin/notifiers/data/ChatNotificationData.java
new file mode 100644
index 0000000..1eb8560
--- /dev/null
+++ b/src/main/java/dinkplugin/notifiers/data/ChatNotificationData.java
@@ -0,0 +1,12 @@
+package dinkplugin.notifiers.data;
+
+import lombok.EqualsAndHashCode;
+import lombok.Value;
+import net.runelite.api.ChatMessageType;
+
+@Value
+@EqualsAndHashCode(callSuper = false)
+public class ChatNotificationData extends NotificationData {
+    ChatMessageType type;
+    String message;
+}


Test patch:
diff --git a/src/test/java/dinkplugin/notifiers/ChatNotifierTest.java b/src/test/java/dinkplugin/notifiers/ChatNotifierTest.java
new file mode 100644
index 0000000..3806c4c
--- /dev/null
+++ b/src/test/java/dinkplugin/notifiers/ChatNotifierTest.java
@@ -0,0 +1,93 @@
+package dinkplugin.notifiers;
+
+import com.google.inject.testing.fieldbinder.Bind;
+import dinkplugin.domain.ChatNotificationType;
+import dinkplugin.message.NotificationBody;
+import dinkplugin.message.NotificationType;
+import dinkplugin.message.templating.Template;
+import dinkplugin.notifiers.data.ChatNotificationData;
+import net.runelite.api.ChatMessageType;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+
+import java.util.EnumSet;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+public class ChatNotifierTest extends MockedNotifierTest {
+
+    @Bind
+    @InjectMocks
+    ChatNotifier notifier;
+
+    @Override
+    @BeforeEach
+    protected void setUp() {
+        super.setUp();
+
+        // config mocks
+        when(config.notifyChat()).thenReturn(true);
+        when(config.chatMessageTypes()).thenReturn(EnumSet.of(ChatNotificationType.GAME));
+        when(config.chatNotifyMessage()).thenReturn("%USERNAME% received a chat message:\n\n```\n%MESSAGE%\n```");
+        setPatterns("You will be logged out in approximately 10 minutes.*\n" +
+            "You will be logged out in approximately 5 minutes.*\n");
+    }
+
+    @Test
+    void testNotify() {
+        // fire event
+        String message = "You will be logged out in approximately 10 minutes.";
+        notifier.onMessage(ChatMessageType.GAMEMESSAGE, message);
+
+        // verify notification message
+        verify(messageHandler).createMessage(
+            PRIMARY_WEBHOOK_URL,
+            false,
+            NotificationBody.builder()
+                .text(
+                    Template.builder()
+                        .template(PLAYER_NAME + " received a chat message:\n\n```\n" + message + "\n```")
+                        .build()
+                )
+                .extra(new ChatNotificationData(ChatMessageType.GAMEMESSAGE, message))
+                .type(NotificationType.CHAT)
+                .playerName(PLAYER_NAME)
+                .build()
+        );
+    }
+
+    @Test
+    void testIgnore() {
+        // fire event
+        notifier.onMessage(ChatMessageType.GAMEMESSAGE, "You will be logged out in approximately 30 minutes.");
+        notifier.onMessage(ChatMessageType.PUBLICCHAT, "You will be logged out in approximately 10 minutes.");
+        notifier.onMessage(ChatMessageType.TRADE, "You will be logged out in approximately 10 minutes.");
+        notifier.onMessage(ChatMessageType.CLAN_MESSAGE, "You will be logged out in approximately 10 minutes.");
+
+        // ensure no notification occurred
+        verify(messageHandler, never()).createMessage(any(), anyBoolean(), any());
+    }
+
+    @Test
+    void testDisabled() {
+        // update config mock
+        when(config.notifyChat()).thenReturn(false);
+
+        // fire event
+        notifier.onMessage(ChatMessageType.GAMEMESSAGE, "You will be logged out in approximately 10 minutes.");
+
+        // ensure no notification occurred
+        verify(messageHandler, never()).createMessage(any(), anyBoolean(), any());
+    }
+
+    private void setPatterns(String configValue) {
+        when(config.chatPatterns()).thenReturn(configValue);
+        notifier.onConfig(ChatNotifier.PATTERNS_CONFIG_KEY, configValue);
+    }
+
+}