diff --git a/core/src/main/java/to/itsme/itsmyconfig/processor/packetevents/PEventsListener.java b/core/src/main/java/to/itsme/itsmyconfig/processor/packetevents/PEventsListener.java index 0a2926a..44a8ed6 100644 --- a/core/src/main/java/to/itsme/itsmyconfig/processor/packetevents/PEventsListener.java +++ b/core/src/main/java/to/itsme/itsmyconfig/processor/packetevents/PEventsListener.java @@ -17,6 +17,7 @@ import to.itsme.itsmyconfig.util.IMCSerializer; import to.itsme.itsmyconfig.util.Strings; import to.itsme.itsmyconfig.util.Utilities; +import to.itsme.itsmyconfig.util.ChatResendDetector; import java.util.Map; import java.util.Optional; @@ -89,7 +90,14 @@ public void onPacketSend(final PacketSendEvent event) { } final String message = packet.message(); - Utilities.debug(() -> "Found message: " + message); + final Player player = event.getPlayer(); + final String playerIdentifier = player.getUniqueId().toString(); + + // Check message for resend patterns (blank lines, invisible unicode) + // This also updates burst state for this player + final boolean isInBurst = ChatResendDetector.checkMessage(playerIdentifier, message); + + Utilities.debug(() -> "Found message: " + message + (isInBurst ? " [RESEND DETECTED]" : "")); if (message.startsWith(FAIL_MESSAGE_PREFIX)) { Utilities.debug(() -> "Message send failure message, cancelling..."); @@ -98,13 +106,18 @@ public void onPacketSend(final PacketSendEvent event) { } final Optional parsed = Strings.parsePrefixedMessage(message); - if (parsed.isEmpty()) { - Utilities.debug(() -> "Message doesn't start w/ the symbol-prefix: " + message + "\n" + Strings.DEBUG_HYPHEN); + + // Also check if message contains ItsMyConfig placeholders even without prefix + final boolean hasPlaceholders = message.contains(" "Message doesn't start w/ the symbol-prefix and has no "Component is empty, cancelling...\n" + Strings.DEBUG_HYPHEN); diff --git a/core/src/main/java/to/itsme/itsmyconfig/processor/protocollib/PLibListener.java b/core/src/main/java/to/itsme/itsmyconfig/processor/protocollib/PLibListener.java index 7f9cc05..5efa8bf 100644 --- a/core/src/main/java/to/itsme/itsmyconfig/processor/protocollib/PLibListener.java +++ b/core/src/main/java/to/itsme/itsmyconfig/processor/protocollib/PLibListener.java @@ -14,6 +14,7 @@ import to.itsme.itsmyconfig.util.IMCSerializer; import to.itsme.itsmyconfig.util.Strings; import to.itsme.itsmyconfig.util.Utilities; +import to.itsme.itsmyconfig.util.ChatResendDetector; import java.util.HashMap; import java.util.Map; @@ -53,7 +54,9 @@ public void load() { public void onPacketSending(final PacketEvent event) { final PacketContainer container = event.getPacket(); final PacketType type = container.getType(); - Utilities.debug(() -> "################# CHAT PACKET #################\nProccessing packet " + type.name()); + + Utilities.debug(() -> "################# CHAT PACKET #################\nProcessing packet " + type.name()); + final PacketContent packet = this.processPacket(container); if (packet == null || packet.isEmpty()) { Utilities.debug(() -> "Packet is null or empty\n" + Strings.DEBUG_HYPHEN); @@ -61,16 +64,28 @@ public void onPacketSending(final PacketEvent event) { } final String message = packet.message(); - Utilities.debug(() -> "Found message: " + message); + final Player player = event.getPlayer(); + final String playerIdentifier = player.getUniqueId().toString(); + + // Check message for resend patterns (blank lines, invisible unicode) + // This also updates burst state for this player + final boolean isInBurst = ChatResendDetector.checkMessage(playerIdentifier, message); + + Utilities.debug(() -> "Found message: " + message + (isInBurst ? " [RESEND DETECTED]" : "")); final Optional parsed = Strings.parsePrefixedMessage(message); - if (parsed.isEmpty()) { - Utilities.debug(() -> "Message doesn't start w/ the symbol-prefix: " + message + "\n" + Strings.DEBUG_HYPHEN); + + // Also check if message contains ItsMyConfig placeholders even without prefix + final boolean hasPlaceholders = message.contains(" "Message doesn't start w/ the symbol-prefix and has no "Component is empty, cancelling...\n" + Strings.DEBUG_HYPHEN); diff --git a/core/src/main/java/to/itsme/itsmyconfig/tag/TagManager.java b/core/src/main/java/to/itsme/itsmyconfig/tag/TagManager.java index 3de44b2..135ced5 100644 --- a/core/src/main/java/to/itsme/itsmyconfig/tag/TagManager.java +++ b/core/src/main/java/to/itsme/itsmyconfig/tag/TagManager.java @@ -14,6 +14,7 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.regex.Matcher; import java.util.regex.Pattern; +import to.itsme.itsmyconfig.util.ChatResendDetector; public final class TagManager { @@ -51,6 +52,10 @@ public static String processArgumentTags( final Player player, @NotNull String text ) { + // Check if player is currently in burst mode (chat resend detected) + final String playerIdentifier = player.getUniqueId().toString(); + final boolean isChatResendBurst = ChatResendDetector.isInBurst(playerIdentifier); + Matcher matcher = ARG_TAG_PATTERN.matcher(text); while (matcher.find()) { final int start = matcher.start(); @@ -67,6 +72,21 @@ public static String processArgumentTags( continue; // unknown tag — skip safely, do NOT replace } + // During chat resend bursts, filter out sound and actionbar tags + if (isChatResendBurst) { + if (tagName.equals("actionbar")) { + // Actionbar during burst: cancel entire message by returning empty + // This causes packet listeners to cancel the packet + return ""; + } + if (tagName.equals("sound")) { + // Sound during burst: strip tag but keep rest of message + text = text.substring(0, start) + text.substring(end); + matcher = ARG_TAG_PATTERN.matcher(text); + continue; + } + } + final String arguments = matcher.group(2); final String[] args = extractArguments(arguments); if (args.length == 1 && "cancel".equals(args[0])) { diff --git a/core/src/main/java/to/itsme/itsmyconfig/util/ChatResendDetector.java b/core/src/main/java/to/itsme/itsmyconfig/util/ChatResendDetector.java new file mode 100644 index 0000000..2cd325a --- /dev/null +++ b/core/src/main/java/to/itsme/itsmyconfig/util/ChatResendDetector.java @@ -0,0 +1,193 @@ +package to.itsme.itsmyconfig.util; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.regex.Pattern; + +/** + * Detects chat resend patterns used by chat deletion/moderation plugins. + * When plugins delete messages from chat, they typically flood blank lines + * (often with invisible unicode characters) to clear the chat window, + * then resend the chat history. This detector identifies that pattern + * to prevent actionbar/sound effects from firing during resends. + * + * Detection triggers on: + * - 20+ blank lines (empty or whitespace-only messages) + * - Messages containing invisible unicode characters (like Allium plugin uses) + * + * Once triggered, burst mode lasts for 150ms to filter subsequent messages. + */ +public final class ChatResendDetector { + + private static final long BURST_DURATION_MS = 150; // How long burst mode lasts after detection + private static final int BLANK_LINE_THRESHOLD = 20; // 20+ blank lines triggers burst + private static final long TRACKER_EXPIRY_MS = 5000; // Remove stale trackers after 5 seconds + + private static final Map trackers = new ConcurrentHashMap<>(); + + // Invisible unicode characters commonly used by chat clear plugins to prevent + // chat mods from condensing repeated blank lines. Includes (but not limited to): + // Zero Width Space (U+200B), Zero Width Non-Joiner (U+200C), Zero Width Joiner (U+200D), + // Word Joiner (U+2060), Zero Width No-Break Space/BOM (U+FEFF), Mongolian Vowel Separator (U+180E), + // Soft Hyphen (U+00AD), Combining Grapheme Joiner (U+034F), Arabic Letter Mark (U+061C), + // Hangul Filler chars (U+115F, U+1160), Khmer Inherent Vowels (U+17B4, U+17B5), + // Invisible math operators (U+2061-U+2064), and various format characters (U+206A-U+206F). + private static final Pattern INVISIBLE_UNICODE_PATTERN = Pattern.compile( + "[\u200B\u200C\u200D\u2060\uFEFF\u180E\u00AD\u034F\u061C\u115F\u1160\u17B4\u17B5\u2061-\u2064\u206A-\u206F]" + ); + + /** + * Checks a message for chat resend patterns and updates burst state. + * Call this for every chat message to detect blank line floods or invisible unicode. + * Also performs periodic cleanup of stale trackers to prevent memory leaks. + * + * @param identifier Unique identifier (usually player UUID) + * @param message The message content to check + * @return true if currently in burst mode (either just triggered or already active) + */ + public static boolean checkMessage(String identifier, String message) { + final long currentTime = System.currentTimeMillis(); + + // Periodic cleanup of stale trackers to prevent memory leaks + cleanupStaleTrackers(currentTime); + + final BurstTracker tracker = trackers.computeIfAbsent(identifier, k -> new BurstTracker()); + tracker.updateLastActivity(currentTime); + + // Check if message contains invisible unicode (immediate burst trigger) + if (containsInvisibleUnicode(message)) { + tracker.triggerBurst(currentTime); + return true; + } + + // Check if message is blank (empty or whitespace only) + if (isBlankMessage(message)) { + tracker.recordBlankLine(currentTime); + } + + return tracker.isInBurst(currentTime); + } + + /** + * Removes trackers that have been inactive for longer than TRACKER_EXPIRY_MS. + * Called periodically during checkMessage to prevent unbounded memory growth. + */ + private static void cleanupStaleTrackers(long currentTime) { + trackers.entrySet().removeIf(entry -> + entry.getValue().isStale(currentTime, TRACKER_EXPIRY_MS)); + } + + /** + * Checks if player is currently in burst mode. + * @param identifier Unique identifier (usually player UUID) + * @return true if player is in burst mode + */ + public static boolean isInBurst(String identifier) { + final BurstTracker tracker = trackers.get(identifier); + if (tracker == null) { + return false; + } + return tracker.isInBurst(System.currentTimeMillis()); + } + + /** + * Forcefully ends burst tracking for the given identifier. + * Can be used for testing or manual intervention. + * Note: Stale trackers are automatically cleaned up during checkMessage() calls, + * so calling this on player disconnect is optional but recommended for immediate cleanup. + * + * @param identifier Unique identifier (usually player UUID) + */ + public static void endBurst(String identifier) { + trackers.remove(identifier); + } + + /** + * Checks if a message is blank (null, empty, or whitespace only). + */ + public static boolean isBlankMessage(String message) { + return message == null || message.trim().isEmpty(); + } + + /** + * Checks if a message contains invisible unicode characters. + * These are commonly used by chat clear plugins (like Allium) to prevent + * chat mods from condensing repeated blank lines. + * @param message The message to check + * @return true if the message contains invisible unicode characters + */ + public static boolean containsInvisibleUnicode(String message) { + if (message == null || message.isEmpty()) { + return false; + } + return INVISIBLE_UNICODE_PATTERN.matcher(message).find(); + } + + /** + * Tracks message patterns to identify chat resend behavior. + * Counts blank lines and triggers burst mode when threshold reached. + */ + private static class BurstTracker { + private final AtomicInteger blankLineCount = new AtomicInteger(0); + private final AtomicLong lastBlankLineTime = new AtomicLong(0); + private final AtomicLong burstStartTime = new AtomicLong(0); + private final AtomicLong lastActivityTime = new AtomicLong(0); + + // Window for counting blank lines (500ms - blank lines should come rapidly) + private static final long BLANK_LINE_WINDOW_MS = 500; + + /** + * Updates the last activity timestamp for staleness tracking. + */ + public void updateLastActivity(long currentTime) { + lastActivityTime.set(currentTime); + } + + /** + * Checks if this tracker is stale and can be removed. + */ + public boolean isStale(long currentTime, long expiryMs) { + return (currentTime - lastActivityTime.get()) > expiryMs; + } + + /** + * Records a blank line and checks if threshold is reached. + */ + public void recordBlankLine(long currentTime) { + // Reset count if too much time has passed since last blank line + if ((currentTime - lastBlankLineTime.get()) > BLANK_LINE_WINDOW_MS) { + blankLineCount.set(0); + } + + lastBlankLineTime.set(currentTime); + final int count = blankLineCount.incrementAndGet(); + + // Trigger burst if threshold reached + if (count >= BLANK_LINE_THRESHOLD) { + triggerBurst(currentTime); + } + } + + /** + * Triggers burst mode immediately. + */ + public void triggerBurst(long currentTime) { + burstStartTime.set(currentTime); + blankLineCount.set(0); // Reset counter + } + + /** + * Checks if currently in burst mode. + */ + public boolean isInBurst(long currentTime) { + final long burstStart = burstStartTime.get(); + if (burstStart == 0) { + return false; + } + // Burst lasts for BURST_DURATION_MS after trigger + return (currentTime - burstStart) <= BURST_DURATION_MS; + } + } +} diff --git a/core/src/test/java/to/itsme/itsmyconfig/util/ChatResendDetectorTest.java b/core/src/test/java/to/itsme/itsmyconfig/util/ChatResendDetectorTest.java new file mode 100644 index 0000000..c72d8b4 --- /dev/null +++ b/core/src/test/java/to/itsme/itsmyconfig/util/ChatResendDetectorTest.java @@ -0,0 +1,122 @@ +package to.itsme.itsmyconfig.util; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +public class ChatResendDetectorTest { + + @Test + public void testNoBurstOnSingleMessage() { + final String testId = "test-player-single"; + + // Clean up any existing state + ChatResendDetector.endBurst(testId); + + // Single non-blank message should not trigger burst + assertFalse(ChatResendDetector.checkMessage(testId, "Hello world")); + assertFalse(ChatResendDetector.isInBurst(testId)); + } + + @Test + public void testNoBurstOnFewBlankLines() { + final String testId = "test-player-few"; + + // Clean up any existing state + ChatResendDetector.endBurst(testId); + + // Few blank lines (under threshold of 20) should not trigger burst + for (int i = 0; i < 10; i++) { + assertFalse(ChatResendDetector.checkMessage(testId, "")); + } + assertFalse(ChatResendDetector.isInBurst(testId)); + } + + @Test + public void testBurstOnManyBlankLines() { + final String testId = "test-player-burst"; + + // Clean up any existing state + ChatResendDetector.endBurst(testId); + + // 20+ blank lines should trigger burst + for (int i = 0; i < 19; i++) { + assertFalse(ChatResendDetector.checkMessage(testId, "")); // 1-19 + } + assertTrue(ChatResendDetector.checkMessage(testId, "")); // 20th - triggers burst + assertTrue(ChatResendDetector.isInBurst(testId)); + } + + @Test + public void testBurstOnInvisibleUnicode() { + final String testId = "test-player-unicode"; + + // Clean up any existing state + ChatResendDetector.endBurst(testId); + + // Message with invisible unicode should immediately trigger burst + assertTrue(ChatResendDetector.checkMessage(testId, "\u200B")); // Zero Width Space + assertTrue(ChatResendDetector.isInBurst(testId)); + } + + @Test + public void testBurstResetAfterDelay() throws InterruptedException { + final String testId = "test-player-reset"; + + // Clean up any existing state + ChatResendDetector.endBurst(testId); + + // Trigger burst with invisible unicode + assertTrue(ChatResendDetector.checkMessage(testId, "\u200B")); + assertTrue(ChatResendDetector.isInBurst(testId)); + + // Wait for burst to expire (150ms) + Thread.sleep(160); + + // Should no longer be in burst + assertFalse(ChatResendDetector.isInBurst(testId)); + } + + @Test + public void testIndependentPlayerTracking() { + final String player1 = "test-player-1"; + final String player2 = "test-player-2"; + + // Clean up + ChatResendDetector.endBurst(player1); + ChatResendDetector.endBurst(player2); + + // Trigger burst for player1 + ChatResendDetector.checkMessage(player1, "\u200B"); + + // Player1 should be in burst, player2 should not + assertTrue(ChatResendDetector.isInBurst(player1)); + assertFalse(ChatResendDetector.isInBurst(player2)); + } + + @Test + public void testInvisibleUnicodeDetection() { + // Test various invisible unicode characters + assertTrue(ChatResendDetector.containsInvisibleUnicode("\u200B")); // Zero Width Space + assertTrue(ChatResendDetector.containsInvisibleUnicode("\u200C")); // Zero Width Non-Joiner + assertTrue(ChatResendDetector.containsInvisibleUnicode("\u200D")); // Zero Width Joiner + assertTrue(ChatResendDetector.containsInvisibleUnicode("\u2060")); // Word Joiner + assertTrue(ChatResendDetector.containsInvisibleUnicode("\uFEFF")); // BOM + assertTrue(ChatResendDetector.containsInvisibleUnicode("Hello\u200Bworld")); // Mixed + + // Normal text should not be detected + assertFalse(ChatResendDetector.containsInvisibleUnicode("Hello world")); + assertFalse(ChatResendDetector.containsInvisibleUnicode("")); + assertFalse(ChatResendDetector.containsInvisibleUnicode(null)); + } + + @Test + public void testBlankMessageDetection() { + assertTrue(ChatResendDetector.isBlankMessage(null)); + assertTrue(ChatResendDetector.isBlankMessage("")); + assertTrue(ChatResendDetector.isBlankMessage(" ")); + assertTrue(ChatResendDetector.isBlankMessage("\t\n")); + + assertFalse(ChatResendDetector.isBlankMessage("Hello")); + assertFalse(ChatResendDetector.isBlankMessage(" a ")); + } +}