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>
This commit is contained in:
copilot-swe-agent[bot] 2026-03-09 15:33:33 +00:00 committed by Jason Xu
parent 0f5bda94ca
commit bde4900e98
23 changed files with 943 additions and 6 deletions

View file

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

View file

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

View file

@ -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.
* <p>
* <b>Minecraft side (0 args):</b> Generates or refreshes a verification code for the executing player.
* Available in single_server and multi_server_client modes.
* <p>
* <b>Discord side (1 arg):</b> 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.
* <p>
* 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.
* <p>
* 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 <code>"));
}
}
/**
* 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"));
}
}
}

View file

@ -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.
* <p>
* 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<String, List<LinkedAccountManager.LinkEntry>> 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<String, List<LinkedAccountManager.LinkEntry>> entry : allLinks.entrySet()) {
String discordId = entry.getKey();
List<LinkedAccountManager.LinkEntry> 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());
}
}

View file

@ -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.
* <p>
* <b>Minecraft side:</b> Unlinks the executing player's Minecraft account.
* <b>Discord side:</b> 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"));
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<List<Command>> updateFuture = jda.updateCommands().addCommands(commands).submit();
CompletableFuture<Void> checkFuture = CompletableFuture.runAsync(() -> {
if (!updateFuture.isDone()) {

View file

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

View file

@ -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.
* <p>
* 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.
* <p>
* 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<String, PendingVerification> PENDING_CODES = new ConcurrentHashMap<>();
// Minecraft UUID -> Code (for fast lookup by player UUID)
private static final Map<String, String> 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.
* <p>
* 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();
}
}

View file

@ -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<String> suggestions
) {
}
/**
* Posted when a Minecraft player needs a verification code for account linking.
* <p>
* 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<String> callback
) {
}
/**
* Posted when a Minecraft player wants to unlink their account.
* <p>
* 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<Boolean> callback
) {
}
/**
* Posted when a Minecraft player joins the server to check their account link status.
* <p>
* 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<String> callback
) {
}
/**
* Posted by the Client when receiving a link code response from the Server.
* <p>
* 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.
* <p>
* 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
) {
}
}

View file

@ -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
# 统计数据收集设置

View file

@ -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
# 检查更新设置

View file

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

View file

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

View file

@ -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 \"{}\" 翻译。"

View file

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

View file

@ -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<String> getSuggestionsForInput(String input, CommandSourceStack source) throws Exception {