From 9bb9883fe2f58fd235cd253323a347573350c7ff Mon Sep 17 00:00:00 2001 From: barsherror404 Date: Sat, 25 Apr 2026 12:17:19 +0300 Subject: [PATCH 1/4] feat: add /leaderboard command showing top helpers hall of fame --- .../togetherjava/tjbot/features/Features.java | 2 + .../leaderboard/LeaderboardCommand.java | 146 ++++++++++++++++++ .../features/leaderboard/package-info.java | 4 + 3 files changed, 152 insertions(+) create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/leaderboard/LeaderboardCommand.java create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/leaderboard/package-info.java diff --git a/application/src/main/java/org/togetherjava/tjbot/features/Features.java b/application/src/main/java/org/togetherjava/tjbot/features/Features.java index c360bacdd1..f2eb164d06 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/Features.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/Features.java @@ -38,6 +38,7 @@ import org.togetherjava.tjbot.features.help.PinnedNotificationRemover; import org.togetherjava.tjbot.features.jshell.JShellCommand; import org.togetherjava.tjbot.features.jshell.JShellEval; +import org.togetherjava.tjbot.features.leaderboard.LeaderboardCommand; import org.togetherjava.tjbot.features.mathcommands.TeXCommand; import org.togetherjava.tjbot.features.mathcommands.wolframalpha.WolframAlphaCommand; import org.togetherjava.tjbot.features.mediaonly.MediaOnlyChannelListener; @@ -202,6 +203,7 @@ public static Collection createFeatures(JDA jda, Database database, Con features.add(new AuditCommand(actionsStore)); features.add(new MuteCommand(actionsStore, config)); features.add(new UnmuteCommand(actionsStore, config)); + features.add(new LeaderboardCommand(config)); features.add(new TopHelpersCommand(topHelpersService, topHelpersAssignmentRoutine)); features.add(new RoleSelectCommand()); features.add(new NoteCommand(actionsStore)); diff --git a/application/src/main/java/org/togetherjava/tjbot/features/leaderboard/LeaderboardCommand.java b/application/src/main/java/org/togetherjava/tjbot/features/leaderboard/LeaderboardCommand.java new file mode 100644 index 0000000000..c440c6d25c --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/leaderboard/LeaderboardCommand.java @@ -0,0 +1,146 @@ +package org.togetherjava.tjbot.features.leaderboard; + +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.entities.User; +import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.togetherjava.tjbot.config.Config; +import org.togetherjava.tjbot.features.CommandVisibility; +import org.togetherjava.tjbot.features.SlashCommandAdapter; +import org.togetherjava.tjbot.features.tophelper.TopHelpersService; +import org.togetherjava.tjbot.features.utils.Colors; + +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.StringJoiner; +import java.util.regex.Pattern; + +/** + * Implements the {@code /leaderboard} slash command, which displays the all-time top helpers + * leaderboard by reading the hall-of-fame channel history. + */ +public final class LeaderboardCommand extends SlashCommandAdapter { + private static final Logger logger = LoggerFactory.getLogger(LeaderboardCommand.class); + + private static final String COMMAND_NAME = "leaderboard"; + private static final int TOP_LIMIT = 10; + private static final int HISTORY_LIMIT = 500; + + private static final String MEDAL_FIRST = "🥇"; + private static final String MEDAL_SECOND = "🥈"; + private static final String MEDAL_THIRD = "🥉"; + private static final String BULLET = "▸"; + + private final Config config; + + public LeaderboardCommand(Config config) { + super(COMMAND_NAME, "Show the all-time top helpers leaderboard", CommandVisibility.GUILD); + this.config = config; + } + + @Override + public void onSlashCommand(SlashCommandInteractionEvent event) { + Guild guild = event.getGuild(); + if (guild == null) { + event.reply("This command can only be used inside a server.") + .setEphemeral(true) + .queue(); + return; + } + + event.deferReply().queue(); + + Pattern channelPattern = + Pattern.compile(config.getTopHelpers().getAnnouncementChannelPattern()); + TextChannel hallOfFame = guild.getTextChannels() + .stream() + .filter(channel -> channelPattern.matcher(channel.getName()).find()) + .findFirst() + .orElse(null); + + if (hallOfFame == null) { + event.getHook().editOriginal("Could not find the hall of fame channel.").queue(); + return; + } + + hallOfFame.getIterableHistory().takeAsync(HISTORY_LIMIT).thenAccept(messages -> { + Map winsByUser = countWins(messages); + + List> sorted = winsByUser.entrySet() + .stream() + .sorted(Map.Entry.comparingByValue(Comparator.reverseOrder())) + .limit(TOP_LIMIT) + .toList(); + + if (sorted.isEmpty()) { + event.getHook().editOriginal("No top helper data found.").queue(); + return; + } + + List ids = sorted.stream().map(Map.Entry::getKey).toList(); + + guild.retrieveMembersByIds(ids).onSuccess(members -> { + Map memberById = TopHelpersService.mapUserIdToMember(members); + + StringJoiner description = new StringJoiner("\n"); + for (int i = 0; i < sorted.size(); i++) { + Map.Entry entry = sorted.get(i); + Member member = memberById.get(entry.getKey()); + String name = TopHelpersService.getUsernameDisplay(member); + int wins = entry.getValue(); + description.add("%s **%s** — %d month%s".formatted(rankPrefix(i), name, wins, + wins == 1 ? "" : "s")); + } + + EmbedBuilder embed = new EmbedBuilder().setTitle("🏆 Top Helpers — Hall of Fame") + .setDescription(description.toString()) + .setColor(Colors.SUCCESS_COLOR) + .setFooter("Times awarded Top Helper"); + + event.getHook().editOriginalEmbeds(embed.build()).queue(); + + }).onError(error -> { + logger.error("Failed to retrieve members for leaderboard", error); + event.getHook() + .editOriginal("Failed to load member data, please try again.") + .queue(); + }); + + }).exceptionally(error -> { + logger.error("Failed to read hall of fame channel", error); + event.getHook().editOriginal("Failed to read the hall of fame channel.").queue(); + return null; + }); + } + + private static Map countWins(List messages) { + Map wins = new HashMap<>(); + for (Message message : messages) { + String content = message.getContentRaw(); + if (!content.toLowerCase().contains("top helper")) { + continue; + } + for (User user : message.getMentions().getUsers()) { + wins.merge(user.getIdLong(), 1, Integer::sum); + } + } + return wins; + } + + private static String rankPrefix(int zeroBasedIndex) { + return switch (zeroBasedIndex) { + case 0 -> MEDAL_FIRST; + case 1 -> MEDAL_SECOND; + case 2 -> MEDAL_THIRD; + default -> BULLET + " #" + (zeroBasedIndex + 1); + }; + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/leaderboard/package-info.java b/application/src/main/java/org/togetherjava/tjbot/features/leaderboard/package-info.java new file mode 100644 index 0000000000..9531a98e7f --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/leaderboard/package-info.java @@ -0,0 +1,4 @@ +@MethodsReturnNonnullByDefault +package org.togetherjava.tjbot.features.leaderboard; + +import org.togetherjava.tjbot.annotations.MethodsReturnNonnullByDefault; From 23b6d5fee3889cbdc10af670bc501a8582fc1f94 Mon Sep 17 00:00:00 2001 From: barsherror404 Date: Tue, 2 Jun 2026 22:18:27 +0300 Subject: [PATCH 2/4] /leaderboard command showing top helpers hall of fame --- .../leaderboard/LeaderboardCommand.java | 116 +++++++++++------- 1 file changed, 75 insertions(+), 41 deletions(-) diff --git a/application/src/main/java/org/togetherjava/tjbot/features/leaderboard/LeaderboardCommand.java b/application/src/main/java/org/togetherjava/tjbot/features/leaderboard/LeaderboardCommand.java index c440c6d25c..438eb82a16 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/leaderboard/LeaderboardCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/leaderboard/LeaderboardCommand.java @@ -7,6 +7,7 @@ import net.dv8tion.jda.api.entities.User; import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.InteractionHook; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -16,11 +17,13 @@ import org.togetherjava.tjbot.features.tophelper.TopHelpersService; import org.togetherjava.tjbot.features.utils.Colors; +import java.time.OffsetDateTime; import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.StringJoiner; +import java.util.concurrent.ConcurrentHashMap; import java.util.regex.Pattern; /** @@ -41,6 +44,9 @@ public final class LeaderboardCommand extends SlashCommandAdapter { private final Config config; + private final Map> winsByGuild = new ConcurrentHashMap<>(); + private final Map lastFetchedPerGuild = new ConcurrentHashMap<>(); + public LeaderboardCommand(Config config) { super(COMMAND_NAME, "Show the all-time top helpers leaderboard", CommandVisibility.GUILD); this.config = config; @@ -71,58 +77,87 @@ public void onSlashCommand(SlashCommandInteractionEvent event) { return; } - hallOfFame.getIterableHistory().takeAsync(HISTORY_LIMIT).thenAccept(messages -> { - Map winsByUser = countWins(messages); + long guildId = guild.getIdLong(); + InteractionHook hook = event.getHook(); - List> sorted = winsByUser.entrySet() - .stream() - .sorted(Map.Entry.comparingByValue(Comparator.reverseOrder())) - .limit(TOP_LIMIT) - .toList(); + if (!winsByGuild.containsKey(guildId)) { + hallOfFame.getIterableHistory().takeAsync(HISTORY_LIMIT).thenAccept(messages -> { + Map wins = new HashMap<>(); + countWinsInto(messages, wins); + winsByGuild.put(guildId, new ConcurrentHashMap<>(wins)); - if (sorted.isEmpty()) { - event.getHook().editOriginal("No top helper data found.").queue(); - return; - } + if (!messages.isEmpty()) { + lastFetchedPerGuild.put(guildId, messages.getFirst().getTimeCreated()); + } + + sendLeaderboard(guild, wins, hook); + }).exceptionally(error -> { + logger.error("Failed to read hall of fame channel", error); + hook.editOriginal("Failed to read the hall of fame channel.").queue(); + return null; + }); + } else { + OffsetDateTime lastFetched = lastFetchedPerGuild.get(guildId); + Map cachedWins = winsByGuild.get(guildId); + + hallOfFame.getIterableHistory() + .takeWhileAsync(HISTORY_LIMIT, msg -> msg.getTimeCreated().isAfter(lastFetched)) + .thenAccept(newMessages -> { + if (!newMessages.isEmpty()) { + countWinsInto(newMessages, cachedWins); + lastFetchedPerGuild.put(guildId, newMessages.getFirst().getTimeCreated()); + } + sendLeaderboard(guild, cachedWins, hook); + }) + .exceptionally(error -> { + logger.error("Failed to read hall of fame channel", error); + hook.editOriginal("Failed to read the hall of fame channel.").queue(); + return null; + }); + } + } - List ids = sorted.stream().map(Map.Entry::getKey).toList(); + private void sendLeaderboard(Guild guild, Map wins, InteractionHook hook) { + List> sorted = wins.entrySet() + .stream() + .sorted(Map.Entry.comparingByValue(Comparator.reverseOrder())) + .limit(TOP_LIMIT) + .toList(); - guild.retrieveMembersByIds(ids).onSuccess(members -> { - Map memberById = TopHelpersService.mapUserIdToMember(members); + if (sorted.isEmpty()) { + hook.editOriginal("No top helper data found.").queue(); + return; + } - StringJoiner description = new StringJoiner("\n"); - for (int i = 0; i < sorted.size(); i++) { - Map.Entry entry = sorted.get(i); - Member member = memberById.get(entry.getKey()); - String name = TopHelpersService.getUsernameDisplay(member); - int wins = entry.getValue(); - description.add("%s **%s** — %d month%s".formatted(rankPrefix(i), name, wins, - wins == 1 ? "" : "s")); - } + List ids = sorted.stream().map(Map.Entry::getKey).toList(); - EmbedBuilder embed = new EmbedBuilder().setTitle("🏆 Top Helpers — Hall of Fame") - .setDescription(description.toString()) - .setColor(Colors.SUCCESS_COLOR) - .setFooter("Times awarded Top Helper"); + guild.retrieveMembersByIds(ids).onSuccess(members -> { + Map memberById = TopHelpersService.mapUserIdToMember(members); - event.getHook().editOriginalEmbeds(embed.build()).queue(); + StringJoiner description = new StringJoiner("\n"); + for (int i = 0; i < sorted.size(); i++) { + Map.Entry entry = sorted.get(i); + Member member = memberById.get(entry.getKey()); + String name = TopHelpersService.getUsernameDisplay(member); + int winCount = entry.getValue(); + description.add("%s **%s** — %d month%s".formatted(rankPrefix(i), name, winCount, + winCount == 1 ? "" : "s")); + } - }).onError(error -> { - logger.error("Failed to retrieve members for leaderboard", error); - event.getHook() - .editOriginal("Failed to load member data, please try again.") - .queue(); - }); + EmbedBuilder embed = new EmbedBuilder().setTitle("🏆 Top Helpers — Hall of Fame") + .setDescription(description.toString()) + .setColor(Colors.SUCCESS_COLOR) + .setFooter("Times awarded Top Helper"); + + hook.editOriginalEmbeds(embed.build()).queue(); - }).exceptionally(error -> { - logger.error("Failed to read hall of fame channel", error); - event.getHook().editOriginal("Failed to read the hall of fame channel.").queue(); - return null; + }).onError(error -> { + logger.error("Failed to retrieve members for leaderboard", error); + hook.editOriginal("Failed to load member data, please try again.").queue(); }); } - private static Map countWins(List messages) { - Map wins = new HashMap<>(); + private static void countWinsInto(List messages, Map wins) { for (Message message : messages) { String content = message.getContentRaw(); if (!content.toLowerCase().contains("top helper")) { @@ -132,7 +167,6 @@ private static Map countWins(List messages) { wins.merge(user.getIdLong(), 1, Integer::sum); } } - return wins; } private static String rankPrefix(int zeroBasedIndex) { From 94b6922a891a9405b07425c77c07cdda4bff3b0f Mon Sep 17 00:00:00 2001 From: barsherror404 Date: Wed, 3 Jun 2026 19:50:16 +0300 Subject: [PATCH 3/4] refactor: use Instant instead of OffsetDateTime and extract fetchNewMessages to remove duplication --- .../leaderboard/LeaderboardCommand.java | 67 ++++++++----------- 1 file changed, 29 insertions(+), 38 deletions(-) diff --git a/application/src/main/java/org/togetherjava/tjbot/features/leaderboard/LeaderboardCommand.java b/application/src/main/java/org/togetherjava/tjbot/features/leaderboard/LeaderboardCommand.java index 438eb82a16..438bb5ad54 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/leaderboard/LeaderboardCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/leaderboard/LeaderboardCommand.java @@ -17,12 +17,12 @@ import org.togetherjava.tjbot.features.tophelper.TopHelpersService; import org.togetherjava.tjbot.features.utils.Colors; -import java.time.OffsetDateTime; +import java.time.Instant; import java.util.Comparator; -import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.StringJoiner; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.regex.Pattern; @@ -45,7 +45,7 @@ public final class LeaderboardCommand extends SlashCommandAdapter { private final Config config; private final Map> winsByGuild = new ConcurrentHashMap<>(); - private final Map lastFetchedPerGuild = new ConcurrentHashMap<>(); + private final Map lastFetchedPerGuild = new ConcurrentHashMap<>(); public LeaderboardCommand(Config config) { super(COMMAND_NAME, "Show the all-time top helpers leaderboard", CommandVisibility.GUILD); @@ -80,47 +80,38 @@ public void onSlashCommand(SlashCommandInteractionEvent event) { long guildId = guild.getIdLong(); InteractionHook hook = event.getHook(); - if (!winsByGuild.containsKey(guildId)) { - hallOfFame.getIterableHistory().takeAsync(HISTORY_LIMIT).thenAccept(messages -> { - Map wins = new HashMap<>(); - countWinsInto(messages, wins); - winsByGuild.put(guildId, new ConcurrentHashMap<>(wins)); - - if (!messages.isEmpty()) { - lastFetchedPerGuild.put(guildId, messages.getFirst().getTimeCreated()); - } - - sendLeaderboard(guild, wins, hook); - }).exceptionally(error -> { - logger.error("Failed to read hall of fame channel", error); - hook.editOriginal("Failed to read the hall of fame channel.").queue(); - return null; - }); - } else { - OffsetDateTime lastFetched = lastFetchedPerGuild.get(guildId); - Map cachedWins = winsByGuild.get(guildId); - - hallOfFame.getIterableHistory() - .takeWhileAsync(HISTORY_LIMIT, msg -> msg.getTimeCreated().isAfter(lastFetched)) - .thenAccept(newMessages -> { - if (!newMessages.isEmpty()) { - countWinsInto(newMessages, cachedWins); - lastFetchedPerGuild.put(guildId, newMessages.getFirst().getTimeCreated()); - } - sendLeaderboard(guild, cachedWins, hook); - }) - .exceptionally(error -> { - logger.error("Failed to read hall of fame channel", error); - hook.editOriginal("Failed to read the hall of fame channel.").queue(); - return null; - }); + Map cachedWins = + winsByGuild.computeIfAbsent(guildId, _ -> new ConcurrentHashMap<>()); + Instant lastFetched = lastFetchedPerGuild.get(guildId); + + fetchNewMessages(hallOfFame, lastFetched).thenAccept(newMessages -> { + if (!newMessages.isEmpty()) { + countWinsInto(newMessages, cachedWins); + lastFetchedPerGuild.put(guildId, + newMessages.getFirst().getTimeCreated().toInstant()); + } + sendLeaderboard(guild, cachedWins, hook); + }).exceptionally(error -> { + logger.error("Failed to read hall of fame channel", error); + hook.editOriginal("Failed to read the hall of fame channel.").queue(); + return null; + }); + } + + private static CompletableFuture> fetchNewMessages(TextChannel channel, + Instant lastFetched) { + if (lastFetched == null) { + return channel.getIterableHistory().takeAsync(HISTORY_LIMIT); } + return channel.getIterableHistory() + .takeWhileAsync(HISTORY_LIMIT, + msg -> msg.getTimeCreated().toInstant().isAfter(lastFetched)); } private void sendLeaderboard(Guild guild, Map wins, InteractionHook hook) { List> sorted = wins.entrySet() .stream() - .sorted(Map.Entry.comparingByValue(Comparator.reverseOrder())) + .sorted(Map.Entry.comparingByValue(Comparator.reverseOrder())) .limit(TOP_LIMIT) .toList(); From 7ce48b635e84a41e5b97a9b2b9bee9655cbbf1b6 Mon Sep 17 00:00:00 2001 From: barsherror404 Date: Sun, 7 Jun 2026 22:29:39 +0300 Subject: [PATCH 4/4] used requireNonNull for guild and included pattern in channel not found message --- .../features/leaderboard/LeaderboardCommand.java | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/application/src/main/java/org/togetherjava/tjbot/features/leaderboard/LeaderboardCommand.java b/application/src/main/java/org/togetherjava/tjbot/features/leaderboard/LeaderboardCommand.java index 438bb5ad54..7314166469 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/leaderboard/LeaderboardCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/leaderboard/LeaderboardCommand.java @@ -21,6 +21,7 @@ import java.util.Comparator; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.StringJoiner; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; @@ -54,13 +55,7 @@ public LeaderboardCommand(Config config) { @Override public void onSlashCommand(SlashCommandInteractionEvent event) { - Guild guild = event.getGuild(); - if (guild == null) { - event.reply("This command can only be used inside a server.") - .setEphemeral(true) - .queue(); - return; - } + Guild guild = Objects.requireNonNull(event.getGuild()); event.deferReply().queue(); @@ -73,7 +68,10 @@ public void onSlashCommand(SlashCommandInteractionEvent event) { .orElse(null); if (hallOfFame == null) { - event.getHook().editOriginal("Could not find the hall of fame channel.").queue(); + event.getHook() + .editOriginal( + "Could not find channel matching '%s'.".formatted(channelPattern.pattern())) + .queue(); return; }