mirror of
https://github.com/System-End/Discord-MC-Chat.git
synced 2026-04-19 19:45:14 +00:00
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:
parent
0f5bda94ca
commit
bde4900e98
23 changed files with 943 additions and 6 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
# 统计数据收集设置
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
# 检查更新设置
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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 \"{}\" 翻译。"
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue