diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/chatfilter/ChatFilterConfig.java b/runelite-client/src/main/java/net/runelite/client/plugins/chatfilter/ChatFilterConfig.java index 253afd7e18..d5592318b6 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/chatfilter/ChatFilterConfig.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/chatfilter/ChatFilterConfig.java @@ -108,4 +108,26 @@ public interface ChatFilterConfig extends Config { return false; } + + @ConfigItem( + keyName = "collapseGameChat", + name = "Collapse Game Chat", + description = "Collapse duplicate game chat messages into a single line", + position = 9 + ) + default boolean collapseGameChat() + { + return false; + } + + @ConfigItem( + keyName = "collapsePlayerChat", + name = "Collapse Player Chat", + description = "Collapse duplicate player chat messages into a single line", + position = 10 + ) + default boolean collapsePlayerChat() + { + return false; + } } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/chatfilter/ChatFilterPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/chatfilter/ChatFilterPlugin.java index 67561b0833..ee74245a59 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/chatfilter/ChatFilterPlugin.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/chatfilter/ChatFilterPlugin.java @@ -28,23 +28,36 @@ package net.runelite.client.plugins.chatfilter; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.CharMatcher; import com.google.common.base.Splitter; +import com.google.common.collect.ImmutableSet; import com.google.inject.Provides; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Objects; +import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.regex.PatternSyntaxException; import javax.inject.Inject; import net.runelite.api.ChatMessageType; +import static net.runelite.api.ChatMessageType.ENGINE; +import static net.runelite.api.ChatMessageType.GAMEMESSAGE; +import static net.runelite.api.ChatMessageType.ITEM_EXAMINE; +import static net.runelite.api.ChatMessageType.MODCHAT; +import static net.runelite.api.ChatMessageType.NPC_EXAMINE; +import static net.runelite.api.ChatMessageType.OBJECT_EXAMINE; +import static net.runelite.api.ChatMessageType.PUBLICCHAT; +import static net.runelite.api.ChatMessageType.SPAM; import net.runelite.api.Client; import net.runelite.api.MessageNode; import net.runelite.api.Player; -import net.runelite.client.events.ConfigChanged; +import net.runelite.api.events.ChatMessage; import net.runelite.api.events.OverheadTextChanged; import net.runelite.api.events.ScriptCallbackEvent; import net.runelite.client.config.ConfigManager; import net.runelite.client.eventbus.Subscribe; +import net.runelite.client.events.ConfigChanged; import net.runelite.client.game.ClanManager; import net.runelite.client.plugins.Plugin; import net.runelite.client.plugins.PluginDescriptor; @@ -66,10 +79,38 @@ public class ChatFilterPlugin extends Plugin @VisibleForTesting static final String CENSOR_MESSAGE = "Hey, everyone, I just tried to say something very silly!"; + private static final Set COLLAPSIBLE_MESSAGETYPES = ImmutableSet.of( + ENGINE, + GAMEMESSAGE, + ITEM_EXAMINE, + NPC_EXAMINE, + OBJECT_EXAMINE, + SPAM, + PUBLICCHAT, + MODCHAT + ); + private final CharMatcher jagexPrintableCharMatcher = Text.JAGEX_PRINTABLE_CHAR_MATCHER; private final List filteredPatterns = new ArrayList<>(); private final List filteredNamePatterns = new ArrayList<>(); + private static class Duplicate + { + int messageId; + int count; + } + + private final LinkedHashMap duplicateChatCache = new LinkedHashMap() + { + private static final int MAX_ENTRIES = 100; + + @Override + protected boolean removeEldestEntry(Map.Entry eldest) + { + return size() > MAX_ENTRIES; + } + }; + @Inject private Client client; @@ -96,6 +137,7 @@ public class ChatFilterPlugin extends Plugin protected void shutDown() throws Exception { filteredPatterns.clear(); + duplicateChatCache.clear(); client.refreshChat(); } @@ -109,10 +151,18 @@ public class ChatFilterPlugin extends Plugin int[] intStack = client.getIntStack(); int intStackSize = client.getIntStackSize(); - int messageType = intStack[intStackSize - 2]; - int messageId = intStack[intStackSize - 1]; + String[] stringStack = client.getStringStack(); + int stringStackSize = client.getStringStackSize(); + + final int messageType = intStack[intStackSize - 2]; + final int messageId = intStack[intStackSize - 1]; + String message = stringStack[stringStackSize - 1]; ChatMessageType chatMessageType = ChatMessageType.of(messageType); + final MessageNode messageNode = client.getMessages().get(messageId); + final String name = messageNode.getName(); + int duplicateCount = 0; + boolean blockMessage = false; // Only filter public chat and private messages switch (chatMessageType) @@ -123,32 +173,34 @@ public class ChatFilterPlugin extends Plugin case PRIVATECHAT: case MODPRIVATECHAT: case FRIENDSCHAT: + if (shouldFilterPlayerMessage(name)) + { + message = censorMessage(name, message); + blockMessage = message == null; + } break; case LOGINLOGOUTNOTIFICATION: if (config.filterLogin()) { - // Block the message - intStack[intStackSize - 3] = 0; + blockMessage = true; } - return; - default: - return; + break; } - MessageNode messageNode = client.getMessages().get(messageId); - String name = messageNode.getName(); - if (!shouldFilterPlayerMessage(name)) + boolean shouldCollapse = chatMessageType == PUBLICCHAT || chatMessageType == MODCHAT + ? config.collapsePlayerChat() + : COLLAPSIBLE_MESSAGETYPES.contains(chatMessageType) && config.collapseGameChat(); + if (!blockMessage && shouldCollapse) { - return; + Duplicate duplicateCacheEntry = duplicateChatCache.get(name + ":" + message); + if (duplicateCacheEntry != null) + { + blockMessage = duplicateCacheEntry.messageId != messageId; + duplicateCount = duplicateCacheEntry.count; + } } - String[] stringStack = client.getStringStack(); - int stringStackSize = client.getStringStackSize(); - - String message = stringStack[stringStackSize - 1]; - String censoredMessage = censorMessage(name, message); - - if (censoredMessage == null) + if (blockMessage) { // Block the message intStack[intStackSize - 3] = 0; @@ -156,7 +208,12 @@ public class ChatFilterPlugin extends Plugin else { // Replace the message - stringStack[stringStackSize - 1] = censoredMessage; + if (duplicateCount > 1) + { + message += " (" + duplicateCount + ")"; + } + + stringStack[stringStackSize - 1] = message; } } @@ -178,6 +235,25 @@ public class ChatFilterPlugin extends Plugin event.getActor().setOverheadText(message); } + @Subscribe + public void onChatMessage(ChatMessage chatMessage) + { + if (COLLAPSIBLE_MESSAGETYPES.contains(chatMessage.getType())) + { + // remove and re-insert into map to move to end of list + final String key = chatMessage.getName() + ":" + chatMessage.getMessage(); + Duplicate duplicate = duplicateChatCache.remove(key); + if (duplicate == null) + { + duplicate = new Duplicate(); + } + + duplicate.count++; + duplicate.messageId = chatMessage.getMessageNode().getId(); + duplicateChatCache.put(key, duplicate); + } + } + boolean shouldFilterPlayerMessage(String playerName) { boolean isMessageFromSelf = playerName.equals(client.getLocalPlayer().getName()); diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/chatfilter/ChatFilterPluginTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/chatfilter/ChatFilterPluginTest.java index dfa8a08712..3964273a92 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/chatfilter/ChatFilterPluginTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/chatfilter/ChatFilterPluginTest.java @@ -34,6 +34,7 @@ import net.runelite.api.Client; import net.runelite.api.IterableHashTable; import net.runelite.api.MessageNode; import net.runelite.api.Player; +import net.runelite.api.events.ChatMessage; import net.runelite.api.events.ScriptCallbackEvent; import net.runelite.client.game.ClanManager; import static net.runelite.client.plugins.chatfilter.ChatFilterPlugin.CENSOR_MESSAGE; @@ -108,6 +109,13 @@ public class ChatFilterPluginTest return node; } + private MessageNode mockMessageNode(int id) + { + MessageNode node = mock(MessageNode.class); + when(node.getId()).thenReturn(id); + return node; + } + @Test public void testCensorWords() { @@ -322,4 +330,42 @@ public class ChatFilterPluginTest chatFilterPlugin.onScriptCallbackEvent(event); assertEquals(CENSOR_MESSAGE, client.getStringStack()[client.getStringStackSize() - 1]); } + + @Test + public void testDuplicateChatFiltered() + { + when(chatFilterConfig.collapseGameChat()).thenReturn(true); + chatFilterPlugin.onChatMessage(new ChatMessage(mockMessageNode(0), ChatMessageType.GAMEMESSAGE, null, "testMessage", null, 0)); + ScriptCallbackEvent event = createCallbackEvent(null, "testMessage", ChatMessageType.GAMEMESSAGE); + chatFilterPlugin.onScriptCallbackEvent(event); + + assertEquals(0, client.getIntStack()[client.getIntStackSize() - 3]); + } + + @Test + public void testNoDuplicate() + { + when(chatFilterConfig.collapseGameChat()).thenReturn(true); + chatFilterPlugin.onChatMessage(new ChatMessage(mockMessageNode(1), ChatMessageType.GAMEMESSAGE, null, "testMessage", null, 0)); + ScriptCallbackEvent event = createCallbackEvent(null, "testMessage", ChatMessageType.GAMEMESSAGE); + chatFilterPlugin.onScriptCallbackEvent(event); + + assertEquals(1, client.getIntStack()[client.getIntStackSize() - 3]); + assertEquals("testMessage", client.getStringStack()[client.getStringStackSize() - 1]); + } + + @Test + public void testDuplicateChatCount() + { + when(chatFilterConfig.collapseGameChat()).thenReturn(true); + chatFilterPlugin.onChatMessage(new ChatMessage(mockMessageNode(4), ChatMessageType.GAMEMESSAGE, null, "testMessage", null, 0)); + chatFilterPlugin.onChatMessage(new ChatMessage(mockMessageNode(3), ChatMessageType.GAMEMESSAGE, null, "testMessage", null, 0)); + chatFilterPlugin.onChatMessage(new ChatMessage(mockMessageNode(2), ChatMessageType.GAMEMESSAGE, null, "testMessage", null, 0)); + chatFilterPlugin.onChatMessage(new ChatMessage(mockMessageNode(1), ChatMessageType.GAMEMESSAGE, null, "testMessage", null, 0)); + ScriptCallbackEvent event = createCallbackEvent(null, "testMessage", ChatMessageType.GAMEMESSAGE); + chatFilterPlugin.onScriptCallbackEvent(event); + + assertEquals(1, client.getIntStack()[client.getIntStackSize() - 3]); + assertEquals("testMessage (4)", client.getStringStack()[client.getStringStackSize() - 1]); + } } \ No newline at end of file