From c5a6d1eda5bd99c500a3f468377b1327988c8dcf Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Fri, 16 Jan 2026 21:22:45 +0100 Subject: [PATCH 1/8] Begin working on Discord integration --- build.gradle.kts | 3 + .../parcellockers/ParcelLockers.java | 14 ++++ .../implementation/PluginConfig.java | 21 ++++++ .../discord/DiscordClientManager.java | 40 ++++++++++++ .../parcellockers/discord/DiscordLink.java | 6 ++ .../discord/DiscordNotificationType.java | 7 ++ .../discord/command/DiscordLinkCommand.java | 25 ++++++++ .../discord/repository/DiscordLinkEntity.java | 38 +++++++++++ .../repository/DiscordLinkRepository.java | 17 +++++ .../DiscordLinkRepositoryOrmLite.java | 64 +++++++++++++++++++ 10 files changed, 235 insertions(+) create mode 100644 src/main/java/com/eternalcode/parcellockers/discord/DiscordClientManager.java create mode 100644 src/main/java/com/eternalcode/parcellockers/discord/DiscordLink.java create mode 100644 src/main/java/com/eternalcode/parcellockers/discord/DiscordNotificationType.java create mode 100644 src/main/java/com/eternalcode/parcellockers/discord/command/DiscordLinkCommand.java create mode 100644 src/main/java/com/eternalcode/parcellockers/discord/repository/DiscordLinkEntity.java create mode 100644 src/main/java/com/eternalcode/parcellockers/discord/repository/DiscordLinkRepository.java create mode 100644 src/main/java/com/eternalcode/parcellockers/discord/repository/DiscordLinkRepositoryOrmLite.java diff --git a/build.gradle.kts b/build.gradle.kts index 32fc9c0b..dee77d25 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -78,6 +78,9 @@ dependencies { // vault compileOnly("com.github.MilkBowl:VaultAPI:1.7.1") + // discord integration library + paperLibrary("com.discord4j:discord4j-core:3.3.0") + testImplementation("org.junit.jupiter:junit-jupiter-api:6.0.2") testImplementation("org.junit.jupiter:junit-jupiter-params:6.0.2") testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:6.0.2") diff --git a/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java b/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java index cf4c7a5c..4ad80986 100644 --- a/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java +++ b/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java @@ -16,6 +16,7 @@ import com.eternalcode.parcellockers.database.DatabaseManager; import com.eternalcode.parcellockers.delivery.DeliveryManager; import com.eternalcode.parcellockers.delivery.repository.DeliveryRepositoryOrmLite; +import com.eternalcode.parcellockers.discord.DiscordClientManager; import com.eternalcode.parcellockers.gui.GuiManager; import com.eternalcode.parcellockers.gui.implementation.locker.LockerGui; import com.eternalcode.parcellockers.gui.implementation.remote.MainGui; @@ -198,6 +199,19 @@ public void onEnable() { new LoadUserController(userManager, server) ).forEach(controller -> server.getPluginManager().registerEvents(controller, this)); + DiscordClientManager discordClientManager; + + if (config.discord.enabled) { + discordClientManager = new DiscordClientManager( + config.discord.botToken, + config.discord.serverId, + config.discord.channelId, + config.discord.botAdminRoleId, + this.getLogger() + ); + discordClientManager.initialize(); + } + new Metrics(this, 17677); new UpdaterService(this.getPluginMeta().getVersion()); diff --git a/src/main/java/com/eternalcode/parcellockers/configuration/implementation/PluginConfig.java b/src/main/java/com/eternalcode/parcellockers/configuration/implementation/PluginConfig.java index 2132a377..8ec773ad 100644 --- a/src/main/java/com/eternalcode/parcellockers/configuration/implementation/PluginConfig.java +++ b/src/main/java/com/eternalcode/parcellockers/configuration/implementation/PluginConfig.java @@ -24,6 +24,9 @@ public class PluginConfig extends OkaeriConfig { @Comment({ "", "# The plugin GUI settings." }) public GuiSettings guiSettings = new GuiSettings(); + @Comment({ "", "# The plugin Discord integration settings." }) + public DiscordSettings discord = new DiscordSettings(); + public static class Settings extends OkaeriConfig { @Comment("# Whether the player after entering the server should receive information about the new version of the plugin?") @@ -357,4 +360,22 @@ public static class GuiSettings extends OkaeriConfig { @Comment({ "", "# The lore line showing when the parcel has arrived. Placeholders: {DATE} - arrival date" }) public String parcelArrivedLine = "&aArrived on: &2{DATE}"; } + + public static class DiscordSettings extends OkaeriConfig { + + @Comment("# Whether Discord integration is enabled.") + public boolean enabled = false; + + @Comment("# The Discord bot token.") + public String botToken = ""; + + @Comment("# The Discord server ID.") + public String serverId = ""; + + @Comment("# The Discord channel ID for parcel notifications.") + public String channelId = ""; + + @Comment("# The Discord role ID for bot administrators.") + public String botAdminRoleId = ""; + } } diff --git a/src/main/java/com/eternalcode/parcellockers/discord/DiscordClientManager.java b/src/main/java/com/eternalcode/parcellockers/discord/DiscordClientManager.java new file mode 100644 index 00000000..fa2c3dcd --- /dev/null +++ b/src/main/java/com/eternalcode/parcellockers/discord/DiscordClientManager.java @@ -0,0 +1,40 @@ +package com.eternalcode.parcellockers.discord; + +import discord4j.common.util.Snowflake; +import discord4j.core.DiscordClient; +import discord4j.core.GatewayDiscordClient; +import java.util.logging.Logger; + +public class DiscordClientManager { + + private final String token; + private final Snowflake serverId; + private final Snowflake channelId; + private final Snowflake botAdminRole; + private final Logger logger; + + private GatewayDiscordClient client; + + public DiscordClientManager(String token, String serverId, String channelId, String botAdminRole, Logger logger) { + this.token = token; + this.serverId = Snowflake.of(serverId); + this.channelId = Snowflake.of(channelId); + this.botAdminRole = Snowflake.of(botAdminRole); + this.logger = logger; + } + + public void initialize() { + this.logger.info("Discord integration is enabled. Logging in to Discord..."); + this.client = DiscordClient.create(this.token) + .login() + .block(); + this.logger.info("Successfully logged in to Discord."); + } + + public void shutdown() { + this.logger.info("Shutting down Discord client..."); + if (this.client != null) { + this.client.logout().block(); + } + } +} diff --git a/src/main/java/com/eternalcode/parcellockers/discord/DiscordLink.java b/src/main/java/com/eternalcode/parcellockers/discord/DiscordLink.java new file mode 100644 index 00000000..023817b1 --- /dev/null +++ b/src/main/java/com/eternalcode/parcellockers/discord/DiscordLink.java @@ -0,0 +1,6 @@ +package com.eternalcode.parcellockers.discord; + +import java.util.UUID; + +public record DiscordLink(UUID minecraftUuid, String discordId) { +} diff --git a/src/main/java/com/eternalcode/parcellockers/discord/DiscordNotificationType.java b/src/main/java/com/eternalcode/parcellockers/discord/DiscordNotificationType.java new file mode 100644 index 00000000..de1880f4 --- /dev/null +++ b/src/main/java/com/eternalcode/parcellockers/discord/DiscordNotificationType.java @@ -0,0 +1,7 @@ +package com.eternalcode.parcellockers.discord; + +public enum DiscordNotificationType { + SERVER, + DM, + BOTH +} diff --git a/src/main/java/com/eternalcode/parcellockers/discord/command/DiscordLinkCommand.java b/src/main/java/com/eternalcode/parcellockers/discord/command/DiscordLinkCommand.java new file mode 100644 index 00000000..6684d822 --- /dev/null +++ b/src/main/java/com/eternalcode/parcellockers/discord/command/DiscordLinkCommand.java @@ -0,0 +1,25 @@ +package com.eternalcode.parcellockers.discord.command; + +import dev.rollczi.litecommands.annotations.argument.Arg; +import dev.rollczi.litecommands.annotations.command.Command; +import dev.rollczi.litecommands.annotations.context.Context; +import dev.rollczi.litecommands.annotations.execute.Execute; +import dev.rollczi.litecommands.annotations.permission.Permission; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +@Command(name = "parcel discordlink") +public class DiscordLinkCommand { + + @Execute + void execute(@Context Player player, @Arg long discordId) { + // Implementation for linking Discord ID to Minecraft player goes here + } + + @Execute + @Permission("parcellockers.admin") + void admin(@Context CommandSender sender, @Arg Player player, @Arg long discordId) { + + } + +} diff --git a/src/main/java/com/eternalcode/parcellockers/discord/repository/DiscordLinkEntity.java b/src/main/java/com/eternalcode/parcellockers/discord/repository/DiscordLinkEntity.java new file mode 100644 index 00000000..3d6d3916 --- /dev/null +++ b/src/main/java/com/eternalcode/parcellockers/discord/repository/DiscordLinkEntity.java @@ -0,0 +1,38 @@ +package com.eternalcode.parcellockers.discord.repository; + +import com.eternalcode.parcellockers.discord.DiscordLink; +import com.j256.ormlite.field.DatabaseField; +import com.j256.ormlite.table.DatabaseTable; +import java.util.UUID; + +@DatabaseTable(tableName = "discord_links") +class DiscordLinkEntity { + + @DatabaseField(id = true, columnName = "minecraft_uuid") + private String minecraftUuid; + + @DatabaseField(index = true, columnName = "discord_id") + private String discordId; + + DiscordLinkEntity() {} + + DiscordLinkEntity(String minecraftUuid, String discordId) { + this.minecraftUuid = minecraftUuid; + this.discordId = discordId; + } + + public static DiscordLinkEntity fromDomain(DiscordLink link) { + return new DiscordLinkEntity( + link.minecraftUuid().toString(), + link.discordId() + ); + } + + public DiscordLink toDomain() { + return new DiscordLink( + UUID.fromString(this.minecraftUuid), + this.discordId + ); + } + +} diff --git a/src/main/java/com/eternalcode/parcellockers/discord/repository/DiscordLinkRepository.java b/src/main/java/com/eternalcode/parcellockers/discord/repository/DiscordLinkRepository.java new file mode 100644 index 00000000..22872ff5 --- /dev/null +++ b/src/main/java/com/eternalcode/parcellockers/discord/repository/DiscordLinkRepository.java @@ -0,0 +1,17 @@ +package com.eternalcode.parcellockers.discord.repository; + +import com.eternalcode.parcellockers.discord.DiscordLink; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +public interface DiscordLinkRepository { + + CompletableFuture save(DiscordLink link); + + CompletableFuture> findByPlayerUuid(UUID playerUuid); + CompletableFuture> findByDiscordId(String discordId); + + CompletableFuture deleteByPlayerUuid(UUID playerUuid); + CompletableFuture deleteByDiscordId(String discordId); +} \ No newline at end of file diff --git a/src/main/java/com/eternalcode/parcellockers/discord/repository/DiscordLinkRepositoryOrmLite.java b/src/main/java/com/eternalcode/parcellockers/discord/repository/DiscordLinkRepositoryOrmLite.java new file mode 100644 index 00000000..8b1d105d --- /dev/null +++ b/src/main/java/com/eternalcode/parcellockers/discord/repository/DiscordLinkRepositoryOrmLite.java @@ -0,0 +1,64 @@ +package com.eternalcode.parcellockers.discord.repository; + +import com.eternalcode.commons.scheduler.Scheduler; +import com.eternalcode.parcellockers.database.DatabaseManager; +import com.eternalcode.parcellockers.database.wrapper.AbstractRepositoryOrmLite; +import com.eternalcode.parcellockers.discord.DiscordLink; +import com.eternalcode.parcellockers.shared.exception.DatabaseException; +import com.j256.ormlite.dao.Dao.CreateOrUpdateStatus; +import com.j256.ormlite.table.TableUtils; +import java.sql.SQLException; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +public class DiscordLinkRepositoryOrmLite extends AbstractRepositoryOrmLite implements DiscordLinkRepository { + + public static final String ID_COLUMN_NAME = "discord_id"; + + public DiscordLinkRepositoryOrmLite(DatabaseManager databaseManager, Scheduler scheduler) { + super(databaseManager, scheduler); + + try { + TableUtils.createTableIfNotExists(databaseManager.connectionSource(), DiscordLink.class); + } catch (SQLException ex) { + throw new DatabaseException("Failed to initialize DiscordLink table", ex); + } + } + + @Override + public CompletableFuture save(DiscordLink link) { + return this.save(DiscordLinkEntity.class, DiscordLinkEntity.fromDomain(link)) + .thenApply(CreateOrUpdateStatus::isCreated); + } + + @Override + public CompletableFuture> findByPlayerUuid(UUID playerUuid) { + return this.selectSafe(DiscordLinkEntity.class, playerUuid.toString()) + .thenApply(optionalEntity -> optionalEntity.map(DiscordLinkEntity::toDomain)); + } + + @Override + public CompletableFuture> findByDiscordId(String discordId) { + return this.action(DiscordLinkEntity.class, dao -> { + var queryBuilder = dao.queryBuilder() + .where().eq(ID_COLUMN_NAME, discordId); + return dao.queryForFirst(queryBuilder.prepare()); + }).thenApply(optionalEntity -> optionalEntity != null ? Optional.of(optionalEntity.toDomain()) : Optional.empty()); + } + + @Override + public CompletableFuture deleteByPlayerUuid(UUID playerUuid) { + return this.deleteById(DiscordLinkEntity.class, playerUuid.toString()) + .thenApply(deletedRows -> deletedRows > 0); + } + + @Override + public CompletableFuture deleteByDiscordId(String discordId) { + return this.action(DiscordLinkEntity.class, dao -> { + var deleteBuilder = dao.deleteBuilder(); + deleteBuilder.where().eq(ID_COLUMN_NAME, discordId); + return deleteBuilder.delete(); + }).thenApply(deletedRows -> deletedRows > 0); + } +} From 6307b0e8a2884408d266852c27a688780573e32e Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Sat, 17 Jan 2026 15:51:14 +0100 Subject: [PATCH 2/8] Finish implementing Discord integration --- .../parcellockers/ParcelLockers.java | 68 ++++-- .../implementation/MessageConfig.java | 79 ++++++ .../implementation/PluginConfig.java | 10 +- .../discord/DiscordClientManager.java | 13 +- .../discord/command/DiscordLinkCommand.java | 224 +++++++++++++++++- .../discord/command/DiscordUnlinkCommand.java | 90 +++++++ .../DiscordLinkRepositoryOrmLite.java | 5 +- 7 files changed, 451 insertions(+), 38 deletions(-) create mode 100644 src/main/java/com/eternalcode/parcellockers/discord/command/DiscordUnlinkCommand.java diff --git a/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java b/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java index 4ad80986..038ebe39 100644 --- a/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java +++ b/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java @@ -17,6 +17,10 @@ import com.eternalcode.parcellockers.delivery.DeliveryManager; import com.eternalcode.parcellockers.delivery.repository.DeliveryRepositoryOrmLite; import com.eternalcode.parcellockers.discord.DiscordClientManager; +import com.eternalcode.parcellockers.discord.command.DiscordLinkCommand; +import com.eternalcode.parcellockers.discord.command.DiscordUnlinkCommand; +import com.eternalcode.parcellockers.discord.repository.DiscordLinkRepository; +import com.eternalcode.parcellockers.discord.repository.DiscordLinkRepositoryOrmLite; import com.eternalcode.parcellockers.gui.GuiManager; import com.eternalcode.parcellockers.gui.implementation.locker.LockerGui; import com.eternalcode.parcellockers.gui.implementation.remote.MainGui; @@ -73,6 +77,7 @@ public final class ParcelLockers extends JavaPlugin { private SkullAPI skullAPI; private DatabaseManager databaseManager; private Economy economy; + private DiscordClientManager discordClientManager; @Override public void onEnable() { @@ -177,19 +182,53 @@ public void onEnable() { this.skullAPI ); - this.liteCommands = LiteBukkitFactory.builder(this.getName(), this) + var liteCommandsBuilder = LiteBukkitFactory.builder(this.getName(), this) .extension(new LiteAdventureExtension<>()) .message(LiteBukkitMessages.PLAYER_ONLY, messageConfig.playerOnlyCommand) .message(LiteBukkitMessages.PLAYER_NOT_FOUND, messageConfig.playerNotFound) .commands(LiteCommandsAnnotations.of( new ParcelCommand(mainGUI), new ParcelLockersCommand(configService, config, noticeService), - new DebugCommand(parcelService, lockerManager, itemStorageManager, parcelContentManager, + new DebugCommand( + parcelService, lockerManager, itemStorageManager, parcelContentManager, noticeService, deliveryManager) )) .invalidUsage(new InvalidUsageHandlerImpl(noticeService)) - .missingPermission(new MissingPermissionsHandlerImpl(noticeService)) - .build(); + .missingPermission(new MissingPermissionsHandlerImpl(noticeService)); + + DiscordLinkRepository discordLinkRepository = new DiscordLinkRepositoryOrmLite(databaseManager, scheduler); + + if (config.discord.enabled) { + if (config.discord.botToken.isBlank() || + config.discord.botAdminRoleId.isBlank() || + config.discord.serverId.isBlank() || + config.discord.channelId.isBlank() || + config.discord.botAdminRoleId.isBlank() + ) { + this.getLogger().severe("Discord integration is enabled but some of the properties are not set! Disabling..."); + server.getPluginManager().disablePlugin(this); + return; + } + + this.discordClientManager = new DiscordClientManager( + config.discord.botToken, + this.getLogger() + ); + this.discordClientManager.initialize(); + + liteCommandsBuilder.commands( + new DiscordLinkCommand( + this.discordClientManager.getClient(), + discordLinkRepository, + noticeService, + miniMessage, + scheduler, + messageConfig), + new DiscordUnlinkCommand(discordLinkRepository, noticeService) + ); + } + + this.liteCommands = liteCommandsBuilder.build(); Stream.of( new LockerInteractionController(lockerManager, lockerGUI, scheduler), @@ -199,21 +238,8 @@ public void onEnable() { new LoadUserController(userManager, server) ).forEach(controller -> server.getPluginManager().registerEvents(controller, this)); - DiscordClientManager discordClientManager; - - if (config.discord.enabled) { - discordClientManager = new DiscordClientManager( - config.discord.botToken, - config.discord.serverId, - config.discord.channelId, - config.discord.botAdminRoleId, - this.getLogger() - ); - discordClientManager.initialize(); - } - - new Metrics(this, 17677); - new UpdaterService(this.getPluginMeta().getVersion()); + Metrics metrics = new Metrics(this, 17677); + UpdaterService updaterService = new UpdaterService(this.getPluginMeta().getVersion()); parcelRepository.findAll().thenAccept(optionalParcels -> optionalParcels .stream() @@ -239,6 +265,10 @@ public void onDisable() { if (this.skullAPI != null) { this.skullAPI.shutdown(); } + + if (this.discordClientManager != null) { + this.discordClientManager.shutdown(); + } } private boolean setupEconomy() { diff --git a/src/main/java/com/eternalcode/parcellockers/configuration/implementation/MessageConfig.java b/src/main/java/com/eternalcode/parcellockers/configuration/implementation/MessageConfig.java index 531bd961..1dfab877 100644 --- a/src/main/java/com/eternalcode/parcellockers/configuration/implementation/MessageConfig.java +++ b/src/main/java/com/eternalcode/parcellockers/configuration/implementation/MessageConfig.java @@ -37,6 +37,10 @@ public class MessageConfig extends OkaeriConfig { @Comment("# These messages are used for administrative actions such as deleting all lockers or parcels.") public AdminMessages admin = new AdminMessages(); + @Comment({"", "# Messages related to Discord integration can be configured here." }) + @Comment("# These messages are used for linking Discord accounts with Minecraft accounts.") + public DiscordMessages discord = new DiscordMessages(); + public static class ParcelMessages extends OkaeriConfig { public Notice sent = Notice.builder() .chat("&2✔ &aParcel sent successfully.") @@ -178,4 +182,79 @@ public static class AdminMessages extends OkaeriConfig { public Notice deletedContents = Notice.chat("&4⚠ &cAll ({COUNT}) parcel contents have been deleted!"); public Notice deletedDeliveries = Notice.chat("&4⚠ &cAll ({COUNT}) deliveries have been deleted!"); } + + public static class DiscordMessages extends OkaeriConfig { + public Notice verificationAlreadyPending = Notice.builder() + .chat("&4✘ &cYou already have a pending verification. Please complete it or wait for it to expire.") + .sound(SoundEventKeys.ENTITY_VILLAGER_NO) + .build(); + public Notice alreadyLinked = Notice.builder() + .chat("&4✘ &cYour Minecraft account is already linked to a Discord account!") + .sound(SoundEventKeys.ENTITY_VILLAGER_NO) + .build(); + public Notice discordAlreadyLinked = Notice.builder() + .chat("&4✘ &cThis Discord account is already linked to another Minecraft account!") + .sound(SoundEventKeys.ENTITY_VILLAGER_NO) + .build(); + public Notice userNotFound = Notice.builder() + .chat("&4✘ &cCould not find a Discord user with that ID!") + .sound(SoundEventKeys.ENTITY_VILLAGER_NO) + .build(); + public Notice verificationCodeSent = Notice.builder() + .chat("&2✔ &aA verification code has been sent to your Discord DM. Please check your messages.") + .sound(SoundEventKeys.ENTITY_EXPERIENCE_ORB_PICKUP) + .build(); + public Notice cannotSendDm = Notice.builder() + .chat("&4✘ &cCould not send a DM to your Discord account. Please make sure your DMs are open.") + .sound(SoundEventKeys.ENTITY_VILLAGER_NO) + .build(); + public Notice verificationExpired = Notice.builder() + .chat("&4✘ &cYour verification code has expired. Please run the command again.") + .sound(SoundEventKeys.ENTITY_VILLAGER_NO) + .build(); + public Notice invalidCode = Notice.builder() + .chat("&4✘ &cInvalid verification code. Please try again in 2 minutes.") + .sound(SoundEventKeys.ENTITY_VILLAGER_NO) + .build(); + public Notice linkSuccess = Notice.builder() + .chat("&2✔ &aYour Discord account has been successfully linked!") + .sound(SoundEventKeys.ENTITY_PLAYER_LEVELUP) + .build(); + public Notice linkFailed = Notice.builder() + .chat("&4✘ &cFailed to link your Discord account. Please try again later.") + .sound(SoundEventKeys.ENTITY_VILLAGER_NO) + .build(); + public Notice verificationCancelled = Notice.builder() + .chat("&6⚠ &eVerification cancelled.") + .sound(SoundEventKeys.BLOCK_NOTE_BLOCK_BASS) + .build(); + public Notice playerAlreadyLinked = Notice.chat("&4✘ &cThis player already has a linked Discord account!"); + public Notice adminLinkSuccess = Notice.chat("&2✔ &aSuccessfully linked the Discord account to the player."); + + @Comment({"", "# Unlink messages" }) + public Notice notLinked = Notice.builder() + .chat("&4✘ &cYour Minecraft account is not linked to any Discord account!") + .sound(SoundEventKeys.ENTITY_VILLAGER_NO) + .build(); + public Notice unlinkSuccess = Notice.builder() + .chat("&2✔ &aYour Discord account has been successfully unlinked!") + .sound(SoundEventKeys.ENTITY_EXPERIENCE_ORB_PICKUP) + .build(); + public Notice unlinkFailed = Notice.builder() + .chat("&4✘ &cFailed to unlink the Discord account. Please try again later.") + .sound(SoundEventKeys.ENTITY_VILLAGER_NO) + .build(); + public Notice playerNotLinked = Notice.chat("&4✘ &cThis player does not have a linked Discord account!"); + public Notice adminUnlinkSuccess = Notice.chat("&2✔ &aSuccessfully unlinked the Discord account from the player."); + public Notice discordNotLinked = Notice.chat("&4✘ &cNo Minecraft account is linked to this Discord ID!"); + public Notice adminUnlinkByDiscordSuccess = Notice.chat("&2✔ &aSuccessfully unlinked the Minecraft account from the Discord ID."); + + @Comment({"", "# Dialog configuration for verification" }) + public String verificationDialogTitle = "&6Enter your Discord verification code:"; + public String verificationDialogPlaceholder = "&7Enter 4-digit code"; + + @Comment({"", "# The message sent to the Discord user via DM" }) + @Comment("# Placeholders: {CODE} - the verification code, {PLAYER} - the Minecraft player name") + public String discordDmVerificationMessage = "**📦 ParcelLockers Verification**\n\nPlayer **{PLAYER}** is trying to link their Minecraft account to your Discord account.\n\nYour verification code is: **{CODE}**\n\nThis code will expire in 2 minutes."; + } } diff --git a/src/main/java/com/eternalcode/parcellockers/configuration/implementation/PluginConfig.java b/src/main/java/com/eternalcode/parcellockers/configuration/implementation/PluginConfig.java index 8ec773ad..28a1f757 100644 --- a/src/main/java/com/eternalcode/parcellockers/configuration/implementation/PluginConfig.java +++ b/src/main/java/com/eternalcode/parcellockers/configuration/implementation/PluginConfig.java @@ -364,18 +364,18 @@ public static class GuiSettings extends OkaeriConfig { public static class DiscordSettings extends OkaeriConfig { @Comment("# Whether Discord integration is enabled.") - public boolean enabled = false; + public boolean enabled = true; @Comment("# The Discord bot token.") - public String botToken = ""; + public String botToken = System.getenv("DISCORD_BOT_TOKEN"); @Comment("# The Discord server ID.") - public String serverId = ""; + public String serverId = "1179117429301977251"; @Comment("# The Discord channel ID for parcel notifications.") - public String channelId = ""; + public String channelId = "1317827115147853834"; @Comment("# The Discord role ID for bot administrators.") - public String botAdminRoleId = ""; + public String botAdminRoleId = "1317589501169893427"; } } diff --git a/src/main/java/com/eternalcode/parcellockers/discord/DiscordClientManager.java b/src/main/java/com/eternalcode/parcellockers/discord/DiscordClientManager.java index fa2c3dcd..16b1cb47 100644 --- a/src/main/java/com/eternalcode/parcellockers/discord/DiscordClientManager.java +++ b/src/main/java/com/eternalcode/parcellockers/discord/DiscordClientManager.java @@ -1,6 +1,5 @@ package com.eternalcode.parcellockers.discord; -import discord4j.common.util.Snowflake; import discord4j.core.DiscordClient; import discord4j.core.GatewayDiscordClient; import java.util.logging.Logger; @@ -8,18 +7,12 @@ public class DiscordClientManager { private final String token; - private final Snowflake serverId; - private final Snowflake channelId; - private final Snowflake botAdminRole; private final Logger logger; private GatewayDiscordClient client; - public DiscordClientManager(String token, String serverId, String channelId, String botAdminRole, Logger logger) { + public DiscordClientManager(String token, Logger logger) { this.token = token; - this.serverId = Snowflake.of(serverId); - this.channelId = Snowflake.of(channelId); - this.botAdminRole = Snowflake.of(botAdminRole); this.logger = logger; } @@ -37,4 +30,8 @@ public void shutdown() { this.client.logout().block(); } } + + public GatewayDiscordClient getClient() { + return this.client; + } } diff --git a/src/main/java/com/eternalcode/parcellockers/discord/command/DiscordLinkCommand.java b/src/main/java/com/eternalcode/parcellockers/discord/command/DiscordLinkCommand.java index 6684d822..77c3b902 100644 --- a/src/main/java/com/eternalcode/parcellockers/discord/command/DiscordLinkCommand.java +++ b/src/main/java/com/eternalcode/parcellockers/discord/command/DiscordLinkCommand.java @@ -1,25 +1,241 @@ package com.eternalcode.parcellockers.discord.command; +import com.eternalcode.commons.scheduler.Scheduler; +import com.eternalcode.parcellockers.configuration.implementation.MessageConfig; +import com.eternalcode.parcellockers.discord.DiscordLink; +import com.eternalcode.parcellockers.discord.repository.DiscordLinkRepository; +import com.eternalcode.parcellockers.notification.NoticeService; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; import dev.rollczi.litecommands.annotations.argument.Arg; import dev.rollczi.litecommands.annotations.command.Command; import dev.rollczi.litecommands.annotations.context.Context; import dev.rollczi.litecommands.annotations.execute.Execute; import dev.rollczi.litecommands.annotations.permission.Permission; +import discord4j.common.util.Snowflake; +import discord4j.core.GatewayDiscordClient; +import discord4j.core.object.entity.User; +import io.papermc.paper.dialog.Dialog; +import io.papermc.paper.dialog.DialogResponseView; +import io.papermc.paper.registry.data.dialog.ActionButton; +import io.papermc.paper.registry.data.dialog.DialogBase; +import io.papermc.paper.registry.data.dialog.action.DialogAction; +import io.papermc.paper.registry.data.dialog.input.DialogInput; +import io.papermc.paper.registry.data.dialog.type.DialogType; +import java.util.List; +import java.util.Random; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import net.kyori.adventure.audience.Audience; +import net.kyori.adventure.text.event.ClickCallback; +import net.kyori.adventure.text.minimessage.MiniMessage; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; -@Command(name = "parcel discordlink") +@SuppressWarnings("UnstableApiUsage") +@Command(name = "parcel linkdiscord") public class DiscordLinkCommand { + private static final Random RANDOM = new Random(); + + private final GatewayDiscordClient client; + private final DiscordLinkRepository discordLinkRepository; + private final NoticeService noticeService; + private final MiniMessage miniMessage; + private final Scheduler scheduler; + private final MessageConfig messageConfig; + + private final Cache authCodesCache = Caffeine.newBuilder() + .expireAfterWrite(2, TimeUnit.MINUTES) + .build(); + + public DiscordLinkCommand( + GatewayDiscordClient client, + DiscordLinkRepository discordLinkRepository, + NoticeService noticeService, + MiniMessage miniMessage, + Scheduler scheduler, + MessageConfig messageConfig + ) { + this.client = client; + this.discordLinkRepository = discordLinkRepository; + this.noticeService = noticeService; + this.miniMessage = miniMessage; + this.scheduler = scheduler; + this.messageConfig = messageConfig; + } + @Execute - void execute(@Context Player player, @Arg long discordId) { - // Implementation for linking Discord ID to Minecraft player goes here + void linkSelf(@Context Player player, @Arg long discordId) { + UUID playerUuid = player.getUniqueId(); + String discordIdString = String.valueOf(discordId); + + // Check if the player already has a pending verification + if (this.authCodesCache.getIfPresent(playerUuid) != null) { + this.noticeService.player(playerUuid, messages -> messages.discord.verificationAlreadyPending); + return; + } + + // Check if the player already has a linked Discord account + this.discordLinkRepository.findByPlayerUuid(playerUuid).thenAccept(existingLink -> { + if (existingLink.isPresent()) { + this.noticeService.player(playerUuid, messages -> messages.discord.alreadyLinked); + return; + } + + // Check if the Discord account is already linked to another Minecraft account + this.discordLinkRepository.findByDiscordId(discordIdString).thenAccept(existingDiscordLink -> { + if (existingDiscordLink.isPresent()) { + this.noticeService.player(playerUuid, messages -> messages.discord.discordAlreadyLinked); + return; + } + + // Try to get the Discord user + User discordUser; + try { + discordUser = this.client.getUserById(Snowflake.of(discordId)).block(); + } catch (Exception ex) { + this.noticeService.player(playerUuid, messages -> messages.discord.userNotFound); + return; + } + + if (discordUser == null) { + this.noticeService.player(playerUuid, messages -> messages.discord.userNotFound); + return; + } + + // Generate a 4-digit verification code + String verificationCode = this.generateVerificationCode(); + + // Store the verification data in cache + this.authCodesCache.put(playerUuid, new VerificationData(discordIdString, verificationCode)); + + // Send the verification code to the Discord user via DM + discordUser.getPrivateChannel() + .flatMap(channel -> channel.createMessage( + this.messageConfig.discord.discordDmVerificationMessage + .replace("{CODE}", verificationCode) + .replace("{PLAYER}", player.getName()) + )) + .doOnSuccess(message -> { + this.noticeService.player(playerUuid, messages -> messages.discord.verificationCodeSent); + this.scheduler.run(() -> this.showVerificationDialog(player)); + }) + .doOnError(error -> { + this.authCodesCache.invalidate(playerUuid); + this.noticeService.player(playerUuid, messages -> messages.discord.cannotSendDm); + }) + .subscribe(); + }); + }); } @Execute @Permission("parcellockers.admin") - void admin(@Context CommandSender sender, @Arg Player player, @Arg long discordId) { + void linkOther(@Context CommandSender sender, @Arg Player player, @Arg long discordId) { + UUID playerUuid = player.getUniqueId(); + String discordIdString = String.valueOf(discordId); + + // Admin bypass - directly link without verification + this.discordLinkRepository.findByPlayerUuid(playerUuid).thenAccept(existingLink -> { + if (existingLink.isPresent()) { + this.noticeService.console(messages -> messages.discord.playerAlreadyLinked); + return; + } + + this.discordLinkRepository.findByDiscordId(discordIdString).thenAccept(existingDiscordLink -> { + if (existingDiscordLink.isPresent()) { + this.noticeService.console(messages -> messages.discord.discordAlreadyLinked); + return; + } + + DiscordLink link = new DiscordLink(playerUuid, discordIdString); + this.discordLinkRepository.save(link).thenAccept(success -> { + if (success) { + this.noticeService.viewer(sender, messages -> messages.discord.adminLinkSuccess); + this.noticeService.player(playerUuid, messages -> messages.discord.linkSuccess); + } else { + this.noticeService.viewer(sender, messages -> messages.discord.linkFailed); + } + }); + }); + }); + } + + private void showVerificationDialog(Player player) { + Dialog verificationDialog = Dialog.create(builder -> builder.empty() + .base(DialogBase.builder(this.miniMessage.deserialize(this.messageConfig.discord.verificationDialogTitle)) + .canCloseWithEscape(false) + .inputs(List.of( + DialogInput.text("code", this.miniMessage.deserialize(this.messageConfig.discord.verificationDialogPlaceholder)) + .build() + )) + .build() + ) + .type(DialogType.confirmation( + ActionButton.create( + this.miniMessage.deserialize("Verify"), + this.miniMessage.deserialize("Click to verify your Discord account"), + 200, + DialogAction.customClick((DialogResponseView view, Audience audience) -> { + String enteredCode = view.getText("code"); + this.handleVerification(player, enteredCode); + }, ClickCallback.Options.builder() + .uses(1) + .lifetime(ClickCallback.DEFAULT_LIFETIME) + .build()) + ), + ActionButton.create( + this.miniMessage.deserialize("Cancel"), + this.miniMessage.deserialize("Click to cancel verification"), + 200, + DialogAction.customClick( + (DialogResponseView view, Audience audience) -> { + this.authCodesCache.invalidate(player.getUniqueId()); + this.noticeService.player(player.getUniqueId(), messages -> messages.discord.verificationCancelled); + }, + ClickCallback.Options.builder() + .uses(1) + .lifetime(ClickCallback.DEFAULT_LIFETIME) + .build()) + ) + )) + ); + + player.showDialog(verificationDialog); + } + + private void handleVerification(Player player, String enteredCode) { + UUID playerUuid = player.getUniqueId(); + VerificationData verificationData = this.authCodesCache.getIfPresent(playerUuid); + + if (verificationData == null) { + this.noticeService.player(playerUuid, messages -> messages.discord.verificationExpired); + return; + } + + if (!verificationData.code().equals(enteredCode)) { + this.noticeService.player(playerUuid, messages -> messages.discord.invalidCode); + return; + } + + // Code matches - remove from cache and create the link + this.authCodesCache.invalidate(playerUuid); + + DiscordLink link = new DiscordLink(playerUuid, verificationData.discordId()); + this.discordLinkRepository.save(link).thenAccept(success -> { + if (success) { + this.noticeService.player(playerUuid, messages -> messages.discord.linkSuccess); + } else { + this.noticeService.player(playerUuid, messages -> messages.discord.linkFailed); + } + }); + } + private String generateVerificationCode() { + int code = 1000 + RANDOM.nextInt(9000); // generates 1000-9999 + return String.valueOf(code); } + private record VerificationData(String discordId, String code) {} } diff --git a/src/main/java/com/eternalcode/parcellockers/discord/command/DiscordUnlinkCommand.java b/src/main/java/com/eternalcode/parcellockers/discord/command/DiscordUnlinkCommand.java new file mode 100644 index 00000000..c7edf7f1 --- /dev/null +++ b/src/main/java/com/eternalcode/parcellockers/discord/command/DiscordUnlinkCommand.java @@ -0,0 +1,90 @@ +package com.eternalcode.parcellockers.discord.command; + +import com.eternalcode.parcellockers.discord.repository.DiscordLinkRepository; +import com.eternalcode.parcellockers.notification.NoticeService; +import dev.rollczi.litecommands.annotations.argument.Arg; +import dev.rollczi.litecommands.annotations.command.Command; +import dev.rollczi.litecommands.annotations.context.Context; +import dev.rollczi.litecommands.annotations.execute.Execute; +import dev.rollczi.litecommands.annotations.permission.Permission; +import java.util.UUID; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +@Command(name = "parcel unlinkdiscord") +public class DiscordUnlinkCommand { + + private final DiscordLinkRepository discordLinkRepository; + private final NoticeService noticeService; + + public DiscordUnlinkCommand( + DiscordLinkRepository discordLinkRepository, + NoticeService noticeService + ) { + this.discordLinkRepository = discordLinkRepository; + this.noticeService = noticeService; + } + + @Execute + void unlinkSelf(@Context Player player) { + UUID playerUuid = player.getUniqueId(); + + this.discordLinkRepository.findByPlayerUuid(playerUuid).thenAccept(existingLink -> { + if (existingLink.isEmpty()) { + this.noticeService.player(playerUuid, messages -> messages.discord.notLinked); + return; + } + + this.discordLinkRepository.deleteByPlayerUuid(playerUuid).thenAccept(success -> { + if (success) { + this.noticeService.player(playerUuid, messages -> messages.discord.unlinkSuccess); + } else { + this.noticeService.player(playerUuid, messages -> messages.discord.unlinkFailed); + } + }); + }); + } + + @Execute + @Permission("parcellockers.admin") + void unlinkPlayer(@Context CommandSender sender, @Arg Player targetPlayer) { + UUID targetUuid = targetPlayer.getUniqueId(); + + this.discordLinkRepository.findByPlayerUuid(targetUuid).thenAccept(existingLink -> { + if (existingLink.isEmpty()) { + this.noticeService.viewer(sender, messages -> messages.discord.playerNotLinked); + return; + } + + this.discordLinkRepository.deleteByPlayerUuid(targetUuid).thenAccept(success -> { + if (success) { + this.noticeService.viewer(sender, messages -> messages.discord.adminUnlinkSuccess); + this.noticeService.player(targetUuid, messages -> messages.discord.unlinkSuccess); + } else { + this.noticeService.viewer(sender, messages -> messages.discord.unlinkFailed); + } + }); + }); + } + + @Execute + @Permission("parcellockers.admin") + void unlinkByDiscordId(@Context CommandSender sender, @Arg long discordId) { + String discordIdString = String.valueOf(discordId); + + this.discordLinkRepository.findByDiscordId(discordIdString).thenAccept(existingLink -> { + if (existingLink.isEmpty()) { + this.noticeService.viewer(sender, messages -> messages.discord.discordNotLinked); + return; + } + + this.discordLinkRepository.deleteByDiscordId(discordIdString).thenAccept(success -> { + if (success) { + this.noticeService.viewer(sender, messages -> messages.discord.adminUnlinkByDiscordSuccess); + } else { + this.noticeService.viewer(sender, messages -> messages.discord.unlinkFailed); + } + }); + }); + } +} diff --git a/src/main/java/com/eternalcode/parcellockers/discord/repository/DiscordLinkRepositoryOrmLite.java b/src/main/java/com/eternalcode/parcellockers/discord/repository/DiscordLinkRepositoryOrmLite.java index 8b1d105d..32f63a19 100644 --- a/src/main/java/com/eternalcode/parcellockers/discord/repository/DiscordLinkRepositoryOrmLite.java +++ b/src/main/java/com/eternalcode/parcellockers/discord/repository/DiscordLinkRepositoryOrmLite.java @@ -6,6 +6,7 @@ import com.eternalcode.parcellockers.discord.DiscordLink; import com.eternalcode.parcellockers.shared.exception.DatabaseException; import com.j256.ormlite.dao.Dao.CreateOrUpdateStatus; +import com.j256.ormlite.stmt.DeleteBuilder; import com.j256.ormlite.table.TableUtils; import java.sql.SQLException; import java.util.Optional; @@ -20,7 +21,7 @@ public DiscordLinkRepositoryOrmLite(DatabaseManager databaseManager, Scheduler s super(databaseManager, scheduler); try { - TableUtils.createTableIfNotExists(databaseManager.connectionSource(), DiscordLink.class); + TableUtils.createTableIfNotExists(databaseManager.connectionSource(), DiscordLinkEntity.class); } catch (SQLException ex) { throw new DatabaseException("Failed to initialize DiscordLink table", ex); } @@ -56,7 +57,7 @@ public CompletableFuture deleteByPlayerUuid(UUID playerUuid) { @Override public CompletableFuture deleteByDiscordId(String discordId) { return this.action(DiscordLinkEntity.class, dao -> { - var deleteBuilder = dao.deleteBuilder(); + DeleteBuilder deleteBuilder = dao.deleteBuilder(); deleteBuilder.where().eq(ID_COLUMN_NAME, discordId); return deleteBuilder.delete(); }).thenApply(deletedRows -> deletedRows > 0); From 9e4a99876379108cda653dbf26cbdfc961fb3b25 Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Sat, 17 Jan 2026 16:07:52 +0100 Subject: [PATCH 3/8] Add `@Async` annotation to command executors to ensure that the server's main thread is not blocked --- .../parcellockers/discord/command/DiscordLinkCommand.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/com/eternalcode/parcellockers/discord/command/DiscordLinkCommand.java b/src/main/java/com/eternalcode/parcellockers/discord/command/DiscordLinkCommand.java index 77c3b902..e9b3bef5 100644 --- a/src/main/java/com/eternalcode/parcellockers/discord/command/DiscordLinkCommand.java +++ b/src/main/java/com/eternalcode/parcellockers/discord/command/DiscordLinkCommand.java @@ -8,6 +8,7 @@ import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; import dev.rollczi.litecommands.annotations.argument.Arg; +import dev.rollczi.litecommands.annotations.async.Async; import dev.rollczi.litecommands.annotations.command.Command; import dev.rollczi.litecommands.annotations.context.Context; import dev.rollczi.litecommands.annotations.execute.Execute; @@ -65,6 +66,7 @@ public DiscordLinkCommand( this.messageConfig = messageConfig; } + @Async @Execute void linkSelf(@Context Player player, @Arg long discordId) { UUID playerUuid = player.getUniqueId(); @@ -130,6 +132,7 @@ void linkSelf(@Context Player player, @Arg long discordId) { }); } + @Async @Execute @Permission("parcellockers.admin") void linkOther(@Context CommandSender sender, @Arg Player player, @Arg long discordId) { From 1109f1190777440eeec384c078563694d24c595e Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Sat, 17 Jan 2026 16:24:20 +0100 Subject: [PATCH 4/8] Remove `@Async` annotations --- .../parcellockers/discord/command/DiscordLinkCommand.java | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/main/java/com/eternalcode/parcellockers/discord/command/DiscordLinkCommand.java b/src/main/java/com/eternalcode/parcellockers/discord/command/DiscordLinkCommand.java index e9b3bef5..77c3b902 100644 --- a/src/main/java/com/eternalcode/parcellockers/discord/command/DiscordLinkCommand.java +++ b/src/main/java/com/eternalcode/parcellockers/discord/command/DiscordLinkCommand.java @@ -8,7 +8,6 @@ import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; import dev.rollczi.litecommands.annotations.argument.Arg; -import dev.rollczi.litecommands.annotations.async.Async; import dev.rollczi.litecommands.annotations.command.Command; import dev.rollczi.litecommands.annotations.context.Context; import dev.rollczi.litecommands.annotations.execute.Execute; @@ -66,7 +65,6 @@ public DiscordLinkCommand( this.messageConfig = messageConfig; } - @Async @Execute void linkSelf(@Context Player player, @Arg long discordId) { UUID playerUuid = player.getUniqueId(); @@ -132,7 +130,6 @@ void linkSelf(@Context Player player, @Arg long discordId) { }); } - @Async @Execute @Permission("parcellockers.admin") void linkOther(@Context CommandSender sender, @Arg Player player, @Arg long discordId) { From 67ac9a29fc4b73ebacda881b9e72ba45deb54366 Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Sat, 17 Jan 2026 20:05:59 +0100 Subject: [PATCH 5/8] Refactor Discord integration to use reactive programming for login and verification processes --- .../parcellockers/ParcelLockers.java | 1 - .../discord/DiscordClientManager.java | 15 +- .../discord/command/DiscordLinkCommand.java | 209 +++++++++++------- 3 files changed, 144 insertions(+), 81 deletions(-) diff --git a/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java b/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java index 038ebe39..617553e5 100644 --- a/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java +++ b/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java @@ -222,7 +222,6 @@ public void onEnable() { discordLinkRepository, noticeService, miniMessage, - scheduler, messageConfig), new DiscordUnlinkCommand(discordLinkRepository, noticeService) ); diff --git a/src/main/java/com/eternalcode/parcellockers/discord/DiscordClientManager.java b/src/main/java/com/eternalcode/parcellockers/discord/DiscordClientManager.java index 16b1cb47..b638b681 100644 --- a/src/main/java/com/eternalcode/parcellockers/discord/DiscordClientManager.java +++ b/src/main/java/com/eternalcode/parcellockers/discord/DiscordClientManager.java @@ -3,6 +3,7 @@ import discord4j.core.DiscordClient; import discord4j.core.GatewayDiscordClient; import java.util.logging.Logger; +import reactor.core.scheduler.Schedulers; public class DiscordClientManager { @@ -18,10 +19,18 @@ public DiscordClientManager(String token, Logger logger) { public void initialize() { this.logger.info("Discord integration is enabled. Logging in to Discord..."); - this.client = DiscordClient.create(this.token) + DiscordClient.create(this.token) .login() - .block(); - this.logger.info("Successfully logged in to Discord."); + .subscribeOn(Schedulers.boundedElastic()) + .doOnSuccess(client -> { + this.client = client; + this.logger.info("Successfully logged in to Discord."); + }) + .doOnError(error -> { + this.logger.severe("Failed to log in to Discord: " + error.getMessage()); + error.printStackTrace(); + }) + .subscribe(); } public void shutdown() { diff --git a/src/main/java/com/eternalcode/parcellockers/discord/command/DiscordLinkCommand.java b/src/main/java/com/eternalcode/parcellockers/discord/command/DiscordLinkCommand.java index 77c3b902..d3f95b09 100644 --- a/src/main/java/com/eternalcode/parcellockers/discord/command/DiscordLinkCommand.java +++ b/src/main/java/com/eternalcode/parcellockers/discord/command/DiscordLinkCommand.java @@ -1,6 +1,6 @@ package com.eternalcode.parcellockers.discord.command; -import com.eternalcode.commons.scheduler.Scheduler; +import com.eternalcode.multification.notice.provider.NoticeProvider; import com.eternalcode.parcellockers.configuration.implementation.MessageConfig; import com.eternalcode.parcellockers.discord.DiscordLink; import com.eternalcode.parcellockers.discord.repository.DiscordLinkRepository; @@ -25,12 +25,16 @@ import java.util.List; import java.util.Random; import java.util.UUID; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; import net.kyori.adventure.audience.Audience; import net.kyori.adventure.text.event.ClickCallback; import net.kyori.adventure.text.minimessage.MiniMessage; +import org.bukkit.Bukkit; +import org.bukkit.OfflinePlayer; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; +import reactor.core.publisher.Mono; @SuppressWarnings("UnstableApiUsage") @Command(name = "parcel linkdiscord") @@ -42,7 +46,6 @@ public class DiscordLinkCommand { private final DiscordLinkRepository discordLinkRepository; private final NoticeService noticeService; private final MiniMessage miniMessage; - private final Scheduler scheduler; private final MessageConfig messageConfig; private final Cache authCodesCache = Caffeine.newBuilder() @@ -54,14 +57,12 @@ public DiscordLinkCommand( DiscordLinkRepository discordLinkRepository, NoticeService noticeService, MiniMessage miniMessage, - Scheduler scheduler, MessageConfig messageConfig ) { this.client = client; this.discordLinkRepository = discordLinkRepository; this.noticeService = noticeService; this.miniMessage = miniMessage; - this.scheduler = scheduler; this.messageConfig = messageConfig; } @@ -70,96 +71,88 @@ void linkSelf(@Context Player player, @Arg long discordId) { UUID playerUuid = player.getUniqueId(); String discordIdString = String.valueOf(discordId); - // Check if the player already has a pending verification if (this.authCodesCache.getIfPresent(playerUuid) != null) { this.noticeService.player(playerUuid, messages -> messages.discord.verificationAlreadyPending); return; } - // Check if the player already has a linked Discord account - this.discordLinkRepository.findByPlayerUuid(playerUuid).thenAccept(existingLink -> { - if (existingLink.isPresent()) { - this.noticeService.player(playerUuid, messages -> messages.discord.alreadyLinked); - return; - } - - // Check if the Discord account is already linked to another Minecraft account - this.discordLinkRepository.findByDiscordId(discordIdString).thenAccept(existingDiscordLink -> { - if (existingDiscordLink.isPresent()) { - this.noticeService.player(playerUuid, messages -> messages.discord.discordAlreadyLinked); - return; - } - - // Try to get the Discord user - User discordUser; - try { - discordUser = this.client.getUserById(Snowflake.of(discordId)).block(); - } catch (Exception ex) { - this.noticeService.player(playerUuid, messages -> messages.discord.userNotFound); - return; - } - - if (discordUser == null) { - this.noticeService.player(playerUuid, messages -> messages.discord.userNotFound); - return; + this.validateAndLink(playerUuid, discordIdString) + .thenCompose(validationResult -> { + if (!validationResult.isValid()) { + this.noticeService.player(playerUuid, validationResult.errorMessage()); + return CompletableFuture.completedFuture(null); } - // Generate a 4-digit verification code - String verificationCode = this.generateVerificationCode(); - - // Store the verification data in cache - this.authCodesCache.put(playerUuid, new VerificationData(discordIdString, verificationCode)); - - // Send the verification code to the Discord user via DM - discordUser.getPrivateChannel() - .flatMap(channel -> channel.createMessage( - this.messageConfig.discord.discordDmVerificationMessage - .replace("{CODE}", verificationCode) - .replace("{PLAYER}", player.getName()) - )) - .doOnSuccess(message -> { - this.noticeService.player(playerUuid, messages -> messages.discord.verificationCodeSent); - this.scheduler.run(() -> this.showVerificationDialog(player)); - }) - .doOnError(error -> { - this.authCodesCache.invalidate(playerUuid); - this.noticeService.player(playerUuid, messages -> messages.discord.cannotSendDm); - }) - .subscribe(); + return this.sendVerification(playerUuid, discordIdString, player, validationResult.discordUser()) + .toFuture(); + }) + .exceptionally(error -> { + this.noticeService.player(playerUuid, messages -> messages.discord.linkFailed); + return null; }); - }); } @Execute @Permission("parcellockers.admin") - void linkOther(@Context CommandSender sender, @Arg Player player, @Arg long discordId) { - UUID playerUuid = player.getUniqueId(); + void linkOther(@Context CommandSender sender, @Arg String playerName, @Arg long discordId) { String discordIdString = String.valueOf(discordId); - // Admin bypass - directly link without verification - this.discordLinkRepository.findByPlayerUuid(playerUuid).thenAccept(existingLink -> { - if (existingLink.isPresent()) { - this.noticeService.console(messages -> messages.discord.playerAlreadyLinked); - return; - } - - this.discordLinkRepository.findByDiscordId(discordIdString).thenAccept(existingDiscordLink -> { - if (existingDiscordLink.isPresent()) { - this.noticeService.console(messages -> messages.discord.discordAlreadyLinked); - return; + this.resolvePlayerUuid(playerName) + .thenCompose(playerUuid -> { + if (playerUuid == null) { + this.noticeService.viewer(sender, messages -> messages.discord.userNotFound); + return CompletableFuture.completedFuture(null); } - DiscordLink link = new DiscordLink(playerUuid, discordIdString); - this.discordLinkRepository.save(link).thenAccept(success -> { - if (success) { - this.noticeService.viewer(sender, messages -> messages.discord.adminLinkSuccess); - this.noticeService.player(playerUuid, messages -> messages.discord.linkSuccess); - } else { - this.noticeService.viewer(sender, messages -> messages.discord.linkFailed); - } - }); + return this.validateAndLink(playerUuid, discordIdString) + .thenCompose(validationResult -> { + if (!validationResult.isValid()) { + this.noticeService.viewer(sender, validationResult.errorMessage()); + return CompletableFuture.completedFuture(null); + } + + DiscordLink link = new DiscordLink(playerUuid, discordIdString); + return this.discordLinkRepository.save(link) + .thenAccept(success -> { + if (success) { + this.noticeService.viewer(sender, messages -> messages.discord.adminLinkSuccess); + this.noticeService.player(playerUuid, messages -> messages.discord.linkSuccess); + } else { + this.noticeService.viewer(sender, messages -> messages.discord.linkFailed); + } + }); + }); + }) + .exceptionally(error -> { + this.noticeService.viewer(sender, messages -> messages.discord.linkFailed); + return null; }); - }); + } + + private Mono sendVerification(UUID playerUuid, String discordId, Player player, User discordUser) { + String code = this.generateVerificationCode(); + + VerificationData data = new VerificationData(discordId, code); + if (this.authCodesCache.asMap().putIfAbsent(playerUuid, data) != null) { + this.noticeService.player(playerUuid, messages -> messages.discord.verificationAlreadyPending); + return Mono.empty(); + } + + return discordUser.getPrivateChannel() + .flatMap(channel -> channel.createMessage( + this.messageConfig.discord.discordDmVerificationMessage + .replace("{CODE}", code) + .replace("{PLAYER}", player.getName()) + )) + .doOnSuccess(msg -> { + this.noticeService.player(playerUuid, messages -> messages.discord.verificationCodeSent); + this.showVerificationDialog(player); + }) + .doOnError(error -> { + this.authCodesCache.invalidate(playerUuid); + this.noticeService.player(playerUuid, messages -> messages.discord.cannotSendDm); + }) + .then(); } private void showVerificationDialog(Player player) { @@ -232,10 +225,72 @@ private void handleVerification(Player player, String enteredCode) { }); } + private CompletableFuture validateAndLink(UUID playerUuid, String discordIdString) { + return this.discordLinkRepository.findByPlayerUuid(playerUuid) + .thenCompose(existingPlayerLink -> { + if (existingPlayerLink.isPresent()) { + return CompletableFuture.completedFuture( + ValidationResult.error(messages -> messages.discord.alreadyLinked) + ); + } + + return this.discordLinkRepository.findByDiscordId(discordIdString) + .thenCompose(existingDiscordLink -> { + if (existingDiscordLink.isPresent()) { + return CompletableFuture.completedFuture( + ValidationResult.error(messages -> messages.discord.discordAlreadyLinked) + ); + } + + return this.client.getUserById(Snowflake.of(Long.parseLong(discordIdString))) + .map(ValidationResult::success) + .onErrorResume(error -> Mono.just( + ValidationResult.error(messages -> messages.discord.userNotFound) + )) + .toFuture(); + }); + }); + } + private String generateVerificationCode() { int code = 1000 + RANDOM.nextInt(9000); // generates 1000-9999 return String.valueOf(code); } + private CompletableFuture resolvePlayerUuid(String playerName) { + return CompletableFuture.supplyAsync(() -> { + Player online = Bukkit.getPlayerExact(playerName); + if (online != null) { + return online.getUniqueId(); + } + + OfflinePlayer offline = Bukkit.getOfflinePlayer(playerName); + return offline.hasPlayedBefore() ? offline.getUniqueId() : null; + }); + } + private record VerificationData(String discordId, String code) {} + + private record ValidationResult( + boolean valid, + User discordUser, + NoticeProvider errorMessageGetter + ) { + static ValidationResult success(User user) { + return new ValidationResult(true, user, null); + } + + static ValidationResult error(NoticeProvider messageGetter) { + return new ValidationResult(false, null, messageGetter); + } + + boolean isValid() { + return this.valid; + } + + NoticeProvider errorMessage() { + return this.errorMessageGetter; + } + } } + From e98e26342e0a0b4478908f81b15d1cbb4cdde94b Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Sat, 17 Jan 2026 20:07:23 +0100 Subject: [PATCH 6/8] Remove redundant check for bot admin role ID in Discord configuration validation --- src/main/java/com/eternalcode/parcellockers/ParcelLockers.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java b/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java index 617553e5..4dfd37d6 100644 --- a/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java +++ b/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java @@ -200,7 +200,6 @@ public void onEnable() { if (config.discord.enabled) { if (config.discord.botToken.isBlank() || - config.discord.botAdminRoleId.isBlank() || config.discord.serverId.isBlank() || config.discord.channelId.isBlank() || config.discord.botAdminRoleId.isBlank() From 875103046046791ee4a4fea4a00f5494f7214258 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20K=C4=99dziora?= <77227023+Jakubk15@users.noreply.github.com> Date: Sat, 17 Jan 2026 20:08:19 +0100 Subject: [PATCH 7/8] Update src/main/java/com/eternalcode/parcellockers/discord/repository/DiscordLinkRepositoryOrmLite.java Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../discord/repository/DiscordLinkRepositoryOrmLite.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/eternalcode/parcellockers/discord/repository/DiscordLinkRepositoryOrmLite.java b/src/main/java/com/eternalcode/parcellockers/discord/repository/DiscordLinkRepositoryOrmLite.java index 32f63a19..1e8547aa 100644 --- a/src/main/java/com/eternalcode/parcellockers/discord/repository/DiscordLinkRepositoryOrmLite.java +++ b/src/main/java/com/eternalcode/parcellockers/discord/repository/DiscordLinkRepositoryOrmLite.java @@ -30,7 +30,7 @@ public DiscordLinkRepositoryOrmLite(DatabaseManager databaseManager, Scheduler s @Override public CompletableFuture save(DiscordLink link) { return this.save(DiscordLinkEntity.class, DiscordLinkEntity.fromDomain(link)) - .thenApply(CreateOrUpdateStatus::isCreated); + .thenApply(status -> status.isCreated() || status.isUpdated()); } @Override From 031d4669bd6151be32396c92afa974ad82ad6c2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20K=C4=99dziora?= <77227023+Jakubk15@users.noreply.github.com> Date: Sat, 17 Jan 2026 20:08:39 +0100 Subject: [PATCH 8/8] Update src/main/java/com/eternalcode/parcellockers/discord/repository/DiscordLinkRepositoryOrmLite.java Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../discord/repository/DiscordLinkRepositoryOrmLite.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/eternalcode/parcellockers/discord/repository/DiscordLinkRepositoryOrmLite.java b/src/main/java/com/eternalcode/parcellockers/discord/repository/DiscordLinkRepositoryOrmLite.java index 1e8547aa..485e0bb0 100644 --- a/src/main/java/com/eternalcode/parcellockers/discord/repository/DiscordLinkRepositoryOrmLite.java +++ b/src/main/java/com/eternalcode/parcellockers/discord/repository/DiscordLinkRepositoryOrmLite.java @@ -45,7 +45,7 @@ public CompletableFuture> findByDiscordId(String discordId var queryBuilder = dao.queryBuilder() .where().eq(ID_COLUMN_NAME, discordId); return dao.queryForFirst(queryBuilder.prepare()); - }).thenApply(optionalEntity -> optionalEntity != null ? Optional.of(optionalEntity.toDomain()) : Optional.empty()); + }).thenApply(entity -> Optional.ofNullable(entity).map(DiscordLinkEntity::toDomain)); } @Override