fix: rename links path, improve messages, fix help visibility, remove unnecessary messages

Co-authored-by: Xujiayao <58985541+Xujiayao@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2026-03-10 12:46:15 +00:00 committed by Jason Xu
parent 0a89500cbe
commit 95095b3c32
14 changed files with 133 additions and 51 deletions

View file

@ -196,11 +196,11 @@ public class ClientHandler extends SimpleChannelInboundHandler<Packet> {
}
case LinkResponsePacket p -> {
// Handle link code response from server - notify the player
EventManager.post(new CoreEvents.LinkCodeResponseEvent(p.minecraftUuid, p.code, p.alreadyLinked));
EventManager.post(new CoreEvents.LinkCodeResponseEvent(p.minecraftUuid, p.code, p.alreadyLinked, p.discordName != null ? p.discordName : ""));
}
case UnlinkResponsePacket p -> {
// Handle unlink response from server - notify the player
EventManager.post(new CoreEvents.UnlinkResponseEvent(p.minecraftUuid, p.success));
EventManager.post(new CoreEvents.UnlinkResponseEvent(p.minecraftUuid, p.success, p.discordName != null ? p.discordName : ""));
}
case DisconnectPacket p -> {
// If we receive a DisconnectPacket, it means the server explicitly rejected us.

View file

@ -28,6 +28,19 @@ public interface Command {
*/
String description();
/**
* Gets the list of arguments for the command, potentially adjusted for the sender context.
* <p>
* By default, delegates to {@link #args()}. Commands can override this to
* show different arguments based on sender type (e.g., Discord vs Minecraft).
*
* @param sender The command sender requesting the help listing.
* @return The command arguments appropriate for the given sender.
*/
default CommandArgument[] argsForSender(CommandSender sender) {
return args();
}
/**
* Whether this command accepts more arguments than defined by {@link #args()}.
* <p>

View file

@ -60,7 +60,7 @@ public class HelpCommand implements Command {
String commandLabel = "- " + mcPrefix + cmd.name();
maxLeftWidth = Math.max(maxLeftWidth, commandLabel.length());
Command.CommandArgument[] arguments = cmd.args();
Command.CommandArgument[] arguments = cmd.argsForSender(sender);
for (int i = 0; i < arguments.length; i++) {
boolean isLast = (i == arguments.length - 1);
String branch = isLast ? " └─ " : " ├─ ";
@ -77,7 +77,7 @@ public class HelpCommand implements Command {
.append(" | ")
.append(cmd.description());
Command.CommandArgument[] arguments = cmd.args();
Command.CommandArgument[] arguments = cmd.argsForSender(sender);
for (int i = 0; i < arguments.length; i++) {
boolean isLast = (i == arguments.length - 1);
String branch = isLast ? " └─ " : " ├─ ";

View file

@ -74,6 +74,28 @@ public class LinkCommand implements Command {
return new CommandArgument[0];
}
@Override
public CommandArgument[] argsForSender(CommandSender sender) {
// In Discord context, show the <code> argument
if (sender instanceof DiscordUserContextProvider) {
return new CommandArgument[]{
new CommandArgument() {
@Override
public String name() {
return "code";
}
@Override
public String description() {
return I18nManager.getDmccTranslation("commands.link.args_desc.code");
}
}
};
}
// In Minecraft context, show no arguments
return new CommandArgument[0];
}
@Override
public boolean acceptsExtraArgs() {
return true;
@ -86,8 +108,8 @@ public class LinkCommand implements Command {
@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);
// Always visible - link is available to players and Discord users
return true;
}
@Override
@ -124,7 +146,9 @@ public class LinkCommand implements Command {
case "single_server" -> {
// Direct access to server-side managers
if (LinkedAccountManager.isMinecraftUuidLinked(uuid)) {
sender.reply(I18nManager.getDmccTranslation("commands.link.already_linked"));
String discordId = LinkedAccountManager.getDiscordIdByMinecraftUuid(uuid);
String discordName = LinkedAccountManager.resolveDiscordName(discordId);
sender.reply(I18nManager.getDmccTranslation("commands.link.already_linked", discordName));
return;
}
String code = VerificationCodeManager.generateOrRefreshCode(uuid, name);
@ -133,7 +157,6 @@ public class LinkCommand implements Command {
case "multi_server_client" -> {
// Send request to standalone server via network
NetworkManager.sendPacketToServer(new LinkRequestPacket(uuid, name));
sender.reply(I18nManager.getDmccTranslation("commands.link.code_requested"));
}
default -> sender.reply(I18nManager.getDmccTranslation("commands.link.not_available"));
}

View file

@ -35,7 +35,8 @@ public class UnlinkCommand implements Command {
@Override
public boolean isVisibleInHelp(CommandSender sender) {
return (sender instanceof LinkCommand.PlayerContextProvider) || (sender instanceof LinkCommand.DiscordUserContextProvider);
// Always visible - unlink is available to players and Discord users
return true;
}
@Override
@ -58,16 +59,18 @@ public class UnlinkCommand implements Command {
switch (ModeManager.getMode()) {
case "single_server" -> {
boolean success = LinkedAccountManager.unlinkByMinecraftUuid(uuid, name);
if (success) {
sender.reply(I18nManager.getDmccTranslation("commands.unlink.success"));
// Look up the linked Discord ID before unlinking so we can show it
String discordId = LinkedAccountManager.getDiscordIdByMinecraftUuid(uuid);
String unlinkResult = LinkedAccountManager.unlinkByMinecraftUuid(uuid, name);
if (unlinkResult != null) {
String discordName = LinkedAccountManager.resolveDiscordName(discordId);
sender.reply(I18nManager.getDmccTranslation("commands.unlink.success", discordName));
} else {
sender.reply(I18nManager.getDmccTranslation("commands.unlink.not_linked"));
}
}
case "multi_server_client" -> {
NetworkManager.sendPacketToServer(new UnlinkRequestPacket(uuid, name));
sender.reply(I18nManager.getDmccTranslation("commands.unlink.request_sent"));
}
default -> sender.reply(I18nManager.getDmccTranslation("commands.unlink.not_available"));
}
@ -82,7 +85,7 @@ public class UnlinkCommand implements Command {
int count = LinkedAccountManager.unlinkByDiscordId(discordId, discordName);
if (count > 0) {
sender.reply(I18nManager.getDmccTranslation("commands.unlink.discord_success", count));
sender.reply(I18nManager.getDmccTranslation("commands.unlink.discord_success", count, discordName));
} else {
sender.reply(I18nManager.getDmccTranslation("commands.unlink.not_linked"));
}

View file

@ -14,10 +14,12 @@ public class LinkResponsePacket extends Packet {
public String minecraftUuid;
public String code;
public boolean alreadyLinked;
public String discordName;
public LinkResponsePacket(String minecraftUuid, String code, boolean alreadyLinked) {
public LinkResponsePacket(String minecraftUuid, String code, boolean alreadyLinked, String discordName) {
this.minecraftUuid = minecraftUuid;
this.code = code;
this.alreadyLinked = alreadyLinked;
this.discordName = discordName;
}
}

View file

@ -12,9 +12,11 @@ import com.xujiayao.discord_mc_chat.network.packets.Packet;
public class UnlinkResponsePacket extends Packet {
public String minecraftUuid;
public boolean success;
public String discordName;
public UnlinkResponsePacket(String minecraftUuid, boolean success) {
public UnlinkResponsePacket(String minecraftUuid, boolean success, String discordName) {
this.minecraftUuid = minecraftUuid;
this.success = success;
this.discordName = discordName;
}
}

View file

@ -46,6 +46,9 @@ public class ServerDMCC {
return -1;
}
// Register Discord name resolver for LinkedAccountManager log messages
LinkedAccountManager.setDiscordNameResolver(DiscordManager::resolveDiscordUserName);
nettyServer = new NettyServer(host, port, sharedSecret);
port = nettyServer.start();

View file

@ -110,15 +110,21 @@ public class ServerHandler extends SimpleChannelInboundHandler<Packet> {
NetworkManager.cacheConsoleAutoCompleteResponse(clientName, p.suggestions);
case LinkRequestPacket p -> {
if (LinkedAccountManager.isMinecraftUuidLinked(p.minecraftUuid)) {
ctx.writeAndFlush(new LinkResponsePacket(p.minecraftUuid, null, true));
String discordId = LinkedAccountManager.getDiscordIdByMinecraftUuid(p.minecraftUuid);
String discordName = DiscordManager.resolveDiscordUserName(discordId != null ? discordId : "");
ctx.writeAndFlush(new LinkResponsePacket(p.minecraftUuid, null, true, discordName));
} else {
String code = VerificationCodeManager.generateOrRefreshCode(p.minecraftUuid, p.playerName);
ctx.writeAndFlush(new LinkResponsePacket(p.minecraftUuid, code, false));
ctx.writeAndFlush(new LinkResponsePacket(p.minecraftUuid, code, false, ""));
}
}
case UnlinkRequestPacket p -> {
boolean success = LinkedAccountManager.unlinkByMinecraftUuid(p.minecraftUuid, p.playerName);
ctx.writeAndFlush(new UnlinkResponsePacket(p.minecraftUuid, success));
String unlinkedDiscordId = LinkedAccountManager.unlinkByMinecraftUuid(p.minecraftUuid, p.playerName);
String discordName = "";
if (unlinkedDiscordId != null) {
discordName = DiscordManager.resolveDiscordUserName(unlinkedDiscordId);
}
ctx.writeAndFlush(new UnlinkResponsePacket(p.minecraftUuid, unlinkedDiscordId != null, discordName));
}
case null, default -> LOGGER.warn(unexpectedPacketMessage);
}

View file

@ -12,6 +12,7 @@ import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
import static com.xujiayao.discord_mc_chat.Constants.JSON_MAPPER;
import static com.xujiayao.discord_mc_chat.Constants.LOGGER;
@ -21,19 +22,45 @@ import static com.xujiayao.discord_mc_chat.Constants.LOGGER;
* <p>
* One Discord account can link multiple Minecraft accounts (1:N).
* One Minecraft account can only link to one Discord account (N:1 uniqueness).
* Data is stored in {@code linking/links.json} in the DMCC config directory.
* Data is stored in {@code account_linking/linked_accounts.json} in the DMCC config directory.
*
* @author Xujiayao
*/
public class LinkedAccountManager {
private static final Path LINKS_FILE = Paths.get("./config/discord_mc_chat/linking/links.json");
private static final Path LINKS_FILE = Paths.get("./config/discord_mc_chat/account_linking/linked_accounts.json");
private static final ConcurrentHashMap<String, List<LinkEntry>> LINKED_ACCOUNTS = new ConcurrentHashMap<>();
// Reverse index: Minecraft UUID -> Discord ID for O(1) lookups
private static final ConcurrentHashMap<String, String> UUID_TO_DISCORD = new ConcurrentHashMap<>();
// Discord name resolver, set by the server module to avoid circular dependencies
private static Function<String, String> discordNameResolver;
/**
* Registers a function that resolves Discord user ID to username.
*
* @param resolver A function that takes Discord ID and returns the username (or the ID itself as fallback).
*/
public static void setDiscordNameResolver(Function<String, String> resolver) {
discordNameResolver = resolver;
}
/**
* Resolves a Discord username from an ID using the registered resolver.
* Falls back to the raw ID if no resolver is registered.
*
* @param discordId The Discord user ID.
* @return The resolved username, or the raw ID.
*/
public static String resolveDiscordName(String discordId) {
if (discordNameResolver != null) {
return discordNameResolver.apply(discordId);
}
return discordId;
}
/**
* A linked Minecraft account entry.
*
@ -90,7 +117,7 @@ public class LinkedAccountManager {
try {
Files.createDirectories(LINKS_FILE.getParent());
JSON_MAPPER.writerWithDefaultPrettyPrinter().writeValue(LINKS_FILE.toFile(), LINKED_ACCOUNTS);
LOGGER.info(I18nManager.getDmccTranslation("linking.manager.saved", LINKS_FILE));
LOGGER.info(I18nManager.getDmccTranslation("linking.manager.saved"));
} catch (IOException e) {
LOGGER.error(I18nManager.getDmccTranslation("linking.manager.save_failed"), e);
}
@ -108,6 +135,7 @@ public class LinkedAccountManager {
public static void shutdown() {
LINKED_ACCOUNTS.clear();
UUID_TO_DISCORD.clear();
discordNameResolver = null;
}
/**
@ -123,7 +151,8 @@ public class LinkedAccountManager {
public static synchronized boolean linkAccount(String discordId, String discordName, String minecraftUuid, String minecraftName) {
String existingDiscordId = UUID_TO_DISCORD.get(minecraftUuid);
if (existingDiscordId != null) {
LOGGER.warn(I18nManager.getDmccTranslation("linking.manager.uuid_already_linked", minecraftName, minecraftUuid, existingDiscordId));
String existingDiscordName = resolveDiscordName(existingDiscordId);
LOGGER.warn(I18nManager.getDmccTranslation("linking.manager.uuid_already_linked", minecraftName, minecraftUuid, existingDiscordName, existingDiscordId));
return false;
}
@ -161,12 +190,12 @@ public class LinkedAccountManager {
*
* @param minecraftUuid The Minecraft account UUID to unlink.
* @param minecraftName The Minecraft player name (for logging only).
* @return true if the UUID was found and removed, false otherwise.
* @return The Discord user ID that was unlinked from, or null if the UUID was not linked.
*/
public static synchronized boolean unlinkByMinecraftUuid(String minecraftUuid, String minecraftName) {
public static synchronized String unlinkByMinecraftUuid(String minecraftUuid, String minecraftName) {
String discordId = UUID_TO_DISCORD.remove(minecraftUuid);
if (discordId == null) {
return false;
return null;
}
List<LinkEntry> entries = LINKED_ACCOUNTS.get(discordId);
@ -177,9 +206,10 @@ public class LinkedAccountManager {
}
}
LOGGER.info(I18nManager.getDmccTranslation("linking.manager.unlinked_minecraft", minecraftName, minecraftUuid, discordId));
String discordName = resolveDiscordName(discordId);
LOGGER.info(I18nManager.getDmccTranslation("linking.manager.unlinked_minecraft", minecraftName, minecraftUuid, discordName, discordId));
save();
return true;
return discordId;
}
/**

View file

@ -113,11 +113,13 @@ public class CoreEvents {
* @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.
* @param discordName The Discord username if already linked (for display), or empty string.
*/
public record LinkCodeResponseEvent(
String playerUuid,
String code,
boolean alreadyLinked
boolean alreadyLinked,
String discordName
) {
}
@ -126,12 +128,14 @@ public class CoreEvents {
* <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.
* @param playerUuid The UUID of the Minecraft player.
* @param success Whether the unlink was successful.
* @param discordName The Discord username that was unlinked from (for display), or empty string.
*/
public record UnlinkResponseEvent(
String playerUuid,
boolean success
boolean success,
String discordName
) {
}
}

View file

@ -131,19 +131,17 @@ commands:
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."
already_linked: "Your Minecraft account is already linked to Discord user \"{}\"."
code_generated: "Your verification code is: {0}. Use /link {0} 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!"
success: "Your Minecraft account has been unlinked from Discord user \"{}\"!"
not_linked: "No linked account found."
request_sent: "Unlink request sent to the server."
discord_success: "Successfully unlinked {} Minecraft account(s)!"
discord_success: "Successfully unlinked {} Minecraft account(s) from Discord user \"{}\"!"
links:
description: "View all linked accounts"
no_links: "No linked accounts found."
@ -262,12 +260,12 @@ linking:
manager:
loaded: "Loaded {} linked account(s)!"
load_failed: "Failed to load linked accounts."
saved: "Linked accounts saved to {}!"
saved: "Linked accounts saved to file!"
save_failed: "Failed to save linked accounts."
linked: "Linked Discord user {} ({}) to Minecraft player {} ({})!"
unlinked_discord: "Unlinked {0} Minecraft account(s) from Discord user {1} ({2})!"
unlinked_minecraft: "Unlinked Minecraft player {} ({}) from Discord user {}!"
uuid_already_linked: "Minecraft player {} ({}) is already linked to Discord user {}."
unlinked_minecraft: "Unlinked Minecraft player {} ({}) from Discord user {} ({})!"
uuid_already_linked: "Minecraft player {} ({}) is already linked to Discord user {} ({})."
verification:
generated: "Generated verification code {} for player {}!"
refreshed: "Refreshed verification code {} for player {}!"

View file

@ -131,19 +131,17 @@ commands:
player_only: "此命令只能由 Minecraft 玩家使用。"
discord_only: "此命令只能在 Discord 上使用。"
not_available: "此命令在当前模式下不可用。"
already_linked: "你的 Minecraft 账户已绑定到一个 Discord 账户。"
already_linked: "你的 Minecraft 账户已绑定到 Discord 用户 \"{}\"。"
code_generated: "你的验证码是:{0}。在 Discord 上使用 /link {0} 来完成绑定。验证码将在 5 分钟后过期。"
code_requested: "验证码请求已发送。请查看你的 Minecraft 聊天获取验证码。"
invalid_code: "无效或已过期的验证码。"
success: "成功绑定 Minecraft 玩家 \"{}\""
uuid_already_linked: "此 Minecraft 账户已绑定到另一个 Discord 用户。"
unlink:
description: "解除你的 Minecraft 账户与 Discord 的绑定"
not_available: "此命令在当前模式下不可用。"
success: "你的 Minecraft 账户已解除绑定!"
success: "你的 Minecraft 账户已与 Discord 用户 \"{}\" 解除绑定!"
not_linked: "未找到已绑定的账户。"
request_sent: "解绑请求已发送到服务端。"
discord_success: "已成功解除 {} 个 Minecraft 账户的绑定!"
discord_success: "已成功解除 Discord 用户 \"{1}\" 的 {0} 个 Minecraft 账户绑定!"
links:
description: "查看所有已绑定的账户"
no_links: "未找到已绑定的账户。"
@ -262,12 +260,12 @@ linking:
manager:
loaded: "已加载 {} 个已绑定账户!"
load_failed: "加载已绑定账户失败。"
saved: "已绑定账户已保存到 {}"
saved: "已绑定账户已保存到文件"
save_failed: "保存已绑定账户失败。"
linked: "已将 Discord 用户 {} ({}) 绑定到 Minecraft 玩家 {} ({})"
unlinked_discord: "已解除 Discord 用户 {1} ({2}) 的 {0} 个 Minecraft 账户绑定!"
unlinked_minecraft: "已解除 Minecraft 玩家 {} ({}) 与 Discord 用户 {} 的绑定!"
uuid_already_linked: "Minecraft 玩家 {} ({}) 已绑定到 Discord 用户 {}。"
unlinked_minecraft: "已解除 Minecraft 玩家 {} ({}) 与 Discord 用户 {} ({}) 的绑定!"
uuid_already_linked: "Minecraft 玩家 {} ({}) 已绑定到 Discord 用户 {} ({})。"
verification:
generated: "已为玩家 {} 生成验证码 {}"
refreshed: "已为玩家 {} 刷新验证码 {}"

View file

@ -360,7 +360,7 @@ public class MinecraftEventHandler {
if (player != null) {
if (event.alreadyLinked()) {
player.sendSystemMessage(Component.literal(
I18nManager.getDmccTranslation("commands.link.already_linked")));
I18nManager.getDmccTranslation("commands.link.already_linked", event.discordName())));
} else if (event.code() != null) {
player.sendSystemMessage(Component.literal(
I18nManager.getDmccTranslation("linking.player_join.code_hint", event.code())));
@ -381,7 +381,7 @@ public class MinecraftEventHandler {
if (player != null) {
if (event.success()) {
player.sendSystemMessage(Component.literal(
I18nManager.getDmccTranslation("commands.unlink.success")));
I18nManager.getDmccTranslation("commands.unlink.success", event.discordName())));
} else {
player.sendSystemMessage(Component.literal(
I18nManager.getDmccTranslation("commands.unlink.not_linked")));