feat improve spoiler coverage edit layout and console parity logging

Co-authored-by: Xujiayao <58985541+Xujiayao@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2026-03-17 13:45:25 +00:00 committed by Jason Xu
parent d8e3554a04
commit 1bb5c52b1e
6 changed files with 151 additions and 18 deletions

View file

@ -25,10 +25,12 @@ import com.xujiayao.discord_mc_chat.network.packets.commands.link.LinkResponsePa
import com.xujiayao.discord_mc_chat.network.packets.commands.link.OpSyncPacket;
import com.xujiayao.discord_mc_chat.network.packets.commands.unlink.UnlinkResponsePacket;
import com.xujiayao.discord_mc_chat.network.packets.events.DiscordEventPacket;
import com.xujiayao.discord_mc_chat.network.packets.events.TextSegment;
import com.xujiayao.discord_mc_chat.network.packets.misc.KeepAlivePacket;
import com.xujiayao.discord_mc_chat.network.packets.misc.LatencyPongPacket;
import com.xujiayao.discord_mc_chat.utils.CryptUtils;
import com.xujiayao.discord_mc_chat.utils.EnvironmentUtils;
import com.xujiayao.discord_mc_chat.utils.config.ModeManager;
import com.xujiayao.discord_mc_chat.utils.events.CoreEvents;
import com.xujiayao.discord_mc_chat.utils.events.EventManager;
import com.xujiayao.discord_mc_chat.utils.i18n.I18nManager;
@ -202,6 +204,9 @@ public class ClientHandler extends SimpleChannelInboundHandler<Packet> {
EventManager.post(new CoreEvents.OpSyncEvent(p.opLevels));
case DiscordEventPacket p -> {
// Handle Discord event forwarded from server - render in Minecraft
if ("multi_server_client".equals(ModeManager.getMode())) {
logDiscordEventForConsole(p);
}
switch (p.type) {
case CHAT -> EventManager.post(new CoreEvents.DiscordChatMessageEvent(
p.segments,
@ -265,4 +270,16 @@ public class ClientHandler extends SimpleChannelInboundHandler<Packet> {
}
ctx.close();
}
private static void logDiscordEventForConsole(DiscordEventPacket p) {
if (p.replySegments != null && !p.replySegments.isEmpty()) {
LOGGER.info(TextSegment.toPlainText(p.replySegments));
}
if (p.segments != null && !p.segments.isEmpty()) {
LOGGER.info(TextSegment.toPlainText(p.segments));
}
if (p.type == DiscordEventPacket.EventType.EDIT && p.editedMessageSegments != null && !p.editedMessageSegments.isEmpty()) {
LOGGER.info(TextSegment.toPlainText(p.editedMessageSegments));
}
}
}

View file

@ -2,6 +2,7 @@ package com.xujiayao.discord_mc_chat.network.packets.events;
import java.io.Serial;
import java.io.Serializable;
import java.util.List;
/**
* Represents a single rich text segment for in-game rendering.
@ -92,4 +93,20 @@ public class TextSegment implements Serializable {
this.bold = bold;
this.color = color;
}
@Override
public String toString() {
return text == null ? "" : text;
}
public static String toPlainText(List<TextSegment> segments) {
if (segments == null || segments.isEmpty()) {
return "";
}
StringBuilder sb = new StringBuilder();
for (TextSegment segment : segments) {
sb.append(segment == null ? "" : segment.toString());
}
return sb.toString();
}
}

View file

@ -32,6 +32,8 @@ import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
import static com.xujiayao.discord_mc_chat.Constants.LOGGER;
/**
* Handles Discord JDA events.
*
@ -409,6 +411,11 @@ public class DiscordEventHandler extends ListenerAdapter {
packet.mentionedPlayerUuids = mentionedPlayerUuids;
packet.mentionEveryone = isMentionEveryone;
if (replySegments != null && !replySegments.isEmpty()) {
LOGGER.info(TextSegment.toPlainText(replySegments));
}
LOGGER.info(TextSegment.toPlainText(mainSegments));
NetworkManager.broadcastToClients(packet);
// Cache message for edit/delete reference
@ -501,7 +508,7 @@ public class DiscordEventHandler extends ListenerAdapter {
List<TextSegment> notificationSegments = DiscordMessageParser.buildEditNotificationSegments(editorName, roleColor);
// Build new message content segments
List<TextSegment> editedMessageSegments = DiscordMessageParser.buildChatSegments(message);
List<TextSegment> editedMessageSegments = DiscordMessageParser.buildEditedMessageSegments(message);
DiscordEventPacket packet = new DiscordEventPacket(DiscordEventPacket.EventType.EDIT, notificationSegments);
packet.replySegments = replySegments;

View file

@ -92,6 +92,7 @@ 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*|~`<>)\\]]+)\\|\\|");
private static final Pattern SPOILER_CONTENT_PATTERN = Pattern.compile("\\|\\|(.+?)\\|\\|");
private static final Pattern LINK_TOKEN_PATTERN = Pattern.compile("\\[([^]]+)]\\((https?://[^)]+)\\)");
private static final List<String> MARKDOWN_DELIMITERS = List.of("***", "~~", "||", "**", "__", "*", "_");
@ -344,6 +345,53 @@ segments.add(new TextSegment(text, bold, color));
return segments;
}
/**
* Builds segments for the edited message content line shown after edit notification.
* <p>
* Format follows the custom_messages {@code discord_to_minecraft.edited_message} pattern.
* This is intentionally separated from {@code common.chat} so edit events can render a
* "bottom bun" style complementary to {@code discord_to_minecraft.response}.
*
* @param message The edited Discord message.
* @return The list of text segments for the edited message content line.
*/
public static List<TextSegment> buildEditedMessageSegments(Message message) {
List<TextSegment> segments = new ArrayList<>();
Member member = message.getMember();
String effectiveName = member != null ? member.getEffectiveName() : message.getAuthor().getName();
String roleColor = getRoleColorHex(member);
String truncatedRaw = truncateMainRaw(message.getContentRaw());
List<TextSegment> contentSegments = parseMessageContent(message, truncatedRaw);
JsonNode editedNode = I18nManager.getCustomMessages().path("discord_to_minecraft").path("edited_message");
if (editedNode.isArray()) {
for (JsonNode segNode : editedNode) {
String text = segNode.path("text").asText("");
boolean bold = segNode.path("bold").asBoolean(false);
String color = segNode.path("color").asText("");
text = text.replace("{effective_name}", effectiveName);
color = color.replace("{role_color}", roleColor);
if (text.contains("{message}")) {
String[] parts = text.split("\\{message}", -1);
if (!parts[0].isEmpty()) {
segments.add(new TextSegment(parts[0], bold, color));
}
applyDefaultColor(contentSegments, color);
segments.addAll(contentSegments);
if (parts.length > 1 && !parts[1].isEmpty()) {
segments.add(new TextSegment(parts[1], bold, color));
}
} else {
segments.add(new TextSegment(text, bold, color));
}
}
}
return segments;
}
/**
* Builds segments for a message delete notification.
* <p>
@ -481,14 +529,17 @@ type = "image";
} else if (attachment.isVideo()) {
type = "video";
}
String label = "<attachment type=[" + type + "] name=[" + attachment.getFileName() + "]>";
String label = "<attachment type=[" + type + "] name=[" + attachment.getFileName() + "]>";
TextSegment seg = new TextSegment(label, false, "#3366CC");
seg.underlined = true;
seg.clickUrl = attachment.getUrl();
seg.hoverText = I18nManager.getDmccTranslation("discord.message_parser.click_to_open_link");
segments.add(seg);
}
TextSegment seg = new TextSegment(label, false, "#3366CC");
seg.underlined = true;
seg.clickUrl = attachment.getUrl();
seg.hoverText = I18nManager.getDmccTranslation("discord.message_parser.click_to_open_link");
if (attachment.isSpoiler() || attachment.getFileName().startsWith("SPOILER_")) {
seg.obfuscated = true;
}
segments.add(seg);
}
}
// Append stickers
@ -516,16 +567,19 @@ title = safeTruncate(title, 20) + "...";
}
TextSegment seg;
if (embed.getUrl() == null) {
seg = new TextSegment("<embed title=[" + title + "]>", false, "yellow");
} else {
seg = new TextSegment("<embed title=[" + title + "]>", false, "#3366CC");
seg.underlined = true;
seg.clickUrl = embed.getUrl();
seg.hoverText = I18nManager.getDmccTranslation("discord.message_parser.click_to_open_link");
}
segments.add(seg);
}
if (embed.getUrl() == null) {
seg = new TextSegment("<embed title=[" + title + "]>", false, "yellow");
} else {
seg = new TextSegment("<embed title=[" + title + "]>", false, "#3366CC");
seg.underlined = true;
seg.clickUrl = embed.getUrl();
seg.hoverText = I18nManager.getDmccTranslation("discord.message_parser.click_to_open_link");
}
if (isSpoilerWrappedUrl(raw, embed.getUrl())) {
seg.obfuscated = true;
}
segments.add(seg);
}
}
// Append interactive components indicator
@ -1330,6 +1384,24 @@ lastEnd = span.end;
return result;
}
private static boolean isSpoilerWrappedUrl(String raw, String url) {
if (raw == null || raw.isEmpty() || url == null || url.isEmpty()) {
return false;
}
Matcher spoilerMatcher = SPOILER_CONTENT_PATTERN.matcher(raw);
while (spoilerMatcher.find()) {
String content = spoilerMatcher.group(1);
if (content == null) {
continue;
}
String normalized = content.replaceAll("[*_~`\\s]", "");
if (url.equals(normalized)) {
return true;
}
}
return false;
}
private static String truncateMainRaw(String raw) {
String lineLimited = applyMainLineLimit(raw);
int maxLength = containsFullWidthCharacter(raw) ? MAIN_TRUNCATE_LIMIT_WIDE : MAIN_TRUNCATE_LIMIT_NARROW;

View file

@ -95,6 +95,16 @@ discord_to_minecraft:
- text: "edited this message! Edited message becomes:"
bold: false
color: "gray"
edited_message: # Edited message content format (reverse of response)
- text: " <{effective_name}> "
bold: false
color: "{role_color}"
- text: "└──── "
bold: true
color: "dark_gray"
- text: "{message}"
bold: false
color: "dark_gray"
delete: # Message delete notification format
- text: "[Discord] "
bold: true

View file

@ -95,6 +95,16 @@ discord_to_minecraft:
- text: "编辑了此消息!编辑后的消息为:"
bold: false
color: "gray"
edited_message: # 编辑后的消息内容格式(与 response 反向)
- text: " <{effective_name}> "
bold: false
color: "{role_color}"
- text: "└──── "
bold: true
color: "dark_gray"
- text: "{message}"
bold: false
color: "dark_gray"
delete: # 消息删除通知格式
- text: "[Discord] "
bold: true