group MessageParsers and TextSegmentUtils

This commit is contained in:
Xujiayao 2026-03-23 01:50:55 +08:00
parent 085607e362
commit a2735cea86
8 changed files with 387 additions and 619 deletions

View file

@ -26,7 +26,6 @@ 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.DiscordRelayPacket;
import com.xujiayao.discord_mc_chat.network.packets.events.MinecraftRelayPacket;
import com.xujiayao.discord_mc_chat.utils.message.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;
@ -35,6 +34,7 @@ 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;
import com.xujiayao.discord_mc_chat.utils.message.TextSegment;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.handler.timeout.IdleState;

View file

@ -22,7 +22,6 @@ import com.xujiayao.discord_mc_chat.network.packets.commands.unlink.UnlinkReques
import com.xujiayao.discord_mc_chat.network.packets.commands.unlink.UnlinkResponsePacket;
import com.xujiayao.discord_mc_chat.network.packets.events.MinecraftEventPacket;
import com.xujiayao.discord_mc_chat.network.packets.events.MinecraftRelayPacket;
import com.xujiayao.discord_mc_chat.utils.message.TextSegment;
import com.xujiayao.discord_mc_chat.network.packets.misc.KeepAlivePacket;
import com.xujiayao.discord_mc_chat.network.packets.misc.LatencyPingPacket;
import com.xujiayao.discord_mc_chat.network.packets.misc.LatencyPongPacket;
@ -36,6 +35,7 @@ import com.xujiayao.discord_mc_chat.utils.CryptUtils;
import com.xujiayao.discord_mc_chat.utils.config.ConfigManager;
import com.xujiayao.discord_mc_chat.utils.config.ModeManager;
import com.xujiayao.discord_mc_chat.utils.i18n.I18nManager;
import com.xujiayao.discord_mc_chat.utils.message.TextSegment;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.handler.timeout.IdleState;

View file

@ -4,11 +4,11 @@ import com.xujiayao.discord_mc_chat.commands.CommandManager;
import com.xujiayao.discord_mc_chat.commands.impl.StatsCommand;
import com.xujiayao.discord_mc_chat.network.NetworkManager;
import com.xujiayao.discord_mc_chat.network.packets.events.DiscordRelayPacket;
import com.xujiayao.discord_mc_chat.utils.message.TextSegment;
import com.xujiayao.discord_mc_chat.server.message.DiscordMessageParser;
import com.xujiayao.discord_mc_chat.utils.LogFileUtils;
import com.xujiayao.discord_mc_chat.utils.config.ConfigManager;
import com.xujiayao.discord_mc_chat.utils.i18n.I18nManager;
import com.xujiayao.discord_mc_chat.utils.message.TextSegment;
import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.entities.Message;
import net.dv8tion.jda.api.entities.User;

View file

@ -1,10 +1,11 @@
package com.xujiayao.discord_mc_chat.server.message;
import com.fasterxml.jackson.databind.JsonNode;
import com.xujiayao.discord_mc_chat.utils.message.TextSegment;
import com.xujiayao.discord_mc_chat.server.linking.LinkedAccountManager;
import com.xujiayao.discord_mc_chat.utils.config.ConfigManager;
import com.xujiayao.discord_mc_chat.utils.i18n.I18nManager;
import com.xujiayao.discord_mc_chat.utils.message.TextSegment;
import com.xujiayao.discord_mc_chat.utils.message.TextSegmentUtils;
import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.entities.Message;
import net.dv8tion.jda.api.entities.MessageEmbed;
@ -16,15 +17,10 @@ import net.dv8tion.jda.api.entities.sticker.StickerItem;
import net.fellbaum.jemoji.EmojiManager;
import java.awt.Color;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.format.FormatStyle;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@ -60,26 +56,9 @@ public final class DiscordMessageParser {
private static final Pattern CUSTOM_EMOJI_PATTERN = Pattern.compile("<a?:(\\w+):\\d+>");
private static final Pattern DISCORD_ALIAS_EMOJI_PATTERN = Pattern.compile("(?<![A-Za-z0-9_]):[A-Za-z0-9_+\\-]+:(?![A-Za-z0-9_])");
// Unicode emoji pattern (basic, covering common emoji ranges)
private static final Pattern UNICODE_EMOJI_PATTERN = Pattern.compile(
"[\\x{1F600}-\\x{1F64F}]|[\\x{1F300}-\\x{1F5FF}]|[\\x{1F680}-\\x{1F6FF}]|" +
"[\\x{1F1E0}-\\x{1F1FF}]|[\\x{2600}-\\x{26FF}]|[\\x{2700}-\\x{27BF}]|" +
"[\\x{FE00}-\\x{FE0F}]|[\\x{1F900}-\\x{1F9FF}]|[\\x{1FA00}-\\x{1FA6F}]|" +
"[\\x{1FA70}-\\x{1FAFF}]|\\x{200D}|\\x{20E3}|" +
"[\\x{231A}-\\x{231B}]|[\\x{23E9}-\\x{23F3}]|[\\x{23F8}-\\x{23FA}]|" +
"[\\x{25AA}-\\x{25AB}]|\\x{25B6}|\\x{25C0}|[\\x{25FB}-\\x{25FE}]|" +
"[\\x{2614}-\\x{2615}]|[\\x{2648}-\\x{2653}]|\\x{267F}|\\x{2693}|" +
"\\x{26A1}|[\\x{26AA}-\\x{26AB}]|[\\x{26BD}-\\x{26BE}]|" +
"[\\x{26C4}-\\x{26C5}]|\\x{26CE}|\\x{26D4}|\\x{26EA}|" +
"[\\x{26F2}-\\x{26F3}]|\\x{26F5}|\\x{26FA}|\\x{26FD}|" +
"\\x{2702}|\\x{2705}|[\\x{2708}-\\x{270D}]|\\x{270F}"
);
// ANSI escape sequence pattern for ```ansi code blocks
private static final Pattern ANSI_ESCAPE_PATTERN = Pattern.compile("\\x1B\\[(\\d+(?:;\\d+)*)m");
// Hyperlink pattern: [text](url) or bare URLs (excluding trailing Markdown delimiters)
private static final Pattern BARE_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>||
@ -89,7 +68,6 @@ public final class DiscordMessageParser {
// 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?://[^)]+)\\)");
private static final List<String> MARKDOWN_DELIMITERS = List.of("***", "~~", "||", "**", "__", "*", "_");
private static final int MAX_CONTENT_LINES = 6;
@ -158,7 +136,7 @@ public final class DiscordMessageParser {
List<TextSegment> contentSegments = parseMessageContent(message, truncatedRaw);
// Apply default color inheritance
applyDefaultColor(contentSegments, color);
TextSegmentUtils.applyDefaultColor(contentSegments, color);
// Prepend newline to first content segment
if (!contentSegments.isEmpty()) {
@ -169,7 +147,7 @@ public final class DiscordMessageParser {
List<TextSegment> contentSegments = parseMessageContent(message, truncatedRaw);
// Apply default color inheritance
applyDefaultColor(contentSegments, color);
TextSegmentUtils.applyDefaultColor(contentSegments, color);
segments.addAll(contentSegments);
}
@ -273,7 +251,7 @@ public final class DiscordMessageParser {
if (!parts[0].isEmpty()) {
segments.add(new TextSegment(parts[0], bold, color));
}
applyDefaultColor(refContentSegments, color);
TextSegmentUtils.applyDefaultColor(refContentSegments, color);
segments.addAll(refContentSegments);
if (parts.length > 1 && !parts[1].isEmpty()) {
segments.add(new TextSegment(parts[1], bold, color));
@ -380,7 +358,7 @@ public final class DiscordMessageParser {
if (!parts[0].isEmpty()) {
segments.add(new TextSegment(parts[0], bold, color));
}
applyDefaultColor(contentSegments, color);
TextSegmentUtils.applyDefaultColor(contentSegments, color);
segments.addAll(contentSegments);
if (parts.length > 1 && !parts[1].isEmpty()) {
segments.add(new TextSegment(parts[1], bold, color));
@ -813,7 +791,7 @@ public final class DiscordMessageParser {
try {
long epoch = Long.parseLong(matcher.group(1));
String style = matcher.group(2);
String formatted = formatDiscordTimestamp(epoch, style);
String formatted = MessageParserCommon.formatDiscordTimestamp(epoch, style);
TextSegment seg = new TextSegment("[" + formatted + "]", false, "yellow");
tokens.add(new TokenSpan(matcher.start(), matcher.end(), seg));
} catch (Exception ignored) {
@ -822,92 +800,6 @@ public final class DiscordMessageParser {
}
}
private static String formatDiscordTimestamp(long epoch, String style) {
Instant instant = Instant.ofEpochSecond(epoch);
Locale locale = getDmccLocale();
ZoneId zone = ZoneId.systemDefault();
if (style == null) {
style = "f"; // Discord default
}
return switch (style) {
case "t" -> DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale)
.format(instant.atZone(zone));
case "T" -> DateTimeFormatter.ofLocalizedTime(FormatStyle.MEDIUM).withLocale(locale)
.format(instant.atZone(zone));
case "d" -> DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT).withLocale(locale)
.format(instant.atZone(zone));
case "D" -> DateTimeFormatter.ofLocalizedDate(FormatStyle.LONG).withLocale(locale)
.format(instant.atZone(zone));
case "s" -> DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT, FormatStyle.SHORT).withLocale(locale)
.format(instant.atZone(zone));
case "S" -> DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT, FormatStyle.MEDIUM).withLocale(locale)
.format(instant.atZone(zone));
case "F" -> DateTimeFormatter.ofLocalizedDateTime(FormatStyle.FULL, FormatStyle.SHORT).withLocale(locale)
.format(instant.atZone(zone));
case "R" -> {
long now = Instant.now().getEpochSecond();
long diff = now - epoch;
yield formatRelativeTime(diff);
}
default -> DateTimeFormatter.ofLocalizedDateTime(FormatStyle.LONG, FormatStyle.SHORT).withLocale(locale)
.format(instant.atZone(zone));
};
}
private static String formatRelativeTime(long diffSeconds) {
boolean past = diffSeconds >= 0;
long abs = Math.abs(diffSeconds);
String unitKey;
long value;
if (abs < 60) {
value = abs;
unitKey = "second";
} else if (abs < 3600) {
value = abs / 60;
unitKey = "minute";
} else if (abs < 86400) {
value = abs / 3600;
unitKey = "hour";
} else if (abs < 2592000) {
value = abs / 86400;
unitKey = "day";
} else if (abs < 31536000) {
value = abs / 2592000;
unitKey = "month";
} else {
value = abs / 31536000;
unitKey = "year";
}
String unitTranslationKey = String.format(
"discord.message_parser.relative.units.%s.%s",
unitKey,
value == 1 ? "one" : "other"
);
String unit = I18nManager.getDmccTranslation(unitTranslationKey);
return past
? I18nManager.getDmccTranslation("discord.message_parser.relative.past", value, unit)
: I18nManager.getDmccTranslation("discord.message_parser.relative.future", value, unit);
}
private static Locale getDmccLocale() {
String languageCode = I18nManager.getLanguage();
if (languageCode == null || languageCode.isBlank()) {
return Locale.ENGLISH;
}
// DMCC language codes are configured as snake_case (e.g. en_us, zh_cn),
// while Locale.forLanguageTag expects BCP-47 with hyphen separators.
String tag = languageCode.replace('_', '-');
Locale locale = Locale.forLanguageTag(tag);
if (locale.getLanguage().isBlank()) {
return Locale.ENGLISH;
}
return locale;
}
private static List<TokenSpan> removeOverlaps(List<TokenSpan> tokens) {
List<TokenSpan> result = new ArrayList<>();
int lastEnd = -1;
@ -1017,7 +909,8 @@ public final class DiscordMessageParser {
private static String matchMarkdownDelimiter(String text, int index) {
for (String delimiter : MARKDOWN_DELIMITERS) {
if (text.startsWith(delimiter, index)) {
if (isUnderscoreDelimiter(delimiter) && isInsideDiscordAliasEmoji(text, index)) {
if (MessageParserCommon.isUnderscoreDelimiter(delimiter)
&& MessageParserCommon.isInsideDiscordAliasEmoji(text, index, DISCORD_ALIAS_EMOJI_PATTERN)) {
continue;
}
return delimiter;
@ -1032,7 +925,8 @@ public final class DiscordMessageParser {
i++;
continue;
}
if (isUnderscoreDelimiter(delimiter) && isInsideDiscordAliasEmoji(text, i)) {
if (MessageParserCommon.isUnderscoreDelimiter(delimiter)
&& MessageParserCommon.isInsideDiscordAliasEmoji(text, i, DISCORD_ALIAS_EMOJI_PATTERN)) {
continue;
}
if (text.startsWith(delimiter, i)) {
@ -1042,29 +936,6 @@ public final class DiscordMessageParser {
return -1;
}
private static boolean isUnderscoreDelimiter(String delimiter) {
return "_".equals(delimiter) || "__".equals(delimiter);
}
private static boolean isInsideDiscordAliasEmoji(String text, int index) {
if (index <= 0 || index >= text.length()) {
return false;
}
int leftColon = text.lastIndexOf(':', index);
if (leftColon < 0) {
return false;
}
int rightColon = text.indexOf(':', index);
if (rightColon < 0 || rightColon <= leftColon + 1) {
return false;
}
if (index <= leftColon || index >= rightColon) {
return false;
}
String candidate = text.substring(leftColon, rightColon + 1);
return DISCORD_ALIAS_EMOJI_PATTERN.matcher(candidate).matches();
}
private static MarkdownState applyDelimiterStyle(MarkdownState base, String delimiter) {
MarkdownState state = base.copy();
switch (delimiter) {
@ -1131,18 +1002,18 @@ public final class DiscordMessageParser {
current = splitSegmentsByEveryoneHereMention(current, message);
}
if (parseTimestamps) {
current = splitSegmentsByTimestamp(current);
current = MessageParserCommon.splitSegmentsByTimestamp(current);
}
if (parseHyperlinks) {
current = splitSegmentsByMarkdownLink(current);
current = splitSegmentsByBareUrl(current);
current = MessageParserCommon.splitSegmentsByMarkdownLink(current);
current = MessageParserCommon.splitSegmentsByBareUrl(current);
}
if (parseCustomEmojis) {
current = splitSegmentsByCustomEmoji(current);
current = splitSegmentsByDiscordAliasEmoji(current);
}
if (parseUnicodeEmojis) {
current = splitSegmentsByUnicodeEmoji(current);
current = MessageParserCommon.splitSegmentsByUnicodeEmoji(current);
}
for (TextSegment seg : current) {
if (seg.obfuscated && seg.clickUrl == null && (seg.hoverText == null || seg.hoverText.isEmpty())) {
@ -1165,7 +1036,7 @@ public final class DiscordMessageParser {
int cursor = 0;
while (matcher.find()) {
if (matcher.start() > cursor) {
out.add(copySegment(segment, segment.text.substring(cursor, matcher.start())));
out.add(TextSegmentUtils.copySegment(segment, segment.text.substring(cursor, matcher.start())));
}
String userId = matcher.group(1);
String displayName = userId;
@ -1178,7 +1049,7 @@ public final class DiscordMessageParser {
break;
}
}
TextSegment mention = copySegment(segment, "[@" + displayName + "]");
TextSegment mention = TextSegmentUtils.copySegment(segment, "[@" + displayName + "]");
mention.color = color;
out.add(mention);
cursor = matcher.end();
@ -1186,7 +1057,7 @@ public final class DiscordMessageParser {
if (cursor == 0) {
out.add(segment);
} else if (cursor < segment.text.length()) {
out.add(copySegment(segment, segment.text.substring(cursor)));
out.add(TextSegmentUtils.copySegment(segment, segment.text.substring(cursor)));
}
}
return out;
@ -1203,7 +1074,7 @@ public final class DiscordMessageParser {
int cursor = 0;
while (matcher.find()) {
if (matcher.start() > cursor) {
out.add(copySegment(segment, segment.text.substring(cursor, matcher.start())));
out.add(TextSegmentUtils.copySegment(segment, segment.text.substring(cursor, matcher.start())));
}
String roleId = matcher.group(1);
String roleName = roleId;
@ -1218,7 +1089,7 @@ public final class DiscordMessageParser {
break;
}
}
TextSegment mention = copySegment(segment, "[@" + roleName + "]");
TextSegment mention = TextSegmentUtils.copySegment(segment, "[@" + roleName + "]");
mention.color = color;
out.add(mention);
cursor = matcher.end();
@ -1226,7 +1097,7 @@ public final class DiscordMessageParser {
if (cursor == 0) {
out.add(segment);
} else if (cursor < segment.text.length()) {
out.add(copySegment(segment, segment.text.substring(cursor)));
out.add(TextSegmentUtils.copySegment(segment, segment.text.substring(cursor)));
}
}
return out;
@ -1243,7 +1114,7 @@ public final class DiscordMessageParser {
int cursor = 0;
while (matcher.find()) {
if (matcher.start() > cursor) {
out.add(copySegment(segment, segment.text.substring(cursor, matcher.start())));
out.add(TextSegmentUtils.copySegment(segment, segment.text.substring(cursor, matcher.start())));
}
String channelId = matcher.group(1);
String channelName = channelId;
@ -1253,7 +1124,7 @@ public final class DiscordMessageParser {
break;
}
}
TextSegment mention = copySegment(segment, "[#" + channelName + "]");
TextSegment mention = TextSegmentUtils.copySegment(segment, "[#" + channelName + "]");
mention.color = "yellow";
out.add(mention);
cursor = matcher.end();
@ -1261,7 +1132,7 @@ public final class DiscordMessageParser {
if (cursor == 0) {
out.add(segment);
} else if (cursor < segment.text.length()) {
out.add(copySegment(segment, segment.text.substring(cursor)));
out.add(TextSegmentUtils.copySegment(segment, segment.text.substring(cursor)));
}
}
return out;
@ -1281,9 +1152,9 @@ public final class DiscordMessageParser {
int cursor = 0;
while (matcher.find()) {
if (matcher.start() > cursor) {
out.add(copySegment(segment, segment.text.substring(cursor, matcher.start())));
out.add(TextSegmentUtils.copySegment(segment, segment.text.substring(cursor, matcher.start())));
}
TextSegment mention = copySegment(segment, "[@" + matcher.group(1) + "]");
TextSegment mention = TextSegmentUtils.copySegment(segment, "[@" + matcher.group(1) + "]");
mention.color = "yellow";
out.add(mention);
cursor = matcher.end();
@ -1291,100 +1162,7 @@ public final class DiscordMessageParser {
if (cursor == 0) {
out.add(segment);
} else if (cursor < segment.text.length()) {
out.add(copySegment(segment, segment.text.substring(cursor)));
}
}
return out;
}
private static List<TextSegment> splitSegmentsByTimestamp(List<TextSegment> segments) {
List<TextSegment> out = new ArrayList<>();
for (TextSegment segment : segments) {
if (segment.clickUrl != null || segment.text == null || segment.text.isEmpty()) {
out.add(segment);
continue;
}
Matcher matcher = DISCORD_TIMESTAMP_PATTERN.matcher(segment.text);
int cursor = 0;
while (matcher.find()) {
if (matcher.start() > cursor) {
out.add(copySegment(segment, segment.text.substring(cursor, matcher.start())));
}
try {
long epoch = Long.parseLong(matcher.group(1));
String style = matcher.group(2);
TextSegment timestampSegment = copySegment(segment, "[" + formatDiscordTimestamp(epoch, style) + "]");
timestampSegment.color = "yellow";
out.add(timestampSegment);
} catch (Exception ignored) {
out.add(copySegment(segment, matcher.group()));
}
cursor = matcher.end();
}
if (cursor == 0) {
out.add(segment);
} else if (cursor < segment.text.length()) {
out.add(copySegment(segment, segment.text.substring(cursor)));
}
}
return out;
}
private static List<TextSegment> splitSegmentsByMarkdownLink(List<TextSegment> segments) {
List<TextSegment> out = new ArrayList<>();
for (TextSegment segment : segments) {
if (segment.clickUrl != null || segment.text == null || segment.text.isEmpty()) {
out.add(segment);
continue;
}
Matcher matcher = LINK_TOKEN_PATTERN.matcher(segment.text);
int cursor = 0;
while (matcher.find()) {
if (matcher.start() > cursor) {
out.add(copySegment(segment, segment.text.substring(cursor, matcher.start())));
}
TextSegment linkSegment = copySegment(segment, matcher.group(1));
linkSegment.clickUrl = matcher.group(2);
linkSegment.underlined = true;
linkSegment.color = URL_COLOR;
linkSegment.hoverText = I18nManager.getDmccTranslation("discord.message_parser.click_to_open_link");
out.add(linkSegment);
cursor = matcher.end();
}
if (cursor == 0) {
out.add(segment);
} else if (cursor < segment.text.length()) {
out.add(copySegment(segment, segment.text.substring(cursor)));
}
}
return out;
}
private static List<TextSegment> splitSegmentsByBareUrl(List<TextSegment> segments) {
List<TextSegment> out = new ArrayList<>();
for (TextSegment segment : segments) {
if (segment.clickUrl != null || segment.text == null || segment.text.isEmpty()) {
out.add(segment);
continue;
}
Matcher matcher = BARE_URL_PATTERN.matcher(segment.text);
int cursor = 0;
while (matcher.find()) {
if (matcher.start() > cursor) {
out.add(copySegment(segment, segment.text.substring(cursor, matcher.start())));
}
TextSegment urlSegment = copySegment(segment, matcher.group(1));
urlSegment.clickUrl = matcher.group(1);
urlSegment.underlined = true;
urlSegment.color = URL_COLOR;
urlSegment.hoverText = I18nManager.getDmccTranslation("discord.message_parser.click_to_open_link");
out.add(urlSegment);
cursor = matcher.end();
}
if (cursor == 0) {
out.add(segment);
} else if (cursor < segment.text.length()) {
out.add(copySegment(segment, segment.text.substring(cursor)));
out.add(TextSegmentUtils.copySegment(segment, segment.text.substring(cursor)));
}
}
return out;
@ -1401,9 +1179,9 @@ public final class DiscordMessageParser {
int cursor = 0;
while (matcher.find()) {
if (matcher.start() > cursor) {
out.add(copySegment(segment, segment.text.substring(cursor, matcher.start())));
out.add(TextSegmentUtils.copySegment(segment, segment.text.substring(cursor, matcher.start())));
}
TextSegment emojiSegment = copySegment(segment, ":" + matcher.group(1) + ":");
TextSegment emojiSegment = TextSegmentUtils.copySegment(segment, ":" + matcher.group(1) + ":");
emojiSegment.color = "yellow";
out.add(emojiSegment);
cursor = matcher.end();
@ -1411,7 +1189,7 @@ public final class DiscordMessageParser {
if (cursor == 0) {
out.add(segment);
} else if (cursor < segment.text.length()) {
out.add(copySegment(segment, segment.text.substring(cursor)));
out.add(TextSegmentUtils.copySegment(segment, segment.text.substring(cursor)));
}
}
return out;
@ -1433,9 +1211,9 @@ public final class DiscordMessageParser {
continue;
}
if (matcher.start() > cursor) {
out.add(copySegment(segment, segment.text.substring(cursor, matcher.start())));
out.add(TextSegmentUtils.copySegment(segment, segment.text.substring(cursor, matcher.start())));
}
TextSegment emojiSegment = copySegment(segment, alias);
TextSegment emojiSegment = TextSegmentUtils.copySegment(segment, alias);
emojiSegment.color = "yellow";
out.add(emojiSegment);
cursor = matcher.end();
@ -1444,36 +1222,7 @@ public final class DiscordMessageParser {
if (!matched) {
out.add(segment);
} else if (cursor < segment.text.length()) {
out.add(copySegment(segment, segment.text.substring(cursor)));
}
}
return out;
}
private static List<TextSegment> splitSegmentsByUnicodeEmoji(List<TextSegment> segments) {
List<TextSegment> out = new ArrayList<>();
for (TextSegment segment : segments) {
if (segment.clickUrl != null || segment.text == null || segment.text.isEmpty()) {
out.add(segment);
continue;
}
Matcher matcher = UNICODE_EMOJI_PATTERN.matcher(segment.text);
int cursor = 0;
while (matcher.find()) {
if (matcher.start() > cursor) {
out.add(copySegment(segment, segment.text.substring(cursor, matcher.start())));
}
String unicodeEmoji = matcher.group();
String alias = EmojiManager.replaceAllEmojis(unicodeEmoji, emoji -> emoji.getDiscordAliases().getFirst());
TextSegment emojiSegment = copySegment(segment, alias);
emojiSegment.color = "yellow";
out.add(emojiSegment);
cursor = matcher.end();
}
if (cursor == 0) {
out.add(segment);
} else if (cursor < segment.text.length()) {
out.add(copySegment(segment, segment.text.substring(cursor)));
out.add(TextSegmentUtils.copySegment(segment, segment.text.substring(cursor)));
}
}
return out;
@ -1739,38 +1488,18 @@ public final class DiscordMessageParser {
String text = segment.text == null ? "" : segment.text;
int newline = text.indexOf('\n');
if (newline < 0) {
result.add(copySegment(segment, text));
result.add(TextSegmentUtils.copySegment(segment, text));
continue;
}
if (newline > 0) {
result.add(copySegment(segment, text.substring(0, newline)));
result.add(TextSegmentUtils.copySegment(segment, text.substring(0, newline)));
}
appendEllipsis(result);
TextSegmentUtils.appendEllipsis(result);
cut = true;
}
return result;
}
private static TextSegment copySegment(TextSegment source, String text) {
TextSegment copy = new TextSegment(text, source.bold, source.color);
copy.italic = source.italic;
copy.underlined = source.underlined;
copy.strikethrough = source.strikethrough;
copy.obfuscated = source.obfuscated;
copy.clickUrl = source.clickUrl;
copy.hoverText = source.hoverText;
return copy;
}
private static void appendEllipsis(List<TextSegment> segments) {
if (segments.isEmpty()) {
segments.add(new TextSegment("..."));
return;
}
TextSegment tail = segments.getLast();
segments.set(segments.size() - 1, copySegment(tail, tail.text + "..."));
}
/**
* Detects whether text contains full-width CJK characters/punctuation.
* Used to choose stricter truncation limits for visually wider glyphs.
@ -1795,17 +1524,6 @@ public final class DiscordMessageParser {
return false;
}
private static void applyDefaultColor(List<TextSegment> segments, String defaultColor) {
if (defaultColor == null || defaultColor.isEmpty()) {
return;
}
for (TextSegment seg : segments) {
if (seg.color == null || seg.color.isEmpty()) {
seg.color = defaultColor;
}
}
}
private static String replacePlaceholders(String text, String effectiveName, String roleColor) {
String serverName = getServerName();
String serverColor = getServerColor();

View file

@ -0,0 +1,276 @@
package com.xujiayao.discord_mc_chat.server.message;
import com.xujiayao.discord_mc_chat.utils.i18n.I18nManager;
import com.xujiayao.discord_mc_chat.utils.message.TextSegment;
import com.xujiayao.discord_mc_chat.utils.message.TextSegmentUtils;
import net.fellbaum.jemoji.EmojiManager;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.format.FormatStyle;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Shared parser helpers used by both Discord and Minecraft message parsing pipelines.
*
* @author Xujiayao
*/
public final class MessageParserCommon {
private static final Pattern DISCORD_TIMESTAMP_PATTERN = Pattern.compile("<t:(\\d+)(?::([tTdDfFRsS]))?>");
private static final Pattern LINK_TOKEN_PATTERN = Pattern.compile("\\[([^]]+)]\\((https?://[^)]+)\\)");
private static final Pattern BARE_URL_PATTERN = Pattern.compile("(https?://[^\\s*|~`<>)\\]]+)");
private static final Pattern UNICODE_EMOJI_PATTERN = Pattern.compile(
"[\\x{1F600}-\\x{1F64F}]|[\\x{1F300}-\\x{1F5FF}]|[\\x{1F680}-\\x{1F6FF}]|" +
"[\\x{1F1E0}-\\x{1F1FF}]|[\\x{2600}-\\x{26FF}]|[\\x{2700}-\\x{27BF}]|" +
"[\\x{FE00}-\\x{FE0F}]|[\\x{1F900}-\\x{1F9FF}]|[\\x{1FA00}-\\x{1FA6F}]|" +
"[\\x{1FA70}-\\x{1FAFF}]|\\x{200D}|\\x{20E3}|" +
"[\\x{231A}-\\x{231B}]|[\\x{23E9}-\\x{23F3}]|[\\x{23F8}-\\x{23FA}]|" +
"[\\x{25AA}-\\x{25AB}]|\\x{25B6}|\\x{25C0}|[\\x{25FB}-\\x{25FE}]|" +
"[\\x{2614}-\\x{2615}]|[\\x{2648}-\\x{2653}]|\\x{267F}|\\x{2693}|" +
"\\x{26A1}|[\\x{26AA}-\\x{26AB}]|[\\x{26BD}-\\x{26BE}]|" +
"[\\x{26C4}-\\x{26C5}]|\\x{26CE}|\\x{26D4}|\\x{26EA}|" +
"[\\x{26F2}-\\x{26F3}]|\\x{26F5}|\\x{26FA}|\\x{26FD}|" +
"\\x{2702}|\\x{2705}|[\\x{2708}-\\x{270D}]|\\x{270F}"
);
private static final String URL_COLOR = "#3366CC";
private MessageParserCommon() {
}
static List<TextSegment> splitSegmentsByTimestamp(List<TextSegment> segments) {
List<TextSegment> out = new ArrayList<>();
for (TextSegment segment : segments) {
if (segment.clickUrl != null || segment.text == null || segment.text.isEmpty()) {
out.add(segment);
continue;
}
Matcher matcher = DISCORD_TIMESTAMP_PATTERN.matcher(segment.text);
int cursor = 0;
while (matcher.find()) {
if (matcher.start() > cursor) {
out.add(TextSegmentUtils.copySegment(segment, segment.text.substring(cursor, matcher.start())));
}
String timestamp;
try {
long epoch = Long.parseLong(matcher.group(1));
timestamp = "[" + formatDiscordTimestamp(epoch, matcher.group(2)) + "]";
} catch (Exception ignored) {
timestamp = matcher.group();
}
TextSegment ts = TextSegmentUtils.copySegment(segment, timestamp);
ts.color = "yellow";
out.add(ts);
cursor = matcher.end();
}
if (cursor == 0) {
out.add(segment);
} else if (cursor < segment.text.length()) {
out.add(TextSegmentUtils.copySegment(segment, segment.text.substring(cursor)));
}
}
return out;
}
static List<TextSegment> splitSegmentsByMarkdownLink(List<TextSegment> segments) {
List<TextSegment> out = new ArrayList<>();
for (TextSegment segment : segments) {
if (segment.clickUrl != null || segment.text == null || segment.text.isEmpty()) {
out.add(segment);
continue;
}
Matcher matcher = LINK_TOKEN_PATTERN.matcher(segment.text);
int cursor = 0;
while (matcher.find()) {
if (matcher.start() > cursor) {
out.add(TextSegmentUtils.copySegment(segment, segment.text.substring(cursor, matcher.start())));
}
TextSegment link = TextSegmentUtils.copySegment(segment, matcher.group(1));
link.clickUrl = matcher.group(2);
link.underlined = true;
link.color = URL_COLOR;
link.hoverText = I18nManager.getDmccTranslation("discord.message_parser.click_to_open_link");
out.add(link);
cursor = matcher.end();
}
if (cursor == 0) {
out.add(segment);
} else if (cursor < segment.text.length()) {
out.add(TextSegmentUtils.copySegment(segment, segment.text.substring(cursor)));
}
}
return out;
}
static List<TextSegment> splitSegmentsByBareUrl(List<TextSegment> segments) {
List<TextSegment> out = new ArrayList<>();
for (TextSegment segment : segments) {
if (segment.clickUrl != null || segment.text == null || segment.text.isEmpty()) {
out.add(segment);
continue;
}
Matcher matcher = BARE_URL_PATTERN.matcher(segment.text);
int cursor = 0;
while (matcher.find()) {
if (matcher.start() > cursor) {
out.add(TextSegmentUtils.copySegment(segment, segment.text.substring(cursor, matcher.start())));
}
TextSegment url = TextSegmentUtils.copySegment(segment, matcher.group(1));
url.clickUrl = matcher.group(1);
url.underlined = true;
url.color = URL_COLOR;
url.hoverText = I18nManager.getDmccTranslation("discord.message_parser.click_to_open_link");
out.add(url);
cursor = matcher.end();
}
if (cursor == 0) {
out.add(segment);
} else if (cursor < segment.text.length()) {
out.add(TextSegmentUtils.copySegment(segment, segment.text.substring(cursor)));
}
}
return out;
}
static List<TextSegment> splitSegmentsByUnicodeEmoji(List<TextSegment> segments) {
List<TextSegment> out = new ArrayList<>();
for (TextSegment segment : segments) {
if (segment.clickUrl != null || segment.text == null || segment.text.isEmpty()) {
out.add(segment);
continue;
}
Matcher matcher = UNICODE_EMOJI_PATTERN.matcher(segment.text);
int cursor = 0;
while (matcher.find()) {
if (matcher.start() > cursor) {
out.add(TextSegmentUtils.copySegment(segment, segment.text.substring(cursor, matcher.start())));
}
String unicodeEmoji = matcher.group();
String alias = EmojiManager.replaceAllEmojis(unicodeEmoji, emoji -> emoji.getDiscordAliases().getFirst());
TextSegment emojiSegment = TextSegmentUtils.copySegment(segment, alias);
emojiSegment.color = "yellow";
out.add(emojiSegment);
cursor = matcher.end();
}
if (cursor == 0) {
out.add(segment);
} else if (cursor < segment.text.length()) {
out.add(TextSegmentUtils.copySegment(segment, segment.text.substring(cursor)));
}
}
return out;
}
static boolean isUnderscoreDelimiter(String delimiter) {
return "_".equals(delimiter) || "__".equals(delimiter);
}
static boolean isInsideDiscordAliasEmoji(String text, int index, Pattern discordAliasEmojiPattern) {
if (index <= 0 || index >= text.length()) {
return false;
}
int leftColon = text.lastIndexOf(':', index);
if (leftColon < 0) {
return false;
}
int rightColon = text.indexOf(':', index);
if (rightColon < 0 || rightColon <= leftColon + 1) {
return false;
}
if (index <= leftColon || index >= rightColon) {
return false;
}
String candidate = text.substring(leftColon, rightColon + 1);
return discordAliasEmojiPattern.matcher(candidate).matches();
}
static String formatDiscordTimestamp(long epoch, String style) {
Instant instant = Instant.ofEpochSecond(epoch);
Locale locale = getDmccLocale();
ZoneId zone = ZoneId.systemDefault();
if (style == null) {
style = "f";
}
return switch (style) {
case "t" -> DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale)
.format(instant.atZone(zone));
case "T" -> DateTimeFormatter.ofLocalizedTime(FormatStyle.MEDIUM).withLocale(locale)
.format(instant.atZone(zone));
case "d" -> DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT).withLocale(locale)
.format(instant.atZone(zone));
case "D" -> DateTimeFormatter.ofLocalizedDate(FormatStyle.LONG).withLocale(locale)
.format(instant.atZone(zone));
case "s" -> DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT, FormatStyle.SHORT).withLocale(locale)
.format(instant.atZone(zone));
case "S" -> DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT, FormatStyle.MEDIUM).withLocale(locale)
.format(instant.atZone(zone));
case "F" -> DateTimeFormatter.ofLocalizedDateTime(FormatStyle.FULL, FormatStyle.SHORT).withLocale(locale)
.format(instant.atZone(zone));
case "R" -> {
long now = Instant.now().getEpochSecond();
long diff = now - epoch;
yield formatRelativeTime(diff);
}
default -> DateTimeFormatter.ofLocalizedDateTime(FormatStyle.LONG, FormatStyle.SHORT).withLocale(locale)
.format(instant.atZone(zone));
};
}
private static String formatRelativeTime(long diffSeconds) {
boolean past = diffSeconds >= 0;
long abs = Math.abs(diffSeconds);
String unitKey;
long value;
if (abs < 60) {
value = abs;
unitKey = "second";
} else if (abs < 3600) {
value = abs / 60;
unitKey = "minute";
} else if (abs < 86400) {
value = abs / 3600;
unitKey = "hour";
} else if (abs < 2592000) {
value = abs / 86400;
unitKey = "day";
} else if (abs < 31536000) {
value = abs / 2592000;
unitKey = "month";
} else {
value = abs / 31536000;
unitKey = "year";
}
String unitTranslationKey = String.format(
"discord.message_parser.relative.units.%s.%s",
unitKey,
value == 1 ? "one" : "other"
);
String unit = I18nManager.getDmccTranslation(unitTranslationKey);
return past
? I18nManager.getDmccTranslation("discord.message_parser.relative.past", value, unit)
: I18nManager.getDmccTranslation("discord.message_parser.relative.future", value, unit);
}
private static Locale getDmccLocale() {
String languageCode = I18nManager.getLanguage();
if (languageCode == null || languageCode.isBlank()) {
return Locale.ENGLISH;
}
String tag = languageCode.replace('_', '-');
Locale locale = Locale.forLanguageTag(tag);
if (locale.getLanguage().isBlank()) {
return Locale.ENGLISH;
}
return locale;
}
}

View file

@ -1,12 +1,13 @@
package com.xujiayao.discord_mc_chat.server.message;
import com.fasterxml.jackson.databind.JsonNode;
import com.xujiayao.discord_mc_chat.utils.message.TextSegment;
import com.xujiayao.discord_mc_chat.server.discord.DiscordManager;
import com.xujiayao.discord_mc_chat.server.linking.LinkedAccountManager;
import com.xujiayao.discord_mc_chat.utils.MojangUtils;
import com.xujiayao.discord_mc_chat.utils.config.ConfigManager;
import com.xujiayao.discord_mc_chat.utils.i18n.I18nManager;
import com.xujiayao.discord_mc_chat.utils.message.TextSegment;
import com.xujiayao.discord_mc_chat.utils.message.TextSegmentUtils;
import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.entities.Role;
import net.dv8tion.jda.api.entities.User;
@ -14,10 +15,6 @@ import net.dv8tion.jda.api.entities.emoji.RichCustomEmoji;
import net.fellbaum.jemoji.EmojiManager;
import java.awt.Color;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.format.FormatStyle;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
@ -39,27 +36,11 @@ import java.util.regex.Pattern;
* @author Xujiayao
*/
public final class MinecraftMessageParser {
private static final Pattern SIMPLE_MENTION_PATTERN = Pattern.compile("(?<![A-Za-z0-9_])@([A-Za-z0-9_]+)(?![A-Za-z0-9_])");
private static final Pattern DISCORD_ALIAS_EMOJI_PATTERN = Pattern.compile("(?<![A-Za-z0-9_]):([A-Za-z0-9_+\\-]+):(?![A-Za-z0-9_])");
private static final Pattern DISCORD_TIMESTAMP_PATTERN = Pattern.compile("<t:(\\d+)(?::([tTdDfFRsS]))?>");
private static final Pattern LINK_TOKEN_PATTERN = Pattern.compile("\\[([^]]+)]\\((https?://[^)]+)\\)");
private static final Pattern BARE_URL_PATTERN = Pattern.compile("(https?://[^\\s*|~`<>)\\]]+)");
private static final Pattern UNICODE_EMOJI_PATTERN = Pattern.compile(
"[\\x{1F600}-\\x{1F64F}]|[\\x{1F300}-\\x{1F5FF}]|[\\x{1F680}-\\x{1F6FF}]|" +
"[\\x{1F1E0}-\\x{1F1FF}]|[\\x{2600}-\\x{26FF}]|[\\x{2700}-\\x{27BF}]|" +
"[\\x{FE00}-\\x{FE0F}]|[\\x{1F900}-\\x{1F9FF}]|[\\x{1FA00}-\\x{1FA6F}]|" +
"[\\x{1FA70}-\\x{1FAFF}]|\\x{200D}|\\x{20E3}|" +
"[\\x{231A}-\\x{231B}]|[\\x{23E9}-\\x{23F3}]|[\\x{23F8}-\\x{23FA}]|" +
"[\\x{25AA}-\\x{25AB}]|\\x{25B6}|\\x{25C0}|[\\x{25FB}-\\x{25FE}]|" +
"[\\x{2614}-\\x{2615}]|[\\x{2648}-\\x{2653}]|\\x{267F}|\\x{2693}|" +
"\\x{26A1}|[\\x{26AA}-\\x{26AB}]|[\\x{26BD}-\\x{26BE}]|" +
"[\\x{26C4}-\\x{26C5}]|\\x{26CE}|\\x{26D4}|\\x{26EA}|" +
"[\\x{26F2}-\\x{26F3}]|\\x{26F5}|\\x{26FA}|\\x{26FD}|" +
"\\x{2702}|\\x{2705}|[\\x{2708}-\\x{270D}]|\\x{270F}"
);
private static final List<String> MARKDOWN_DELIMITERS = List.of("***", "~~", "||", "**", "__", "*", "_");
private static final String URL_COLOR = "#3366CC";
private MinecraftMessageParser() {
}
@ -201,17 +182,17 @@ public final class MinecraftMessageParser {
segments = splitSegmentsByMentions(segments, context);
}
if (parseTimestamps) {
segments = splitSegmentsByTimestamp(segments);
segments = MessageParserCommon.splitSegmentsByTimestamp(segments);
}
if (parseHyperlinks) {
segments = splitSegmentsByMarkdownLink(segments);
segments = splitSegmentsByBareUrl(segments);
segments = MessageParserCommon.splitSegmentsByMarkdownLink(segments);
segments = MessageParserCommon.splitSegmentsByBareUrl(segments);
}
if (parseCustomEmojis) {
segments = splitSegmentsByCustomEmoji(segments, context);
}
if (parseUnicodeEmojis) {
segments = splitSegmentsByUnicodeEmoji(segments);
segments = MessageParserCommon.splitSegmentsByUnicodeEmoji(segments);
}
return segments;
@ -311,7 +292,8 @@ public final class MinecraftMessageParser {
if (!raw.startsWith(delimiter, i)) {
continue;
}
if (isUnderscoreDelimiter(delimiter) && isInsideDiscordAliasEmoji(raw, i)) {
if (MessageParserCommon.isUnderscoreDelimiter(delimiter)
&& MessageParserCommon.isInsideDiscordAliasEmoji(raw, i, DISCORD_ALIAS_EMOJI_PATTERN)) {
continue;
}
if (!shouldConsumeDelimiter(state, delimiter, raw, i)) {
@ -362,9 +344,9 @@ public final class MinecraftMessageParser {
}
if (i > cursor) {
out.add(copySegment(segment, text.substring(cursor, i)));
out.add(TextSegmentUtils.copySegment(segment, text.substring(cursor, i)));
}
TextSegment mention = copySegment(segment, "[@" + match.target.displayName + "]");
TextSegment mention = TextSegmentUtils.copySegment(segment, "[@" + match.target.displayName + "]");
mention.color = match.target.color;
out.add(mention);
context.mentionedPlayerUuids.addAll(match.target.linkedMinecraftUuids);
@ -378,101 +360,7 @@ public final class MinecraftMessageParser {
if (cursor == 0) {
out.add(segment);
} else if (cursor < text.length()) {
out.add(copySegment(segment, text.substring(cursor)));
}
return out;
}
private static List<TextSegment> splitSegmentsByTimestamp(List<TextSegment> segments) {
List<TextSegment> out = new ArrayList<>();
for (TextSegment segment : segments) {
if (segment.clickUrl != null || segment.text == null || segment.text.isEmpty()) {
out.add(segment);
continue;
}
Matcher matcher = DISCORD_TIMESTAMP_PATTERN.matcher(segment.text);
int cursor = 0;
while (matcher.find()) {
if (matcher.start() > cursor) {
out.add(copySegment(segment, segment.text.substring(cursor, matcher.start())));
}
String timestamp;
try {
long epoch = Long.parseLong(matcher.group(1));
timestamp = "[" + formatDiscordTimestamp(epoch, matcher.group(2)) + "]";
} catch (Exception ignored) {
timestamp = matcher.group();
}
TextSegment ts = copySegment(segment, timestamp);
ts.color = "yellow";
out.add(ts);
cursor = matcher.end();
}
if (cursor == 0) {
out.add(segment);
} else if (cursor < segment.text.length()) {
out.add(copySegment(segment, segment.text.substring(cursor)));
}
}
return out;
}
private static List<TextSegment> splitSegmentsByMarkdownLink(List<TextSegment> segments) {
List<TextSegment> out = new ArrayList<>();
for (TextSegment segment : segments) {
if (segment.clickUrl != null || segment.text == null || segment.text.isEmpty()) {
out.add(segment);
continue;
}
Matcher matcher = LINK_TOKEN_PATTERN.matcher(segment.text);
int cursor = 0;
while (matcher.find()) {
if (matcher.start() > cursor) {
out.add(copySegment(segment, segment.text.substring(cursor, matcher.start())));
}
TextSegment link = copySegment(segment, matcher.group(1));
link.clickUrl = matcher.group(2);
link.underlined = true;
link.color = URL_COLOR;
link.hoverText = I18nManager.getDmccTranslation("discord.message_parser.click_to_open_link");
out.add(link);
cursor = matcher.end();
}
if (cursor == 0) {
out.add(segment);
} else if (cursor < segment.text.length()) {
out.add(copySegment(segment, segment.text.substring(cursor)));
}
}
return out;
}
private static List<TextSegment> splitSegmentsByBareUrl(List<TextSegment> segments) {
List<TextSegment> out = new ArrayList<>();
for (TextSegment segment : segments) {
if (segment.clickUrl != null || segment.text == null || segment.text.isEmpty()) {
out.add(segment);
continue;
}
Matcher matcher = BARE_URL_PATTERN.matcher(segment.text);
int cursor = 0;
while (matcher.find()) {
if (matcher.start() > cursor) {
out.add(copySegment(segment, segment.text.substring(cursor, matcher.start())));
}
TextSegment url = copySegment(segment, matcher.group(1));
url.clickUrl = matcher.group(1);
url.underlined = true;
url.color = URL_COLOR;
url.hoverText = I18nManager.getDmccTranslation("discord.message_parser.click_to_open_link");
out.add(url);
cursor = matcher.end();
}
if (cursor == 0) {
out.add(segment);
} else if (cursor < segment.text.length()) {
out.add(copySegment(segment, segment.text.substring(cursor)));
}
out.add(TextSegmentUtils.copySegment(segment, text.substring(cursor)));
}
return out;
}
@ -494,9 +382,9 @@ public final class MinecraftMessageParser {
}
matched = true;
if (matcher.start() > cursor) {
out.add(copySegment(segment, segment.text.substring(cursor, matcher.start())));
out.add(TextSegmentUtils.copySegment(segment, segment.text.substring(cursor, matcher.start())));
}
TextSegment emoji = copySegment(segment, matcher.group());
TextSegment emoji = TextSegmentUtils.copySegment(segment, matcher.group());
emoji.color = "yellow";
out.add(emoji);
cursor = matcher.end();
@ -504,36 +392,7 @@ public final class MinecraftMessageParser {
if (!matched) {
out.add(segment);
} else if (cursor < segment.text.length()) {
out.add(copySegment(segment, segment.text.substring(cursor)));
}
}
return out;
}
private static List<TextSegment> splitSegmentsByUnicodeEmoji(List<TextSegment> segments) {
List<TextSegment> out = new ArrayList<>();
for (TextSegment segment : segments) {
if (segment.clickUrl != null || segment.text == null || segment.text.isEmpty()) {
out.add(segment);
continue;
}
Matcher matcher = UNICODE_EMOJI_PATTERN.matcher(segment.text);
int cursor = 0;
while (matcher.find()) {
if (matcher.start() > cursor) {
out.add(copySegment(segment, segment.text.substring(cursor, matcher.start())));
}
String unicodeEmoji = matcher.group();
String alias = EmojiManager.replaceAllEmojis(unicodeEmoji, emoji -> emoji.getDiscordAliases().getFirst());
TextSegment emojiSegment = copySegment(segment, alias);
emojiSegment.color = "yellow";
out.add(emojiSegment);
cursor = matcher.end();
}
if (cursor == 0) {
out.add(segment);
} else if (cursor < segment.text.length()) {
out.add(copySegment(segment, segment.text.substring(cursor)));
out.add(TextSegmentUtils.copySegment(segment, segment.text.substring(cursor)));
}
}
return out;
@ -575,29 +434,6 @@ public final class MinecraftMessageParser {
return hasClosingDelimiter(text, at + delimiter.length(), delimiter);
}
private static boolean isUnderscoreDelimiter(String delimiter) {
return "_".equals(delimiter) || "__".equals(delimiter);
}
private static boolean isInsideDiscordAliasEmoji(String text, int index) {
if (index <= 0 || index >= text.length()) {
return false;
}
int leftColon = text.lastIndexOf(':', index);
if (leftColon < 0) {
return false;
}
int rightColon = text.indexOf(':', index);
if (rightColon < 0 || rightColon <= leftColon + 1) {
return false;
}
if (index <= leftColon || index >= rightColon) {
return false;
}
String candidate = text.substring(leftColon, rightColon + 1);
return DISCORD_ALIAS_EMOJI_PATTERN.matcher(candidate).matches();
}
private static boolean isDelimiterActive(MarkdownState state, String delimiter) {
return switch (delimiter) {
case "***" -> state.bold && state.italic;
@ -627,103 +463,6 @@ public final class MinecraftMessageParser {
plain.setLength(0);
}
private static TextSegment copySegment(TextSegment source, String text) {
TextSegment copy = new TextSegment(text, source.bold, source.color);
copy.italic = source.italic;
copy.underlined = source.underlined;
copy.strikethrough = source.strikethrough;
copy.obfuscated = source.obfuscated;
copy.clickUrl = source.clickUrl;
copy.hoverText = source.hoverText;
return copy;
}
private static String formatDiscordTimestamp(long epoch, String style) {
Instant instant = Instant.ofEpochSecond(epoch);
Locale locale = getDmccLocale();
ZoneId zone = ZoneId.systemDefault();
if (style == null) {
style = "f"; // Discord default
}
return switch (style) {
case "t" -> DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale)
.format(instant.atZone(zone));
case "T" -> DateTimeFormatter.ofLocalizedTime(FormatStyle.MEDIUM).withLocale(locale)
.format(instant.atZone(zone));
case "d" -> DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT).withLocale(locale)
.format(instant.atZone(zone));
case "D" -> DateTimeFormatter.ofLocalizedDate(FormatStyle.LONG).withLocale(locale)
.format(instant.atZone(zone));
case "s" -> DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT, FormatStyle.SHORT).withLocale(locale)
.format(instant.atZone(zone));
case "S" -> DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT, FormatStyle.MEDIUM).withLocale(locale)
.format(instant.atZone(zone));
case "F" -> DateTimeFormatter.ofLocalizedDateTime(FormatStyle.FULL, FormatStyle.SHORT).withLocale(locale)
.format(instant.atZone(zone));
case "R" -> {
long now = Instant.now().getEpochSecond();
long diff = now - epoch;
yield formatRelativeTime(diff);
}
default -> DateTimeFormatter.ofLocalizedDateTime(FormatStyle.LONG, FormatStyle.SHORT).withLocale(locale)
.format(instant.atZone(zone));
};
}
private static Locale getDmccLocale() {
String languageCode = I18nManager.getLanguage();
if (languageCode == null || languageCode.isBlank()) {
return Locale.ENGLISH;
}
// DMCC language codes are configured as snake_case (e.g. en_us, zh_cn),
// while Locale.forLanguageTag expects BCP-47 with hyphen separators.
String tag = languageCode.replace('_', '-');
Locale locale = Locale.forLanguageTag(tag);
if (locale.getLanguage().isBlank()) {
return Locale.ENGLISH;
}
return locale;
}
private static String formatRelativeTime(long diffSeconds) {
boolean past = diffSeconds >= 0;
long abs = Math.abs(diffSeconds);
String unitKey;
long value;
if (abs < 60) {
value = abs;
unitKey = "second";
} else if (abs < 3600) {
value = abs / 60;
unitKey = "minute";
} else if (abs < 86400) {
value = abs / 3600;
unitKey = "hour";
} else if (abs < 2592000) {
value = abs / 86400;
unitKey = "day";
} else if (abs < 31536000) {
value = abs / 2592000;
unitKey = "month";
} else {
value = abs / 31536000;
unitKey = "year";
}
String unitTranslationKey = String.format(
"discord.message_parser.relative.units.%s.%s",
unitKey,
value == 1 ? "one" : "other"
);
String unit = I18nManager.getDmccTranslation(unitTranslationKey);
return past
? I18nManager.getDmccTranslation("discord.message_parser.relative.past", value, unit)
: I18nManager.getDmccTranslation("discord.message_parser.relative.future", value, unit);
}
private static List<TextSegment> buildTemplateSegments(JsonNode templateNode,
String serverName,
String effectiveName,
@ -751,8 +490,8 @@ public final class MinecraftMessageParser {
out.add(new TextSegment(parts[0], bold, color));
}
List<TextSegment> contentSegments = copySegments(parsedMessageSegments);
applyDefaultColor(contentSegments, color);
List<TextSegment> contentSegments = TextSegmentUtils.copySegments(parsedMessageSegments);
TextSegmentUtils.applyDefaultColor(contentSegments, color);
out.addAll(contentSegments);
if (parts.length > 1 && !parts[1].isEmpty()) {
@ -786,25 +525,6 @@ public final class MinecraftMessageParser {
return "white";
}
private static List<TextSegment> copySegments(List<TextSegment> segments) {
List<TextSegment> copy = new ArrayList<>();
for (TextSegment seg : segments) {
copy.add(copySegment(seg, seg.text));
}
return copy;
}
private static void applyDefaultColor(List<TextSegment> segments, String defaultColor) {
if (defaultColor == null || defaultColor.isEmpty()) {
return;
}
for (TextSegment seg : segments) {
if (seg.color == null || seg.color.isEmpty()) {
seg.color = defaultColor;
}
}
}
private static void putMentionAlias(Map<String, MentionTarget> localMap,
Map<String, MentionTarget> allMap,
String alias,

View file

@ -0,0 +1,54 @@
package com.xujiayao.discord_mc_chat.utils.message;
import java.util.ArrayList;
import java.util.List;
/**
* Shared utility helpers for working with {@link TextSegment} collections.
*
* @author Xujiayao
*/
public final class TextSegmentUtils {
private TextSegmentUtils() {
}
public static TextSegment copySegment(TextSegment source, String text) {
TextSegment copy = new TextSegment(text, source.bold, source.color);
copy.italic = source.italic;
copy.underlined = source.underlined;
copy.strikethrough = source.strikethrough;
copy.obfuscated = source.obfuscated;
copy.clickUrl = source.clickUrl;
copy.hoverText = source.hoverText;
return copy;
}
public static List<TextSegment> copySegments(List<TextSegment> segments) {
List<TextSegment> copy = new ArrayList<>();
for (TextSegment segment : segments) {
copy.add(copySegment(segment, segment.text));
}
return copy;
}
public static void applyDefaultColor(List<TextSegment> segments, String defaultColor) {
if (defaultColor == null || defaultColor.isEmpty()) {
return;
}
for (TextSegment segment : segments) {
if (segment.color == null || segment.color.isEmpty()) {
segment.color = defaultColor;
}
}
}
public static void appendEllipsis(List<TextSegment> segments) {
if (segments.isEmpty()) {
segments.add(new TextSegment("..."));
return;
}
TextSegment tail = segments.getLast();
segments.set(segments.size() - 1, copySegment(tail, tail.text + "..."));
}
}

View file

@ -13,13 +13,13 @@ import com.xujiayao.discord_mc_chat.network.NetworkManager;
import com.xujiayao.discord_mc_chat.network.packets.commands.info.InfoResponsePacket;
import com.xujiayao.discord_mc_chat.network.packets.commands.link.LinkRequestPacket;
import com.xujiayao.discord_mc_chat.network.packets.events.MinecraftEventPacket;
import com.xujiayao.discord_mc_chat.utils.message.TextSegment;
import com.xujiayao.discord_mc_chat.utils.EnvironmentUtils;
import com.xujiayao.discord_mc_chat.utils.config.ConfigManager;
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;
import com.xujiayao.discord_mc_chat.utils.message.TextSegment;
import net.minecraft.ChatFormatting;
import net.minecraft.advancements.DisplayInfo;
import net.minecraft.commands.CommandSourceStack;