From 6d76662523024692772c6b82cb7f1ba6886f0424 Mon Sep 17 00:00:00 2001 From: castledking Date: Sat, 13 Dec 2025 11:42:44 -0800 Subject: [PATCH 1/2] Add smart chat resend/clear detection with invisible unicode and burst detection --- .../packetevents/PEventsListener.java | 32 ++++- .../processor/protocollib/PLibListener.java | 28 ++-- .../to/itsme/itsmyconfig/tag/TagManager.java | 15 +++ .../itsmyconfig/util/ChatResendDetector.java | 122 ++++++++++++++++++ .../util/ChatResendDetectorTest.java | 88 +++++++++++++ 5 files changed, 270 insertions(+), 15 deletions(-) create mode 100644 core/src/main/java/to/itsme/itsmyconfig/util/ChatResendDetector.java create mode 100644 core/src/test/java/to/itsme/itsmyconfig/util/ChatResendDetectorTest.java 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..3eb2f97 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; @@ -52,14 +53,21 @@ public void onPacketSend(final PacketSendEvent event) { if (!(type instanceof PacketType.Play.Server server)) { return; } + + // Record EVERY packet for burst detection (including blank lines) + final Player player = event.getPlayer(); + final String playerIdentifier = player.getUniqueId().toString(); + ChatResendDetector.recordPacket(playerIdentifier); + + final boolean isInBurst = ChatResendDetector.isInBurst(playerIdentifier); + Utilities.debug(() -> "################# CHAT PACKET #################\nProcessing packet " + server.name() + + (isInBurst ? " (RESEND DETECTED)" : "")); final PacketProcessor processor = packetTypeMap.get(server); if (processor == null) { return; } - Utilities.debug(() -> "################# CHAT PACKET #################\nProcessing packet " + server.name()); - // Convert to wrapped packet only once Object wrappedPacket = switch (server) { case CHAT_MESSAGE -> new WrapperPlayServerChatMessage(event); @@ -89,7 +97,12 @@ public void onPacketSend(final PacketSendEvent event) { } final String message = packet.message(); - Utilities.debug(() -> "Found message: " + message); + final boolean hasInvisibleUnicode = ChatResendDetector.containsInvisibleUnicode(message); + final boolean isChatClear = isInBurst || hasInvisibleUnicode; + + Utilities.debug(() -> "Found message: " + message + + (hasInvisibleUnicode ? " [INVISIBLE UNICODE]" : "") + + (isChatClear ? " [CHAT CLEAR DETECTED]" : "")); if (message.startsWith(FAIL_MESSAGE_PREFIX)) { Utilities.debug(() -> "Message send failure message, cancelling..."); @@ -98,13 +111,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 (like console does) + 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..ac64c35 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,15 @@ 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()); + + // Record EVERY packet for burst detection (including blank lines) + final String playerIdentifier = event.getPlayer().getUniqueId().toString(); + ChatResendDetector.recordPacket(playerIdentifier); + + final boolean isInBurst = ChatResendDetector.isInBurst(playerIdentifier); + Utilities.debug(() -> "################# CHAT PACKET #################\nProccessing packet " + type.name() + + (isInBurst ? " (RESEND DETECTED)" : "")); + final PacketContent packet = this.processPacket(container); if (packet == null || packet.isEmpty()) { Utilities.debug(() -> "Packet is null or empty\n" + Strings.DEBUG_HYPHEN); @@ -61,16 +70,19 @@ public void onPacketSending(final PacketEvent event) { } final String message = packet.message(); - Utilities.debug(() -> "Found message: " + message); + final boolean hasInvisibleUnicode = ChatResendDetector.containsInvisibleUnicode(message); + final boolean isChatClear = isInBurst || hasInvisibleUnicode; + + Utilities.debug(() -> "Found message: " + message + + (hasInvisibleUnicode ? " [INVISIBLE UNICODE]" : "") + + (isChatClear ? " [CHAT CLEAR 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); - return; - } - + final Player player = event.getPlayer(); - final Component translated = Utilities.translate(parsed.get(), player); + // Use the parsed message if available, otherwise use original message + final String messageToProcess = parsed.isPresent() ? parsed.get() : message; + final Component translated = Utilities.translate(messageToProcess, player); if (translated.equals(Component.empty())) { event.setCancelled(true); Utilities.debug(() -> "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..3f1351b 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 (packet flood already detected by listener) + final String playerIdentifier = player.getUniqueId().toString(); + final boolean isChatClearBurst = ChatResendDetector.isInBurst(playerIdentifier); + Matcher matcher = ARG_TAG_PATTERN.matcher(text); while (matcher.find()) { final int start = matcher.start(); @@ -67,6 +72,16 @@ public static String processArgumentTags( continue; // unknown tag — skip safely, do NOT replace } + // During chat clear bursts, filter out sound and actionbar tags + if (isChatClearBurst && (tagName.equals("sound") || tagName.equals("actionbar"))) { + // For both actionbar and sound during burst: remove entirely + // - Actionbar messages should be cancelled completely (if message becomes empty, packet is cancelled) + // - Sound tags should be stripped so sound doesn't play + 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..29890c4 --- /dev/null +++ b/core/src/main/java/to/itsme/itsmyconfig/util/ChatResendDetector.java @@ -0,0 +1,122 @@ +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 rapid chat resend patterns used by chat deletion/moderation plugins. + * When plugins delete messages from chat, they typically flood blank lines to clear + * the chat window, then resend the chat history. This detector identifies that pattern + * to prevent actionbar/sound effects from firing during resends. + */ +public final class ChatResendDetector { + + private static final long BURST_WINDOW_MS = 100; // 100ms window for detecting rapid packet bursts + private static final int BURST_THRESHOLD = 10; // 10+ packets in 100ms = resend pattern detected + + private static final Map trackers = new ConcurrentHashMap<>(); + + // Invisible unicode characters commonly used to prevent chat condensing + // Includes: Zero Width Space, Zero Width Non-Joiner, Zero Width Joiner, + // Word Joiner, Zero Width No-Break Space (BOM), Mongolian Vowel Separator + 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]" + ); + + /** + * Records a packet for resend detection. Call this for EVERY chat packet to track + * the "blank line flood" pattern used by chat deletion plugins. + * @param identifier Unique identifier (usually player UUID) + */ + public static void recordPacket(String identifier) { + final long currentTime = System.currentTimeMillis(); + final BurstTracker tracker = trackers.computeIfAbsent(identifier, k -> new BurstTracker()); + tracker.addPacket(currentTime); + } + + /** + * Checks if player is currently in a resend burst state. + * Call this when processing messages with actionbar/sound tags. + * @param identifier Unique identifier (usually player UUID) + * @return true if player is receiving a chat resend (rapid packet flood detected) + */ + public static boolean isInBurst(String identifier) { + final BurstTracker tracker = trackers.get(identifier); + if (tracker == null) { + return false; + } + return tracker.isInBurst(); + } + + /** + * Forcefully ends burst tracking for testing or manual intervention. + */ + public static void endBurst(String identifier) { + trackers.remove(identifier); + } + + /** + * 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(); + } + + /** + * Checks if a message appears to be part of a chat clear operation. + * Returns true if the message contains invisible unicode OR if player is in burst. + * @param identifier Unique identifier (usually player UUID) + * @param message The message content to check + * @return true if this appears to be a chat clear/resend operation + */ + public static boolean isChatClearPacket(String identifier, String message) { + return isInBurst(identifier) || containsInvisibleUnicode(message); + } + + /** + * Tracks packet patterns to identify chat resend behavior. + * Uses pure timing-based detection - counts ALL packets including blank lines. + */ + private static class BurstTracker { + private final AtomicInteger count = new AtomicInteger(0); + private final AtomicLong windowStart = new AtomicLong(0); + private volatile boolean inBurst = false; + + public void addPacket(long currentTime) { + // If window expired, start new window + if ((currentTime - windowStart.get()) > BURST_WINDOW_MS) { + windowStart.set(currentTime); + count.set(1); + inBurst = false; + return; + } + + // Add packet to current window + final int newCount = count.incrementAndGet(); + + // Enter burst mode on high frequency (6+ packets in 100ms) + if (newCount >= BURST_THRESHOLD) { + inBurst = true; + } + } + + public boolean isInBurst() { + // Check if window has expired + if ((System.currentTimeMillis() - windowStart.get()) > BURST_WINDOW_MS) { + inBurst = false; + count.set(0); + } + return inBurst; + } + } +} 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..6e5853f --- /dev/null +++ b/core/src/test/java/to/itsme/itsmyconfig/util/ChatResendDetectorTest.java @@ -0,0 +1,88 @@ +package to.itsme.itsmyconfig.util; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +public class ChatResendDetectorTest { + + @Test + public void testNoBurstOnSinglePacket() { + final String testId = "test-player-single"; + + // Clean up any existing state + ChatResendDetector.endBurst(testId); + + // Single packet should not trigger burst + ChatResendDetector.recordPacket(testId); + assertFalse(ChatResendDetector.isInBurst(testId)); + } + + @Test + public void testNoBurstOnFewPackets() { + final String testId = "test-player-few"; + + // Clean up any existing state + ChatResendDetector.endBurst(testId); + + // Few packets (under threshold of 6) should not trigger burst + for (int i = 0; i < 4; i++) { + ChatResendDetector.recordPacket(testId); + assertFalse(ChatResendDetector.isInBurst(testId)); + } + } + + @Test + public void testBurstOnManyRapidPackets() { + final String testId = "test-player-burst"; + + // Clean up any existing state + ChatResendDetector.endBurst(testId); + + // 6+ packets in rapid succession should trigger burst + for (int i = 0; i < 5; i++) { + ChatResendDetector.recordPacket(testId); + assertFalse(ChatResendDetector.isInBurst(testId)); // 1-5 + } + ChatResendDetector.recordPacket(testId); // 6th + assertTrue(ChatResendDetector.isInBurst(testId)); // Should now be in burst + } + + @Test + public void testBurstResetAfterDelay() throws InterruptedException { + final String testId = "test-player-reset"; + + // Clean up any existing state + ChatResendDetector.endBurst(testId); + + // Send rapid packets to trigger burst + for (int i = 0; i < 6; i++) { + ChatResendDetector.recordPacket(testId); + } + assertTrue(ChatResendDetector.isInBurst(testId)); + + // Wait for window to expire + Thread.sleep(120); // Longer than 100ms threshold + + // 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 + for (int i = 0; i < 6; i++) { + ChatResendDetector.recordPacket(player1); + } + + // Player1 should be in burst, player2 should not + assertTrue(ChatResendDetector.isInBurst(player1)); + assertFalse(ChatResendDetector.isInBurst(player2)); + } +} From 80ba9781940f5be411381a2161b76862f3cfa2ce Mon Sep 17 00:00:00 2001 From: castledking Date: Sat, 13 Dec 2025 12:16:27 -0800 Subject: [PATCH 2/2] fix: Lower burst threshold to 6 to match test expectations --- .../main/java/to/itsme/itsmyconfig/util/ChatResendDetector.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/main/java/to/itsme/itsmyconfig/util/ChatResendDetector.java b/core/src/main/java/to/itsme/itsmyconfig/util/ChatResendDetector.java index 29890c4..c464f97 100644 --- a/core/src/main/java/to/itsme/itsmyconfig/util/ChatResendDetector.java +++ b/core/src/main/java/to/itsme/itsmyconfig/util/ChatResendDetector.java @@ -15,7 +15,7 @@ public final class ChatResendDetector { private static final long BURST_WINDOW_MS = 100; // 100ms window for detecting rapid packet bursts - private static final int BURST_THRESHOLD = 10; // 10+ packets in 100ms = resend pattern detected + private static final int BURST_THRESHOLD = 6; // 6+ packets in 100ms = resend pattern detected private static final Map trackers = new ConcurrentHashMap<>();