Add MinecraftRelayPacket and implement message handling for Minecraft user and system messages

This commit is contained in:
Xujiayao 2026-03-22 18:51:36 +08:00
parent 3b7a48cbbf
commit 09bbe31b65
10 changed files with 1238 additions and 5 deletions

View file

@ -25,6 +25,7 @@ import com.xujiayao.discord_mc_chat.network.packets.commands.link.LinkResponsePa
import com.xujiayao.discord_mc_chat.network.packets.commands.link.OpSyncPacket;
import com.xujiayao.discord_mc_chat.network.packets.commands.unlink.UnlinkResponsePacket;
import com.xujiayao.discord_mc_chat.network.packets.events.DiscordRelayPacket;
import com.xujiayao.discord_mc_chat.network.packets.events.MinecraftRelayPacket;
import com.xujiayao.discord_mc_chat.network.packets.events.TextSegment;
import com.xujiayao.discord_mc_chat.network.packets.misc.KeepAlivePacket;
import com.xujiayao.discord_mc_chat.network.packets.misc.LatencyPongPacket;
@ -245,6 +246,13 @@ public final class ClientHandler extends SimpleChannelInboundHandler<Packet> {
));
}
}
case MinecraftRelayPacket p -> EventManager.post(new CoreEvents.MinecraftRelayMessageEvent(
p.segments,
p.mentionNotificationText,
p.mentionNotificationStyle,
p.mentionedPlayerUuids,
p.mentionEveryone
));
case DisconnectPacket p -> {
// If we receive a DisconnectPacket, it means the server explicitly rejected us.
// In most cases (whitelist, auth fail, version mismatch), retrying immediately won't help.

View file

@ -0,0 +1,33 @@
package com.xujiayao.discord_mc_chat.network.packets.events;
import com.xujiayao.discord_mc_chat.network.packets.Packet;
import java.util.List;
/**
* Packet sent from DMCC Server to Minecraft client(s) when a message originating
* from Minecraft needs to be rendered in chat.
*
* @author Xujiayao
*/
public final class MinecraftRelayPacket extends Packet {
public MessageType type;
public List<TextSegment> segments;
public String mentionNotificationText;
public String mentionNotificationStyle;
public List<String> mentionedPlayerUuids;
public boolean mentionEveryone;
public MinecraftRelayPacket(MessageType type, List<TextSegment> segments) {
this.type = type;
this.segments = segments;
}
public enum MessageType {
USER_MESSAGE,
SYSTEM_MESSAGE,
COMMAND
}
}

View file

@ -21,6 +21,8 @@ import com.xujiayao.discord_mc_chat.network.packets.commands.link.LinkResponsePa
import com.xujiayao.discord_mc_chat.network.packets.commands.unlink.UnlinkRequestPacket;
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.network.packets.events.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;
@ -28,6 +30,8 @@ import com.xujiayao.discord_mc_chat.server.discord.DiscordManager;
import com.xujiayao.discord_mc_chat.server.linking.LinkedAccountManager;
import com.xujiayao.discord_mc_chat.server.linking.OpSyncManager;
import com.xujiayao.discord_mc_chat.server.linking.VerificationCodeManager;
import com.xujiayao.discord_mc_chat.server.message.DiscordMessageParser;
import com.xujiayao.discord_mc_chat.server.message.MinecraftMessageParser;
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;
@ -37,6 +41,10 @@ import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.handler.timeout.IdleState;
import io.netty.handler.timeout.IdleStateEvent;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static com.xujiayao.discord_mc_chat.Constants.LOGGER;
/**
@ -108,6 +116,11 @@ public final class ServerHandler extends SimpleChannelInboundHandler<Packet> {
DiscordManager.clientBroadcast(clientName, "player.advancement", "player.advancement." + p.placeholders.get("type"), p.placeholders);
case PLAYER_CHANGE_GAME_MODE ->
DiscordManager.clientBroadcast(clientName, "player.change_game_mode", "player.change_game_mode", p.placeholders);
case PLAYER_CHAT -> handleMinecraftUserMessage(p, clientName, "player.chat");
case PLAYER_COMMAND -> handleMinecraftCommandMessage(p, clientName);
case SOURCE_SAY -> handleMinecraftUserMessage(p, clientName, "source.say");
case SOURCE_MSG -> handleMinecraftUserMessage(p, clientName, "source.msg");
case SOURCE_ME -> handleMinecraftSystemMessage(p, clientName);
// TODO Unhandled events
}
}
@ -274,4 +287,114 @@ public final class ServerHandler extends SimpleChannelInboundHandler<Packet> {
JsonNode config = findServerConfig(serverName);
return config != null ? config.path("minecraft_version").asText() : "";
}
private void handleMinecraftUserMessage(MinecraftEventPacket packet,
String sourceClientName,
String channelNode) {
String rawContent = packet.placeholders.getOrDefault("message", "");
String displayName = packet.placeholders.getOrDefault("display_name", packet.placeholders.getOrDefault("player_name", "Unknown"));
String roleColor = resolveDisplayRoleColor(packet.placeholders.getOrDefault("player_uuid", ""));
boolean parseForMinecraft = true;
MinecraftMessageParser.ParsedMessage parsed = MinecraftMessageParser.parseUserMessage(rawContent, parseForMinecraft);
List<TextSegment> relaySegments = MinecraftMessageParser.buildUserMessageSegments(sourceClientName, displayName, roleColor, parsed.minecraftSegments());
List<TextSegment> overwriteSegments = MinecraftMessageParser.buildOverwriteUserMessageSegments(sourceClientName, displayName, roleColor, parsed.minecraftSegments());
Map<String, String> discordPlaceholders = new HashMap<>(packet.placeholders);
discordPlaceholders.put("server", sourceClientName);
discordPlaceholders.put("message", parsed.discordContent());
DiscordManager.sendMinecraftUserMessage(sourceClientName, channelNode, discordPlaceholders);
broadcastMinecraftRelay(packet, sourceClientName, MinecraftRelayPacket.MessageType.USER_MESSAGE, relaySegments, overwriteSegments, parsed, true, true, channelNode);
}
private void handleMinecraftCommandMessage(MinecraftEventPacket packet, String sourceClientName) {
String command = packet.placeholders.getOrDefault("command", "");
String displayName = packet.placeholders.getOrDefault("display_name", packet.placeholders.getOrDefault("player_name", "Unknown"));
String roleColor = resolveDisplayRoleColor(packet.placeholders.getOrDefault("player_uuid", ""));
MinecraftMessageParser.ParsedMessage parsed = MinecraftMessageParser.parseCommandMessage(command);
List<TextSegment> relaySegments = MinecraftMessageParser.buildUserMessageSegments(sourceClientName, displayName, roleColor, parsed.minecraftSegments());
List<TextSegment> overwriteSegments = MinecraftMessageParser.buildOverwriteUserMessageSegments(sourceClientName, displayName, roleColor, parsed.minecraftSegments());
Map<String, String> discordPlaceholders = new HashMap<>(packet.placeholders);
discordPlaceholders.put("server", sourceClientName);
discordPlaceholders.put("message", parsed.discordContent());
DiscordManager.sendMinecraftUserMessage(sourceClientName, "player.command", discordPlaceholders);
broadcastMinecraftRelay(packet, sourceClientName, MinecraftRelayPacket.MessageType.COMMAND, relaySegments, overwriteSegments, parsed, false, false, "player.command");
}
private void handleMinecraftSystemMessage(MinecraftEventPacket packet, String sourceClientName) {
String rawAction = packet.placeholders.getOrDefault("action", "");
String displayName = packet.placeholders.getOrDefault("display_name", packet.placeholders.getOrDefault("player_name", "Unknown"));
String combined = (displayName + " " + rawAction).trim();
MinecraftMessageParser.ParsedMessage parsed = MinecraftMessageParser.parseSystemMessage(combined, true);
List<TextSegment> relaySegments = MinecraftMessageParser.buildSystemMessageSegments(sourceClientName, parsed.minecraftSegments());
List<TextSegment> overwriteSegments = MinecraftMessageParser.buildOverwriteSystemMessageSegments(sourceClientName, parsed.minecraftSegments());
Map<String, String> placeholders = new HashMap<>(packet.placeholders);
placeholders.put("action", parsed.discordContent());
DiscordManager.clientBroadcast(sourceClientName, "source.me", "source.me", placeholders);
broadcastMinecraftRelay(packet, sourceClientName, MinecraftRelayPacket.MessageType.SYSTEM_MESSAGE, relaySegments, overwriteSegments, parsed, true, true, "source.me");
}
private void broadcastMinecraftRelay(MinecraftEventPacket packet,
String sourceClientName,
MinecraftRelayPacket.MessageType relayType,
List<TextSegment> relaySegments,
List<TextSegment> overwriteSegments,
MinecraftMessageParser.ParsedMessage parsed,
boolean canOverwriteEchoToSource,
boolean parseMentionsForNotifications,
String broadcastNode) {
boolean overwrite = Boolean.TRUE.equals(ConfigManager.getBoolean("message_parsing.overwrite_minecraft_source_messages"));
boolean supportMinecraftToMinecraftConfig = "standalone".equals(ModeManager.getMode());
boolean toOtherClients = supportMinecraftToMinecraftConfig && Boolean.TRUE.equals(ConfigManager.getBoolean("broadcasts.minecraft_to_minecraft." + broadcastNode));
if (!toOtherClients && !(overwrite && canOverwriteEchoToSource)) {
return;
}
boolean notifyMentions = parseMentionsForNotifications
&& (parsed.mentionEveryone() || !parsed.mentionedPlayerUuids().isEmpty())
&& Boolean.TRUE.equals(ConfigManager.getBoolean("account_linking.mention_notifications.enable"));
if (toOtherClients) {
MinecraftRelayPacket relayPacket = new MinecraftRelayPacket(relayType, relaySegments);
if (notifyMentions) {
relayPacket.mentionNotificationText = MinecraftMessageParser.getMentionNotificationText(packet.placeholders.getOrDefault("display_name", "Unknown"));
relayPacket.mentionNotificationStyle = ConfigManager.getString("account_linking.mention_notifications.style", "title");
relayPacket.mentionedPlayerUuids = List.copyOf(parsed.mentionedPlayerUuids());
relayPacket.mentionEveryone = parsed.mentionEveryone();
}
NetworkManager.broadcastToClientsExcept(relayPacket, sourceClientName);
}
if (overwrite && canOverwriteEchoToSource) {
MinecraftRelayPacket sourcePacket = new MinecraftRelayPacket(relayType, overwriteSegments);
if (notifyMentions) {
sourcePacket.mentionNotificationText = MinecraftMessageParser.getMentionNotificationText(packet.placeholders.getOrDefault("display_name", "Unknown"));
sourcePacket.mentionNotificationStyle = ConfigManager.getString("account_linking.mention_notifications.style", "title");
sourcePacket.mentionedPlayerUuids = List.copyOf(parsed.mentionedPlayerUuids());
sourcePacket.mentionEveryone = parsed.mentionEveryone();
}
NetworkManager.sendPacketToClient(sourcePacket, sourceClientName);
}
}
private String resolveDisplayRoleColor(String playerUuid) {
if (!Boolean.TRUE.equals(ConfigManager.getBoolean("account_linking.use_discord_role_color_for_mc_chats"))) {
return "white";
}
if (playerUuid == null || playerUuid.isBlank()) {
return "white";
}
String discordId = LinkedAccountManager.getDiscordIdByMinecraftUuid(playerUuid);
if (discordId == null || discordId.isBlank()) {
return "white";
}
return DiscordMessageParser.getRoleColorHex(DiscordManager.retrieveMember(discordId));
}
}

View file

@ -413,7 +413,7 @@ public final class DiscordEventHandler extends ListenerAdapter {
String mentionNotificationStyle = null;
List<String> mentionedPlayerUuids = null;
boolean mentionNotificationsEnabled = ConfigManager.getBoolean("account_linking.discord_mention_notifications.enable");
boolean mentionNotificationsEnabled = ConfigManager.getBoolean("account_linking.mention_notifications.enable");
boolean isMentionEveryone = DiscordMessageParser.isMentionEveryone(message);
if (mentionNotificationsEnabled) {
Set<String> uuids = DiscordMessageParser.collectMentionedPlayerUuids(message);
@ -421,7 +421,7 @@ public final class DiscordEventHandler extends ListenerAdapter {
Member member = message.getMember();
String effectiveName = member != null ? member.getEffectiveName() : message.getAuthor().getName();
mentionNotificationText = DiscordMessageParser.getMentionNotificationText(effectiveName);
mentionNotificationStyle = ConfigManager.getString("account_linking.discord_mention_notifications.style", "title");
mentionNotificationStyle = ConfigManager.getString("account_linking.mention_notifications.style", "title");
mentionedPlayerUuids = new ArrayList<>(uuids);
}
}

View file

@ -3,6 +3,7 @@ package com.xujiayao.discord_mc_chat.server.discord;
import com.fasterxml.jackson.databind.JsonNode;
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.server.linking.LinkedAccountManager;
import com.xujiayao.discord_mc_chat.utils.ExecutorServiceUtils;
import com.xujiayao.discord_mc_chat.utils.StringUtils;
import com.xujiayao.discord_mc_chat.utils.config.ConfigManager;
@ -14,9 +15,11 @@ import net.dv8tion.jda.api.OnlineStatus;
import net.dv8tion.jda.api.entities.Activity;
import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.entities.Message;
import net.dv8tion.jda.api.entities.Role;
import net.dv8tion.jda.api.entities.User;
import net.dv8tion.jda.api.entities.Webhook;
import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
import net.dv8tion.jda.api.entities.emoji.RichCustomEmoji;
import net.dv8tion.jda.api.exceptions.InsufficientPermissionException;
import net.dv8tion.jda.api.interactions.commands.Command;
import net.dv8tion.jda.api.interactions.commands.OptionType;
@ -29,8 +32,10 @@ import net.dv8tion.jda.api.utils.MemberCachePolicy;
import java.time.Duration;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
@ -342,6 +347,142 @@ public final class DiscordManager {
return null;
}
/**
* Gets all guild roles from every connected guild.
*/
public static List<Role> getAllRoles() {
if (jda == null) {
return List.of();
}
Set<String> seen = new LinkedHashSet<>();
List<Role> roles = new ArrayList<>();
for (var guild : jda.getGuilds()) {
for (Role role : guild.getRoles()) {
if (seen.add(role.getId())) {
roles.add(role);
}
}
}
return roles;
}
/**
* Gets all Discord user IDs for members that currently have the specified role.
*/
public static List<String> getDiscordIdsByRoleId(String roleId) {
if (jda == null || roleId == null || roleId.isBlank()) {
return List.of();
}
Set<String> ids = new LinkedHashSet<>();
for (var guild : jda.getGuilds()) {
Role role = guild.getRoleById(roleId);
if (role == null) {
continue;
}
for (Member member : guild.getMembersWithRoles(role)) {
ids.add(member.getId());
}
}
return new ArrayList<>(ids);
}
/**
* Gets all custom emojis from every connected guild.
*/
public static List<RichCustomEmoji> getAllCustomEmojis() {
if (jda == null) {
return List.of();
}
Set<String> seen = new LinkedHashSet<>();
List<RichCustomEmoji> emojis = new ArrayList<>();
for (var guild : jda.getGuilds()) {
for (RichCustomEmoji emoji : guild.getEmojis()) {
if (seen.add(emoji.getId())) {
emojis.add(emoji);
}
}
}
return emojis;
}
/**
* Sends a Minecraft user-originated message to Discord using the configured style template.
*/
public static void sendMinecraftUserMessage(String clientName, String channelNode, Map<String, String> placeholders) {
String channelIdentifier = ConfigManager.getString("broadcasts.minecraft_to_discord." + channelNode);
if (channelIdentifier == null || channelIdentifier.isBlank()) {
return;
}
TextChannel channel = getTextChannel(channelIdentifier);
if (channel == null) {
return;
}
try {
JsonNode node = I18nManager.getCustomMessages().path("minecraft_to_discord").path("user_message");
if (node.isMissingNode() || node.isNull()) {
return;
}
String mode = "standalone".equals(ModeManager.getMode()) ? "standalone" : "single_server";
boolean fakeUserStyle = Boolean.TRUE.equals(ConfigManager.getBoolean("discord.webhook.enable_fake_user_style"));
if (fakeUserStyle) {
JsonNode styleNode = node.path("enabled_fake_user_style").path(mode);
String usernameTemplate = styleNode.path("username").asText("{display_name}");
String contentTemplate = styleNode.path("content").asText("{message}");
String username = replacePlaceholders(usernameTemplate, placeholders);
String content = replacePlaceholders(contentTemplate, placeholders);
String avatarUrl = resolveWebhookAvatarUrl(placeholders);
sendWebhookMessage(channel, username, avatarUrl, content);
return;
}
String contentTemplate = node.path("disabled_fake_user_style").path(mode).asText("<{display_name}> {message}");
String content = replacePlaceholders(contentTemplate, placeholders);
if ("standalone".equals(ModeManager.getMode())) {
String avatarUrl = getClientAvatarUrl(clientName);
sendWebhookMessage(channel, clientName, avatarUrl, content);
} else {
sendBotMessage(channelIdentifier, content);
}
} catch (Exception e) {
LOGGER.error(I18nManager.getDmccTranslation("discord.manager.broadcast_failed", e.getLocalizedMessage()), e);
}
}
private static String replacePlaceholders(String template, Map<String, String> placeholders) {
String out = template;
for (Map.Entry<String, String> entry : placeholders.entrySet()) {
out = out.replace("{" + entry.getKey() + "}", entry.getValue() == null ? "" : entry.getValue());
}
return out;
}
private static String resolveWebhookAvatarUrl(Map<String, String> placeholders) {
if (Boolean.TRUE.equals(ConfigManager.getBoolean("account_linking.discord_user_avatar_for_webhooks"))) {
String playerUuid = placeholders.getOrDefault("player_uuid", "");
if (!playerUuid.isBlank()) {
String discordId = LinkedAccountManager.getDiscordIdByMinecraftUuid(playerUuid);
if (discordId != null && !discordId.isBlank()) {
User user = retrieveUser(discordId);
if (user != null) {
String avatar = user.getEffectiveAvatarUrl();
if (!avatar.isBlank()) {
return avatar;
}
}
}
}
}
String avatarTemplate = ConfigManager.getString("discord.webhook.avatar_url", "https://mc-heads.net/avatar/{player_name}.png");
return replacePlaceholders(avatarTemplate, placeholders);
}
/**
* Sends a message to a Discord channel using the JDA bot.
*

View file

@ -1,12 +1,891 @@
package com.xujiayao.discord_mc_chat.server.message;
import com.fasterxml.jackson.databind.JsonNode;
import com.xujiayao.discord_mc_chat.network.packets.events.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 net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.entities.Role;
import net.dv8tion.jda.api.entities.User;
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;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* TODO
* Parses plain-text messages originating from Minecraft into:
* <ul>
* <li>Discord-ready message strings (custom emoji + mention conversion)</li>
* <li>Minecraft-ready rich segments (markdown/emoji/mention/link/timestamp rendering)</li>
* </ul>
*
* @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() {
}
public static ParsedMessage parseUserMessage(String raw, boolean parseForMinecraft) {
return parse(raw, parseForMinecraft);
}
public static ParsedMessage parseSystemMessage(String raw, boolean parseForMinecraft) {
return parse(raw, parseForMinecraft);
}
public static ParsedMessage parseCommandMessage(String command) {
String discordContent = "`" + command + "`";
List<TextSegment> mc = List.of(new TextSegment(command));
return new ParsedMessage(discordContent, mc, Set.of(), false);
}
public static String getMentionNotificationText(String senderDisplayName) {
String template = I18nManager.getCustomMessages().path("xxxxx_to_minecraft").path("mentioned").asText("{effective_name} mentioned you!");
return template.replace("{effective_name}", senderDisplayName);
}
public static List<TextSegment> buildUserMessageSegments(String serverName,
String effectiveName,
String roleColor,
List<TextSegment> parsedMessageSegments) {
return buildTemplateSegments(
I18nManager.getCustomMessages().path("xxxxx_to_minecraft").path("user_message"),
serverName,
effectiveName,
roleColor,
parsedMessageSegments
);
}
public static List<TextSegment> buildSystemMessageSegments(String serverName, List<TextSegment> parsedMessageSegments) {
return buildTemplateSegments(
I18nManager.getCustomMessages().path("xxxxx_to_minecraft").path("system_message"),
serverName,
"",
"white",
parsedMessageSegments
);
}
public static List<TextSegment> buildOverwriteUserMessageSegments(String serverName,
String effectiveName,
String roleColor,
List<TextSegment> parsedMessageSegments) {
String mode = ConfigManager.getString("mode", "single_server");
return buildTemplateSegments(
I18nManager.getCustomMessages().path("overwrite").path(mode).path("user_message"),
serverName,
effectiveName,
roleColor,
parsedMessageSegments
);
}
public static List<TextSegment> buildOverwriteSystemMessageSegments(String serverName, List<TextSegment> parsedMessageSegments) {
String mode = ConfigManager.getString("mode", "single_server");
return buildTemplateSegments(
I18nManager.getCustomMessages().path("overwrite").path(mode).path("system_message"),
serverName,
"",
"white",
parsedMessageSegments
);
}
private static ParsedMessage parse(String raw, boolean parseForMinecraft) {
String source = raw == null ? "" : raw;
MentionContext context = buildMentionContext();
boolean parseDiscordMentions = ConfigManager.getBoolean("message_parsing.minecraft_to_discord.mentions");
boolean parseDiscordCustomEmojis = ConfigManager.getBoolean("message_parsing.minecraft_to_discord.custom_emojis");
String discordContent = parseForDiscord(source, context, parseDiscordMentions, parseDiscordCustomEmojis);
boolean parseMentions = parseForMinecraft && ConfigManager.getBoolean("message_parsing.minecraft_to_minecraft.mentions");
boolean parseCustomEmojis = parseForMinecraft && ConfigManager.getBoolean("message_parsing.minecraft_to_minecraft.custom_emojis");
boolean parseUnicodeEmojis = parseForMinecraft && ConfigManager.getBoolean("message_parsing.minecraft_to_minecraft.unicode_emojis");
boolean parseMarkdown = parseForMinecraft && ConfigManager.getBoolean("message_parsing.minecraft_to_minecraft.markdown");
boolean parseHyperlinks = parseForMinecraft && ConfigManager.getBoolean("message_parsing.minecraft_to_minecraft.hyperlinks");
boolean parseTimestamps = parseForMinecraft && ConfigManager.getBoolean("message_parsing.minecraft_to_minecraft.timestamps");
List<TextSegment> segments = parseForMinecraft
? parseForMinecraft(source, context, parseMentions, parseCustomEmojis, parseUnicodeEmojis, parseMarkdown, parseHyperlinks, parseTimestamps)
: List.of(new TextSegment(source));
return new ParsedMessage(discordContent, segments, context.mentionedPlayerUuids, context.mentionEveryone);
}
private static String parseForDiscord(String raw,
MentionContext context,
boolean parseMentions,
boolean parseCustomEmojis) {
if ((!parseMentions && !parseCustomEmojis) || raw.isEmpty()) {
return raw;
}
String out = parseMentions ? convertMentionsForDiscord(raw, context) : raw;
if (!parseCustomEmojis) {
return out;
}
Matcher emojiMatcher = DISCORD_ALIAS_EMOJI_PATTERN.matcher(out);
StringBuilder rebuilt = new StringBuilder(out.length() + 32);
int cursor = 0;
while (emojiMatcher.find()) {
rebuilt.append(out, cursor, emojiMatcher.start());
String emojiAlias = emojiMatcher.group(1);
RichCustomEmoji emoji = context.customEmojiByName.get(emojiAlias.toLowerCase(Locale.ROOT));
if (emoji != null) {
rebuilt.append(emoji.isAnimated() ? "<a:" : "<:")
.append(emoji.getName())
.append(":")
.append(emoji.getId())
.append(">");
} else {
rebuilt.append(emojiMatcher.group());
}
cursor = emojiMatcher.end();
}
rebuilt.append(out.substring(cursor));
return rebuilt.toString();
}
private static List<TextSegment> parseForMinecraft(String raw,
MentionContext context,
boolean parseMentions,
boolean parseCustomEmojis,
boolean parseUnicodeEmojis,
boolean parseMarkdown,
boolean parseHyperlinks,
boolean parseTimestamps) {
List<TextSegment> segments = parseMarkdown ? parseMarkdownSegments(raw) : List.of(new TextSegment(raw));
if (parseMentions) {
segments = splitSegmentsByMentions(segments, context);
}
if (parseTimestamps) {
segments = splitSegmentsByTimestamp(segments);
}
if (parseHyperlinks) {
segments = splitSegmentsByMarkdownLink(segments);
segments = splitSegmentsByBareUrl(segments);
}
if (parseCustomEmojis) {
segments = splitSegmentsByCustomEmoji(segments, context);
}
if (parseUnicodeEmojis) {
segments = splitSegmentsByUnicodeEmoji(segments);
}
return segments;
}
private static MentionContext buildMentionContext() {
Map<String, MentionTarget> userByAlias = new HashMap<>();
Map<String, MentionTarget> roleByAlias = new HashMap<>();
Map<String, MentionTarget> allMentionByAlias = new HashMap<>();
Map<String, RichCustomEmoji> emojiByAlias = new HashMap<>();
Map<String, List<LinkedAccountManager.LinkEntry>> allLinks = LinkedAccountManager.getAllLinks();
for (Map.Entry<String, List<LinkedAccountManager.LinkEntry>> entry : allLinks.entrySet()) {
String discordId = entry.getKey();
List<String> linkedUuids = entry.getValue().stream().map(LinkedAccountManager.LinkEntry::minecraftUuid).toList();
User user = DiscordManager.retrieveUser(discordId);
Member member = DiscordManager.retrieveMember(discordId);
String displayName = member != null ? member.getEffectiveName() : (user != null ? user.getName() : discordId);
String roleColor = DiscordMessageParser.getRoleColorHex(member);
MentionTarget target = new MentionTarget(MentionType.USER, discordId, displayName, roleColor, linkedUuids);
if (user != null) {
putMentionAlias(userByAlias, allMentionByAlias, user.getName(), target);
}
if (member != null) {
putMentionAlias(userByAlias, allMentionByAlias, member.getEffectiveName(), target);
}
for (LinkedAccountManager.LinkEntry link : entry.getValue()) {
String playerName = MojangUtils.resolvePlayerName(link.minecraftUuid(), link.offlinePlayerName());
if (playerName != null && !playerName.isBlank()) {
putMentionAlias(userByAlias, allMentionByAlias, playerName, target);
}
}
}
for (Role role : DiscordManager.getAllRoles()) {
String color = "white";
Color roleColor = role.getColors().getPrimary();
if (roleColor != null) {
color = String.format("#%06X", roleColor.getRGB() & 0xFFFFFF);
}
Set<String> uuids = new HashSet<>();
for (String discordId : DiscordManager.getDiscordIdsByRoleId(role.getId())) {
uuids.addAll(LinkedAccountManager.getMinecraftUuidsByDiscordId(discordId));
}
MentionTarget roleTarget = new MentionTarget(MentionType.ROLE, role.getId(), role.getName(), color, new ArrayList<>(uuids));
putMentionAlias(roleByAlias, allMentionByAlias, role.getName(), roleTarget);
}
MentionTarget everyone = new MentionTarget(MentionType.EVERYONE_HERE, "everyone", "everyone", "yellow", List.of());
MentionTarget here = new MentionTarget(MentionType.EVERYONE_HERE, "here", "here", "yellow", List.of());
allMentionByAlias.put("everyone", everyone);
allMentionByAlias.put("here", here);
for (RichCustomEmoji emoji : DiscordManager.getAllCustomEmojis()) {
emojiByAlias.putIfAbsent(emoji.getName().toLowerCase(Locale.ROOT), emoji);
}
List<String> aliasesByLengthDesc = new ArrayList<>(allMentionByAlias.keySet());
aliasesByLengthDesc.sort(Comparator.comparingInt(String::length).reversed());
return new MentionContext(allMentionByAlias, aliasesByLengthDesc, emojiByAlias, new HashSet<>());
}
private static List<TextSegment> parseMarkdownSegments(String raw) {
if (raw.isEmpty()) {
return List.of(new TextSegment(""));
}
List<TextSegment> out = new ArrayList<>();
MarkdownState state = new MarkdownState();
StringBuilder plain = new StringBuilder();
int i = 0;
while (i < raw.length()) {
boolean matched = false;
for (String delimiter : MARKDOWN_DELIMITERS) {
if (!raw.startsWith(delimiter, i)) {
continue;
}
if (!shouldConsumeDelimiter(state, delimiter, raw, i)) {
continue;
}
appendStyled(out, plain, state);
toggleMarkdownState(state, delimiter);
i += delimiter.length();
matched = true;
break;
}
if (!matched) {
plain.append(raw.charAt(i));
i++;
}
}
appendStyled(out, plain, state);
return out.isEmpty() ? List.of(new TextSegment(raw)) : out;
}
private static List<TextSegment> splitSegmentsByMentions(List<TextSegment> segments, MentionContext context) {
List<TextSegment> out = new ArrayList<>();
for (TextSegment segment : segments) {
if (segment.text == null || segment.text.isEmpty() || segment.clickUrl != null) {
out.add(segment);
continue;
}
out.addAll(splitSegmentByMention(segment, context));
}
return out;
}
private static List<TextSegment> splitSegmentByMention(TextSegment segment, MentionContext context) {
List<TextSegment> out = new ArrayList<>();
String text = segment.text;
int cursor = 0;
int i = 0;
while (i < text.length()) {
if (text.charAt(i) != '@' || !isMentionStartBoundary(text, i)) {
i++;
continue;
}
MentionMatch match = findMentionMatch(text, i + 1, context);
if (match == null) {
i++;
continue;
}
if (i > cursor) {
out.add(copySegment(segment, text.substring(cursor, i)));
}
TextSegment mention = copySegment(segment, "[@" + match.target.displayName + "]");
mention.color = match.target.color;
out.add(mention);
context.mentionedPlayerUuids.addAll(match.target.linkedMinecraftUuids);
if (match.target.type == MentionType.EVERYONE_HERE) {
context.mentionEveryone = true;
}
i = match.endExclusive;
cursor = i;
}
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)));
}
}
return out;
}
private static List<TextSegment> splitSegmentsByCustomEmoji(List<TextSegment> segments, MentionContext context) {
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_ALIAS_EMOJI_PATTERN.matcher(segment.text);
int cursor = 0;
boolean matched = false;
while (matcher.find()) {
String aliasName = matcher.group(1).toLowerCase(Locale.ROOT);
if (!context.customEmojiByName.containsKey(aliasName) && EmojiManager.getByDiscordAlias(":" + matcher.group(1) + ":").isEmpty()) {
continue;
}
matched = true;
if (matcher.start() > cursor) {
out.add(copySegment(segment, segment.text.substring(cursor, matcher.start())));
}
TextSegment emoji = copySegment(segment, matcher.group());
emoji.color = "yellow";
out.add(emoji);
cursor = matcher.end();
}
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)));
}
}
return out;
}
private static void toggleMarkdownState(MarkdownState state, String delimiter) {
switch (delimiter) {
case "***" -> {
state.bold = !state.bold;
state.italic = !state.italic;
}
case "**" -> state.bold = !state.bold;
case "*", "_" -> state.italic = !state.italic;
case "__" -> state.underlined = !state.underlined;
case "~~" -> state.strikethrough = !state.strikethrough;
case "||" -> state.obfuscated = !state.obfuscated;
default -> {
}
}
}
private static boolean hasClosingDelimiter(String text, int start, String delimiter) {
for (int i = start; i <= text.length() - delimiter.length(); i++) {
if (text.charAt(i) == '\\') {
i++;
continue;
}
if (text.startsWith(delimiter, i)) {
return true;
}
}
return false;
}
private static boolean shouldConsumeDelimiter(MarkdownState state, String delimiter, String text, int at) {
if (isDelimiterActive(state, delimiter)) {
return true;
}
return hasClosingDelimiter(text, at + delimiter.length(), delimiter);
}
private static boolean isDelimiterActive(MarkdownState state, String delimiter) {
return switch (delimiter) {
case "***" -> state.bold && state.italic;
case "**" -> state.bold;
case "*", "_" -> state.italic;
case "__" -> state.underlined;
case "~~" -> state.strikethrough;
case "||" -> state.obfuscated;
default -> false;
};
}
private static void appendStyled(List<TextSegment> segments, StringBuilder plain, MarkdownState state) {
if (plain.isEmpty()) {
return;
}
TextSegment segment = new TextSegment(plain.toString());
segment.bold = state.bold;
segment.italic = state.italic;
segment.underlined = state.underlined;
segment.strikethrough = state.strikethrough;
segment.obfuscated = state.obfuscated;
if (segment.obfuscated) {
segment.hoverText = segment.text;
}
segments.add(segment);
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,
String roleColor,
List<TextSegment> parsedMessageSegments) {
List<TextSegment> out = new ArrayList<>();
if (!templateNode.isArray()) {
return out;
}
for (JsonNode segNode : templateNode) {
String text = segNode.path("text").asText("");
boolean bold = segNode.path("bold").asBoolean(false);
String color = segNode.path("color").asText("");
text = replaceTemplatePlaceholders(text, serverName, effectiveName, roleColor);
color = replaceTemplatePlaceholders(color, serverName, effectiveName, roleColor);
if (!text.contains("{message}")) {
out.add(new TextSegment(text, bold, color));
continue;
}
String[] parts = text.split("\\{message}", -1);
if (!parts[0].isEmpty()) {
out.add(new TextSegment(parts[0], bold, color));
}
List<TextSegment> contentSegments = copySegments(parsedMessageSegments);
applyDefaultColor(contentSegments, color);
out.addAll(contentSegments);
if (parts.length > 1 && !parts[1].isEmpty()) {
out.add(new TextSegment(parts[1], bold, color));
}
}
return out;
}
private static String replaceTemplatePlaceholders(String text, String serverName, String effectiveName, String roleColor) {
return text.replace("{server}", serverName)
.replace("{server_color}", getServerColor(serverName))
.replace("{effective_name}", effectiveName)
.replace("{display_name}", effectiveName)
.replace("{role_color}", roleColor);
}
private static String getServerColor(String serverName) {
if (!"standalone".equals(ConfigManager.getString("mode", ""))) {
return "white";
}
JsonNode servers = ConfigManager.getConfigNode("multi_server.servers");
if (servers.isArray()) {
for (JsonNode node : servers) {
if (serverName.equals(node.path("name").asText())) {
String color = node.path("color").asText("white");
return color == null || color.isBlank() ? "white" : color;
}
}
}
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,
MentionTarget target) {
if (alias == null) {
return;
}
String normalized = alias.trim().toLowerCase(Locale.ROOT);
if (normalized.isEmpty()) {
return;
}
localMap.putIfAbsent(normalized, target);
allMap.putIfAbsent(normalized, target);
}
private static String convertMentionsForDiscord(String raw, MentionContext context) {
StringBuilder out = new StringBuilder(raw.length() + 16);
int cursor = 0;
int i = 0;
while (i < raw.length()) {
if (raw.charAt(i) != '@' || !isMentionStartBoundary(raw, i)) {
i++;
continue;
}
MentionMatch match = findMentionMatch(raw, i + 1, context);
if (match == null) {
i++;
continue;
}
out.append(raw, cursor, i);
switch (match.target.type) {
case USER -> out.append("<@").append(match.target.id).append(">");
case ROLE -> out.append("<@&").append(match.target.id).append(">");
case EVERYONE_HERE -> out.append("@").append(match.target.displayName);
}
i = match.endExclusive;
cursor = i;
}
out.append(raw.substring(cursor));
return out.toString();
}
private static MentionMatch findMentionMatch(String text, int contentStart, MentionContext context) {
for (String alias : context.mentionAliasesByLengthDesc) {
int end = contentStart + alias.length();
if (end > text.length()) {
continue;
}
if (!text.regionMatches(true, contentStart, alias, 0, alias.length())) {
continue;
}
if (end < text.length() && isWordChar(text.charAt(end))) {
continue;
}
MentionTarget target = context.allMentionByAlias.get(alias);
if (target != null) {
return new MentionMatch(target, end);
}
}
Matcher simple = SIMPLE_MENTION_PATTERN.matcher(text.substring(contentStart - 1));
if (simple.lookingAt()) {
String token = simple.group(1).toLowerCase(Locale.ROOT);
MentionTarget fallback = context.allMentionByAlias.get(token);
if (fallback != null) {
return new MentionMatch(fallback, contentStart + token.length());
}
}
return null;
}
private static boolean isMentionStartBoundary(String text, int atIndex) {
return atIndex == 0 || !isWordChar(text.charAt(atIndex - 1));
}
private static boolean isWordChar(char ch) {
return Character.isLetterOrDigit(ch) || ch == '_';
}
private enum MentionType {
USER,
ROLE,
EVERYONE_HERE
}
public record ParsedMessage(
String discordContent,
List<TextSegment> minecraftSegments,
Set<String> mentionedPlayerUuids,
boolean mentionEveryone
) {
}
private record MentionTarget(MentionType type, String id, String displayName, String color,
List<String> linkedMinecraftUuids) {
}
private record MentionMatch(MentionTarget target, int endExclusive) {
}
private static final class MentionContext {
private final Map<String, MentionTarget> allMentionByAlias;
private final List<String> mentionAliasesByLengthDesc;
private final Map<String, RichCustomEmoji> customEmojiByName;
private final Set<String> mentionedPlayerUuids;
private boolean mentionEveryone;
private MentionContext(Map<String, MentionTarget> allMentionByAlias,
List<String> mentionAliasesByLengthDesc,
Map<String, RichCustomEmoji> customEmojiByName,
Set<String> mentionedPlayerUuids) {
this.allMentionByAlias = allMentionByAlias;
this.mentionAliasesByLengthDesc = mentionAliasesByLengthDesc;
this.customEmojiByName = customEmojiByName;
this.mentionedPlayerUuids = mentionedPlayerUuids;
this.mentionEveryone = false;
}
}
private static final class MarkdownState {
private boolean bold;
private boolean italic;
private boolean underlined;
private boolean strikethrough;
private boolean obfuscated;
}
}

View file

@ -178,4 +178,21 @@ public final class CoreEvents {
List<TextSegment> replySegments
) {
}
/**
* Posted when a Minecraft-originated relayed message should be displayed in Minecraft.
*
* @param segments The parsed message segments.
* @param mentionNotificationText Mention notification text, or null.
* @param mentionNotificationStyle Mention notification style.
* @param mentionedPlayerUuids UUIDs to receive mention notifications.
*/
public record MinecraftRelayMessageEvent(
List<TextSegment> segments,
String mentionNotificationText,
String mentionNotificationStyle,
List<String> mentionedPlayerUuids,
boolean mentionEveryone
) {
}
}

View file

@ -164,7 +164,7 @@ account_linking:
# 示例 2可将基础认证角色映射为 OP 0使其有权执行无需特殊权限的 DMCC 命令(如委托执行白名单)。
- role: "Players"
op_level: 0
discord_mention_notifications:
mention_notifications:
enable: true
style: "title" # action_bar, title, or chat
discord_user_avatar_for_webhooks: false

View file

@ -218,7 +218,7 @@ account_linking:
op_level: 0
- server: "CMP"
op_level: 2
discord_mention_notifications:
mention_notifications:
enable: true
style: "title" # action_bar, title, or chat
discord_user_avatar_for_webhooks: false

View file

@ -667,6 +667,38 @@ public final class MinecraftEventHandler {
}
});
});
EventManager.register(CoreEvents.MinecraftRelayMessageEvent.class, event -> {
if (serverInstance == null) return;
serverInstance.execute(() -> {
PlayerList playerList = serverInstance.getPlayerList();
Component component = buildComponentFromSegments(event.segments());
for (ServerPlayer player : playerList.getPlayers()) {
player.sendSystemMessage(component);
}
if (event.mentionNotificationText() != null) {
Component notificationComponent = Component.literal(event.mentionNotificationText())
.withStyle(ChatFormatting.GOLD, ChatFormatting.BOLD);
if (event.mentionEveryone()) {
for (ServerPlayer player : playerList.getPlayers()) {
sendMentionNotification(player, notificationComponent, event.mentionNotificationStyle());
}
} else if (event.mentionedPlayerUuids() != null && !event.mentionedPlayerUuids().isEmpty()) {
for (String uuidStr : event.mentionedPlayerUuids()) {
try {
ServerPlayer player = playerList.getPlayer(UUID.fromString(uuidStr));
if (player != null) {
sendMentionNotification(player, notificationComponent, event.mentionNotificationStyle());
}
} catch (Exception ignored) {
}
}
}
}
});
});
}
private static List<String> getSuggestionsForInput(String input, CommandSourceStack source) throws Exception {