Files
Runelite-EssentialsPlus/src/main/java/com/example/essentialsplus/helpers/ChatHelper.java
T

579 lines
13 KiB
Java
Raw Normal View History

2026-09-18 17:52:12 +02:00
package com.example.essentialsplus.helpers;
import com.example.essentialsplus.EssentialsPlusConfig;
import java.awt.Color;
import java.awt.Toolkit;
import java.awt.datatransfer.StringSelection;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
import javax.inject.Inject;
import javax.inject.Singleton;
import lombok.extern.slf4j.Slf4j;
import net.runelite.api.ChatLineBuffer;
import net.runelite.api.ChatMessageType;
import net.runelite.api.Client;
import net.runelite.api.GameState;
import net.runelite.api.MenuAction;
import net.runelite.api.MessageNode;
import net.runelite.api.Player;
import net.runelite.api.events.ChatMessage;
import net.runelite.api.events.GameStateChanged;
import net.runelite.api.events.GameTick;
import net.runelite.api.events.MenuEntryAdded;
import net.runelite.api.events.ScriptCallbackEvent;
import net.runelite.api.gameval.InterfaceID;
import net.runelite.api.widgets.ComponentID;
import net.runelite.api.widgets.Widget;
import net.runelite.api.widgets.WidgetUtil;
import net.runelite.client.callback.ClientThread;
import net.runelite.client.eventbus.Subscribe;
import net.runelite.client.util.ColorUtil;
import net.runelite.client.util.Text;
/**
* Chat Helper:
* - Auto-clears messages after a configurable duration (e.g. 1 minute) to keep the chat tidy.
* - Collapses consecutive duplicate messages (x2, x3...).
* - Filters repetitive game noise and spam messages.
* - Highlights mentions of the player's name in speech chats.
* - Adds right-click "Copy message" and "Clear chat" options.
*/
@Slf4j
@Singleton
public class ChatHelper
{
private static final Set<String> NOISE_MESSAGES = Set.of(
"Nothing interesting happens.",
"You can't reach that.",
"You cannot reach that.",
"I can't reach that!",
"You can't do that here.",
"You cannot do that here.",
"You can't do that while moving.",
"You cannot do that while moving.",
"You can't do that right now.",
"You cannot do that right now.",
"You can't log out right now.",
"You don't have enough inventory space.",
"You do not have enough inventory space.",
"There is no player by that name.",
"That player is currently offline.",
"You are not currently in a clan.",
"You are not currently in a friends chat."
);
private static final Pattern NOISE_PREFIX_PATTERN = Pattern.compile(
"^(Your inventory is too full to hold any more|You need a higher level to|You need to be in a member's world to).*",
Pattern.CASE_INSENSITIVE
);
private static final Pattern PUBLIC_SPAM_PATTERN = Pattern.compile(
"(?i).*(giveaway|free gp|quitting.*search|search on yt|search yt|sub to my yt|2b for|swap cc|dicing cc|roll 55|autoclicker|osrsgold|buy gp).*"
);
private static class Duplicate
{
int messageId = -1;
int count = 1;
}
private final Client client;
private final EssentialsPlusConfig config;
private final ClientThread clientThread;
private final LinkedHashMap<String, Duplicate> duplicateChatCache = new LinkedHashMap<String, Duplicate>(100, 0.75f, true)
{
@Override
protected boolean removeEldestEntry(Map.Entry<String, Duplicate> eldest)
{
return size() > 100;
}
};
private String lastConsecutiveKey = null;
private int lastCheckEpoch = 0;
@Inject
public ChatHelper(Client client, EssentialsPlusConfig config, ClientThread clientThread)
{
this.client = client;
this.config = config;
this.clientThread = clientThread;
}
public void reset()
{
duplicateChatCache.clear();
lastConsecutiveKey = null;
lastCheckEpoch = 0;
}
@Subscribe
public void onGameStateChanged(GameStateChanged event)
{
if (event.getGameState() == GameState.LOGIN_SCREEN || event.getGameState() == GameState.HOPPING)
{
reset();
}
}
/**
* Auto-clear expired chat messages based on configured retention time.
*/
@Subscribe
public void onGameTick(GameTick event)
{
if (!config.chatHelper() || !config.chatAutoClear())
{
return;
}
int timeoutSeconds = config.chatAutoClearTime();
if (timeoutSeconds <= 0)
{
return;
}
int now = (int) Instant.now().getEpochSecond();
if (now - lastCheckEpoch < 1)
{
return;
}
lastCheckEpoch = now;
Map<Integer, ChatLineBuffer> chatLineMap = client.getChatLineMap();
if (chatLineMap == null || chatLineMap.isEmpty())
{
return;
}
boolean removedAny = false;
for (ChatLineBuffer buffer : chatLineMap.values())
{
if (buffer == null)
{
continue;
}
MessageNode[] lines = buffer.getLines();
if (lines == null || lines.length == 0)
{
continue;
}
MessageNode[] copy = lines.clone();
for (MessageNode node : copy)
{
if (node == null)
{
continue;
}
int ts = node.getTimestamp();
if (ts > 0 && (now - ts) >= timeoutSeconds && shouldClearType(node.getType()))
{
buffer.removeMessageNode(node);
removedAny = true;
}
}
}
if (removedAny)
{
client.runScript(83);
}
}
private boolean shouldClearType(ChatMessageType type)
{
if (type == null)
{
return false;
}
switch (type)
{
case GAMEMESSAGE:
case ENGINE:
case ITEM_EXAMINE:
case NPC_EXAMINE:
case OBJECT_EXAMINE:
case SPAM:
case BROADCAST:
case CONSOLE:
return config.chatClearGame();
case PUBLICCHAT:
case MODCHAT:
case AUTOTYPER:
case TRADEREQ:
case TRADE:
return config.chatClearPublic();
case PRIVATECHAT:
case PRIVATECHATOUT:
case MODPRIVATECHAT:
return config.chatClearPrivate();
case CLAN_CHAT:
case CLAN_GUEST_CHAT:
case FRIENDSCHAT:
case CLAN_MESSAGE:
case CLAN_GIM_CHAT:
case CLAN_GIM_MESSAGE:
return config.chatClearClan();
default:
return false;
}
}
/**
* Tracks incoming chat messages for duplicate collapsing.
*/
@Subscribe
public void onChatMessage(ChatMessage event)
{
if (!config.chatHelper() || !config.chatCollapseDuplicates())
{
return;
}
ChatMessageType type = event.getType();
if (!isCollapsible(type))
{
return;
}
String name = event.getName() != null ? event.getName() : "";
String message = event.getMessage();
String key = name + ":" + type.ordinal() + ":" + message;
if (!key.equals(lastConsecutiveKey))
{
lastConsecutiveKey = key;
duplicateChatCache.remove(key);
}
Duplicate dup = duplicateChatCache.get(key);
if (dup == null)
{
dup = new Duplicate();
dup.count = 1;
dup.messageId = event.getMessageNode() != null ? event.getMessageNode().getId() : -1;
duplicateChatCache.put(key, dup);
}
else
{
dup.count++;
dup.messageId = event.getMessageNode() != null ? event.getMessageNode().getId() : -1;
}
}
/**
* Intercepts chat line rendering to filter spam, collapse duplicates, and highlight names.
*/
@Subscribe
public void onScriptCallbackEvent(ScriptCallbackEvent event)
{
if (!"chatFilterCheck".equals(event.getEventName()))
{
return;
}
if (!config.chatHelper())
{
return;
}
int[] intStack = client.getIntStack();
int intStackSize = client.getIntStackSize();
Object[] objectStack = client.getObjectStack();
int objectStackSize = client.getObjectStackSize();
if (intStackSize < 3 || objectStackSize < 1)
{
return;
}
int messageTypeVal = intStack[intStackSize - 2];
int messageId = intStack[intStackSize - 1];
String message = (String) objectStack[objectStackSize - 1];
if (message == null)
{
return;
}
// 1. Spam & Noise filtering
if (config.chatFilterSpam() && isSpamOrNoise(messageTypeVal, message))
{
intStack[intStackSize - 3] = 0; // hide message
return;
}
// 2. Duplicate collapsing
if (config.chatCollapseDuplicates())
{
ChatMessageType type = ChatMessageType.of(messageTypeVal);
if (isCollapsible(type))
{
MessageNode node = null;
if (client.getMessages() != null)
{
node = (MessageNode) client.getMessages().get((long) messageId);
}
String name = node != null && node.getName() != null ? node.getName() : "";
String key = name + ":" + (type != null ? type.ordinal() : messageTypeVal) + ":" + message;
Duplicate dup = duplicateChatCache.get(key);
if (dup != null && dup.messageId != -1)
{
if (dup.messageId == messageId)
{
if (dup.count > 1)
{
message = message + " <col=888888>(x" + dup.count + ")</col>";
objectStack[objectStackSize - 1] = message;
}
}
else
{
// Hide earlier duplicate instances
intStack[intStackSize - 3] = 0;
return;
}
}
}
}
// 3. Highlight own name
if (config.chatHighlightName() && isPlayerSpeechType(messageTypeVal))
{
String highlighted = highlightPlayerName(message);
if (highlighted != null)
{
objectStack[objectStackSize - 1] = highlighted;
}
}
}
private boolean isCollapsible(ChatMessageType type)
{
if (type == null)
{
return false;
}
switch (type)
{
case GAMEMESSAGE:
case ENGINE:
case SPAM:
case PUBLICCHAT:
case MODCHAT:
case AUTOTYPER:
case CLAN_CHAT:
case FRIENDSCHAT:
case ITEM_EXAMINE:
case OBJECT_EXAMINE:
case NPC_EXAMINE:
return true;
default:
return false;
}
}
private boolean isPlayerSpeechType(int messageTypeVal)
{
ChatMessageType type = ChatMessageType.of(messageTypeVal);
if (type == null)
{
return false;
}
switch (type)
{
case PUBLICCHAT:
case MODCHAT:
case FRIENDSCHAT:
case CLAN_CHAT:
case CLAN_GUEST_CHAT:
case CLAN_GIM_CHAT:
return true;
default:
return false;
}
}
private boolean isSpamOrNoise(int messageTypeVal, String message)
{
ChatMessageType type = ChatMessageType.of(messageTypeVal);
if (type == null || message == null)
{
return false;
}
String clean = Text.removeTags(message).trim();
if (type == ChatMessageType.GAMEMESSAGE || type == ChatMessageType.ENGINE || type == ChatMessageType.SPAM)
{
if (NOISE_MESSAGES.contains(clean) || NOISE_PREFIX_PATTERN.matcher(clean).matches())
{
return true;
}
}
if (type == ChatMessageType.PUBLICCHAT || type == ChatMessageType.MODCHAT || type == ChatMessageType.AUTOTYPER)
{
if (PUBLIC_SPAM_PATTERN.matcher(clean).matches())
{
return true;
}
}
return false;
}
private String highlightPlayerName(String message)
{
Player local = client.getLocalPlayer();
if (local == null)
{
return null;
}
String name = local.getName();
if (name == null || name.isEmpty())
{
return null;
}
String lower = message.toLowerCase();
String lowerName = name.toLowerCase();
if (!lower.contains(lowerName))
{
return null;
}
Color highlightColor = config.chatHighlightColor();
String hex = ColorUtil.colorToHexCode(highlightColor != null ? highlightColor : new Color(0xFFD700));
Pattern pattern = Pattern.compile("(?i)(?<![a-zA-Z0-9_])" + Pattern.quote(name) + "(?![a-zA-Z0-9_])");
return pattern.matcher(message).replaceAll("<col=" + hex + "><b>" + name + "</b></col>");
}
/**
* Adds "Copy message" and "Clear chat" to right-click menus.
*/
@Subscribe
public void onMenuEntryAdded(MenuEntryAdded event)
{
if (!config.chatHelper())
{
return;
}
int type = event.getType();
if (type != MenuAction.CC_OP.getId() && type != MenuAction.CC_OP_LOW_PRIORITY.getId())
{
return;
}
int param1 = event.getActionParam1();
int interfaceId = WidgetUtil.componentToInterface(param1);
int childId = WidgetUtil.componentToId(param1);
if (interfaceId != InterfaceID.CHATBOX)
{
return;
}
// 1. Copy message
if (config.chatCopyToClipboard() && "Report".equals(event.getOption()))
{
Widget lineWidget = client.getWidget(interfaceId, childId);
if (lineWidget != null && lineWidget.getParent() != null && lineWidget.getParent().getId() == ComponentID.CHATBOX_MESSAGE_LINES)
{
int lineIndex = (childId - WidgetUtil.componentToId(ComponentID.CHATBOX_FRAME)) * 4 + 1;
Widget textWidget = lineWidget.getParent().getChild(lineIndex);
if (textWidget != null && textWidget.getText() != null)
{
String fullText = textWidget.getText();
client.createMenuEntry(1)
.setOption("Copy message")
.setTarget(event.getTarget())
.setType(MenuAction.RUNELITE)
.onClick(e -> copyToClipboard(fullText));
}
}
}
// 2. Clear chat on chat tab buttons
String option = event.getOption();
if (option != null && (option.startsWith("Switch tab") || option.startsWith("Filter")))
{
client.createMenuEntry(-2)
.setOption("Clear chat")
.setType(MenuAction.RUNELITE_HIGH_PRIORITY)
.onClick(e -> clearAllChat());
}
}
private void copyToClipboard(String text)
{
try
{
String cleanText = Text.removeTags(text);
StringSelection selection = new StringSelection(cleanText);
Toolkit.getDefaultToolkit().getSystemClipboard().setContents(selection, null);
}
catch (Exception e)
{
log.warn("Failed to copy chat text to clipboard", e);
}
}
/**
* Manually empties all chat line buffers and rebuilds the chatbox widget.
*/
public void clearAllChat()
{
clientThread.invokeLater(() ->
{
Map<Integer, ChatLineBuffer> chatLineMap = client.getChatLineMap();
if (chatLineMap == null)
{
return;
}
for (ChatLineBuffer buffer : chatLineMap.values())
{
if (buffer == null)
{
continue;
}
MessageNode[] lines = buffer.getLines();
if (lines == null || lines.length == 0)
{
continue;
}
MessageNode[] copy = lines.clone();
for (MessageNode node : copy)
{
if (node != null)
{
buffer.removeMessageNode(node);
}
}
}
client.runScript(83);
});
}
}