feat: implement per-server OP level resolution and sync_op_level_to_minecraft

Co-authored-by: Xujiayao <58985541+Xujiayao@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2026-03-10 12:54:48 +00:00 committed by Jason Xu
parent 95095b3c32
commit 461bbe8d39
13 changed files with 430 additions and 53 deletions

View file

@ -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<Packet> {
// 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.

View file

@ -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<ConsoleResponsePacket> 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);

View file

@ -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<ExecuteResponsePacket> 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);

View file

@ -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.
* <p>
* 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<String, Integer> opLevels;
public OpSyncPacket(Map<String, Integer> opLevels) {
this.opLevels = opLevels;
}
}

View file

@ -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<Packet> {
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

View file

@ -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.
* <p>
* 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.
* <p>
* 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<String> 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.
* <p>
* 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<Command.Choice> 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.
* <p>
* 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<Command.Choice> 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);

View file

@ -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.
*

View file

@ -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;

View file

@ -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.
* <p>
* 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.
* <p>
* 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<String> 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);
}
}

View file

@ -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;
}

View file

@ -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.
* <p>
* 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.
* <p>
* 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<String, List<LinkedAccountManager.LinkEntry>> allLinks = LinkedAccountManager.getAllLinks();
switch (ModeManager.getMode()) {
case "single_server" -> {
// Compute OP levels for all linked accounts using flat mappings
Map<String, Integer> opLevels = new HashMap<>();
for (Map.Entry<String, List<LinkedAccountManager.LinkEntry>> 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<String> clients = NetworkManager.getConnectedClientNames();
for (String clientName : clients) {
Map<String, Integer> opLevels = new HashMap<>();
for (Map.Entry<String, List<LinkedAccountManager.LinkEntry>> 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.
* <p>
* 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;
}
}
}

View file

@ -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.
* <p>
* 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<String, Integer> opLevels
) {
}
}

View file

@ -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<net.minecraft.server.players.ServerOpListEntry> 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<String, Integer> 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<String> getSuggestionsForInput(String input, CommandSourceStack source) throws Exception {