Skip to content
3 changes: 3 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
54 changes: 48 additions & 6 deletions src/main/java/com/eternalcode/parcellockers/ParcelLockers.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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);
Copy link

Copilot AI Jan 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The DiscordLinkRepository is initialized on line 199 even when Discord integration is disabled (config.discord.enabled = false). This creates an unnecessary database table and wastes resources. The repository should only be initialized when Discord integration is enabled.

Copilot uses AI. Check for mistakes.

if (config.discord.enabled) {
if (config.discord.botToken.isBlank() ||
config.discord.serverId.isBlank() ||
config.discord.channelId.isBlank() ||
config.discord.botAdminRoleId.isBlank()
Comment on lines +202 to +205
Copy link

Copilot AI Jan 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The null check for botToken uses isBlank(), but if the environment variable is not set, botToken will be null, which will cause a NullPointerException. The check should handle null values explicitly before calling isBlank().

Suggested change
if (config.discord.botToken.isBlank() ||
config.discord.serverId.isBlank() ||
config.discord.channelId.isBlank() ||
config.discord.botAdminRoleId.isBlank()
if (config.discord.botToken == null || config.discord.botToken.isBlank() ||
config.discord.serverId == null || config.discord.serverId.isBlank() ||
config.discord.channelId == null || config.discord.channelId.isBlank() ||
config.discord.botAdminRoleId == null || config.discord.botAdminRoleId.isBlank()

Copilot uses AI. Check for mistakes.
) {
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)
);
Comment on lines +218 to +226
Copy link

Copilot AI Jan 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Discord client is passed to DiscordLinkCommand via getClient() immediately after initialization, which may return null due to the asynchronous login in initialize(). This will cause a NullPointerException when the command is executed and tries to use the client.

Suggested change
liteCommandsBuilder.commands(
new DiscordLinkCommand(
this.discordClientManager.getClient(),
discordLinkRepository,
noticeService,
miniMessage,
messageConfig),
new DiscordUnlinkCommand(discordLinkRepository, noticeService)
);
var discordClient = this.discordClientManager.getClient();
if (discordClient == null) {
this.getLogger().severe("Failed to initialize Discord client. Discord commands will not be available.");
}
else {
liteCommandsBuilder.commands(
new DiscordLinkCommand(
discordClient,
discordLinkRepository,
noticeService,
miniMessage,
messageConfig),
new DiscordUnlinkCommand(discordLinkRepository, noticeService)
);
}

Copilot uses AI. Check for mistakes.
}

this.liteCommands = liteCommandsBuilder.build();

Stream.of(
new LockerInteractionController(lockerManager, lockerGUI, scheduler),
Expand All @@ -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());
Comment on lines +239 to +240
Copy link

Copilot AI Jan 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The variables 'metrics' and 'updaterService' are assigned but never used after initialization. These variables should either be removed if they're not needed, or stored as class fields if they need to be referenced later.

Suggested change
Metrics metrics = new Metrics(this, 17677);
UpdaterService updaterService = new UpdaterService(this.getPluginMeta().getVersion());
new Metrics(this, 17677);
new UpdaterService(this.getPluginMeta().getVersion());

Copilot uses AI. Check for mistakes.

parcelRepository.findAll().thenAccept(optionalParcels -> optionalParcels
.stream()
Expand All @@ -225,6 +263,10 @@ public void onDisable() {
if (this.skullAPI != null) {
this.skullAPI.shutdown();
}

if (this.discordClientManager != null) {
this.discordClientManager.shutdown();
}
}

private boolean setupEconomy() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
Expand Down Expand Up @@ -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.")
Copy link

Copilot AI Jan 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error message for invalid verification code is misleading. It says "Please try again in 2 minutes" which suggests the user needs to wait before trying again, but actually they need to restart the entire linking process by running the command again (since the code remains the same but invalidation doesn't happen). The message should clarify that the user needs to start the linking process over.

Suggested change
.chat("&4✘ &cInvalid verification code. Please try again in 2 minutes.")
.chat("&4✘ &cInvalid verification code. Please run the command again to restart the verification process.")

Copilot uses AI. Check for mistakes.
.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.";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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?")
Expand Down Expand Up @@ -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");
Copy link

Copilot AI Jan 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Discord bot token is read from an environment variable with no validation or fallback handling. If the environment variable is not set, the token will be null, which will only be caught later during the blank check. This could lead to confusion during deployment. Consider providing a clearer error message or default value to indicate that the token must be configured.

Copilot uses AI. Check for mistakes.

@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";
Comment on lines +372 to +379
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The default values for serverId, channelId, and botAdminRoleId are hardcoded with specific IDs. For a distributable plugin, it's better to leave these as empty strings by default. This forces server administrators to configure them explicitly, avoiding confusion and potential errors if they don't notice the pre-filled values.

Suggested change
@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";
@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 = "";

Comment on lines +373 to +379
Copy link

Copilot AI Jan 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hardcoded Discord server ID, channel ID, and role ID are exposed in the configuration defaults. These appear to be specific to a particular Discord server and should not be included as defaults in production code. Consider using placeholder values or empty strings to make it clear these need to be configured per deployment.

Suggested change
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";
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 = "";

Copilot uses AI. Check for mistakes.
Comment on lines +371 to +379
Copy link

Copilot AI Jan 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Discord-related configuration fields (serverId, channelId, botAdminRoleId) are defined but never used in the implementation. These fields suggest incomplete functionality - they appear to be for server notifications or admin role verification, but no code utilizes them. Consider either implementing the intended functionality or removing these unused configuration fields.

Suggested change
@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";

Copilot uses AI. Check for mistakes.
}
}
Original file line number Diff line number Diff line change
@@ -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();
Comment on lines +22 to +33
Copy link

Copilot AI Jan 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When Discord login fails, the error is logged but the plugin continues running with a null client. This will cause NullPointerExceptions when commands try to use the client. Consider throwing an exception or disabling the Discord integration when login fails to prevent the plugin from entering an invalid state.

Suggested change
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();
try {
GatewayDiscordClient loggedInClient = DiscordClient.create(this.token)
.login()
.block();
if (loggedInClient == null) {
this.logger.severe("Failed to log in to Discord: login returned null client.");
throw new IllegalStateException("Discord login failed: client is null");
}
this.client = loggedInClient;
this.logger.info("Successfully logged in to Discord.");
} catch (Exception error) {
this.logger.severe("Failed to log in to Discord: " + error.getMessage());
error.printStackTrace();
throw new IllegalStateException("Discord login failed", error);
}

Copilot uses AI. Check for mistakes.
Comment on lines +22 to +33
Copy link

Copilot AI Jan 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Discord client login is performed asynchronously in the initialize() method, but getClient() can be called immediately after, potentially returning null. This creates a race condition where commands or other components trying to use the client might receive null before the login completes. Consider blocking until the client is initialized or implementing a check mechanism to ensure the client is ready before use.

Suggested change
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();
try {
GatewayDiscordClient loggedInClient = DiscordClient.create(this.token)
.login()
.block();
if (loggedInClient != null) {
this.client = loggedInClient;
this.logger.info("Successfully logged in to Discord.");
} else {
this.logger.severe("Failed to log in to Discord: login returned null client.");
}
} catch (Exception error) {
this.logger.severe("Failed to log in to Discord: " + error.getMessage());
error.printStackTrace();
}

Copilot uses AI. Check for mistakes.
}

public void shutdown() {
this.logger.info("Shutting down Discord client...");
if (this.client != null) {
this.client.logout().block();
}
}

public GatewayDiscordClient getClient() {
return this.client;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.eternalcode.parcellockers.discord;

import java.util.UUID;

public record DiscordLink(UUID minecraftUuid, String discordId) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.eternalcode.parcellockers.discord;

public enum DiscordNotificationType {
SERVER,
DM,
BOTH
}
Comment on lines +3 to +7
Copy link

Copilot AI Jan 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The DiscordNotificationType enum is defined but not used anywhere in the codebase. This appears to be a placeholder for future notification functionality that has not been implemented. Consider removing this unused enum or implementing the notification type feature.

Suggested change
public enum DiscordNotificationType {
SERVER,
DM,
BOTH
}
// Previously contained the DiscordNotificationType enum, which was unused
// and has been removed to eliminate dead code and placeholder functionality.

Copilot uses AI. Check for mistakes.
Loading
Loading