Skip to content

783: refresh command#802

Open
alfardil wants to merge 15 commits intomainfrom
783
Open

783: refresh command#802
alfardil wants to merge 15 commits intomainfrom
783

Conversation

@alfardil
Copy link
Collaborator

@alfardil alfardil commented Feb 20, 2026

783

Description of changes

Create the refresh command to refresh submissions without having to visit codebloom

Checklist before review

  • I have done a thorough self-review of the PR
  • Copilot has reviewed my latest changes, and all comments have been fixed and/or closed.
  • If I have made database changes, I have made sure I followed all the db repo rules listed in the wiki here. (check if no db changes)
  • All tests have passed
  • I have successfully deployed this PR to staging
  • I have done manual QA in both dev (and staging if possible) and attached screenshots below.

Screenshots

Dev

image image image

Staging

@alfardil alfardil force-pushed the 783 branch 3 times, most recently from fd8bb86 to d3ce2e8 Compare February 22, 2026 23:37
@alfardil
Copy link
Collaborator Author

/ai

@tahminator
Copy link
Owner

/review

@tahminator
Copy link
Owner

/describe

@tahminator
Copy link
Owner

/improve

@github-actions
Copy link
Contributor

Preparing PR description...

@github-actions
Copy link
Contributor

PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

⏱️ Estimated effort to review: 3 🔵🔵🔵⚪⚪
🧪 No relevant tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Double reply bug

The cooldown helper sends a reply but the calling handlers continue and attempt a second reply. This will cause "interaction already acknowledged" errors. Have handleRedis return a boolean (e.g., blocked/allowed) and short‑circuit in the callers, or move the reply logic back to the handlers.

private void handleRedis(SlashCommandInteractionEvent event, final long time) {
    String guildId = event.getGuild().getId();

    if (simpleRedis.containsKey(guildId)) {
        long timeThen = simpleRedis.get(guildId);
        long timeNow = System.currentTimeMillis();
        long difference = (timeNow - timeThen) / 1000;

        if (difference < time) {
            long remainingTime = time - difference;
            long minutes = remainingTime / 60;
            long seconds = remainingTime % 60;

            EmbedBuilder embed = new EmbedBuilder()
                    .setTitle("Please try again in **" + minutes + " minutes and " + seconds + " seconds**.")
                    .setColor(new Color(69, 129, 103));

            event.replyEmbeds(embed.build()).setEphemeral(true).queue();

            return;
        }
    }

    simpleRedis.put(guildId, System.currentTimeMillis());
}

private void handleRefreshSlashCommand(SlashCommandInteractionEvent event) {
    if (event.getGuild() == null) {
        event.reply("This command can only be used in a server.")
                .setEphemeral(true)
                .queue();
        return;
    }
    handleRedis(event, 5 * 60);

    String guildId = event.getGuild().getId();
    String userId = event.getUser().getId();

    MessageCreateData message = discordClubManager.buildRefreshMessageForClub(guildId, userId);

    event.reply(message).queue();
}

private void handleLeaderboardSlashCommand(SlashCommandInteractionEvent event) {
    if (event.getGuild() == null) {
        event.reply("This command can only be used in a server.")
                .setEphemeral(true)
                .queue();
        return;
    }
    handleRedis(event, 10 * 60);
    String serverId = event.getGuild().getId();
    MessageCreateData message = discordClubManager.buildLeaderboardMessageForClub(serverId, false);

    event.reply(message).queue();
}
Error handling

buildRefreshMessageForClub assumes the club, user mapping, and LeetCode username all exist and that the client call succeeds. Add graceful handling for missing club/user/username and LeetCode failures or empty results to avoid NPEs and to provide a helpful message instead of throwing.

public MessageCreateData buildRefreshMessageForClub(String guildId, String discordId) {

    DiscordClub club =
            discordClubRepository.getDiscordClubByGuildId(guildId).orElseThrow();
    Leaderboard currentLeaderboard = leaderboardRepository.getRecentLeaderboardMetadata();

    LeaderboardFilterOptions options = LeaderboardFilterGenerator.builderWithTag(club.getTag())
            .page(1)
            .pageSize(5)
            .build();

    String userId = userRepository.getUserByDiscordId(discordId).getId();
    UserWithScore user =
            userRepository.getUserWithScoreByIdAndLeaderboardId(userId, currentLeaderboard.getId(), null);

    List<LeetcodeSubmission> leetcodeSubmissions =
            leetcodeClient.findSubmissionsByUsername(user.getLeetcodeUsername(), 20);

    submissionsHandler.handleSubmissions(leetcodeSubmissions, user);

    int score = user.getTotalScore();
    Indexed<UserWithScore> globalIndex =
            leaderboardRepository.getGlobalRankedUserById(currentLeaderboard.getId(), userId);
    Indexed<UserWithScore> clubIndex =
            leaderboardRepository.getFilteredRankedUserById(currentLeaderboard.getId(), userId, options);
    int globalPlace = globalIndex.getIndex();
    int clubPlace = clubIndex.getIndex();

    String description =
            String.format("""
            Your score is %d

            You are currently number %d on the global leaderboard: %s

            You are currently number %d for %s
            """, score, globalPlace, currentLeaderboard.getName(), clubPlace, club.getName());

    MessageEmbed embed = new EmbedBuilder()
            .setTitle("%s Leaderboard Update for %s".formatted(currentLeaderboard.getName(), club.getName()))
            .setDescription(description)
            .setFooter(
                    "Codebloom - LeetCode Leaderboard for %s".formatted(club.getName()),
                    "%s/favicon.ico".formatted(serverUrlUtils.getUrl()))
            .setColor(new Color(69, 129, 103))
            .build();

    return MessageCreateData.fromEmbeds(embed);
}
Cooldown granularity

The refresh cooldown is keyed by guild, so a single user can block the entire server for 5 minutes. Consider per-user (or per-user-per-guild) keys specifically for the refresh command, and consider making refresh replies ephemeral to avoid channel spam.

private void handleRedis(SlashCommandInteractionEvent event, final long time) {
    String guildId = event.getGuild().getId();

    if (simpleRedis.containsKey(guildId)) {
        long timeThen = simpleRedis.get(guildId);
        long timeNow = System.currentTimeMillis();
        long difference = (timeNow - timeThen) / 1000;

        if (difference < time) {
            long remainingTime = time - difference;
            long minutes = remainingTime / 60;
            long seconds = remainingTime % 60;

            EmbedBuilder embed = new EmbedBuilder()
                    .setTitle("Please try again in **" + minutes + " minutes and " + seconds + " seconds**.")
                    .setColor(new Color(69, 129, 103));

            event.replyEmbeds(embed.build()).setEphemeral(true).queue();

            return;
        }
    }

    simpleRedis.put(guildId, System.currentTimeMillis());
}

private void handleRefreshSlashCommand(SlashCommandInteractionEvent event) {
    if (event.getGuild() == null) {
        event.reply("This command can only be used in a server.")
                .setEphemeral(true)
                .queue();
        return;
    }
    handleRedis(event, 5 * 60);

    String guildId = event.getGuild().getId();
    String userId = event.getUser().getId();

    MessageCreateData message = discordClubManager.buildRefreshMessageForClub(guildId, userId);

    event.reply(message).queue();
}

@alfardil
Copy link
Collaborator Author

/ai

@alfardil
Copy link
Collaborator Author

/review

@alfardil
Copy link
Collaborator Author

/describe

@alfardil
Copy link
Collaborator Author

/improve

@tahminator
Copy link
Owner

/review

@tahminator
Copy link
Owner

/describe

@tahminator
Copy link
Owner

/improve

@github-actions
Copy link
Contributor

Preparing review...

@github-actions
Copy link
Contributor

Preparing PR description...

1 similar comment
@github-actions
Copy link
Contributor

Preparing PR description...

@github-actions
Copy link
Contributor

PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

⏱️ Estimated effort to review: 3 🔵🔵🔵⚪⚪
🧪 No relevant tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Double Reply Bug

The rate limiter replies to the interaction inside handleRedis, but callers (handleRefreshSlashCommand/handleLeaderboardSlashCommand) still proceed to reply again, which will fail because the interaction has already been acknowledged. Return a boolean (allowed/blocked) or throw a handled signal so callers can early-return. Also consider separate cooldown keys per command and/or per user for REFRESH.

private void handleRedis(SlashCommandInteractionEvent event, final long time) {
    String guildId = event.getGuild().getId();

    if (simpleRedis.containsKey(guildId)) {
        long timeThen = simpleRedis.get(guildId);
        long timeNow = System.currentTimeMillis();
        long difference = (timeNow - timeThen) / 1000;

        if (difference < time) {
            long remainingTime = time - difference;
            long minutes = remainingTime / 60;
            long seconds = remainingTime % 60;

            EmbedBuilder embed = new EmbedBuilder()
                    .setTitle("Please try again in **" + minutes + " minutes and " + seconds + " seconds**.")
                    .setColor(new Color(69, 129, 103));

            event.replyEmbeds(embed.build()).setEphemeral(true).queue();

            return;
        }
    }

    simpleRedis.put(guildId, System.currentTimeMillis());
}

private void handleRefreshSlashCommand(SlashCommandInteractionEvent event) {
    if (event.getGuild() == null) {
        event.reply("This command can only be used in a server.")
                .setEphemeral(true)
                .queue();
        return;
    }
    handleRedis(event, 5 * 60);

    String guildId = event.getGuild().getId();
    String userId = event.getUser().getId();

    MessageCreateData message = discordClubManager.buildRefreshMessageForClub(guildId, userId);

    event.reply(message).setEphemeral(true).queue();
}

private void handleLeaderboardSlashCommand(SlashCommandInteractionEvent event) {
    if (event.getGuild() == null) {
        event.reply("This command can only be used in a server.")
                .setEphemeral(true)
                .queue();
        return;
    }
    handleRedis(event, 10 * 60);
    String serverId = event.getGuild().getId();
    MessageCreateData message = discordClubManager.buildLeaderboardMessageForClub(serverId, false);

    event.reply(message).setEphemeral(true).queue();
}
Timeout Risk

buildRefreshMessageForClub performs network and DB work (fetching LeetCode submissions and processing them) before replying. Slash commands must be acknowledged quickly; consider deferReply(true) and performing work asynchronously, then editOriginal/followUp.

public MessageCreateData buildRefreshMessageForClub(String guildId, String discordId) {

    DiscordClub club =
            discordClubRepository.getDiscordClubByGuildId(guildId).orElseThrow();
    Leaderboard currentLeaderboard = leaderboardRepository.getRecentLeaderboardMetadata();

    LeaderboardFilterOptions options = LeaderboardFilterGenerator.builderWithTag(club.getTag())
            .page(1)
            .pageSize(5)
            .build();

    User user = userRepository.getUserByDiscordId(discordId);
    String userId = user.getId();

    List<LeetcodeSubmission> leetcodeSubmissions =
            leetcodeClient.findSubmissionsByUsername(user.getLeetcodeUsername(), 20);

    submissionsHandler.handleSubmissions(leetcodeSubmissions, user);

    UserWithScore scoredUser = userRepository.getUserWithScoreByIdAndLeaderboardId(
            userId, currentLeaderboard.getId(), UserFilterOptions.DEFAULT);

    int score = scoredUser.getTotalScore();
    Indexed<UserWithScore> globalIndex =
            leaderboardRepository.getGlobalRankedUserById(currentLeaderboard.getId(), userId);
    Indexed<UserWithScore> clubIndex =
            leaderboardRepository.getFilteredRankedUserById(currentLeaderboard.getId(), userId, options);
    int globalPlace = globalIndex.getIndex();
    int clubPlace = clubIndex.getIndex();

    String description =
            String.format("""
            After refreshing your submissions, you currently have %s points.

            You are currently number %d on the global leaderboard: %s

            You are currently number %d on the club leaderboard: %s
            """, score, globalPlace, currentLeaderboard.getName(), clubPlace, club.getName());

    MessageEmbed embed = new EmbedBuilder()
            .setTitle("Submissions Refreshed")
            .setDescription(description)
            .setFooter(
                    "Codebloom - LeetCode Leaderboard for %s".formatted(club.getName()),
                    "%s/favicon.ico".formatted(serverUrlUtils.getUrl()))
            .setColor(new Color(69, 129, 103))
            .build();

    return MessageCreateData.fromEmbeds(embed);
}
Error Handling

Missing guards for absent Discord club/user and null LeetCode username; orElseThrow() and direct repository calls can NPE/throw, resulting in no user feedback. Add Optional handling, user-friendly ephemeral error responses, and logging around external calls.

DiscordClub club =
        discordClubRepository.getDiscordClubByGuildId(guildId).orElseThrow();
Leaderboard currentLeaderboard = leaderboardRepository.getRecentLeaderboardMetadata();

LeaderboardFilterOptions options = LeaderboardFilterGenerator.builderWithTag(club.getTag())
        .page(1)
        .pageSize(5)
        .build();

User user = userRepository.getUserByDiscordId(discordId);
String userId = user.getId();

List<LeetcodeSubmission> leetcodeSubmissions =
        leetcodeClient.findSubmissionsByUsername(user.getLeetcodeUsername(), 20);

submissionsHandler.handleSubmissions(leetcodeSubmissions, user);

UserWithScore scoredUser = userRepository.getUserWithScoreByIdAndLeaderboardId(
        userId, currentLeaderboard.getId(), UserFilterOptions.DEFAULT);

int score = scoredUser.getTotalScore();

@alfardil
Copy link
Collaborator Author

/ai

@alfardil
Copy link
Collaborator Author

/review

@alfardil
Copy link
Collaborator Author

/describe

@alfardil
Copy link
Collaborator Author

/improve

@tahminator
Copy link
Owner

/review

@tahminator
Copy link
Owner

/describe

@tahminator
Copy link
Owner

/improve

@github-actions
Copy link
Contributor

Preparing PR description...

@github-actions
Copy link
Contributor

PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

⏱️ Estimated effort to review: 3 🔵🔵🔵⚪⚪
🧪 PR contains tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Error Handling

The refresh flow should handle missing Discord club, missing Discord→User mapping, and LeetCode/DB failures gracefully. Right now it throws on missing club and assumes the user exists; API/network exceptions could result in no reply. Consider catching exceptions and replying with an error embed instead of throwing.

public MessageCreateData buildRefreshMessageForClub(String guildId, String discordId) {

    DiscordClub club =
            discordClubRepository.getDiscordClubByGuildId(guildId).orElseThrow();
    Leaderboard currentLeaderboard = leaderboardRepository.getRecentLeaderboardMetadata();

    LeaderboardFilterOptions options = LeaderboardFilterGenerator.builderWithTag(club.getTag())
            .page(1)
            .pageSize(5)
            .build();

    User user = userRepository.getUserByDiscordId(discordId);
    String userId = user.getId();

    if (user.getLeetcodeUsername() == null) {
        String description = String.format("""
                            Your Discord Account is not linked to a LeetCode username
                            """);

        MessageEmbed errorMsg = new EmbedBuilder()
                .setTitle("Cannot refresh submissions")
                .setDescription(description)
                .setColor(new Color(255, 0, 0))
                .build();
        return MessageCreateData.fromEmbeds(errorMsg);
    }

    List<LeetcodeSubmission> leetcodeSubmissions =
            leetcodeClient.findSubmissionsByUsername(user.getLeetcodeUsername(), 20);

    submissionsHandler.handleSubmissions(leetcodeSubmissions, user);

    UserWithScore scoredUser = userRepository.getUserWithScoreByIdAndLeaderboardId(
            userId, currentLeaderboard.getId(), UserFilterOptions.DEFAULT);

    int score = scoredUser.getTotalScore();
    Indexed<UserWithScore> globalIndex =
            leaderboardRepository.getGlobalRankedUserById(currentLeaderboard.getId(), userId);
    Indexed<UserWithScore> clubIndex =
            leaderboardRepository.getFilteredRankedUserById(currentLeaderboard.getId(), userId, options);
    int globalPlace = globalIndex.getIndex();
    int clubPlace = clubIndex.getIndex();

    String description =
            String.format("""
            After refreshing your submissions, you currently have %s points.

            You are currently number %d on the global leaderboard: `%s`

            You are currently number %d on the club leaderboard: %s
            """, score, globalPlace, currentLeaderboard.getName(), clubPlace, club.getName());

    MessageEmbed embed = new EmbedBuilder()
            .setTitle("Submissions Refreshed")
            .setDescription(description)
            .setFooter(
                    "Codebloom - LeetCode Leaderboard for %s".formatted(club.getName()),
                    "%s/favicon.ico".formatted(serverUrlUtils.getUrl()))
            .setColor(new Color(69, 129, 103))
            .build();

    return MessageCreateData.fromEmbeds(embed);
}
Cooldown Scope

The cooldown key is guild-scoped and shared across commands, so invoking one command (e.g., refresh) throttles the other (leaderboard) and vice versa. Use command-specific keys (e.g., "{guildId}:{command}") to isolate cooldowns.

/**
 * In memory rate limiter.
 *
 * @param event Slash command event
 * @param time Time to reuse
 */
private boolean handleRedis(SlashCommandInteractionEvent event, final long time) {
    String guildId = event.getGuild().getId();

    if (simpleRedis.containsKey(guildId)) {
        long timeThen = simpleRedis.get(guildId);
        long timeNow = System.currentTimeMillis();
        long difference = (timeNow - timeThen) / 1000;

        if (difference < time) {
            long remainingTime = time - difference;
            long minutes = remainingTime / 60;
            long seconds = remainingTime % 60;

            EmbedBuilder embed = new EmbedBuilder()
                    .setTitle("Please try again in **" + minutes + " minutes and " + seconds + " seconds**.")
                    .setColor(new Color(69, 129, 103));

            event.replyEmbeds(embed.build()).setEphemeral(true).queue();

            return true;
        }
    }

    simpleRedis.put(guildId, System.currentTimeMillis());
    return false;
}
Interaction Timeout

The refresh command performs network/DB work before replying, risking the 3s Discord interaction timeout. Consider deferReply(true) and then editOriginal/followUp to ensure responsiveness.

private void handleRefreshSlashCommand(SlashCommandInteractionEvent event) {
    if (event.getGuild() == null) {
        event.reply("This command can only be used in a server.")
                .setEphemeral(true)
                .queue();
        return;
    }
    if (handleRedis(event, 5 * 60)) {
        return;
    }

    String guildId = event.getGuild().getId();
    String userId = event.getUser().getId();

    MessageCreateData message = discordClubManager.buildRefreshMessageForClub(guildId, userId);

    event.reply(message).setEphemeral(true).queue();
}

@alfardil alfardil force-pushed the 783 branch 5 times, most recently from bf2e423 to 299ec0f Compare March 2, 2026 19:44
@alfardil
Copy link
Collaborator Author

alfardil commented Mar 3, 2026

/deploy

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants