Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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...");
Expand All @@ -98,13 +106,18 @@ public void onPacketSend(final PacketSendEvent event) {
}

final Optional<String> 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("<p:");

if (parsed.isEmpty() && !hasPlaceholders) {
Utilities.debug(() -> "Message doesn't start w/ the symbol-prefix and has no <p: placeholders: " + 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -53,24 +54,38 @@ 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<PacketContainer> packet = this.processPacket(container);
if (packet == null || packet.isEmpty()) {
Utilities.debug(() -> "Packet is null or empty\n" + Strings.DEBUG_HYPHEN);
return;
}

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
Comment on lines +70 to +71
Copy link

Copilot AI Dec 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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

Copilot uses AI. Check for mistakes.
final boolean isInBurst = ChatResendDetector.checkMessage(playerIdentifier, message);

Utilities.debug(() -> "Found message: " + message + (isInBurst ? " [RESEND DETECTED]" : ""));

final Optional<String> 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("<p:");

if (parsed.isEmpty() && !hasPlaceholders) {
Utilities.debug(() -> "Message doesn't start w/ the symbol-prefix and has no <p: placeholders: " + 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);
Comment on lines +86 to +88
Copy link

Copilot AI Dec 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
if (translated.equals(Component.empty())) {
event.setCancelled(true);
Utilities.debug(() -> "Component is empty, cancelling...\n" + Strings.DEBUG_HYPHEN);
Expand Down
20 changes: 20 additions & 0 deletions core/src/main/java/to/itsme/itsmyconfig/tag/TagManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down Expand Up @@ -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();
Expand All @@ -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;
}
Comment on lines +77 to +87
Copy link

Copilot AI Dec 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
}
Comment on lines +75 to +88
Copy link

Copilot AI Dec 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Burst mode check happens inside the tag processing loop, but burst state is checked once before the loop starts. If burst mode is active when processArgumentTags is called, the first actionbar or sound tag encountered will be handled according to burst rules. However, if there are multiple actionbar tags in the same message, only the first one will trigger the empty string return (line 80), preventing processing of subsequent tags.

This creates inconsistent behavior: a message with <actionbar:test1><sound:ding> during burst will return empty (canceling the entire message), but the sound tag is never processed. Consider whether this is the intended behavior or if all sound tags should be stripped before returning empty for actionbar.

Copilot uses AI. Check for mistakes.

final String arguments = matcher.group(2);
final String[] args = extractArguments(arguments);
if (args.length == 1 && "cancel".equals(args[0])) {
Expand Down
193 changes: 193 additions & 0 deletions core/src/main/java/to/itsme/itsmyconfig/util/ChatResendDetector.java
Original file line number Diff line number Diff line change
@@ -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

Copy link

Copilot AI Dec 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
private static final Map<String, BurstTracker> 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).
Comment on lines +30 to +36
Copy link

Copilot AI Dec 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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".

Suggested change
// 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)

Copilot uses AI. Check for mistakes.
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)
Comment on lines +54 to +59
Copy link

Copilot AI Dec 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing reset of blank line counter for non-blank messages. When a non-blank message is received during the tracking window, the counter is not reset. This means if a player sends 10 blank lines, then a normal message, then 10 more blank lines within 500ms, the counter continues from 10 and could trigger burst at the 20th total blank line even though they were interrupted by a normal message.

Consider resetting the counter when a non-blank, non-invisible-unicode message is received, as this indicates the blank line flood has ended and a new potential flood should be tracked separately.

Copilot uses AI. Check for mistakes.
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));
}
Comment on lines +75 to +80
Copy link

Copilot AI Dec 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.

/**
* 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()) {
Comment on lines +118 to +122
Copy link

Copilot AI Dec 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
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.
Comment on lines +124 to +130
Copy link

Copilot AI Dec 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
*/
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;
}
}
}
Loading