fix spoiler edge cases and webhook reply fallback consistency

Co-authored-by: Xujiayao <58985541+Xujiayao@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2026-03-17 14:24:35 +00:00 committed by Jason Xu
parent 98dcc2c2ca
commit abcf8a94ce
2 changed files with 61 additions and 23 deletions

View file

@ -47,7 +47,20 @@ public class DiscordEventHandler extends ListenerAdapter {
private static final ConcurrentHashMap<String, CachedMessage> messageCache = new ConcurrentHashMap<>();
private static final int MAX_CACHE_SIZE = 200;
private record CachedMessage(String authorName, String authorRoleColor, String contentRaw, Message contextMessage) {}
/**
* Cached Discord message metadata for reply/edit/delete context rendering.
* <p>
* Stores the already-rendered one-line reply preview so fallback rendering keeps the
* same formatting pipeline (including webhook message formatting) without retaining full
* JDA {@link Message} objects in memory.
* {@code replySegments} may be null when a preview cannot be produced for a message.
*
* @param authorName Cached effective name of the message author.
* @param authorRoleColor Cached role color of the message author.
* @param contentRaw Cached raw content for fallback rebuilding.
* @param replySegments Cached rendered reply preview, nullable.
*/
private record CachedMessage(String authorName, String authorRoleColor, String contentRaw, List<TextSegment> replySegments) {}
/**
* Resolves the OP Level credential for a Discord user based on config mappings.
@ -380,9 +393,12 @@ public class DiscordEventHandler extends ListenerAdapter {
replySegments = DiscordMessageParser.buildReplySegments(
cachedRef.authorName(),
cachedRef.authorRoleColor(),
cachedRef.contextMessage(),
null,
cachedRef.contentRaw()
);
if (cachedRef.replySegments() != null && !cachedRef.replySegments().isEmpty()) {
replySegments = cachedRef.replySegments();
}
}
}
@ -495,12 +511,15 @@ public class DiscordEventHandler extends ListenerAdapter {
CachedMessage cached = messageCache.get(message.getId());
List<TextSegment> replySegments = null;
if (cached != null && cached.contentRaw() != null) {
replySegments = DiscordMessageParser.buildReplySegments(
cached.authorName(),
cached.authorRoleColor(),
cached.contextMessage(),
cached.contentRaw()
);
replySegments = cached.replySegments();
if (replySegments == null || replySegments.isEmpty()) {
replySegments = DiscordMessageParser.buildReplySegments(
cached.authorName(),
cached.authorRoleColor(),
null,
cached.contentRaw()
);
}
}
// Build edit notification segments
@ -552,9 +571,12 @@ public class DiscordEventHandler extends ListenerAdapter {
packet.replySegments = DiscordMessageParser.buildReplySegments(
cached.authorName(),
cached.authorRoleColor(),
cached.contextMessage(),
null,
cached.contentRaw()
);
if (cached.replySegments() != null && !cached.replySegments().isEmpty()) {
packet.replySegments = cached.replySegments();
}
logDiscordEventForConsole(packet);
NetworkManager.broadcastToClients(packet);
}
@ -575,7 +597,8 @@ public class DiscordEventHandler extends ListenerAdapter {
Member member = message.getMember();
String name = member != null ? member.getEffectiveName() : message.getAuthor().getName();
String roleColor = DiscordMessageParser.getRoleColorHex(member);
messageCache.put(message.getId(), new CachedMessage(name, roleColor, message.getContentRaw(), message));
List<TextSegment> replySegments = DiscordMessageParser.buildReplySegments(message);
messageCache.put(message.getId(), new CachedMessage(name, roleColor, message.getContentRaw(), replySegments));
}
private static void logDiscordEventForConsole(DiscordEventPacket packet) {

View file

@ -92,9 +92,13 @@ private static final Pattern BOLD_ITALIC_URL_PATTERN = Pattern.compile("\\*\\*\\
private static final Pattern BOLD_URL_PATTERN = Pattern.compile("\\*\\*(https?://[^\\s*|~`<>)\\]]+)\\*\\*");
private static final Pattern SPOILER_ITALIC_URL_PATTERN = Pattern.compile("\\|\\|\\*(https?://[^\\s*|~`<>)\\]]+)\\*\\|\\|");
private static final Pattern SPOILER_URL_PATTERN = Pattern.compile("\\|\\|(https?://[^\\s*|~`<>)\\]]+)\\|\\|");
// Matches spoiler-wrapped user mentions: ||<@123>|| / ||<@!123>||
private static final Pattern SPOILER_USER_MENTION_PATTERN = Pattern.compile("\\|\\|<@!?(\\d+)>\\|\\|");
// Matches spoiler-wrapped role mentions: ||<@&123>||
private static final Pattern SPOILER_ROLE_MENTION_PATTERN = Pattern.compile("\\|\\|<@&(\\d+)>\\|\\|");
// Matches spoiler-wrapped channel mentions: ||<#123>||
private static final Pattern SPOILER_CHANNEL_MENTION_PATTERN = Pattern.compile("\\|\\|<#(\\d+)>\\|\\|");
// Matches spoiler-wrapped @everyone/@here tokens: ||@everyone|| / ||@here||
private static final Pattern SPOILER_EVERYONE_HERE_PATTERN = Pattern.compile("\\|\\|@(everyone|here)\\|\\|");
private static final Pattern SPOILER_CONTENT_PATTERN = Pattern.compile("\\|\\|(.+?)\\|\\|");
private static final Pattern LINK_TOKEN_PATTERN = Pattern.compile("\\[([^]]+)]\\((https?://[^)]+)\\)");
@ -106,6 +110,9 @@ private static final int REPLY_TRUNCATE_LIMIT_NARROW = 40;
private static final int MAIN_TRUNCATE_LIMIT_WIDE = 200;
private static final int MAIN_TRUNCATE_LIMIT_NARROW = 400;
private static final String URL_COLOR = "#3366CC";
private static final String ATTACHMENT_LABEL_PREFIX = "<attachment type=[%s] name=[";
private static final String EMBED_LABEL_PREFIX = "<embed title=[";
private static final String LABEL_SUFFIX = "]>";
/**
* Builds the main message line segments for a Discord chat message.
@ -758,9 +765,8 @@ break;
if (displayName == null) {
displayName = userId;
}
TextSegment seg = new TextSegment("[@" + displayName + "]", false, color != null ? color : "white");
seg.obfuscated = true;
seg.hoverText = seg.text;
TextSegment seg = new TextSegment("[@" + displayName + "]", false, colorOrDefault(color));
applySpoilerStyle(seg);
tokens.add(new TokenSpan(matcher.start(), matcher.end(), seg));
}
}
@ -782,8 +788,7 @@ break;
}
}
TextSegment seg = new TextSegment("[@" + roleName + "]", false, color);
seg.obfuscated = true;
seg.hoverText = seg.text;
applySpoilerStyle(seg);
tokens.add(new TokenSpan(matcher.start(), matcher.end(), seg));
}
}
@ -800,8 +805,7 @@ break;
}
}
TextSegment seg = new TextSegment("[#" + channelName + "]", false, "yellow");
seg.obfuscated = true;
seg.hoverText = seg.text;
applySpoilerStyle(seg);
tokens.add(new TokenSpan(matcher.start(), matcher.end(), seg));
}
}
@ -814,8 +818,7 @@ Matcher matcher = SPOILER_EVERYONE_HERE_PATTERN.matcher(raw);
while (matcher.find()) {
String mention = matcher.group(1);
TextSegment seg = new TextSegment("[@" + mention + "]", false, "yellow");
seg.obfuscated = true;
seg.hoverText = seg.text;
applySpoilerStyle(seg);
tokens.add(new TokenSpan(matcher.start(), matcher.end(), seg));
}
}
@ -1486,9 +1489,9 @@ return false;
private static List<TextSegment> buildAttachmentSegments(String type, String fileName, String url, boolean spoiler) {
List<TextSegment> segments = new ArrayList<>();
TextSegment prefix = new TextSegment("<attachment type=[" + type + "] name=[", false, URL_COLOR);
TextSegment prefix = new TextSegment(String.format(ATTACHMENT_LABEL_PREFIX, type), false, URL_COLOR);
TextSegment fileNameSegment = new TextSegment(fileName, false, URL_COLOR);
TextSegment suffix = new TextSegment("]>", false, URL_COLOR);
TextSegment suffix = new TextSegment(LABEL_SUFFIX, false, URL_COLOR);
applyLinkStyle(prefix, url);
applyLinkStyle(fileNameSegment, url);
@ -1508,9 +1511,9 @@ return segments;
private static List<TextSegment> buildEmbedSegments(String title, String url, boolean spoiler) {
List<TextSegment> segments = new ArrayList<>();
String color = url == null ? "yellow" : URL_COLOR;
TextSegment prefix = new TextSegment("<embed title=[", false, color);
TextSegment prefix = new TextSegment(EMBED_LABEL_PREFIX, false, color);
TextSegment titleSegment = new TextSegment(title, false, color);
TextSegment suffix = new TextSegment("]>", false, color);
TextSegment suffix = new TextSegment(LABEL_SUFFIX, false, color);
if (url != null) {
applyLinkStyle(prefix, url);
@ -1532,8 +1535,20 @@ return segments;
private static void applyLinkStyle(TextSegment segment, String url) {
segment.underlined = true;
segment.clickUrl = url;
if (segment.hoverText == null) {
segment.hoverText = I18nManager.getDmccTranslation("discord.message_parser.click_to_open_link");
}
}
private static void applySpoilerStyle(TextSegment segment) {
segment.obfuscated = true;
// Obfuscated Minecraft text is unreadable in chat, so we keep original plain text as hover preview.
segment.hoverText = segment.text;
}
private static String colorOrDefault(String color) {
return color != null ? color : "white";
}
private static String truncateMainRaw(String raw) {
String lineLimited = applyMainLineLimit(raw);