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 e2f4ca66..d395e84e 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 @@ -22,6 +22,7 @@ import com.xujiayao.discord_mc_chat.network.packets.commands.execute.ExecuteResp import com.xujiayao.discord_mc_chat.network.packets.commands.info.InfoRequestPacket; import com.xujiayao.discord_mc_chat.network.packets.commands.info.InfoResponsePacket; import com.xujiayao.discord_mc_chat.network.packets.commands.link.LinkResponsePacket; +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.misc.KeepAlivePacket; import com.xujiayao.discord_mc_chat.network.packets.misc.LatencyPongPacket; @@ -202,6 +203,10 @@ public class ClientHandler extends SimpleChannelInboundHandler { // Handle unlink response from server - notify the player EventManager.post(new CoreEvents.UnlinkResponseEvent(p.minecraftUuid, p.success, p.discordName != null ? p.discordName : "")); } + case OpSyncPacket p -> { + // Handle OP sync from server - apply OP levels to Minecraft players + EventManager.post(new CoreEvents.OpSyncEvent(p.opLevels)); + } 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. diff --git a/core/src/main/java/com/xujiayao/discord_mc_chat/commands/impl/ConsoleCommand.java b/core/src/main/java/com/xujiayao/discord_mc_chat/commands/impl/ConsoleCommand.java index 63213739..3a099061 100644 --- a/core/src/main/java/com/xujiayao/discord_mc_chat/commands/impl/ConsoleCommand.java +++ b/core/src/main/java/com/xujiayao/discord_mc_chat/commands/impl/ConsoleCommand.java @@ -8,6 +8,7 @@ import com.xujiayao.discord_mc_chat.network.packets.commands.console.ConsoleRequ import com.xujiayao.discord_mc_chat.network.packets.commands.console.ConsoleResponsePacket; import com.xujiayao.discord_mc_chat.server.discord.DiscordManager; import com.xujiayao.discord_mc_chat.server.discord.JdaCommandSender; +import com.xujiayao.discord_mc_chat.server.discord.OpLevelResolver; 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; @@ -229,8 +230,14 @@ public class ConsoleCommand implements Command { CompletableFuture future = new CompletableFuture<>(); pendingRequests.put(requestId, future); - // Send with the sender's OP level for Minecraft's own permission check on the client - NetworkManager.sendPacketToClient(new ConsoleRequestPacket(requestId, sender.getOpLevel(), commandLine), serverName); + // Resolve per-server OP level for the target client + int opLevel = sender.getOpLevel(); + if (sender instanceof JdaCommandSender jdaSender) { + opLevel = OpLevelResolver.resolveForServer(jdaSender.getMember(), jdaSender.getUser(), serverName); + } + + // Send with the sender's per-server OP level for Minecraft's own permission check on the client + NetworkManager.sendPacketToClient(new ConsoleRequestPacket(requestId, opLevel, commandLine), serverName); try { ConsoleResponsePacket response = future.get(CONSOLE_TIMEOUT_SECONDS, TimeUnit.SECONDS); diff --git a/core/src/main/java/com/xujiayao/discord_mc_chat/commands/impl/ExecuteCommand.java b/core/src/main/java/com/xujiayao/discord_mc_chat/commands/impl/ExecuteCommand.java index 04326cdc..07887858 100644 --- a/core/src/main/java/com/xujiayao/discord_mc_chat/commands/impl/ExecuteCommand.java +++ b/core/src/main/java/com/xujiayao/discord_mc_chat/commands/impl/ExecuteCommand.java @@ -8,6 +8,7 @@ import com.xujiayao.discord_mc_chat.network.packets.commands.execute.ExecuteRequ import com.xujiayao.discord_mc_chat.network.packets.commands.execute.ExecuteResponsePacket; import com.xujiayao.discord_mc_chat.server.discord.DiscordManager; import com.xujiayao.discord_mc_chat.server.discord.JdaCommandSender; +import com.xujiayao.discord_mc_chat.server.discord.OpLevelResolver; import com.xujiayao.discord_mc_chat.utils.CryptUtils; import com.xujiayao.discord_mc_chat.utils.config.ConfigManager; import com.xujiayao.discord_mc_chat.utils.i18n.I18nManager; @@ -144,12 +145,18 @@ public class ExecuteCommand implements Command { continue; } + // Resolve per-server OP level for the target client + int opLevel = sender.getOpLevel(); + if (sender instanceof JdaCommandSender jdaSender) { + opLevel = OpLevelResolver.resolveForServer(jdaSender.getMember(), jdaSender.getUser(), serverName); + } + String requestId = CryptUtils.generateRandomString(16); CompletableFuture future = new CompletableFuture<>(); pendingRequests.put(requestId, future); - // Append sender's OP level credential to the packet for client-side edge authorization - NetworkManager.sendPacketToClient(new ExecuteRequestPacket(requestId, sender.getOpLevel(), commandName, commandArgs), serverName); + // Append sender's per-server OP level credential to the packet for client-side edge authorization + NetworkManager.sendPacketToClient(new ExecuteRequestPacket(requestId, opLevel, commandName, commandArgs), serverName); try { ExecuteResponsePacket response = future.get(EXECUTE_TIMEOUT_SECONDS, TimeUnit.SECONDS); diff --git a/core/src/main/java/com/xujiayao/discord_mc_chat/network/packets/commands/link/OpSyncPacket.java b/core/src/main/java/com/xujiayao/discord_mc_chat/network/packets/commands/link/OpSyncPacket.java new file mode 100644 index 00000000..b1dd29a2 --- /dev/null +++ b/core/src/main/java/com/xujiayao/discord_mc_chat/network/packets/commands/link/OpSyncPacket.java @@ -0,0 +1,21 @@ +package com.xujiayao.discord_mc_chat.network.packets.commands.link; + +import com.xujiayao.discord_mc_chat.network.packets.Packet; + +import java.util.Map; + +/** + * Sent by Server to Client to synchronize OP levels for linked Minecraft accounts. + *

+ * This packet contains a full mapping of Minecraft UUID to desired OP level. + * The client should apply a full reset: de-op all players, then re-apply these levels. + * + * @author Xujiayao + */ +public class OpSyncPacket extends Packet { + public Map opLevels; + + public OpSyncPacket(Map opLevels) { + this.opLevels = opLevels; + } +} 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 e4450865..b5df5b24 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 @@ -26,6 +26,7 @@ import com.xujiayao.discord_mc_chat.network.packets.misc.LatencyPingPacket; import com.xujiayao.discord_mc_chat.network.packets.misc.LatencyPongPacket; 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.utils.CryptUtils; import com.xujiayao.discord_mc_chat.utils.config.ConfigManager; @@ -82,8 +83,11 @@ public class ServerHandler extends SimpleChannelInboundHandler { case MinecraftEventPacket p -> { switch (p.type) { // Server events - case SERVER_STARTED -> - DiscordManager.clientBroadcast(clientName, "server.started", "server.start", false, p.placeholders); + case SERVER_STARTED -> { + DiscordManager.clientBroadcast(clientName, "server.started", "server.start", false, p.placeholders); + // Trigger OP sync for this newly started client + OpSyncManager.syncAll(); + } case SERVER_STOPPING -> DiscordManager.clientBroadcast(clientName, "server.stopped", "server.stop", false, p.placeholders); // Player events 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 be1770e7..efbb5472 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 @@ -1,14 +1,11 @@ package com.xujiayao.discord_mc_chat.server.discord; -import com.fasterxml.jackson.databind.JsonNode; 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.server.linking.LinkedAccountManager; import com.xujiayao.discord_mc_chat.utils.LogFileUtils; import com.xujiayao.discord_mc_chat.utils.config.ConfigManager; 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.events.interaction.command.CommandAutoCompleteInteractionEvent; import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; @@ -35,55 +32,25 @@ public class DiscordEventHandler extends ListenerAdapter { /** * Resolves the OP Level credential for a Discord user based on config mappings. - *

- * Resolution order (highest wins): - * 1. user_mappings: exact user ID or username match. - * 2. role_mappings: iterate user's roles, take the highest mapped OP level. - * 3. Account linking: linked MC account gets at least OP 0. - *

- * For standalone mode, each mapping entry has a top-level {@code op_level} for the DMCC Server itself. - * For single_server mode, mappings use a flat {@code op_level}. * * @param member The Discord Member object (null if in DMs). * @param user The Discord User object. * @return The resolved OP level (-1 to 4). */ private int getOpLevel(Member member, User user) { - int maxOp = -1; + return OpLevelResolver.resolve(member, user); + } - // Check exact user mappings first (highest priority) - JsonNode userMappings = ConfigManager.getConfigNode("account_linking.op_sync.user_mappings"); - if (userMappings.isArray()) { - for (JsonNode node : userMappings) { - if (user.getId().equals(node.path("user").asText()) || user.getName().equals(node.path("user").asText())) { - // Read the top-level op_level (used by standalone and single_server) - maxOp = Math.max(maxOp, node.path("op_level").asInt(-1)); - } - } - } - - // Check role mappings if member exists (in a guild) - if (member != null) { - JsonNode roleMappings = ConfigManager.getConfigNode("account_linking.op_sync.role_mappings"); - if (roleMappings.isArray()) { - for (Role role : member.getRoles()) { - for (JsonNode node : roleMappings) { - if (role.getId().equals(node.path("role").asText()) || role.getName().equals(node.path("role").asText())) { - // Read the top-level op_level (used by standalone and single_server) - maxOp = Math.max(maxOp, node.path("op_level").asInt(-1)); - } - } - } - } - } - - // Account Linking: if user has linked MC accounts, they get at least OP 0 - List linkedUuids = LinkedAccountManager.getMinecraftUuidsByDiscordId(user.getId()); - if (!linkedUuids.isEmpty() && maxOp < 0) { - maxOp = 0; - } - - return maxOp; + /** + * Resolves the OP Level credential for a specific target server. + * + * @param member The Discord Member object (null if in DMs). + * @param user The Discord User object. + * @param serverName The target DMCC client server name. + * @return The resolved OP level (-1 to 4). + */ + private int getOpLevelForServer(Member member, User user, String serverName) { + return OpLevelResolver.resolveForServer(member, user, serverName); } @Override @@ -211,13 +178,21 @@ public class DiscordEventHandler extends ListenerAdapter { * Gets auto-complete choices for the 'command' parameter of the execute command. * Sends a real-time auto-complete request to connected clients with the current input and OP level, * so clients can provide DMCC command suggestions that the user is authorized to execute. + *

+ * When a target server is selected, uses the per-server OP level for accurate suggestions. * * @param currentValue The current user input for filtering * @param event The auto-complete event to read other options * @return List of choices */ private List getExecuteCommandChoices(String currentValue, CommandAutoCompleteInteractionEvent event) { - int opLevel = getOpLevel(event.getMember(), event.getUser()); + String target = event.getOption("at", OptionMapping::getAsString); + int opLevel; + if (target != null && !target.isBlank() && !"all_online_clients".equalsIgnoreCase(target)) { + opLevel = getOpLevelForServer(event.getMember(), event.getUser(), target); + } else { + opLevel = getOpLevel(event.getMember(), event.getUser()); + } if (currentValue.startsWith("/")) { currentValue = currentValue.substring(1); @@ -237,13 +212,21 @@ public class DiscordEventHandler extends ListenerAdapter { * Gets auto-complete choices for the 'command' parameter of the console command. * Sends a real-time auto-complete request to connected clients with the current input and OP level, * so clients can provide Minecraft command suggestions via their Brigadier dispatcher. + *

+ * When a target server is selected, uses the per-server OP level for accurate suggestions. * * @param currentValue The current user input for filtering * @param event The auto-complete event to read other options * @return List of choices */ private List getConsoleCommandChoices(String currentValue, CommandAutoCompleteInteractionEvent event) { - int opLevel = getOpLevel(event.getMember(), event.getUser()); + String target = event.getOption("at", OptionMapping::getAsString); + int opLevel; + if (target != null && !target.isBlank() && !"all_online_clients".equalsIgnoreCase(target)) { + opLevel = getOpLevelForServer(event.getMember(), event.getUser(), target); + } else { + opLevel = getOpLevel(event.getMember(), event.getUser()); + } if (currentValue.startsWith("/")) { currentValue = currentValue.substring(1); diff --git a/core/src/main/java/com/xujiayao/discord_mc_chat/server/discord/DiscordManager.java b/core/src/main/java/com/xujiayao/discord_mc_chat/server/discord/DiscordManager.java index 3687fe7a..260eceb2 100644 --- a/core/src/main/java/com/xujiayao/discord_mc_chat/server/discord/DiscordManager.java +++ b/core/src/main/java/com/xujiayao/discord_mc_chat/server/discord/DiscordManager.java @@ -195,6 +195,41 @@ public class DiscordManager { } } + /** + * Retrieves a Discord User object by ID. + * + * @param discordId The Discord user ID. + * @return The User object, or null if JDA is not available or user not found. + */ + public static net.dv8tion.jda.api.entities.User retrieveUser(String discordId) { + if (jda == null) return null; + try { + return jda.retrieveUserById(discordId).complete(); + } catch (Exception e) { + return null; + } + } + + /** + * Retrieves a Discord Member object by user ID from the first mutual guild. + * + * @param discordId The Discord user ID. + * @return The Member object, or null if JDA is not available or member not found. + */ + public static net.dv8tion.jda.api.entities.Member retrieveMember(String discordId) { + if (jda == null) return null; + try { + for (var guild : jda.getGuilds()) { + net.dv8tion.jda.api.entities.Member member = guild.retrieveMemberById(discordId).complete(); + if (member != null) { + return member; + } + } + } catch (Exception ignored) { + } + return null; + } + /** * Sends a message to a Discord channel using the JDA bot. * diff --git a/core/src/main/java/com/xujiayao/discord_mc_chat/server/discord/JdaCommandSender.java b/core/src/main/java/com/xujiayao/discord_mc_chat/server/discord/JdaCommandSender.java index 9a99e3e4..02ad089a 100644 --- a/core/src/main/java/com/xujiayao/discord_mc_chat/server/discord/JdaCommandSender.java +++ b/core/src/main/java/com/xujiayao/discord_mc_chat/server/discord/JdaCommandSender.java @@ -3,6 +3,8 @@ package com.xujiayao.discord_mc_chat.server.discord; import com.xujiayao.discord_mc_chat.commands.CommandSender; import com.xujiayao.discord_mc_chat.commands.impl.LinkCommand; import com.xujiayao.discord_mc_chat.utils.i18n.I18nManager; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.User; import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; import net.dv8tion.jda.api.utils.FileUpload; @@ -65,6 +67,24 @@ public class JdaCommandSender implements CommandSender, LinkCommand.DiscordUserC } } + /** + * Gets the Discord Member object for this sender. + * + * @return The Member, or null if in DMs. + */ + public Member getMember() { + return event.getMember(); + } + + /** + * Gets the Discord User object for this sender. + * + * @return The User. + */ + public User getUser() { + return event.getUser(); + } + @Override public int getOpLevel() { return opLevel; diff --git a/core/src/main/java/com/xujiayao/discord_mc_chat/server/discord/OpLevelResolver.java b/core/src/main/java/com/xujiayao/discord_mc_chat/server/discord/OpLevelResolver.java new file mode 100644 index 00000000..1b440fcc --- /dev/null +++ b/core/src/main/java/com/xujiayao/discord_mc_chat/server/discord/OpLevelResolver.java @@ -0,0 +1,114 @@ +package com.xujiayao.discord_mc_chat.server.discord; + +import com.fasterxml.jackson.databind.JsonNode; +import com.xujiayao.discord_mc_chat.server.linking.LinkedAccountManager; +import com.xujiayao.discord_mc_chat.utils.config.ConfigManager; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.Role; +import net.dv8tion.jda.api.entities.User; + +import java.util.List; + +/** + * Centralized OP level resolution for Discord users. + *

+ * Resolves OP levels from config mappings with optional per-server granularity. + * Used by both DiscordEventHandler (for command authorization) and the + * sync_op_level_to_minecraft feature. + * + * @author Xujiayao + */ +public class OpLevelResolver { + + /** + * Resolves the top-level OP level for a Discord user (used by standalone/single_server). + * + * @param member The Discord Member object (null if in DMs). + * @param user The Discord User object. + * @return The resolved OP level (-1 to 4). + */ + public static int resolve(Member member, User user) { + return resolveInternal(member, user, null); + } + + /** + * Resolves the OP level for a Discord user targeting a specific DMCC client server. + *

+ * In standalone mode, reads the per-server OP from the {@code servers} array in mappings. + * Falls back to the top-level {@code op_level} if no server-specific entry exists. + * + * @param member The Discord Member object (null if in DMs). + * @param user The Discord User object. + * @param serverName The target DMCC client server name. + * @return The resolved OP level (-1 to 4). + */ + public static int resolveForServer(Member member, User user, String serverName) { + return resolveInternal(member, user, serverName); + } + + /** + * Internal OP level resolution logic. + * + * @param member The Discord Member object (null if in DMs). + * @param user The Discord User object. + * @param serverName If non-null, resolve per-server OP from the {@code servers} list. + * @return The resolved OP level (-1 to 4). + */ + private static int resolveInternal(Member member, User user, String serverName) { + int maxOp = -1; + + // Check exact user mappings first (highest priority) + JsonNode userMappings = ConfigManager.getConfigNode("account_linking.op_sync.user_mappings"); + if (userMappings.isArray()) { + for (JsonNode node : userMappings) { + if (user.getId().equals(node.path("user").asText()) || user.getName().equals(node.path("user").asText())) { + maxOp = Math.max(maxOp, resolveOpFromNode(node, serverName)); + } + } + } + + // Check role mappings if member exists (in a guild) + if (member != null) { + JsonNode roleMappings = ConfigManager.getConfigNode("account_linking.op_sync.role_mappings"); + if (roleMappings.isArray()) { + for (Role role : member.getRoles()) { + for (JsonNode node : roleMappings) { + if (role.getId().equals(node.path("role").asText()) || role.getName().equals(node.path("role").asText())) { + maxOp = Math.max(maxOp, resolveOpFromNode(node, serverName)); + } + } + } + } + } + + // Account Linking: if user has linked MC accounts, they get at least OP 0 + List linkedUuids = LinkedAccountManager.getMinecraftUuidsByDiscordId(user.getId()); + if (!linkedUuids.isEmpty() && maxOp < 0) { + maxOp = 0; + } + + return maxOp; + } + + /** + * Reads the OP level from a mapping node, optionally for a specific server. + * + * @param node The mapping node (user_mappings or role_mappings entry). + * @param serverName The target server name (null for top-level OP). + * @return The OP level from the mapping. + */ + private static int resolveOpFromNode(JsonNode node, String serverName) { + if (serverName != null) { + JsonNode serversArray = node.path("servers"); + if (serversArray.isArray()) { + for (JsonNode serverEntry : serversArray) { + if (serverName.equals(serverEntry.path("server").asText())) { + return serverEntry.path("op_level").asInt(-1); + } + } + } + } + // Top-level op_level (for standalone and single_server) + return node.path("op_level").asInt(-1); + } +} diff --git a/core/src/main/java/com/xujiayao/discord_mc_chat/server/linking/LinkedAccountManager.java b/core/src/main/java/com/xujiayao/discord_mc_chat/server/linking/LinkedAccountManager.java index 1dfaa5bd..a9a6cc0d 100644 --- a/core/src/main/java/com/xujiayao/discord_mc_chat/server/linking/LinkedAccountManager.java +++ b/core/src/main/java/com/xujiayao/discord_mc_chat/server/linking/LinkedAccountManager.java @@ -162,6 +162,7 @@ public class LinkedAccountManager { LOGGER.info(I18nManager.getDmccTranslation("linking.manager.linked", discordName, discordId, minecraftName, minecraftUuid)); save(); + OpSyncManager.syncAll(); return true; } @@ -180,6 +181,7 @@ public class LinkedAccountManager { removed.forEach(entry -> UUID_TO_DISCORD.remove(entry.minecraftUuid())); LOGGER.info(I18nManager.getDmccTranslation("linking.manager.unlinked_discord", count, discordName, discordId)); save(); + OpSyncManager.syncAll(); } return count; @@ -209,6 +211,7 @@ public class LinkedAccountManager { String discordName = resolveDiscordName(discordId); LOGGER.info(I18nManager.getDmccTranslation("linking.manager.unlinked_minecraft", minecraftName, minecraftUuid, discordName, discordId)); save(); + OpSyncManager.syncAll(); return discordId; } diff --git a/core/src/main/java/com/xujiayao/discord_mc_chat/server/linking/OpSyncManager.java b/core/src/main/java/com/xujiayao/discord_mc_chat/server/linking/OpSyncManager.java new file mode 100644 index 00000000..f1739649 --- /dev/null +++ b/core/src/main/java/com/xujiayao/discord_mc_chat/server/linking/OpSyncManager.java @@ -0,0 +1,101 @@ +package com.xujiayao.discord_mc_chat.server.linking; + +import com.xujiayao.discord_mc_chat.network.NetworkManager; +import com.xujiayao.discord_mc_chat.network.packets.commands.link.OpSyncPacket; +import com.xujiayao.discord_mc_chat.server.discord.DiscordManager; +import com.xujiayao.discord_mc_chat.server.discord.OpLevelResolver; +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 net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.User; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static com.xujiayao.discord_mc_chat.Constants.LOGGER; + +/** + * Manages the synchronization of OP levels from Discord mappings to Minecraft servers. + *

+ * When {@code sync_op_level_to_minecraft} is enabled, this class computes the desired OP level + * for every linked Minecraft account based on the Discord user's mapping configuration, + * then applies a full-reset sync to the Minecraft server(s). + * + * @author Xujiayao + */ +public class OpSyncManager { + + /** + * Performs a full OP level sync for all linked accounts. + *

+ * In single_server mode, posts a CoreEvent to the local Minecraft server. + * In standalone mode, sends OpSyncPackets to each connected client. + */ + public static void syncAll() { + if (!Boolean.TRUE.equals(ConfigManager.getBoolean("account_linking.op_sync.sync_op_level_to_minecraft"))) { + return; + } + + Map> allLinks = LinkedAccountManager.getAllLinks(); + + switch (ModeManager.getMode()) { + case "single_server" -> { + // Compute OP levels for all linked accounts using flat mappings + Map opLevels = new HashMap<>(); + for (Map.Entry> entry : allLinks.entrySet()) { + String discordId = entry.getKey(); + int opLevel = resolveOpForDiscordUser(discordId, null); + for (LinkedAccountManager.LinkEntry link : entry.getValue()) { + opLevels.put(link.minecraftUuid(), Math.max(0, opLevel)); + } + } + EventManager.post(new CoreEvents.OpSyncEvent(opLevels)); + } + case "standalone" -> { + // For each connected client, compute per-server OP levels and send + List clients = NetworkManager.getConnectedClientNames(); + for (String clientName : clients) { + Map opLevels = new HashMap<>(); + for (Map.Entry> entry : allLinks.entrySet()) { + String discordId = entry.getKey(); + int opLevel = resolveOpForDiscordUser(discordId, clientName); + for (LinkedAccountManager.LinkEntry link : entry.getValue()) { + opLevels.put(link.minecraftUuid(), Math.max(0, opLevel)); + } + } + NetworkManager.sendPacketToClient(new OpSyncPacket(opLevels), clientName); + } + } + } + } + + /** + * Resolves the OP level for a Discord user, optionally for a specific server. + *

+ * Uses the JDA API to retrieve the user's guild member information for role-based resolution. + * + * @param discordId The Discord user ID. + * @param serverName The target server name (null for top-level/single_server). + * @return The resolved OP level (-1 to 4). + */ + private static int resolveOpForDiscordUser(String discordId, String serverName) { + try { + User user = DiscordManager.retrieveUser(discordId); + if (user == null) { + return -1; + } + Member member = DiscordManager.retrieveMember(discordId); + if (serverName != null) { + return OpLevelResolver.resolveForServer(member, user, serverName); + } else { + return OpLevelResolver.resolve(member, user); + } + } catch (Exception e) { + LOGGER.warn("Failed to resolve OP level for Discord user {}: {}", discordId, e.getMessage()); + return -1; + } + } +} diff --git a/core/src/main/java/com/xujiayao/discord_mc_chat/utils/events/CoreEvents.java b/core/src/main/java/com/xujiayao/discord_mc_chat/utils/events/CoreEvents.java index fbe592a8..ad943e04 100644 --- a/core/src/main/java/com/xujiayao/discord_mc_chat/utils/events/CoreEvents.java +++ b/core/src/main/java/com/xujiayao/discord_mc_chat/utils/events/CoreEvents.java @@ -3,6 +3,7 @@ package com.xujiayao.discord_mc_chat.utils.events; import com.xujiayao.discord_mc_chat.commands.CommandSender; import java.util.List; +import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.function.Consumer; @@ -138,4 +139,17 @@ public class CoreEvents { String discordName ) { } + + /** + * Posted when OP levels should be synchronized to the Minecraft server. + *

+ * This is a full-reset sync: the handler should clear all existing OP assignments + * and reapply the provided mapping. Players not in the map should be de-opped (OP 0). + * + * @param opLevels A map of Minecraft UUID (as string) to the desired OP level (0-4). + */ + public record OpSyncEvent( + Map opLevels + ) { + } } 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 093bc672..58635942 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 @@ -14,6 +14,7 @@ import com.xujiayao.discord_mc_chat.network.packets.commands.info.InfoResponsePa import com.xujiayao.discord_mc_chat.network.packets.events.MinecraftEventPacket; import com.xujiayao.discord_mc_chat.network.packets.commands.link.LinkRequestPacket; 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.utils.EnvironmentUtils; import com.xujiayao.discord_mc_chat.utils.config.ConfigManager; @@ -121,6 +122,11 @@ public class MinecraftEventHandler { // Register info supplier after server is started NetworkManager.registerInfoSupplier(() -> buildInfoResponse(event.minecraftServer())); + + // Trigger initial OP sync after server is ready (single_server mode) + if ("single_server".equals(ModeManager.getMode())) { + OpSyncManager.syncAll(); + } }); EventManager.register(MinecraftEvents.ServerStopping.class, event -> { @@ -391,6 +397,63 @@ public class MinecraftEventHandler { } }); }); + + // ===== OP Level Sync ===== + + EventManager.register(CoreEvents.OpSyncEvent.class, event -> { + if (serverInstance == null) return; + + serverInstance.execute(() -> { + try { + net.minecraft.server.players.PlayerList playerList = serverInstance.getPlayerList(); + net.minecraft.server.players.ServerOpList opList = playerList.getOps(); + + // Step 1: De-op all currently opped players + // Copy the list to avoid ConcurrentModificationException + List currentOps = + new ArrayList<>(opList.getEntries()); + for (net.minecraft.server.players.ServerOpListEntry op : currentOps) { + com.mojang.authlib.GameProfile profile = op.getUser(); + if (profile != null) { + playerList.deop(profile); + } + } + + // Step 2: Apply the new OP levels from the sync event + for (Map.Entry entry : event.opLevels().entrySet()) { + String uuidStr = entry.getKey(); + int opLevel = entry.getValue(); + if (opLevel <= 0) continue; // OP 0 means no special permissions, skip + + try { + UUID uuid = UUID.fromString(uuidStr); + com.mojang.authlib.GameProfile profile = serverInstance.getProfileCache() + .get(uuid).orElse(null); + if (profile == null) { + // Create a minimal profile for offline players + profile = new com.mojang.authlib.GameProfile(uuid, ""); + } + // Add the OP entry with the exact desired level + opList.add(new net.minecraft.server.players.ServerOpListEntry( + profile, opLevel, opList.canBypassPlayerLimit(profile))); + } catch (Exception ignored) { + } + } + + // Step 3: Save the ops list + try { + opList.save(); + } catch (Exception ignored) { + } + + // Step 4: Update permission levels for online players + for (ServerPlayer player : playerList.getPlayers()) { + playerList.sendPlayerPermissionLevel(player); + } + } catch (Exception ignored) { + } + }); + }); } private static List getSuggestionsForInput(String input, CommandSourceStack source) throws Exception {