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;
|
2026-09-30 16:48:47 +02:00
|
|
|
import java.util.Arrays;
|
2026-09-18 17:52:12 +02:00
|
|
|
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;
|
2026-09-30 16:48:47 +02:00
|
|
|
import net.runelite.api.MenuEntry;
|
2026-09-18 17:52:12 +02:00
|
|
|
import net.runelite.api.MessageNode;
|
|
|
|
|
import net.runelite.api.Player;
|
2026-09-30 16:48:47 +02:00
|
|
|
import net.runelite.api.Point;
|
|
|
|
|
import net.runelite.api.ScriptID;
|
2026-09-18 17:52:12 +02:00
|
|
|
import net.runelite.api.events.ChatMessage;
|
|
|
|
|
import net.runelite.api.events.GameStateChanged;
|
|
|
|
|
import net.runelite.api.events.GameTick;
|
|
|
|
|
import net.runelite.api.events.MenuEntryAdded;
|
2026-09-30 16:48:47 +02:00
|
|
|
import net.runelite.api.events.MenuOpened;
|
2026-09-18 17:52:12 +02:00
|
|
|
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)
|
|
|
|
|
{
|
2026-09-30 16:48:47 +02:00
|
|
|
refreshChat();
|
2026-09-18 17:52:12 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-30 16:48:47 +02:00
|
|
|
private void refreshChat()
|
|
|
|
|
{
|
|
|
|
|
// Invalidate the cached chat before rebuilding the chatbox and split PMs.
|
|
|
|
|
client.refreshChat();
|
|
|
|
|
client.runScript(ScriptID.SPLITPM_CHANGED);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-18 17:52:12 +02:00
|
|
|
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:
|
2026-09-30 16:48:47 +02:00
|
|
|
case WELCOME:
|
|
|
|
|
case PLAYERRELATED:
|
|
|
|
|
case TENSECTIMEOUT:
|
|
|
|
|
case SNAPSHOTFEEDBACK:
|
|
|
|
|
case FRIENDNOTIFICATION:
|
|
|
|
|
case IGNORENOTIFICATION:
|
2026-09-18 17:52:12 +02:00
|
|
|
return config.chatClearGame();
|
|
|
|
|
|
|
|
|
|
case PUBLICCHAT:
|
|
|
|
|
case MODCHAT:
|
|
|
|
|
case AUTOTYPER:
|
2026-09-30 16:48:47 +02:00
|
|
|
case MODAUTOTYPER:
|
2026-09-18 17:52:12 +02:00
|
|
|
case TRADEREQ:
|
|
|
|
|
case TRADE:
|
2026-09-30 16:48:47 +02:00
|
|
|
case TRADE_SENT:
|
|
|
|
|
case CHALREQ_TRADE:
|
2026-09-18 17:52:12 +02:00
|
|
|
return config.chatClearPublic();
|
|
|
|
|
|
|
|
|
|
case PRIVATECHAT:
|
|
|
|
|
case PRIVATECHATOUT:
|
|
|
|
|
case MODPRIVATECHAT:
|
|
|
|
|
return config.chatClearPrivate();
|
|
|
|
|
|
|
|
|
|
case CLAN_CHAT:
|
|
|
|
|
case CLAN_GUEST_CHAT:
|
|
|
|
|
case FRIENDSCHAT:
|
|
|
|
|
case CLAN_MESSAGE:
|
2026-09-30 16:48:47 +02:00
|
|
|
case CLAN_GUEST_MESSAGE:
|
|
|
|
|
case FRIENDSCHATNOTIFICATION:
|
2026-09-18 17:52:12 +02:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-30 16:48:47 +02:00
|
|
|
// 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
|
2026-09-18 17:52:12 +02:00
|
|
|
if (config.chatHighlightName() && isPlayerSpeechType(messageTypeVal))
|
|
|
|
|
{
|
|
|
|
|
String highlighted = highlightPlayerName(message);
|
|
|
|
|
if (highlighted != null)
|
|
|
|
|
{
|
|
|
|
|
objectStack[objectStackSize - 1] = highlighted;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-30 16:48:47 +02:00
|
|
|
private boolean isClanChatType(ChatMessageType type)
|
|
|
|
|
{
|
|
|
|
|
return type == ChatMessageType.CLAN_CHAT
|
|
|
|
|
|| type == ChatMessageType.CLAN_GUEST_CHAT
|
|
|
|
|
|| type == ChatMessageType.CLAN_GIM_CHAT;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-18 17:52:12 +02:00
|
|
|
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);
|
2026-09-30 16:48:47 +02:00
|
|
|
if (lineWidget != null && lineWidget.getParent() != null && lineWidget.getParent().getId() == InterfaceID.Chatbox.SCROLLAREA)
|
2026-09-18 17:52:12 +02:00
|
|
|
{
|
2026-09-30 16:48:47 +02:00
|
|
|
int lineIndex = (childId - WidgetUtil.componentToId(InterfaceID.Chatbox.LINE0)) * 4 + 1;
|
|
|
|
|
if (lineIndex < 1)
|
|
|
|
|
{
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-09-18 17:52:12 +02:00
|
|
|
Widget textWidget = lineWidget.getParent().getChild(lineIndex);
|
|
|
|
|
if (textWidget != null && textWidget.getText() != null)
|
|
|
|
|
{
|
2026-09-30 16:48:47 +02:00
|
|
|
addCopyEntry(textWidget.getText(), event.getTarget());
|
2026-09-18 17:52:12 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-30 16:48:47 +02:00
|
|
|
/** 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)
|
2026-09-18 17:52:12 +02:00
|
|
|
{
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-30 16:48:47 +02:00
|
|
|
reset();
|
|
|
|
|
refreshChat();
|
2026-09-18 17:52:12 +02:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|