feat: Chat resend detection based on blank lines and invisible unicode#27
feat: Chat resend detection based on blank lines and invisible unicode#27castledking wants to merge 2 commits intoItsMyStudio:mainfrom
Conversation
- Detect 20+ blank lines within 500ms window as chat resend pattern - Detect invisible unicode characters (like Allium plugin uses) - Burst mode lasts 150ms after detection - During burst: actionbar messages cancelled, sound tags stripped - Process messages with <p: placeholders even without symbol prefix
- Add automatic cleanup of stale trackers to prevent memory leaks - Improve invisible unicode pattern documentation - Improve endBurst method documentation
There was a problem hiding this comment.
Pull request overview
This PR implements chat resend detection to prevent unwanted action bar messages and sound effects when chat deletion/moderation plugins clear and resend chat history. The detection works by identifying patterns of blank lines (20+ within 500ms) or invisible Unicode characters commonly used by these plugins, triggering a 150ms burst mode that filters out actionbar and sound tags.
Key Changes
- Adds
ChatResendDetectorutility class with pattern-based detection using configurable thresholds and time windows - Integrates burst detection into packet listeners (ProtocolLib and PacketEvents) to check all incoming chat messages
- Modifies
TagManagerto suppress actionbar messages and strip sound tags during burst mode
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 12 comments.
Show a summary per file
| File | Description |
|---|---|
core/src/main/java/to/itsme/itsmyconfig/util/ChatResendDetector.java |
New utility class implementing burst detection logic with blank line counting and invisible Unicode pattern matching |
core/src/test/java/to/itsme/itsmyconfig/util/ChatResendDetectorTest.java |
Comprehensive test suite covering detection scenarios, burst timing, and player isolation |
core/src/main/java/to/itsme/itsmyconfig/tag/TagManager.java |
Adds burst mode checks to cancel actionbar messages and strip sound tags during chat resends |
core/src/main/java/to/itsme/itsmyconfig/processor/protocollib/PLibListener.java |
Integrates resend detection into ProtocolLib packet processing, adds placeholder fallback logic, and fixes spelling typo |
core/src/main/java/to/itsme/itsmyconfig/processor/packetevents/PEventsListener.java |
Integrates resend detection into PacketEvents packet processing with placeholder fallback logic |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| 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 ")); | ||
| } | ||
| } |
There was a problem hiding this comment.
Missing test coverage for concurrent access scenarios. The ChatResendDetector uses ConcurrentHashMap and atomic variables, indicating it's designed for multi-threaded use. However, there are no tests that verify thread-safe behavior when multiple threads access the same player identifier simultaneously.
Consider adding tests that simulate concurrent blank line recording from multiple threads to ensure the counter increments correctly and burst mode triggers exactly once at the threshold.
| @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)); | ||
| } |
There was a problem hiding this comment.
Missing test coverage for the 500ms time window behavior. The test sends 20+ blank lines rapidly but doesn't verify that blank lines sent outside the 500ms window don't trigger burst mode.
Consider adding a test that sends 10 blank lines, waits 600ms, sends 10 more blank lines, and verifies that burst mode is NOT triggered since they fall outside the time window.
| * 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)); | ||
| } |
There was a problem hiding this comment.
Missing documentation for the endBurst method. While the method name is self-explanatory, it's a public API method and should have JavaDoc documentation explaining when and why it should be called, especially since the comment mentions "testing or manual intervention" which suggests it has specific use cases.
Add a JavaDoc comment explaining that this method forcefully clears burst tracking state, is primarily intended for testing, and should generally not be called in production code as burst mode expires automatically after 150ms.
| // Check message for resend patterns (blank lines, invisible unicode) | ||
| // This also updates burst state for this player |
There was a problem hiding this comment.
Inconsistent comment style. The comment uses "Check message for resend patterns" while other comments in the file use different phrasing. The comment spans 3 lines where 2 would suffice.
Consider consolidating to: "Check message for resend patterns and update burst state" on a single line for better readability.
| // Check message for resend patterns (blank lines, invisible unicode) | |
| // This also updates burst state for this player | |
| // Check message for resend patterns and update burst state |
| // 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); |
There was a problem hiding this comment.
Logic error in message processing flow. When parsed is empty (no symbol-prefix) but hasPlaceholders is true, the code uses the original message which still lacks the required prefix. The messageToProcess will be the full original message, which will then be passed to translate(). This bypasses the prefix requirement that parsePrefixedMessage is checking for.
If the intent is to allow messages with <p: placeholders to be processed even without the symbol-prefix, this should be documented. Otherwise, consider whether hasPlaceholders messages should also require the prefix, or if the prefix should be stripped from the message before checking for placeholders.
| * @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()) { |
There was a problem hiding this comment.
Race condition in blank line counting logic. The window check and counter reset are not atomic operations. Between checking the time condition and resetting the counter, another thread could increment the counter, causing the reset to discard valid blank line counts.
This can lead to burst mode not triggering when it should, as rapid blank lines from multiple threads could be incorrectly reset. Consider using compareAndSet pattern or moving the reset logic into a synchronized block.
| } | ||
| 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. |
There was a problem hiding this comment.
Race condition between incrementing counter and checking threshold. Multiple threads could increment the counter past the threshold before any of them call triggerBurst, potentially causing the burst to be triggered multiple times with different counter values. While not critically harmful, this creates inconsistent state.
Consider using getAndIncrement() and checking if the returned value equals exactly BLANK_LINE_THRESHOLD - 1 to ensure only one thread triggers the burst.
| 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; | ||
| } |
There was a problem hiding this comment.
String comparison for tag names should use a more maintainable approach. Currently using string literals "actionbar" and "sound" which are magic strings that could get out of sync with the actual tag names defined in ActiobarTag.name() and SoundTag.name().
Consider using constants or calling tag.name() for comparison, or checking tag instanceof ActiobarTag to make the code more maintainable and refactor-safe.
| 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 | ||
|
|
There was a problem hiding this comment.
Potential memory leak: tracker entries are never removed from the map. Once a player triggers burst detection, their BurstTracker remains in the ConcurrentHashMap indefinitely, even after the player disconnects. Over time, this will accumulate entries for all players who ever triggered burst mode.
Consider adding a cleanup mechanism, such as removing trackers when burst expires and no blank lines have been recorded recently, or hooking into player disconnect events to remove their tracker entries.
| // 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). |
There was a problem hiding this comment.
Incomplete documentation for invisible unicode pattern. The comment mentions "Mongolian Vowel Separator, etc." but doesn't list all the characters in the pattern. The pattern includes U+061C (Arabic Letter Mark), U+115F/U+1160 (Hangul), U+17B4/U+17B5 (Khmer), U+2061-2064 (function application markers), and U+206A-206F (formatting controls), which aren't mentioned.
Consider either listing all characters explicitly or removing "etc." and being more general like "and other zero-width/invisible formatting characters".
| // 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). | |
| // Invisible Unicode characters commonly used by chat clear plugins to prevent | |
| // chat mods from condensing repeated blank lines. This pattern matches: | |
| // - 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 Choseong Filler (U+115F) | |
| // - Hangul Jungseong Filler (U+1160) | |
| // - Khmer Vowel Inherent Aq (U+17B4) | |
| // - Khmer Vowel Inherent Aa (U+17B5) | |
| // - Invisible math operators (U+2061–U+2064) | |
| // - Word/segment formatting controls (U+206A–U+206F) |
Detect 20+ blank lines within 500ms window as chat resend pattern
Detect invisible unicode characters (like Allium plugin uses)
Burst mode lasts 150ms after detection
During burst: actionbar messages cancelled, sound tags stripped
Fixes a problem where chat deletion plugins created action bar and sound effects when chat resent