Files
Runelite-EssentialsPlus/src/main/java/com/example/essentialsplus/helpers/ChatHelper.java
T
2026-10-05 21:04:27 +02:00

683 lines
16 KiB
Java

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.Arrays;
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.MenuEntry;
import net.runelite.api.MessageNode;
import net.runelite.api.Player;
import net.runelite.api.Point;
import net.runelite.api.ScriptID;
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.MenuOpened;
import net.runelite.api.events.ScriptCallbackEvent;
import net.runelite.api.gameval.InterfaceID;
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)
{
refreshChat();
}
}
private void refreshChat()
{
// Invalidate the cached chat before rebuilding the chatbox and split PMs.
client.refreshChat();
client.runScript(ScriptID.SPLITPM_CHANGED);
}
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:
case WELCOME:
case PLAYERRELATED:
case TENSECTIMEOUT:
case SNAPSHOTFEEDBACK:
case FRIENDNOTIFICATION:
case IGNORENOTIFICATION:
return config.chatClearGame();
case PUBLICCHAT:
case MODCHAT:
case AUTOTYPER:
case MODAUTOTYPER:
case TRADEREQ:
case TRADE:
case TRADE_SENT:
case CHALREQ_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_GUEST_MESSAGE:
case FRIENDSCHATNOTIFICATION:
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. Recolor clan chat text without changing public, private, or game messages.
if (isClanChatType(ChatMessageType.of(messageTypeVal)))
{
Color clanColor = config.chatClanMessageColor();
String hex = ColorUtil.colorToHexCode(clanColor != null ? clanColor : new Color(0x66FFFF));
message = "<col=" + hex + ">" + message + "</col>";
objectStack[objectStackSize - 1] = message;
}
// 4. Highlight own name
if (config.chatHighlightName() && isPlayerSpeechType(messageTypeVal))
{
String highlighted = highlightPlayerName(message);
if (highlighted != null)
{
objectStack[objectStackSize - 1] = highlighted;
}
}
}
private boolean isClanChatType(ChatMessageType type)
{
return type == ChatMessageType.CLAN_CHAT
|| type == ChatMessageType.CLAN_GUEST_CHAT
|| type == ChatMessageType.CLAN_GIM_CHAT;
}
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() == InterfaceID.Chatbox.SCROLLAREA)
{
int lineIndex = (childId - WidgetUtil.componentToId(InterfaceID.Chatbox.LINE0)) * 4 + 1;
if (lineIndex < 1)
{
return;
}
Widget textWidget = lineWidget.getParent().getChild(lineIndex);
if (textWidget != null && textWidget.getText() != null)
{
addCopyEntry(textWidget.getText(), event.getTarget());
}
}
}
// 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());
}
}
/** Game messages and transparent chat text do not necessarily have a Report action. */
@Subscribe(priority = -1)
public void onMenuOpened(MenuOpened event)
{
if (!config.chatHelper() || !config.chatCopyToClipboard())
{
return;
}
MenuEntry[] entries = client.getMenu().getMenuEntries();
if (Arrays.stream(entries).anyMatch(e -> "Copy message".equals(e.getOption())))
{
return;
}
// Do not intercept another interface or an overlay covering the chat.
if (Arrays.stream(entries).anyMatch(e -> e.getWidget() != null
&& WidgetUtil.componentToInterface(e.getWidget().getId()) != InterfaceID.CHATBOX))
{
return;
}
Widget scroll = client.getWidget(InterfaceID.Chatbox.SCROLLAREA);
Point mouse = client.getMouseCanvasPosition();
if (scroll == null || scroll.isHidden() || mouse == null
|| !scroll.getBounds().contains(mouse.getX(), mouse.getY()))
{
return;
}
Widget[] children = scroll.getDynamicChildren();
if (children == null || children.length == 0)
{
children = scroll.getChildren();
}
if (children == null)
{
return;
}
for (int i = 1; i < children.length; i += 4)
{
Widget message = children[i];
if (message != null && !message.isHidden() && message.getText() != null
&& !Text.removeTags(message.getText()).trim().isEmpty()
&& message.getBounds().contains(mouse.getX(), mouse.getY()))
{
// A world-only menu here is click-through from transparent chat.
boolean hasChatAction = Arrays.stream(entries).anyMatch(e ->
WidgetUtil.componentToInterface(e.getParam1()) == InterfaceID.CHATBOX);
if (!hasChatAction)
{
client.getMenu().setMenuEntries(Arrays.stream(entries)
.filter(e -> e.getType() == MenuAction.CANCEL).toArray(MenuEntry[]::new));
}
addCopyEntry(message.getText(), "");
return;
}
}
}
private void addCopyEntry(String text, String target)
{
// Capture the displayed text now: new messages can arrive while the menu is open.
client.createMenuEntry(Math.min(1, client.getMenu().getMenuEntries().length))
.setOption("Copy message")
.setTarget(target)
.setType(MenuAction.RUNELITE)
.onClick(e -> copyToClipboard(text));
}
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);
}
}
}
reset();
refreshChat();
});
}
}