diff --git a/build.gradle.kts b/build.gradle.kts index d0ff18dd..bdb37dbb 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..4dfd37d6 100644 --- a/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java +++ b/src/main/java/com/eternalcode/parcellockers/ParcelLockers.java @@ -16,6 +16,11 @@ 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.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; @@ -72,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() { @@ -176,19 +182,51 @@ 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.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, + messageConfig), + new DiscordUnlinkCommand(discordLinkRepository, noticeService) + ); + } + + this.liteCommands = liteCommandsBuilder.build(); Stream.of( new LockerInteractionController(lockerManager, lockerGUI, scheduler), @@ -198,8 +236,8 @@ public void onEnable() { new LoadUserController(userManager, server) ).forEach(controller -> server.getPluginManager().registerEvents(controller, this)); - 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() @@ -225,6 +263,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 2132a377..28a1f757 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 = true; + + @Comment("# The Discord bot token.") + public String botToken = System.getenv("DISCORD_BOT_TOKEN"); + + @Comment("# The Discord server ID.") + public String serverId = "1179117429301977251"; + + @Comment("# The Discord channel ID for parcel notifications.") + public String channelId = "1317827115147853834"; + + @Comment("# The Discord role ID for bot administrators.") + 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 new file mode 100644 index 00000000..b638b681 --- /dev/null +++ b/src/main/java/com/eternalcode/parcellockers/discord/DiscordClientManager.java @@ -0,0 +1,46 @@ +package com.eternalcode.parcellockers.discord; + +import discord4j.core.DiscordClient; +import discord4j.core.GatewayDiscordClient; +import java.util.logging.Logger; +import reactor.core.scheduler.Schedulers; + +public class DiscordClientManager { + + private final String token; + private final Logger logger; + + private GatewayDiscordClient client; + + public DiscordClientManager(String token, Logger logger) { + this.token = token; + this.logger = logger; + } + + public void initialize() { + this.logger.info("Discord integration is enabled. Logging in to Discord..."); + DiscordClient.create(this.token) + .login() + .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() { + this.logger.info("Shutting down Discord client..."); + if (this.client != null) { + this.client.logout().block(); + } + } + + public GatewayDiscordClient getClient() { + return this.client; + } +} 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..d3f95b09 --- /dev/null +++ b/src/main/java/com/eternalcode/parcellockers/discord/command/DiscordLinkCommand.java @@ -0,0 +1,296 @@ +package com.eternalcode.parcellockers.discord.command; + +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; +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.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") +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 MessageConfig messageConfig; + + private final Cache authCodesCache = Caffeine.newBuilder() + .expireAfterWrite(2, TimeUnit.MINUTES) + .build(); + + public DiscordLinkCommand( + GatewayDiscordClient client, + DiscordLinkRepository discordLinkRepository, + NoticeService noticeService, + MiniMessage miniMessage, + MessageConfig messageConfig + ) { + this.client = client; + this.discordLinkRepository = discordLinkRepository; + this.noticeService = noticeService; + this.miniMessage = miniMessage; + this.messageConfig = messageConfig; + } + + @Execute + void linkSelf(@Context Player player, @Arg long discordId) { + UUID playerUuid = player.getUniqueId(); + String discordIdString = String.valueOf(discordId); + + if (this.authCodesCache.getIfPresent(playerUuid) != null) { + this.noticeService.player(playerUuid, messages -> messages.discord.verificationAlreadyPending); + return; + } + + this.validateAndLink(playerUuid, discordIdString) + .thenCompose(validationResult -> { + if (!validationResult.isValid()) { + this.noticeService.player(playerUuid, validationResult.errorMessage()); + return CompletableFuture.completedFuture(null); + } + + 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 String playerName, @Arg long discordId) { + String discordIdString = String.valueOf(discordId); + + this.resolvePlayerUuid(playerName) + .thenCompose(playerUuid -> { + if (playerUuid == null) { + this.noticeService.viewer(sender, messages -> messages.discord.userNotFound); + return CompletableFuture.completedFuture(null); + } + + 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) { + 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 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; + } + } +} + 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/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..485e0bb0 --- /dev/null +++ b/src/main/java/com/eternalcode/parcellockers/discord/repository/DiscordLinkRepositoryOrmLite.java @@ -0,0 +1,65 @@ +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.stmt.DeleteBuilder; +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(), DiscordLinkEntity.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(status -> status.isCreated() || status.isUpdated()); + } + + @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(entity -> Optional.ofNullable(entity).map(DiscordLinkEntity::toDomain)); + } + + @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 -> { + DeleteBuilder deleteBuilder = dao.deleteBuilder(); + deleteBuilder.where().eq(ID_COLUMN_NAME, discordId); + return deleteBuilder.delete(); + }).thenApply(deletedRows -> deletedRows > 0); + } +}