From bde4900e98ac07b529f1cc39123f421ecaf06e67 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 15:33:33 +0000 Subject: [PATCH] feat: implement account linking system with LinkedAccountManager, VerificationCodeManager, network packets, commands (link/unlink/links), Discord and Minecraft integration Co-authored-by: Xujiayao <58985541+Xujiayao@users.noreply.github.com> --- .../discord_mc_chat/client/ClientHandler.java | 10 ++ .../commands/CommandManager.java | 11 ++ .../commands/impl/LinkCommand.java | 157 ++++++++++++++++++ .../commands/impl/LinksCommand.java | 81 +++++++++ .../commands/impl/UnlinkCommand.java | 89 ++++++++++ .../linking/LinkCodeRequestPacket.java | 21 +++ .../linking/LinkCodeResponsePacket.java | 23 +++ .../linking/UnlinkByUuidRequestPacket.java | 20 +++ .../linking/UnlinkByUuidResponsePacket.java | 20 +++ .../discord_mc_chat/server/ServerDMCC.java | 8 + .../discord_mc_chat/server/ServerHandler.java | 18 ++ .../server/discord/DiscordEventHandler.java | 15 +- .../server/discord/DiscordManager.java | 6 + .../server/discord/JdaCommandSender.java | 11 +- .../linking/VerificationCodeManager.java | 149 +++++++++++++++++ .../utils/events/CoreEvents.java | 81 +++++++++ .../config/config_multi_server_client.yml | 2 + .../resources/config/config_single_server.yml | 3 + .../resources/config/config_standalone.yml | 3 + core/src/main/resources/lang/en_us.yml | 43 +++++ core/src/main/resources/lang/zh_cn.yml | 43 +++++ .../minecraft/commands/MinecraftCommands.java | 64 ++++++- .../events/MinecraftEventHandler.java | 71 ++++++++ 23 files changed, 943 insertions(+), 6 deletions(-) create mode 100644 core/src/main/java/com/xujiayao/discord_mc_chat/commands/impl/LinkCommand.java create mode 100644 core/src/main/java/com/xujiayao/discord_mc_chat/commands/impl/LinksCommand.java create mode 100644 core/src/main/java/com/xujiayao/discord_mc_chat/commands/impl/UnlinkCommand.java create mode 100644 core/src/main/java/com/xujiayao/discord_mc_chat/network/packets/linking/LinkCodeRequestPacket.java create mode 100644 core/src/main/java/com/xujiayao/discord_mc_chat/network/packets/linking/LinkCodeResponsePacket.java create mode 100644 core/src/main/java/com/xujiayao/discord_mc_chat/network/packets/linking/UnlinkByUuidRequestPacket.java create mode 100644 core/src/main/java/com/xujiayao/discord_mc_chat/network/packets/linking/UnlinkByUuidResponsePacket.java create mode 100644 core/src/main/java/com/xujiayao/discord_mc_chat/server/linking/VerificationCodeManager.java diff --git a/core/src/main/java/com/xujiayao/discord_mc_chat/client/ClientHandler.java b/core/src/main/java/com/xujiayao/discord_mc_chat/client/ClientHandler.java index a1b6d1f8..c0e5b91b 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 @@ -21,6 +21,8 @@ import com.xujiayao.discord_mc_chat.network.packets.commands.ExecuteRequestPacke import com.xujiayao.discord_mc_chat.network.packets.commands.ExecuteResponsePacket; import com.xujiayao.discord_mc_chat.network.packets.commands.InfoRequestPacket; import com.xujiayao.discord_mc_chat.network.packets.commands.InfoResponsePacket; +import com.xujiayao.discord_mc_chat.network.packets.linking.LinkCodeResponsePacket; +import com.xujiayao.discord_mc_chat.network.packets.linking.UnlinkByUuidResponsePacket; import com.xujiayao.discord_mc_chat.network.packets.misc.KeepAlivePacket; import com.xujiayao.discord_mc_chat.network.packets.misc.LatencyPongPacket; import com.xujiayao.discord_mc_chat.utils.CryptUtils; @@ -192,6 +194,14 @@ public class ClientHandler extends SimpleChannelInboundHandler { EventManager.post(new CoreEvents.MinecraftCommandAutoCompleteEvent(p.input, p.opLevel, suggestions)); ctx.writeAndFlush(new ConsoleAutoCompleteResponsePacket(client.getServerName(), suggestions)); } + case LinkCodeResponsePacket p -> { + // Handle link code response from server - notify the player + EventManager.post(new CoreEvents.LinkCodeResponseEvent(p.minecraftUuid, p.code, p.alreadyLinked)); + } + case UnlinkByUuidResponsePacket p -> { + // Handle unlink response from server - notify the player + EventManager.post(new CoreEvents.UnlinkResponseEvent(p.minecraftUuid, p.success)); + } 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/CommandManager.java b/core/src/main/java/com/xujiayao/discord_mc_chat/commands/CommandManager.java index 777dd068..73e1265e 100644 --- a/core/src/main/java/com/xujiayao/discord_mc_chat/commands/CommandManager.java +++ b/core/src/main/java/com/xujiayao/discord_mc_chat/commands/CommandManager.java @@ -4,10 +4,13 @@ import com.xujiayao.discord_mc_chat.commands.impl.ConsoleCommand; import com.xujiayao.discord_mc_chat.commands.impl.ExecuteCommand; import com.xujiayao.discord_mc_chat.commands.impl.HelpCommand; import com.xujiayao.discord_mc_chat.commands.impl.InfoCommand; +import com.xujiayao.discord_mc_chat.commands.impl.LinkCommand; +import com.xujiayao.discord_mc_chat.commands.impl.LinksCommand; import com.xujiayao.discord_mc_chat.commands.impl.LogCommand; import com.xujiayao.discord_mc_chat.commands.impl.ReloadCommand; import com.xujiayao.discord_mc_chat.commands.impl.ShutdownCommand; import com.xujiayao.discord_mc_chat.commands.impl.StatsCommand; +import com.xujiayao.discord_mc_chat.commands.impl.UnlinkCommand; import com.xujiayao.discord_mc_chat.commands.impl.WhitelistCommand; import com.xujiayao.discord_mc_chat.utils.ExecutorServiceUtils; import com.xujiayao.discord_mc_chat.utils.config.ConfigManager; @@ -52,15 +55,23 @@ public class CommandManager { register(new ExecuteCommand()); register(new ConsoleCommand()); register(new ShutdownCommand()); + register(new LinkCommand()); + register(new UnlinkCommand()); + register(new LinksCommand()); } case "single_server" -> { register(new ConsoleCommand()); register(new StatsCommand()); register(new WhitelistCommand()); + register(new LinkCommand()); + register(new UnlinkCommand()); + register(new LinksCommand()); } case "multi_server_client" -> { register(new StatsCommand()); register(new WhitelistCommand()); + register(new LinkCommand()); + register(new UnlinkCommand()); } } } diff --git a/core/src/main/java/com/xujiayao/discord_mc_chat/commands/impl/LinkCommand.java b/core/src/main/java/com/xujiayao/discord_mc_chat/commands/impl/LinkCommand.java new file mode 100644 index 00000000..2294d5e9 --- /dev/null +++ b/core/src/main/java/com/xujiayao/discord_mc_chat/commands/impl/LinkCommand.java @@ -0,0 +1,157 @@ +package com.xujiayao.discord_mc_chat.commands.impl; + +import com.xujiayao.discord_mc_chat.commands.Command; +import com.xujiayao.discord_mc_chat.commands.CommandSender; +import com.xujiayao.discord_mc_chat.commands.LocalCommandSender; +import com.xujiayao.discord_mc_chat.network.NetworkManager; +import com.xujiayao.discord_mc_chat.network.packets.linking.LinkCodeRequestPacket; +import com.xujiayao.discord_mc_chat.server.linking.LinkedAccountManager; +import com.xujiayao.discord_mc_chat.server.linking.VerificationCodeManager; +import com.xujiayao.discord_mc_chat.utils.config.ModeManager; +import com.xujiayao.discord_mc_chat.utils.events.CoreEvents; +import com.xujiayao.discord_mc_chat.utils.events.EventManager; +import com.xujiayao.discord_mc_chat.utils.i18n.I18nManager; + +/** + * Link command implementation supporting both MC-side and Discord-side workflows. + *

+ * Minecraft side (0 args): Generates or refreshes a verification code for the executing player. + * Available in single_server and multi_server_client modes. + *

+ * Discord side (1 arg): Completes account linking using a verification code. + * Available in single_server and standalone modes (where Server is running). + * + * @author Xujiayao + */ +public class LinkCommand implements Command { + + /** + * Functional interface for providing player context in Minecraft-side link commands. + *

+ * Implementations provide the player's UUID and name from the Minecraft server. + */ + public interface PlayerContextProvider { + /** + * Gets the player's UUID as a string. + * + * @return The player UUID string. + */ + String getPlayerUuid(); + + /** + * Gets the player's display name. + * + * @return The player name. + */ + String getPlayerName(); + } + + /** + * Functional interface for providing Discord user context in Discord-side link commands. + *

+ * Implementations provide the Discord user's ID. + */ + public interface DiscordUserContextProvider { + /** + * Gets the Discord user's ID. + * + * @return The Discord user ID. + */ + String getDiscordUserId(); + } + + @Override + public String name() { + return "link"; + } + + @Override + public CommandArgument[] args() { + return new CommandArgument[0]; + } + + @Override + public boolean acceptsExtraArgs() { + return true; + } + + @Override + public String description() { + return I18nManager.getDmccTranslation("commands.link.description"); + } + + @Override + public boolean isVisibleInHelp(CommandSender sender) { + // Hide from help if the sender is not a player or Discord user + return (sender instanceof PlayerContextProvider) || (sender instanceof DiscordUserContextProvider); + } + + @Override + public void execute(CommandSender sender, String... args) { + if (args.length == 0) { + // Minecraft-side: generate/refresh verification code + executeMcLink(sender); + } else if (args.length == 1) { + // Discord-side: complete linking with verification code + executeDiscordLink(sender, args[0]); + } else { + sender.reply(I18nManager.getDmccTranslation("commands.invalid_usage", "link ")); + } + } + + /** + * Minecraft-side link: generates or refreshes a verification code. + */ + private void executeMcLink(CommandSender sender) { + if (!(sender instanceof PlayerContextProvider player)) { + sender.reply(I18nManager.getDmccTranslation("commands.link.player_only")); + return; + } + + String uuid = player.getPlayerUuid(); + String name = player.getPlayerName(); + + switch (ModeManager.getMode()) { + case "single_server" -> { + // Direct access to server-side managers + if (LinkedAccountManager.isMinecraftUuidLinked(uuid)) { + sender.reply(I18nManager.getDmccTranslation("commands.link.already_linked")); + return; + } + String code = VerificationCodeManager.generateOrRefreshCode(uuid, name); + sender.reply(I18nManager.getDmccTranslation("commands.link.code_generated", code)); + } + case "multi_server_client" -> { + // Send request to standalone server via network + NetworkManager.sendPacketToServer(new LinkCodeRequestPacket(uuid, name)); + sender.reply(I18nManager.getDmccTranslation("commands.link.code_requested")); + } + default -> sender.reply(I18nManager.getDmccTranslation("commands.link.not_available")); + } + } + + /** + * Discord-side link: validates the code and creates the account link. + */ + private void executeDiscordLink(CommandSender sender, String code) { + if (!(sender instanceof DiscordUserContextProvider discord)) { + sender.reply(I18nManager.getDmccTranslation("commands.link.discord_only")); + return; + } + + String discordId = discord.getDiscordUserId(); + + VerificationCodeManager.PendingVerification pending = VerificationCodeManager.consumeCode(code); + if (pending == null) { + sender.reply(I18nManager.getDmccTranslation("commands.link.invalid_code")); + return; + } + + boolean success = LinkedAccountManager.linkAccount(discordId, pending.minecraftUuid()); + if (success) { + sender.reply(I18nManager.getDmccTranslation("commands.link.success", pending.playerName())); + } else { + sender.reply(I18nManager.getDmccTranslation("commands.link.uuid_already_linked")); + } + } +} diff --git a/core/src/main/java/com/xujiayao/discord_mc_chat/commands/impl/LinksCommand.java b/core/src/main/java/com/xujiayao/discord_mc_chat/commands/impl/LinksCommand.java new file mode 100644 index 00000000..d653480f --- /dev/null +++ b/core/src/main/java/com/xujiayao/discord_mc_chat/commands/impl/LinksCommand.java @@ -0,0 +1,81 @@ +package com.xujiayao.discord_mc_chat.commands.impl; + +import com.xujiayao.discord_mc_chat.commands.Command; +import com.xujiayao.discord_mc_chat.commands.CommandSender; +import com.xujiayao.discord_mc_chat.server.linking.LinkedAccountManager; +import com.xujiayao.discord_mc_chat.utils.i18n.I18nManager; + +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Map; + +/** + * Links command implementation that displays all currently linked accounts. + *

+ * Available in single_server and standalone modes (where Server is running). + * Display names are resolved at query time; if resolution fails, raw IDs are shown. + * + * @author Xujiayao + */ +public class LinksCommand implements Command { + + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") + .withZone(ZoneId.systemDefault()); + + @Override + public String name() { + return "links"; + } + + @Override + public CommandArgument[] args() { + return new CommandArgument[0]; + } + + @Override + public String description() { + return I18nManager.getDmccTranslation("commands.links.description"); + } + + @Override + public boolean isVisibleFromMinecraft() { + return false; + } + + @Override + public void execute(CommandSender sender, String... args) { + Map> allLinks = LinkedAccountManager.getAllLinks(); + + if (allLinks.isEmpty()) { + sender.reply(I18nManager.getDmccTranslation("commands.links.no_links")); + return; + } + + StringBuilder builder = new StringBuilder(); + builder.append("========== ") + .append(I18nManager.getDmccTranslation("commands.links.title")) + .append(" ==========\n"); + + int totalLinks = 0; + for (Map.Entry> entry : allLinks.entrySet()) { + String discordId = entry.getKey(); + List links = entry.getValue(); + totalLinks += links.size(); + + builder.append("\n[Discord: ").append(discordId).append("]"); + + for (LinkedAccountManager.LinkEntry link : links) { + String time = DATE_FORMATTER.format(Instant.ofEpochMilli(link.linkedAt())); + builder.append("\n - MC UUID: ").append(link.minecraftUuid()); + builder.append(" (").append(time).append(")"); + } + } + + builder.append("\n\n") + .append(I18nManager.getDmccTranslation("commands.links.total", totalLinks, allLinks.size())); + + sender.reply(builder.toString()); + } +} diff --git a/core/src/main/java/com/xujiayao/discord_mc_chat/commands/impl/UnlinkCommand.java b/core/src/main/java/com/xujiayao/discord_mc_chat/commands/impl/UnlinkCommand.java new file mode 100644 index 00000000..b5c0fc1f --- /dev/null +++ b/core/src/main/java/com/xujiayao/discord_mc_chat/commands/impl/UnlinkCommand.java @@ -0,0 +1,89 @@ +package com.xujiayao.discord_mc_chat.commands.impl; + +import com.xujiayao.discord_mc_chat.commands.Command; +import com.xujiayao.discord_mc_chat.commands.CommandSender; +import com.xujiayao.discord_mc_chat.network.NetworkManager; +import com.xujiayao.discord_mc_chat.network.packets.linking.UnlinkByUuidRequestPacket; +import com.xujiayao.discord_mc_chat.server.linking.LinkedAccountManager; +import com.xujiayao.discord_mc_chat.utils.config.ModeManager; +import com.xujiayao.discord_mc_chat.utils.i18n.I18nManager; + +/** + * Unlink command implementation supporting both MC-side and Discord-side workflows. + *

+ * Minecraft side: Unlinks the executing player's Minecraft account. + * Discord side: Unlinks all Minecraft accounts associated with the Discord user. + * + * @author Xujiayao + */ +public class UnlinkCommand implements Command { + + @Override + public String name() { + return "unlink"; + } + + @Override + public CommandArgument[] args() { + return new CommandArgument[0]; + } + + @Override + public String description() { + return I18nManager.getDmccTranslation("commands.unlink.description"); + } + + @Override + public boolean isVisibleInHelp(CommandSender sender) { + return (sender instanceof LinkCommand.PlayerContextProvider) || (sender instanceof LinkCommand.DiscordUserContextProvider); + } + + @Override + public void execute(CommandSender sender, String... args) { + if (sender instanceof LinkCommand.PlayerContextProvider player) { + executeMcUnlink(sender, player); + } else if (sender instanceof LinkCommand.DiscordUserContextProvider discord) { + executeDiscordUnlink(sender, discord); + } else { + sender.reply(I18nManager.getDmccTranslation("commands.unlink.not_available")); + } + } + + /** + * Minecraft-side unlink: removes the executing player's link. + */ + private void executeMcUnlink(CommandSender sender, LinkCommand.PlayerContextProvider player) { + String uuid = player.getPlayerUuid(); + String name = player.getPlayerName(); + + switch (ModeManager.getMode()) { + case "single_server" -> { + boolean success = LinkedAccountManager.unlinkByMinecraftUuid(uuid); + if (success) { + sender.reply(I18nManager.getDmccTranslation("commands.unlink.success")); + } else { + sender.reply(I18nManager.getDmccTranslation("commands.unlink.not_linked")); + } + } + case "multi_server_client" -> { + NetworkManager.sendPacketToServer(new UnlinkByUuidRequestPacket(uuid, name)); + sender.reply(I18nManager.getDmccTranslation("commands.unlink.request_sent")); + } + default -> sender.reply(I18nManager.getDmccTranslation("commands.unlink.not_available")); + } + } + + /** + * Discord-side unlink: removes all links for the Discord user. + */ + private void executeDiscordUnlink(CommandSender sender, LinkCommand.DiscordUserContextProvider discord) { + String discordId = discord.getDiscordUserId(); + int count = LinkedAccountManager.unlinkByDiscordId(discordId); + + if (count > 0) { + sender.reply(I18nManager.getDmccTranslation("commands.unlink.discord_success", count)); + } else { + sender.reply(I18nManager.getDmccTranslation("commands.unlink.not_linked")); + } + } +} diff --git a/core/src/main/java/com/xujiayao/discord_mc_chat/network/packets/linking/LinkCodeRequestPacket.java b/core/src/main/java/com/xujiayao/discord_mc_chat/network/packets/linking/LinkCodeRequestPacket.java new file mode 100644 index 00000000..2f3b783c --- /dev/null +++ b/core/src/main/java/com/xujiayao/discord_mc_chat/network/packets/linking/LinkCodeRequestPacket.java @@ -0,0 +1,21 @@ +package com.xujiayao.discord_mc_chat.network.packets.linking; + +import com.xujiayao.discord_mc_chat.network.packets.Packet; + +/** + * Sent by Client to Server to request a verification code for a Minecraft player. + *

+ * When a player joins the server or runs {@code /dmcc link}, the Client sends this packet + * so the Server can generate or refresh a verification code. + * + * @author Xujiayao + */ +public class LinkCodeRequestPacket extends Packet { + public String minecraftUuid; + public String playerName; + + public LinkCodeRequestPacket(String minecraftUuid, String playerName) { + this.minecraftUuid = minecraftUuid; + this.playerName = playerName; + } +} diff --git a/core/src/main/java/com/xujiayao/discord_mc_chat/network/packets/linking/LinkCodeResponsePacket.java b/core/src/main/java/com/xujiayao/discord_mc_chat/network/packets/linking/LinkCodeResponsePacket.java new file mode 100644 index 00000000..7fb2db43 --- /dev/null +++ b/core/src/main/java/com/xujiayao/discord_mc_chat/network/packets/linking/LinkCodeResponsePacket.java @@ -0,0 +1,23 @@ +package com.xujiayao.discord_mc_chat.network.packets.linking; + +import com.xujiayao.discord_mc_chat.network.packets.Packet; + +/** + * Sent by Server to Client in response to a {@link LinkCodeRequestPacket}. + *

+ * Contains the generated verification code for the Minecraft player, + * or indicates that the player is already linked. + * + * @author Xujiayao + */ +public class LinkCodeResponsePacket extends Packet { + public String minecraftUuid; + public String code; + public boolean alreadyLinked; + + public LinkCodeResponsePacket(String minecraftUuid, String code, boolean alreadyLinked) { + this.minecraftUuid = minecraftUuid; + this.code = code; + this.alreadyLinked = alreadyLinked; + } +} diff --git a/core/src/main/java/com/xujiayao/discord_mc_chat/network/packets/linking/UnlinkByUuidRequestPacket.java b/core/src/main/java/com/xujiayao/discord_mc_chat/network/packets/linking/UnlinkByUuidRequestPacket.java new file mode 100644 index 00000000..9e4ef12b --- /dev/null +++ b/core/src/main/java/com/xujiayao/discord_mc_chat/network/packets/linking/UnlinkByUuidRequestPacket.java @@ -0,0 +1,20 @@ +package com.xujiayao.discord_mc_chat.network.packets.linking; + +import com.xujiayao.discord_mc_chat.network.packets.Packet; + +/** + * Sent by Client to Server to unlink a Minecraft player by UUID. + *

+ * Used when a Minecraft player runs {@code /dmcc unlink} in multi-server mode. + * + * @author Xujiayao + */ +public class UnlinkByUuidRequestPacket extends Packet { + public String minecraftUuid; + public String playerName; + + public UnlinkByUuidRequestPacket(String minecraftUuid, String playerName) { + this.minecraftUuid = minecraftUuid; + this.playerName = playerName; + } +} diff --git a/core/src/main/java/com/xujiayao/discord_mc_chat/network/packets/linking/UnlinkByUuidResponsePacket.java b/core/src/main/java/com/xujiayao/discord_mc_chat/network/packets/linking/UnlinkByUuidResponsePacket.java new file mode 100644 index 00000000..845b12ed --- /dev/null +++ b/core/src/main/java/com/xujiayao/discord_mc_chat/network/packets/linking/UnlinkByUuidResponsePacket.java @@ -0,0 +1,20 @@ +package com.xujiayao.discord_mc_chat.network.packets.linking; + +import com.xujiayao.discord_mc_chat.network.packets.Packet; + +/** + * Sent by Server to Client in response to an {@link UnlinkByUuidRequestPacket}. + *

+ * Contains the result of the unlink operation. + * + * @author Xujiayao + */ +public class UnlinkByUuidResponsePacket extends Packet { + public String minecraftUuid; + public boolean success; + + public UnlinkByUuidResponsePacket(String minecraftUuid, boolean success) { + this.minecraftUuid = minecraftUuid; + this.success = success; + } +} diff --git a/core/src/main/java/com/xujiayao/discord_mc_chat/server/ServerDMCC.java b/core/src/main/java/com/xujiayao/discord_mc_chat/server/ServerDMCC.java index 073cc2de..4b0a748a 100644 --- a/core/src/main/java/com/xujiayao/discord_mc_chat/server/ServerDMCC.java +++ b/core/src/main/java/com/xujiayao/discord_mc_chat/server/ServerDMCC.java @@ -1,6 +1,7 @@ package com.xujiayao.discord_mc_chat.server; 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.i18n.I18nManager; import java.util.concurrent.ExecutorService; @@ -34,6 +35,12 @@ public class ServerDMCC { public int start() { try (ExecutorService executor = Executors.newSingleThreadExecutor(r -> new Thread(r, "DMCC-Server"))) { return executor.submit(() -> { + // Load linked accounts before Discord initialization + if (!LinkedAccountManager.load()) { + LOGGER.warn(I18nManager.getDmccTranslation("linking.manager.load_failed")); + // Non-fatal: continue with empty linked accounts + } + if (!DiscordManager.init()) { LOGGER.error(I18nManager.getDmccTranslation("server.discord_init_failed")); return -1; @@ -55,6 +62,7 @@ public class ServerDMCC { nettyServer.stop(); } + LinkedAccountManager.shutdown(); DiscordManager.shutdown(); } } 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 437c9819..fe693743 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 @@ -17,10 +17,16 @@ import com.xujiayao.discord_mc_chat.network.packets.commands.ExecuteAutoComplete import com.xujiayao.discord_mc_chat.network.packets.commands.ExecuteResponsePacket; import com.xujiayao.discord_mc_chat.network.packets.commands.InfoResponsePacket; import com.xujiayao.discord_mc_chat.network.packets.events.MinecraftEventPacket; +import com.xujiayao.discord_mc_chat.network.packets.linking.LinkCodeRequestPacket; +import com.xujiayao.discord_mc_chat.network.packets.linking.LinkCodeResponsePacket; +import com.xujiayao.discord_mc_chat.network.packets.linking.UnlinkByUuidRequestPacket; +import com.xujiayao.discord_mc_chat.network.packets.linking.UnlinkByUuidResponsePacket; 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; 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.VerificationCodeManager; 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; @@ -102,6 +108,18 @@ public class ServerHandler extends SimpleChannelInboundHandler { NetworkManager.cacheExecuteAutoCompleteResponse(clientName, p.suggestions); case ConsoleAutoCompleteResponsePacket p -> NetworkManager.cacheConsoleAutoCompleteResponse(clientName, p.suggestions); + case LinkCodeRequestPacket p -> { + if (LinkedAccountManager.isMinecraftUuidLinked(p.minecraftUuid)) { + ctx.writeAndFlush(new LinkCodeResponsePacket(p.minecraftUuid, null, true)); + } else { + String code = VerificationCodeManager.generateOrRefreshCode(p.minecraftUuid, p.playerName); + ctx.writeAndFlush(new LinkCodeResponsePacket(p.minecraftUuid, code, false)); + } + } + case UnlinkByUuidRequestPacket p -> { + boolean success = LinkedAccountManager.unlinkByMinecraftUuid(p.minecraftUuid); + ctx.writeAndFlush(new UnlinkByUuidResponsePacket(p.minecraftUuid, success)); + } case null, default -> LOGGER.warn(unexpectedPacketMessage); } } else { 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 96c38ce0..b3da2c78 100644 --- a/core/src/main/java/com/xujiayao/discord_mc_chat/server/discord/DiscordEventHandler.java +++ b/core/src/main/java/com/xujiayao/discord_mc_chat/server/discord/DiscordEventHandler.java @@ -4,6 +4,7 @@ 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; @@ -38,7 +39,7 @@ public class DiscordEventHandler extends ListenerAdapter { * 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. TODO: Account linking (linked MC account's actual OP level). + * 3. Account linking: linked MC account's actual OP level (reserved for future implementation). * * @param member The Discord Member object (null if in DMs). * @param user The Discord User object. @@ -71,9 +72,11 @@ public class DiscordEventHandler extends ListenerAdapter { } } - // TODO: Account Linking logic - // If maxOp is still -1, query links.json for linked Minecraft UUID - // and fetch exact OP level from the bound MC account. + // 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; } @@ -116,6 +119,10 @@ public class DiscordEventHandler extends ListenerAdapter { String stat = event.getOption("stat", OptionMapping::getAsString); CommandManager.execute(new JdaCommandSender(event, opLevel), name, type, stat); } + case "link" -> { + String code = event.getOption("code", OptionMapping::getAsString); + CommandManager.execute(new JdaCommandSender(event, opLevel), name, code); + } default -> CommandManager.execute(new JdaCommandSender(event, opLevel), name); } } 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 5b0ed3c0..1932b622 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 @@ -118,6 +118,12 @@ public class DiscordManager { .addOption(OptionType.STRING, "stat", I18nManager.getDmccTranslation("commands.stats.args_desc.stat"), true, true)); } + // Account linking commands (available in both standalone and single_server modes) + commands.add(Commands.slash("link", I18nManager.getDmccTranslation("commands.link.description")) + .addOption(OptionType.STRING, "code", I18nManager.getDmccTranslation("commands.link.args_desc.code"), true)); + commands.add(Commands.slash("unlink", I18nManager.getDmccTranslation("commands.unlink.description"))); + commands.add(Commands.slash("links", I18nManager.getDmccTranslation("commands.links.description"))); + CompletableFuture> updateFuture = jda.updateCommands().addCommands(commands).submit(); CompletableFuture checkFuture = CompletableFuture.runAsync(() -> { if (!updateFuture.isDone()) { 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 4497ebab..53004f69 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 @@ -1,6 +1,7 @@ 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.events.interaction.command.SlashCommandInteractionEvent; import net.dv8tion.jda.api.utils.FileUpload; @@ -14,10 +15,13 @@ import static com.xujiayao.discord_mc_chat.Constants.LOGGER; *

* For the execute command, the actual results are sent via webhooks by DiscordManager. * This sender only provides the ephemeral acknowledgement to the slash command invoker. + *

+ * Implements {@link LinkCommand.DiscordUserContextProvider} to provide the Discord user ID + * for account linking commands. * * @author Xujiayao */ -public class JdaCommandSender implements CommandSender { +public class JdaCommandSender implements CommandSender, LinkCommand.DiscordUserContextProvider { private final SlashCommandInteractionEvent event; private final int opLevel; @@ -65,4 +69,9 @@ public class JdaCommandSender implements CommandSender { public int getOpLevel() { return opLevel; } + + @Override + public String getDiscordUserId() { + return event.getUser().getId(); + } } diff --git a/core/src/main/java/com/xujiayao/discord_mc_chat/server/linking/VerificationCodeManager.java b/core/src/main/java/com/xujiayao/discord_mc_chat/server/linking/VerificationCodeManager.java new file mode 100644 index 00000000..f2cc442a --- /dev/null +++ b/core/src/main/java/com/xujiayao/discord_mc_chat/server/linking/VerificationCodeManager.java @@ -0,0 +1,149 @@ +package com.xujiayao.discord_mc_chat.server.linking; + +import com.xujiayao.discord_mc_chat.utils.i18n.I18nManager; + +import java.security.SecureRandom; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import static com.xujiayao.discord_mc_chat.Constants.LOGGER; + +/** + * Manages temporary verification codes for the secure account linking workflow. + *

+ * Verification codes are 6-character alphanumeric strings that are valid for 5 minutes. + * Codes are stored in-memory and mapped from code to the pending verification details. + *

+ * This manager runs on the Server side and is the single source of truth for code validation. + * + * @author Xujiayao + */ +public class VerificationCodeManager { + + private static final int CODE_LENGTH = 6; + private static final long CODE_EXPIRY_MILLIS = 5 * 60 * 1000L; // 5 minutes + private static final char[] CODE_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789".toCharArray(); // No I/O/0/1 for readability + private static final SecureRandom RANDOM = new SecureRandom(); + + // Code -> PendingVerification + private static final Map PENDING_CODES = new ConcurrentHashMap<>(); + + // Minecraft UUID -> Code (for fast lookup by player UUID) + private static final Map UUID_TO_CODE = new ConcurrentHashMap<>(); + + /** + * A pending verification entry. + * + * @param minecraftUuid The UUID of the Minecraft player requesting linking. + * @param playerName The display name of the Minecraft player (for messages only). + * @param expiresAt The timestamp (epoch millis) when this code expires. + */ + public record PendingVerification(String minecraftUuid, String playerName, long expiresAt) { + } + + /** + * Generates or refreshes a verification code for a Minecraft player. + *

+ * If the player already has an unexpired code, the same code is returned + * with its expiry time reset to 5 minutes from now. If the code has expired + * or does not exist, a new code is generated. + * + * @param minecraftUuid The UUID of the Minecraft player. + * @param playerName The display name of the Minecraft player. + * @return The verification code. + */ + public static String generateOrRefreshCode(String minecraftUuid, String playerName) { + purgeExpired(); + + String existingCode = UUID_TO_CODE.get(minecraftUuid); + if (existingCode != null) { + PendingVerification existing = PENDING_CODES.get(existingCode); + if (existing != null && existing.expiresAt() > System.currentTimeMillis()) { + // Refresh expiry time for the existing code + long newExpiry = System.currentTimeMillis() + CODE_EXPIRY_MILLIS; + PENDING_CODES.put(existingCode, new PendingVerification(minecraftUuid, playerName, newExpiry)); + LOGGER.info(I18nManager.getDmccTranslation("linking.verification.refreshed", existingCode, playerName)); + return existingCode; + } else { + // Code has expired, remove it + PENDING_CODES.remove(existingCode); + UUID_TO_CODE.remove(minecraftUuid); + } + } + + // Generate a new unique code + String code; + do { + code = generateCode(); + } while (PENDING_CODES.containsKey(code)); + + long expiresAt = System.currentTimeMillis() + CODE_EXPIRY_MILLIS; + PENDING_CODES.put(code, new PendingVerification(minecraftUuid, playerName, expiresAt)); + UUID_TO_CODE.put(minecraftUuid, code); + + LOGGER.info(I18nManager.getDmccTranslation("linking.verification.generated", code, playerName)); + return code; + } + + /** + * Validates and consumes a verification code. If the code is valid and not expired, + * it is removed from the pending map and the associated player info is returned. + * + * @param code The verification code to validate. + * @return The PendingVerification details if the code is valid, or null if invalid/expired. + */ + public static PendingVerification consumeCode(String code) { + purgeExpired(); + + String upperCode = code.toUpperCase(); + PendingVerification pending = PENDING_CODES.remove(upperCode); + + if (pending == null) { + return null; + } + + if (pending.expiresAt() <= System.currentTimeMillis()) { + UUID_TO_CODE.remove(pending.minecraftUuid()); + return null; + } + + UUID_TO_CODE.remove(pending.minecraftUuid()); + LOGGER.info(I18nManager.getDmccTranslation("linking.verification.consumed", upperCode, pending.playerName())); + return pending; + } + + /** + * Clears all pending verification codes. + */ + public static void clear() { + PENDING_CODES.clear(); + UUID_TO_CODE.clear(); + } + + /** + * Removes expired codes from the pending map. + */ + private static void purgeExpired() { + long now = System.currentTimeMillis(); + PENDING_CODES.entrySet().removeIf(entry -> { + if (entry.getValue().expiresAt() <= now) { + UUID_TO_CODE.remove(entry.getValue().minecraftUuid()); + return true; + } + return false; + }); + } + + /** + * Generates a random 6-character verification code. + * + * @return The generated code. + */ + private static String generateCode() { + StringBuilder sb = new StringBuilder(CODE_LENGTH); + for (int i = 0; i < CODE_LENGTH; i++) { + sb.append(CODE_CHARS[RANDOM.nextInt(CODE_CHARS.length)]); + } + return sb.toString(); + } +} 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 5bcef60d..41cdb623 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 @@ -4,6 +4,7 @@ import com.xujiayao.discord_mc_chat.commands.CommandSender; import java.util.List; import java.util.concurrent.CompletableFuture; +import java.util.function.Consumer; /** * Core events for communication between DMCC Core and Minecraft-specific implementations. @@ -53,4 +54,84 @@ public class CoreEvents { List suggestions ) { } + + /** + * Posted when a Minecraft player needs a verification code for account linking. + *

+ * The handler should resolve the player's UUID and name from the Minecraft server + * and invoke the callback with the result. + * + * @param playerUuid The UUID of the Minecraft player (as string). + * @param playerName The display name of the Minecraft player. + * @param callback A consumer that receives the generated verification code, + * or null if the player is already linked. + */ + public record LinkCodeRequestEvent( + String playerUuid, + String playerName, + Consumer callback + ) { + } + + /** + * Posted when a Minecraft player wants to unlink their account. + *

+ * The handler should remove the link for the given UUID and invoke the callback. + * + * @param playerUuid The UUID of the Minecraft player (as string). + * @param callback A consumer that receives true if unlink was successful, false otherwise. + */ + public record UnlinkByUuidEvent( + String playerUuid, + Consumer callback + ) { + } + + /** + * Posted when a Minecraft player joins the server to check their account link status. + *

+ * The handler should check if the player's UUID is linked. If not linked, + * a verification code should be generated and sent back via the callback. + * + * @param playerUuid The UUID of the Minecraft player (as string). + * @param playerName The display name of the Minecraft player. + * @param callback A consumer that receives the verification code if not linked, + * or null if already linked. + */ + public record PlayerJoinLinkCheckEvent( + String playerUuid, + String playerName, + Consumer callback + ) { + } + + /** + * Posted by the Client when receiving a link code response from the Server. + *

+ * The Minecraft module should notify the player with the verification code. + * + * @param playerUuid The UUID of the Minecraft player. + * @param code The verification code, or null if already linked. + * @param alreadyLinked Whether the player is already linked. + */ + public record LinkCodeResponseEvent( + String playerUuid, + String code, + boolean alreadyLinked + ) { + } + + /** + * Posted by the Client when receiving an unlink response from the Server. + *

+ * The Minecraft module should notify the player with the result. + * + * @param playerUuid The UUID of the Minecraft player. + * @param success Whether the unlink was successful. + */ + public record UnlinkResponseEvent( + String playerUuid, + boolean success + ) { + } } diff --git a/core/src/main/resources/config/config_multi_server_client.yml b/core/src/main/resources/config/config_multi_server_client.yml index 7570296e..ae549930 100644 --- a/core/src/main/resources/config/config_multi_server_client.yml +++ b/core/src/main/resources/config/config_multi_server_client.yml @@ -38,9 +38,11 @@ multi_server: command_permission_levels: help: -1 info: -1 + link: 0 log: 4 reload: 4 stats: -1 + unlink: 0 whitelist: 0 # 统计数据收集设置 diff --git a/core/src/main/resources/config/config_single_server.yml b/core/src/main/resources/config/config_single_server.yml index 2eed9d9e..3eaace6a 100644 --- a/core/src/main/resources/config/config_single_server.yml +++ b/core/src/main/resources/config/config_single_server.yml @@ -145,9 +145,12 @@ command_permission_levels: console: 0 help: -1 info: -1 + link: 0 + links: 4 log: 4 reload: 4 stats: -1 + unlink: 0 whitelist: 0 # 检查更新设置 diff --git a/core/src/main/resources/config/config_standalone.yml b/core/src/main/resources/config/config_standalone.yml index e78a802b..93873475 100644 --- a/core/src/main/resources/config/config_standalone.yml +++ b/core/src/main/resources/config/config_standalone.yml @@ -179,9 +179,12 @@ command_permission_levels: execute: -1 help: -1 info: -1 + link: 0 + links: 4 log: 4 reload: 4 shutdown: 4 # This is for standalone DMCC only, which will exit JVM + unlink: 0 # 检查更新设置 check_for_updates: diff --git a/core/src/main/resources/lang/en_us.yml b/core/src/main/resources/lang/en_us.yml index eb2c77dc..d812b174 100644 --- a/core/src/main/resources/lang/en_us.yml +++ b/core/src/main/resources/lang/en_us.yml @@ -124,6 +124,31 @@ commands: description: "Add a player to the server whitelist" args_desc: player: "Player name" + link: + description: "Link your Minecraft account to Discord" + args_desc: + code: "Verification code from Minecraft" + player_only: "This command can only be used by a Minecraft player." + discord_only: "This command can only be used from Discord." + not_available: "This command is not available in the current mode." + already_linked: "Your Minecraft account is already linked to a Discord account." + code_generated: "Your verification code is: {}. Use /link {} on Discord to complete linking. The code expires in 5 minutes." + code_requested: "Verification code request sent. Check your Minecraft chat for the code." + invalid_code: "Invalid or expired verification code." + success: "Successfully linked to Minecraft player \"{}\"!" + uuid_already_linked: "This Minecraft account is already linked to another Discord user." + unlink: + description: "Unlink your Minecraft account from Discord" + not_available: "This command is not available in the current mode." + success: "Your Minecraft account has been unlinked." + not_linked: "No linked account found." + request_sent: "Unlink request sent to the server." + discord_success: "Successfully unlinked {} Minecraft account(s)." + links: + description: "View all linked accounts" + no_links: "No linked accounts found." + title: "Linked Accounts" + total: "Total: {} linked account(s) across {} Discord user(s)." main: init: @@ -233,6 +258,24 @@ utils: custom_load_failed: "Failed to load custom messages." fully_loaded: "DMCC language resources fully loaded!" +linking: + manager: + loaded: "Loaded {} linked account(s)." + load_failed: "Failed to load linked accounts." + saved: "Linked accounts saved successfully." + save_failed: "Failed to save linked accounts." + linked: "Linked Discord user {} to Minecraft UUID {}." + unlinked_discord: "Unlinked {} Minecraft account(s) from Discord user {}." + unlinked_minecraft: "Unlinked Minecraft UUID {} from Discord user {}." + uuid_already_linked: "Minecraft UUID {} is already linked to Discord user {}." + verification: + generated: "Generated verification code {} for player {}." + refreshed: "Refreshed verification code {} for player {}." + consumed: "Verification code {} consumed by player {}." + player_join: + not_linked: "Your Minecraft account is not linked to Discord. Use /dmcc link to get a verification code." + code_hint: "Use /link {} on Discord within 5 minutes to link your account." + minecraft: translations: loaded: "Loaded {}/{} Minecraft \"{}\" translations." diff --git a/core/src/main/resources/lang/zh_cn.yml b/core/src/main/resources/lang/zh_cn.yml index 95307788..3c7eb8ff 100644 --- a/core/src/main/resources/lang/zh_cn.yml +++ b/core/src/main/resources/lang/zh_cn.yml @@ -124,6 +124,31 @@ commands: description: "将玩家添加到服务器白名单" args_desc: player: "玩家名称" + link: + description: "将你的 Minecraft 账户绑定到 Discord" + args_desc: + code: "来自 Minecraft 的验证码" + player_only: "此命令只能由 Minecraft 玩家使用。" + discord_only: "此命令只能在 Discord 上使用。" + not_available: "此命令在当前模式下不可用。" + already_linked: "你的 Minecraft 账户已绑定到一个 Discord 账户。" + code_generated: "你的验证码是:{}。在 Discord 上使用 /link {} 来完成绑定。验证码将在 5 分钟后过期。" + code_requested: "验证码请求已发送。请查看你的 Minecraft 聊天获取验证码。" + invalid_code: "无效或已过期的验证码。" + success: "成功绑定 Minecraft 玩家 \"{}\"!" + uuid_already_linked: "此 Minecraft 账户已绑定到另一个 Discord 用户。" + unlink: + description: "解除你的 Minecraft 账户与 Discord 的绑定" + not_available: "此命令在当前模式下不可用。" + success: "你的 Minecraft 账户已解除绑定。" + not_linked: "未找到已绑定的账户。" + request_sent: "解绑请求已发送到服务端。" + discord_success: "已成功解除 {} 个 Minecraft 账户的绑定。" + links: + description: "查看所有已绑定的账户" + no_links: "未找到已绑定的账户。" + title: "已绑定账户" + total: "共计:{} 个已绑定账户,关联 {} 个 Discord 用户。" main: init: @@ -233,6 +258,24 @@ utils: custom_load_failed: "加载自定义消息失败。" fully_loaded: "DMCC 语言资源已完全加载!" +linking: + manager: + loaded: "已加载 {} 个已绑定账户。" + load_failed: "加载已绑定账户失败。" + saved: "已绑定账户保存成功。" + save_failed: "保存已绑定账户失败。" + linked: "已将 Discord 用户 {} 绑定到 Minecraft UUID {}。" + unlinked_discord: "已解除 Discord 用户 {} 的 {} 个 Minecraft 账户绑定。" + unlinked_minecraft: "已解除 Minecraft UUID {} 与 Discord 用户 {} 的绑定。" + uuid_already_linked: "Minecraft UUID {} 已绑定到 Discord 用户 {}。" + verification: + generated: "已为玩家 {} 生成验证码 {}。" + refreshed: "已为玩家 {} 刷新验证码 {}。" + consumed: "验证码 {} 已被玩家 {} 使用。" + player_join: + not_linked: "你的 Minecraft 账户尚未绑定 Discord。使用 /dmcc link 获取验证码。" + code_hint: "在 5 分钟内在 Discord 上使用 /link {} 来绑定你的账户。" + minecraft: translations: loaded: "已加载 {}/{} 条 Minecraft \"{}\" 翻译。" diff --git a/minecraft/src/main/java/com/xujiayao/discord_mc_chat/minecraft/commands/MinecraftCommands.java b/minecraft/src/main/java/com/xujiayao/discord_mc_chat/minecraft/commands/MinecraftCommands.java index 4fbc9085..7c04c5fc 100644 --- a/minecraft/src/main/java/com/xujiayao/discord_mc_chat/minecraft/commands/MinecraftCommands.java +++ b/minecraft/src/main/java/com/xujiayao/discord_mc_chat/minecraft/commands/MinecraftCommands.java @@ -3,6 +3,7 @@ package com.xujiayao.discord_mc_chat.minecraft.commands; import com.mojang.brigadier.CommandDispatcher; import com.xujiayao.discord_mc_chat.commands.CommandManager; import com.xujiayao.discord_mc_chat.commands.LocalCommandSender; +import com.xujiayao.discord_mc_chat.commands.impl.LinkCommand; import com.xujiayao.discord_mc_chat.utils.config.ConfigManager; import net.minecraft.commands.CommandSourceStack; import net.minecraft.commands.SharedSuggestionProvider; @@ -11,6 +12,7 @@ import net.minecraft.core.Holder; import net.minecraft.core.registries.BuiltInRegistries; import net.minecraft.network.chat.Component; import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.level.ServerPlayer; import net.minecraft.stats.StatType; import java.util.Optional; @@ -79,12 +81,26 @@ public class MinecraftCommands { CommandManager.execute(new MinecraftCommandSender(ctx.getSource()), "stats", typeLoc.toString(), statLoc.toString()); return 1; }))); + var link = literal("link") + .requires(source -> source.hasPermission(ConfigManager.getInt("command_permission_levels.link", 0))) + .executes(ctx -> { + CommandManager.execute(new MinecraftPlayerCommandSender(ctx.getSource()), "link"); + return 1; + }); + var unlink = literal("unlink") + .requires(source -> source.hasPermission(ConfigManager.getInt("command_permission_levels.unlink", 0))) + .executes(ctx -> { + CommandManager.execute(new MinecraftPlayerCommandSender(ctx.getSource()), "unlink"); + return 1; + }); dispatcher.register(root .then(help) .then(info) .then(reload) - .then(stats)); + .then(stats) + .then(link) + .then(unlink)); } /** @@ -117,4 +133,50 @@ public class MinecraftCommands { return 0; } } + + /** + * Command sender implementation for Minecraft players, providing player context + * (UUID and name) for account linking commands. + *

+ * If the command source is not a player (e.g., console or command block), + * the player context methods return null/empty values. + * + * @author Xujiayao + */ + private record MinecraftPlayerCommandSender(CommandSourceStack source) + implements LocalCommandSender, LinkCommand.PlayerContextProvider { + + @Override + public void reply(String message) { + for (String line : message.split("\n")) { + source.sendSuccess(() -> Component.literal(line), false); + } + } + + @Override + public int getOpLevel() { + for (int level = 4; level >= 0; level--) { + if (source.hasPermission(level)) { + return level; + } + } + return 0; + } + + @Override + public String getPlayerUuid() { + if (source.getEntity() instanceof ServerPlayer player) { + return player.getStringUUID(); + } + return null; + } + + @Override + public String getPlayerName() { + if (source.getEntity() instanceof ServerPlayer player) { + return player.getName().getString(); + } + return source.getTextName(); + } + } } 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 78e6e01b..7f8081f7 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 @@ -12,6 +12,9 @@ import com.xujiayao.discord_mc_chat.minecraft.translations.TranslationManager; import com.xujiayao.discord_mc_chat.network.NetworkManager; import com.xujiayao.discord_mc_chat.network.packets.commands.InfoResponsePacket; import com.xujiayao.discord_mc_chat.network.packets.events.MinecraftEventPacket; +import com.xujiayao.discord_mc_chat.network.packets.linking.LinkCodeRequestPacket; +import com.xujiayao.discord_mc_chat.server.linking.LinkedAccountManager; +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; import com.xujiayao.discord_mc_chat.utils.config.ModeManager; @@ -137,6 +140,29 @@ public class MinecraftEventHandler { "display_name", event.serverPlayer().getDisplayName().getString() ); NetworkManager.sendPacketToServer(new MinecraftEventPacket(MinecraftEventPacket.MessageType.PLAYER_JOIN, placeholders)); + + // Account linking: check if this player is linked + String playerUuid = event.serverPlayer().getStringUUID(); + String playerName = event.serverPlayer().getName().getString(); + + switch (ModeManager.getMode()) { + case "single_server" -> { + // Direct access to server-side managers + if (!LinkedAccountManager.isMinecraftUuidLinked(playerUuid)) { + String code = VerificationCodeManager.generateOrRefreshCode(playerUuid, playerName); + // Notify the player in-game + ServerPlayer sp = event.serverPlayer(); + sp.sendSystemMessage(Component.literal( + I18nManager.getDmccTranslation("linking.player_join.not_linked"))); + sp.sendSystemMessage(Component.literal( + I18nManager.getDmccTranslation("linking.player_join.code_hint", code))); + } + } + case "multi_server_client" -> { + // Send request to standalone server + NetworkManager.sendPacketToServer(new LinkCodeRequestPacket(playerUuid, playerName)); + } + } }); EventManager.register(MinecraftEvents.PlayerQuit.class, event -> { @@ -320,6 +346,51 @@ public class MinecraftEventHandler { } catch (Exception ignored) { } }); + + // ===== Account Linking Response Events ===== + + EventManager.register(CoreEvents.LinkCodeResponseEvent.class, event -> { + if (serverInstance == null) return; + + // Find the player and notify them + serverInstance.execute(() -> { + try { + UUID uuid = UUID.fromString(event.playerUuid()); + ServerPlayer player = serverInstance.getPlayerList().getPlayer(uuid); + if (player != null) { + if (event.alreadyLinked()) { + player.sendSystemMessage(Component.literal( + I18nManager.getDmccTranslation("commands.link.already_linked"))); + } else if (event.code() != null) { + player.sendSystemMessage(Component.literal( + I18nManager.getDmccTranslation("linking.player_join.code_hint", event.code()))); + } + } + } catch (Exception ignored) { + } + }); + }); + + EventManager.register(CoreEvents.UnlinkResponseEvent.class, event -> { + if (serverInstance == null) return; + + serverInstance.execute(() -> { + try { + UUID uuid = UUID.fromString(event.playerUuid()); + ServerPlayer player = serverInstance.getPlayerList().getPlayer(uuid); + if (player != null) { + if (event.success()) { + player.sendSystemMessage(Component.literal( + I18nManager.getDmccTranslation("commands.unlink.success"))); + } else { + player.sendSystemMessage(Component.literal( + I18nManager.getDmccTranslation("commands.unlink.not_linked"))); + } + } + } catch (Exception ignored) { + } + }); + }); } private static List getSuggestionsForInput(String input, CommandSourceStack source) throws Exception {