From a2735cea86ab55c531797162603cccba023d1509 Mon Sep 17 00:00:00 2001 From: Xujiayao Date: Mon, 23 Mar 2026 01:50:55 +0800 Subject: [PATCH] group MessageParsers and TextSegmentUtils --- .../discord_mc_chat/client/ClientHandler.java | 2 +- .../discord_mc_chat/server/ServerHandler.java | 2 +- .../server/discord/DiscordEventHandler.java | 2 +- .../server/message/DiscordMessageParser.java | 354 ++---------------- .../server/message/MessageParserCommon.java | 276 ++++++++++++++ .../message/MinecraftMessageParser.java | 314 +--------------- .../utils/message/TextSegmentUtils.java | 54 +++ .../events/MinecraftEventHandler.java | 2 +- 8 files changed, 387 insertions(+), 619 deletions(-) create mode 100644 core/src/main/java/com/xujiayao/discord_mc_chat/server/message/MessageParserCommon.java create mode 100644 core/src/main/java/com/xujiayao/discord_mc_chat/utils/message/TextSegmentUtils.java diff --git a/core/src/main/java/com/xujiayao/discord_mc_chat/client/ClientHandler.java b/core/src/main/java/com/xujiayao/discord_mc_chat/client/ClientHandler.java index 58b6cb50..44ab6624 100644 --- a/core/src/main/java/com/xujiayao/discord_mc_chat/client/ClientHandler.java +++ b/core/src/main/java/com/xujiayao/discord_mc_chat/client/ClientHandler.java @@ -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; diff --git a/core/src/main/java/com/xujiayao/discord_mc_chat/server/ServerHandler.java b/core/src/main/java/com/xujiayao/discord_mc_chat/server/ServerHandler.java index fbaff8c1..b2effefa 100644 --- a/core/src/main/java/com/xujiayao/discord_mc_chat/server/ServerHandler.java +++ b/core/src/main/java/com/xujiayao/discord_mc_chat/server/ServerHandler.java @@ -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; diff --git a/core/src/main/java/com/xujiayao/discord_mc_chat/server/discord/DiscordEventHandler.java b/core/src/main/java/com/xujiayao/discord_mc_chat/server/discord/DiscordEventHandler.java index 303e3606..686a0e58 100644 --- a/core/src/main/java/com/xujiayao/discord_mc_chat/server/discord/DiscordEventHandler.java +++ b/core/src/main/java/com/xujiayao/discord_mc_chat/server/discord/DiscordEventHandler.java @@ -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; diff --git a/core/src/main/java/com/xujiayao/discord_mc_chat/server/message/DiscordMessageParser.java b/core/src/main/java/com/xujiayao/discord_mc_chat/server/message/DiscordMessageParser.java index b2ea0915..2be40b78 100644 --- a/core/src/main/java/com/xujiayao/discord_mc_chat/server/message/DiscordMessageParser.java +++ b/core/src/main/java/com/xujiayao/discord_mc_chat/server/message/DiscordMessageParser.java @@ -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(""); private static final Pattern DISCORD_ALIAS_EMOJI_PATTERN = Pattern.compile("(?)\\]]+)"); // 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 MARKDOWN_DELIMITERS = List.of("***", "~~", "||", "**", "__", "*", "_"); private static final int MAX_CONTENT_LINES = 6; @@ -158,7 +136,7 @@ public final class DiscordMessageParser { List 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 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 removeOverlaps(List tokens) { List 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 splitSegmentsByTimestamp(List segments) { - List 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 splitSegmentsByMarkdownLink(List segments) { - List 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 splitSegmentsByBareUrl(List segments) { - List 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 splitSegmentsByUnicodeEmoji(List segments) { - List 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 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 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(); diff --git a/core/src/main/java/com/xujiayao/discord_mc_chat/server/message/MessageParserCommon.java b/core/src/main/java/com/xujiayao/discord_mc_chat/server/message/MessageParserCommon.java new file mode 100644 index 00000000..1a46f26b --- /dev/null +++ b/core/src/main/java/com/xujiayao/discord_mc_chat/server/message/MessageParserCommon.java @@ -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(""); + 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 splitSegmentsByTimestamp(List segments) { + List 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 splitSegmentsByMarkdownLink(List segments) { + List 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 splitSegmentsByBareUrl(List segments) { + List 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 splitSegmentsByUnicodeEmoji(List segments) { + List 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; + } +} diff --git a/core/src/main/java/com/xujiayao/discord_mc_chat/server/message/MinecraftMessageParser.java b/core/src/main/java/com/xujiayao/discord_mc_chat/server/message/MinecraftMessageParser.java index 40897420..011d52be 100644 --- a/core/src/main/java/com/xujiayao/discord_mc_chat/server/message/MinecraftMessageParser.java +++ b/core/src/main/java/com/xujiayao/discord_mc_chat/server/message/MinecraftMessageParser.java @@ -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("(?"); - 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 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 splitSegmentsByTimestamp(List segments) { - List 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 splitSegmentsByMarkdownLink(List segments) { - List 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 splitSegmentsByBareUrl(List segments) { - List 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 splitSegmentsByUnicodeEmoji(List segments) { - List 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 buildTemplateSegments(JsonNode templateNode, String serverName, String effectiveName, @@ -751,8 +490,8 @@ public final class MinecraftMessageParser { out.add(new TextSegment(parts[0], bold, color)); } - List contentSegments = copySegments(parsedMessageSegments); - applyDefaultColor(contentSegments, color); + List 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 copySegments(List segments) { - List copy = new ArrayList<>(); - for (TextSegment seg : segments) { - copy.add(copySegment(seg, seg.text)); - } - return copy; - } - - private static void applyDefaultColor(List 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 localMap, Map allMap, String alias, diff --git a/core/src/main/java/com/xujiayao/discord_mc_chat/utils/message/TextSegmentUtils.java b/core/src/main/java/com/xujiayao/discord_mc_chat/utils/message/TextSegmentUtils.java new file mode 100644 index 00000000..86c13d24 --- /dev/null +++ b/core/src/main/java/com/xujiayao/discord_mc_chat/utils/message/TextSegmentUtils.java @@ -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 copySegments(List segments) { + List copy = new ArrayList<>(); + for (TextSegment segment : segments) { + copy.add(copySegment(segment, segment.text)); + } + return copy; + } + + public static void applyDefaultColor(List 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 segments) { + if (segments.isEmpty()) { + segments.add(new TextSegment("...")); + return; + } + TextSegment tail = segments.getLast(); + segments.set(segments.size() - 1, copySegment(tail, tail.text + "...")); + } +} diff --git a/minecraft/src/main/java/com/xujiayao/discord_mc_chat/minecraft/events/MinecraftEventHandler.java b/minecraft/src/main/java/com/xujiayao/discord_mc_chat/minecraft/events/MinecraftEventHandler.java index e7e3763a..c8d71d1b 100644 --- a/minecraft/src/main/java/com/xujiayao/discord_mc_chat/minecraft/events/MinecraftEventHandler.java +++ b/minecraft/src/main/java/com/xujiayao/discord_mc_chat/minecraft/events/MinecraftEventHandler.java @@ -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;