From 6ed19dec113156d3d3127e5331134d7fc3cf846c Mon Sep 17 00:00:00 2001 From: Zabuzard Date: Sat, 16 Aug 2025 12:10:36 +0200 Subject: [PATCH 01/14] config, command adjustments, routine and base setup --- application/config.json.template | 3 +- .../org/togetherjava/tjbot/config/Config.java | 17 ++- .../togetherjava/tjbot/features/Features.java | 8 +- .../togetherjava/tjbot/features/Routine.java | 105 +++++++++++++++++ .../moderation/audit/ModAuditLogRoutine.java | 81 +------------ .../TopHelpersAssignmentRoutine.java | 79 +++++++++++++ .../features/tophelper/TopHelpersCommand.java | 106 ++++++++---------- .../features/tophelper/TopHelpersService.java | 72 ++++++++++++ 8 files changed, 328 insertions(+), 143 deletions(-) create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersAssignmentRoutine.java create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersService.java diff --git a/application/config.json.template b/application/config.json.template index f8a14abc2a..dd895a8a69 100644 --- a/application/config.json.template +++ b/application/config.json.template @@ -189,5 +189,6 @@ "fallbackChannelPattern": "java-news-and-changes", "pollIntervalInMinutes": 10 }, - "memberCountCategoryPattern": "Info" + "memberCountCategoryPattern": "Info", + "topHelperAssignmentChannelPattern": "commands" } diff --git a/application/src/main/java/org/togetherjava/tjbot/config/Config.java b/application/src/main/java/org/togetherjava/tjbot/config/Config.java index 79c04e6cad..2685801933 100644 --- a/application/src/main/java/org/togetherjava/tjbot/config/Config.java +++ b/application/src/main/java/org/togetherjava/tjbot/config/Config.java @@ -48,6 +48,7 @@ public final class Config { private final RSSFeedsConfig rssFeedsConfig; private final String selectRolesChannelPattern; private final String memberCountCategoryPattern; + private final String topHelperAssignmentChannelPattern; @SuppressWarnings("ConstructorWithTooManyParameters") @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) @@ -100,7 +101,9 @@ private Config(@JsonProperty(value = "token", required = true) String token, required = true) FeatureBlacklistConfig featureBlacklistConfig, @JsonProperty(value = "rssConfig", required = true) RSSFeedsConfig rssFeedsConfig, @JsonProperty(value = "selectRolesChannelPattern", - required = true) String selectRolesChannelPattern) { + required = true) String selectRolesChannelPattern, + @JsonProperty(value = "topHelperAssignmentChannelPattern", + required = true) String topHelperAssignmentChannelPattern) { this.token = Objects.requireNonNull(token); this.githubApiKey = Objects.requireNonNull(githubApiKey); this.databasePath = Objects.requireNonNull(databasePath); @@ -135,6 +138,8 @@ private Config(@JsonProperty(value = "token", required = true) String token, this.featureBlacklistConfig = Objects.requireNonNull(featureBlacklistConfig); this.rssFeedsConfig = Objects.requireNonNull(rssFeedsConfig); this.selectRolesChannelPattern = Objects.requireNonNull(selectRolesChannelPattern); + this.topHelperAssignmentChannelPattern = + Objects.requireNonNull(topHelperAssignmentChannelPattern); } /** @@ -445,4 +450,14 @@ public String getMemberCountCategoryPattern() { public RSSFeedsConfig getRSSFeedsConfig() { return rssFeedsConfig; } + + /** + * Gets the REGEX pattern used to identify the channel where Top Helper Assignments are + * automatically executed. + * + * @return the channel name pattern + */ + public String getTopHelperAssignmentChannelPattern() { + return topHelperAssignmentChannelPattern; + } } 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 6746c7f8a2..463c3b5248 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/Features.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/Features.java @@ -72,9 +72,11 @@ import org.togetherjava.tjbot.features.tags.TagManageCommand; import org.togetherjava.tjbot.features.tags.TagSystem; import org.togetherjava.tjbot.features.tags.TagsCommand; +import org.togetherjava.tjbot.features.tophelper.TopHelpersAssignmentRoutine; import org.togetherjava.tjbot.features.tophelper.TopHelpersCommand; import org.togetherjava.tjbot.features.tophelper.TopHelpersMessageListener; import org.togetherjava.tjbot.features.tophelper.TopHelpersPurgeMessagesRoutine; +import org.togetherjava.tjbot.features.tophelper.TopHelpersService; import java.util.ArrayList; import java.util.Collection; @@ -119,6 +121,9 @@ public static Collection createFeatures(JDA jda, Database database, Con HelpSystemHelper helpSystemHelper = new HelpSystemHelper(config, database, chatGptService); HelpThreadLifecycleListener helpThreadLifecycleListener = new HelpThreadLifecycleListener(helpSystemHelper, database); + TopHelpersService topHelpersService = new TopHelpersService(database); + TopHelpersAssignmentRoutine topHelpersAssignmentRoutine = + new TopHelpersAssignmentRoutine(config, topHelpersService); // NOTE The system can add special system relevant commands also by itself, // hence this list may not necessarily represent the full list of all commands actually @@ -140,6 +145,7 @@ public static Collection createFeatures(JDA jda, Database database, Con features.add(new MarkHelpThreadCloseInDBRoutine(database, helpThreadLifecycleListener)); features.add(new MemberCountDisplayRoutine(config)); features.add(new RSSHandlerRoutine(config, database)); + features.add(topHelpersAssignmentRoutine); // Message receivers features.add(new TopHelpersMessageListener(database, config)); @@ -182,7 +188,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 TopHelpersCommand(database)); + features.add(new TopHelpersCommand(topHelpersService, topHelpersAssignmentRoutine)); features.add(new RoleSelectCommand()); features.add(new NoteCommand(actionsStore)); features.add(new ReminderCommand(database)); diff --git a/application/src/main/java/org/togetherjava/tjbot/features/Routine.java b/application/src/main/java/org/togetherjava/tjbot/features/Routine.java index b5d2f614af..3f31ae7623 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/Routine.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/Routine.java @@ -2,7 +2,19 @@ import net.dv8tion.jda.api.JDA; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalTime; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import java.util.function.BiFunction; +import java.util.stream.Collectors; /** * Routines are executed on a reoccurring schedule by the core system. @@ -45,6 +57,99 @@ public interface Routine extends Feature { * seconds */ record Schedule(ScheduleMode mode, long initialDuration, long duration, TimeUnit unit) { + + private static final int HOURS_OF_DAY = 24; + + /** + * Creates a schedule for execution at a fixed hour of the day. The initial first execution + * will be delayed to the next fixed time that matches the given hour of the day, + * effectively making execution stable at that fixed hour - regardless of when this method + * was originally triggered. + *

+ * For example, if the given hour is 12 o'clock, this leads to the fixed execution times of + * only 12:00 each day. The first execution is then delayed to the closest time in that + * schedule. For example, if triggered at 7:00, execution will happen at 12:00 and then + * follow the schedule. + *

+ * Execution will also correctly roll over to the next day, for example if the method is + * triggered at 21:30, the next execution will be at 12:00 the following day. + * + * @param hourOfDay the hour of the day that marks the start of this period + * @return the according schedule representing the planned execution + */ + public static Schedule atFixedHour(int hourOfDay) { + return atFixedRateFromNextFixedTime(hourOfDay, HOURS_OF_DAY); + } + + /** + * Creates a schedule for execution at a fixed rate (see + * {@link ScheduledExecutorService#scheduleAtFixedRate(Runnable, long, long, TimeUnit)}). + * The initial first execution will be delayed to the next fixed time that matches the given + * period, effectively making execution stable at fixed times of a day - regardless of when + * this method was originally triggered. + *

+ * For example, if the given period is 8 hours with a start hour of 4 o'clock, this leads to + * the fixed execution times of 4:00, 12:00 and 20:00 each day. The first execution is then + * delayed to the closest time in that schedule. For example, if triggered at 7:00, + * execution will happen at 12:00 and then follow the schedule. + *

+ * Execution will also correctly roll over to the next day, for example if the method is + * triggered at 21:30, the next execution will be at 4:00 the following day. + * + * @param periodStartHour the hour of the day that marks the start of this period + * @param periodHours the scheduling period in hours + * @return the according schedule representing the planned execution + */ + public static Schedule atFixedRateFromNextFixedTime(int periodStartHour, int periodHours) { + // NOTE This scheduler could be improved, for example supporting arbitrary periods (not + // just + // hour-based). Also, it probably does not correctly handle all date/time-quirks, for + // example if a schedule would hit a time that does not exist for a specific date due to + // DST + // or similar issues. Those are minor though and can be ignored for now. + if (periodStartHour < 0 || periodStartHour >= HOURS_OF_DAY) { + throw new IllegalArgumentException( + "Schedule period start hour must be a valid hour of a day (0-23)"); + } + if (periodHours <= 0 || periodHours > HOURS_OF_DAY) { + throw new IllegalArgumentException( + "Schedule period must not be zero and must fit into a single day (0-24)"); + } + + // Compute fixed schedule hours + List fixedScheduleHours = new ArrayList<>(); + + for (int hour = periodStartHour; hour < HOURS_OF_DAY; hour += periodHours) { + fixedScheduleHours.add(hour); + } + + Instant now = Instant.now(); + Instant nextFixedTime = + computeClosestNextScheduleDate(now, fixedScheduleHours, periodHours); + return new Schedule(ScheduleMode.FIXED_RATE, + ChronoUnit.SECONDS.between(now, nextFixedTime), + TimeUnit.HOURS.toSeconds(periodHours), TimeUnit.SECONDS); + } + + private static Instant computeClosestNextScheduleDate(Instant instant, + List scheduleHours, int periodHours) { + OffsetDateTime offsetDateTime = instant.atOffset(ZoneOffset.UTC); + BiFunction dateAtTime = + (date, hour) -> date.with(LocalTime.of(hour, 0)).toInstant(); + + // The instant is either before the given hours, in between, or after. + // For latter, we roll the schedule over once to the next day + List scheduleDates = scheduleHours.stream() + .map(hour -> dateAtTime.apply(offsetDateTime, hour)) + .collect(Collectors.toCollection(ArrayList::new)); + int rolloverHour = (scheduleHours.getLast() + periodHours) % HOURS_OF_DAY; + scheduleDates.add(dateAtTime.apply(offsetDateTime.plusDays(1), rolloverHour)); + + return scheduleDates.stream() + .filter(instant::isBefore) + .min(Comparator.comparing(scheduleDate -> Duration.between(instant, scheduleDate))) + .orElseThrow(); + } } /** diff --git a/application/src/main/java/org/togetherjava/tjbot/features/moderation/audit/ModAuditLogRoutine.java b/application/src/main/java/org/togetherjava/tjbot/features/moderation/audit/ModAuditLogRoutine.java index 6af6fa2248..603c1de578 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/moderation/audit/ModAuditLogRoutine.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/moderation/audit/ModAuditLogRoutine.java @@ -28,24 +28,14 @@ import javax.annotation.Nullable; import java.awt.Color; -import java.time.Duration; import java.time.Instant; -import java.time.LocalTime; -import java.time.OffsetDateTime; -import java.time.ZoneOffset; -import java.time.temporal.ChronoUnit; import java.time.temporal.TemporalAccessor; -import java.util.ArrayList; import java.util.Collection; import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; -import java.util.function.BiFunction; -import java.util.stream.Collectors; /** @@ -60,7 +50,6 @@ public final class ModAuditLogRoutine implements Routine { private static final Logger logger = LoggerFactory.getLogger(ModAuditLogRoutine.class); private static final int CHECK_AUDIT_LOG_START_HOUR = 4; private static final int CHECK_AUDIT_LOG_EVERY_HOURS = 8; - private static final int HOURS_OF_DAY = 24; private static final Color AMBIENT_COLOR = Color.decode("#4FC3F7"); private final Database database; @@ -95,74 +84,6 @@ private static boolean isSnowflakeAfter(ISnowflake snowflake, Instant timestamp) return TimeUtil.getTimeCreated(snowflake.getIdLong()).toInstant().isAfter(timestamp); } - /** - * Creates a schedule for execution at a fixed rate (see - * {@link ScheduledExecutorService#scheduleAtFixedRate(Runnable, long, long, TimeUnit)}). The - * initial first execution will be delayed to the next fixed time that matches the given period, - * effectively making execution stable at fixed times of a day - regardless of when this method - * was originally triggered. - *

- * For example, if the given period is 8 hours with a start hour of 4 o'clock, this leads to the - * fixed execution times of 4:00, 12:00 and 20:00 each day. The first execution is then delayed - * to the closest time in that schedule. For example, if triggered at 7:00, execution will - * happen at 12:00 and then follow the schedule. - *

- * Execution will also correctly roll over to the next day, for example if the method is - * triggered at 21:30, the next execution will be at 4:00 the following day. - * - * @param periodStartHour the hour of the day that marks the start of this period - * @param periodHours the scheduling period in hours - * @return the according schedule representing the planned execution - */ - private static Schedule scheduleAtFixedRateFromNextFixedTime(int periodStartHour, - int periodHours) { - // NOTE This scheduler could be improved, for example supporting arbitrary periods (not just - // hour-based). Also, it probably does not correctly handle all date/time-quirks, for - // example if a schedule would hit a time that does not exist for a specific date due to DST - // or similar issues. Those are minor though and can be ignored for now. - if (periodHours <= 0 || periodHours >= HOURS_OF_DAY) { - throw new IllegalArgumentException( - "Schedule period must not be zero and must fit into a single day"); - } - if (periodStartHour <= 0 || periodStartHour >= HOURS_OF_DAY) { - throw new IllegalArgumentException( - "Schedule period start hour must be a valid hour of a day (0-23)"); - } - - // Compute fixed schedule hours - List fixedScheduleHours = new ArrayList<>(); - - for (int hour = periodStartHour; hour < HOURS_OF_DAY; hour += periodHours) { - fixedScheduleHours.add(hour); - } - - Instant now = Instant.now(); - Instant nextFixedTime = - computeClosestNextScheduleDate(now, fixedScheduleHours, periodHours); - return new Schedule(ScheduleMode.FIXED_RATE, ChronoUnit.SECONDS.between(now, nextFixedTime), - TimeUnit.HOURS.toSeconds(periodHours), TimeUnit.SECONDS); - } - - private static Instant computeClosestNextScheduleDate(Instant instant, - List scheduleHours, int periodHours) { - OffsetDateTime offsetDateTime = instant.atOffset(ZoneOffset.UTC); - BiFunction dateAtTime = - (date, hour) -> date.with(LocalTime.of(hour, 0)).toInstant(); - - // The instant is either before the given hours, in between, or after. - // For latter, we roll the schedule over once to the next day - List scheduleDates = scheduleHours.stream() - .map(hour -> dateAtTime.apply(offsetDateTime, hour)) - .collect(Collectors.toCollection(ArrayList::new)); - int rolloverHour = (scheduleHours.getLast() + periodHours) % HOURS_OF_DAY; - scheduleDates.add(dateAtTime.apply(offsetDateTime.plusDays(1), rolloverHour)); - - return scheduleDates.stream() - .filter(instant::isBefore) - .min(Comparator.comparing(scheduleDate -> Duration.between(instant, scheduleDate))) - .orElseThrow(); - } - private static Optional> handleBanEntry(AuditLogEntry entry) { // NOTE Temporary bans are realized as permanent bans with automated unban, // hence we can not differentiate a permanent or a temporary ban here @@ -205,7 +126,7 @@ public void runRoutine(JDA jda) { @Override public Schedule createSchedule() { - Schedule schedule = scheduleAtFixedRateFromNextFixedTime(CHECK_AUDIT_LOG_START_HOUR, + Schedule schedule = Schedule.atFixedRateFromNextFixedTime(CHECK_AUDIT_LOG_START_HOUR, CHECK_AUDIT_LOG_EVERY_HOURS); logger.info("Checking audit logs is scheduled for {}.", Instant.now().plus(schedule.initialDuration(), schedule.unit().toChronoUnit())); diff --git a/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersAssignmentRoutine.java b/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersAssignmentRoutine.java new file mode 100644 index 0000000000..a625531010 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersAssignmentRoutine.java @@ -0,0 +1,79 @@ +package org.togetherjava.tjbot.features.tophelper; + +import net.dv8tion.jda.api.JDA; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.togetherjava.tjbot.config.Config; +import org.togetherjava.tjbot.features.Routine; + +import java.time.Instant; +import java.time.Month; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Optional; +import java.util.function.Predicate; +import java.util.regex.Pattern; + +// TODO Javadoc everywhere +public final class TopHelpersAssignmentRoutine implements Routine { + private static final Logger logger = LoggerFactory.getLogger(TopHelpersAssignmentRoutine.class); + private static final int CHECK_AT_HOUR = 12; + + private final Config config; + private final TopHelpersService service; + private final Predicate assignmentChannelNamePredicate; + + public TopHelpersAssignmentRoutine(Config config, TopHelpersService service) { + this.config = config; + this.service = service; + assignmentChannelNamePredicate = + Pattern.compile(config.getTopHelperAssignmentChannelPattern()).asMatchPredicate(); + } + + @Override + public Schedule createSchedule() { + return Schedule.atFixedHour(CHECK_AT_HOUR); + } + + @Override + public void runRoutine(JDA jda) { + int dayOfMonth = Instant.now().atOffset(ZoneOffset.UTC).getDayOfMonth(); + if (dayOfMonth != 1) { + return; + } + + jda.getGuilds().forEach(this::startDialogFor); + } + + public void startDialogFor(Guild guild) { + Optional assignmentChannel = guild.getTextChannelCache() + .stream() + .filter(channel -> assignmentChannelNamePredicate.test(channel.getName())) + .findAny(); + + assignmentChannel.ifPresentOrElse(this::startDialogIn, () -> logger.warn( + "Unable to assign Top Helpers, did not find an assignment channel matching the configured pattern '{}' for guild '{}'", + config.getTopHelperAssignmentChannelPattern(), guild.getName())); + } + + private void startDialogIn(TextChannel channel) { + Month previousMonth = Instant.now().atZone(ZoneOffset.UTC).minusMonths(1).getMonth(); + TopHelpersService.TimeRange timeRange = + TopHelpersService.TimeRange.fromMonth(previousMonth); + List topHelpers = service.computeTopHelpersDescending( + channel.getGuild().getIdLong(), timeRange.start(), timeRange.end()); + + if (topHelpers.isEmpty()) { + channel.sendMessage( + "Wanted to assign Top Helpers, but there seems to be no entries for that time range (%s)." + .formatted(timeRange.description())) + .queue(); + return; + } + + logger.error("TODO Implement me"); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersCommand.java b/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersCommand.java index 04295920e1..499da16e9b 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersCommand.java @@ -10,38 +10,31 @@ import net.dv8tion.jda.api.interactions.commands.OptionMapping; import net.dv8tion.jda.api.interactions.commands.OptionType; import net.dv8tion.jda.api.interactions.commands.build.OptionData; -import org.jooq.Records; -import org.jooq.impl.DSL; +import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.togetherjava.tjbot.db.Database; import org.togetherjava.tjbot.features.CommandVisibility; import org.togetherjava.tjbot.features.SlashCommandAdapter; import org.togetherjava.tjbot.features.utils.MessageUtils; import javax.annotation.Nullable; -import java.math.BigDecimal; import java.time.Instant; -import java.time.LocalTime; import java.time.Month; -import java.time.YearMonth; import java.time.ZoneOffset; -import java.time.ZonedDateTime; import java.time.format.TextStyle; import java.util.Arrays; import java.util.Collection; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Objects; import java.util.function.Function; import java.util.function.IntFunction; import java.util.stream.Collectors; import java.util.stream.IntStream; -import static org.togetherjava.tjbot.db.generated.tables.HelpChannelMessages.HELP_CHANNEL_MESSAGES; - /** * Command that displays the top helpers of a given time range. *

@@ -51,39 +44,59 @@ public final class TopHelpersCommand extends SlashCommandAdapter { private static final Logger logger = LoggerFactory.getLogger(TopHelpersCommand.class); private static final String COMMAND_NAME = "top-helpers"; + private static final String SUBCOMMAND_SHOW_NAME = "show"; + private static final String SUBCOMMAND_ASSIGN_NAME = "assign"; private static final String MONTH_OPTION = "at-month"; - private static final int TOP_HELPER_LIMIT = 18; private static final int MAX_USER_NAME_LIMIT = 15; - private final Database database; + private final TopHelpersService service; + private final TopHelpersAssignmentRoutine assignmentRoutine; /** * Creates a new instance. * - * @param database the database containing the message records of top helpers + * @param service the service that can compute top helpers + * @param assignmentRoutine the routine that can assign top helpers automatically */ - public TopHelpersCommand(Database database) { - super(COMMAND_NAME, "Lists top helpers for the last month, or a given month", - CommandVisibility.GUILD); + public TopHelpersCommand(TopHelpersService service, + TopHelpersAssignmentRoutine assignmentRoutine) { + super(COMMAND_NAME, "Manages top helpers", CommandVisibility.GUILD); OptionData monthData = new OptionData(OptionType.STRING, MONTH_OPTION, "the month to compute for, by default the last month", false); Arrays.stream(Month.values()) .forEach(month -> monthData.addChoice( month.getDisplayName(TextStyle.FULL_STANDALONE, Locale.US), month.name())); - getData().addOptions(monthData); - this.database = database; + getData().addSubcommands( + new SubcommandData(SUBCOMMAND_SHOW_NAME, + "Lists top helpers for the last month, or a given month") + .addOptions(monthData), + new SubcommandData(SUBCOMMAND_ASSIGN_NAME, + "Automatically assigns top helpers for the last month")); + + this.service = service; + this.assignmentRoutine = assignmentRoutine; } @Override public void onSlashCommand(SlashCommandInteractionEvent event) { + switch (event.getSubcommandName()) { + case SUBCOMMAND_SHOW_NAME -> showTopHelpers(event); + case SUBCOMMAND_ASSIGN_NAME -> assignTopHelpers(event); + default -> throw new AssertionError( + "Unexpected subcommand '%s'".formatted(event.getSubcommandName())); + } + } + + private void showTopHelpers(SlashCommandInteractionEvent event) { OptionMapping atMonthData = event.getOption(MONTH_OPTION); - TimeRange timeRange = computeTimeRange(computeMonth(atMonthData)); - List topHelpers = - computeTopHelpersDescending(event.getGuild().getIdLong(), timeRange); + TopHelpersService.TimeRange timeRange = + TopHelpersService.TimeRange.fromMonth(computeMonth(atMonthData)); + List topHelpers = service.computeTopHelpersDescending( + event.getGuild().getIdLong(), timeRange.start(), timeRange.end()); if (topHelpers.isEmpty()) { event @@ -94,13 +107,21 @@ public void onSlashCommand(SlashCommandInteractionEvent event) { } event.deferReply().queue(); - List topHelperIds = topHelpers.stream().map(TopHelperResult::authorId).toList(); + List topHelperIds = + topHelpers.stream().map(TopHelpersService.TopHelperResult::authorId).toList(); event.getGuild() .retrieveMembersByIds(topHelperIds) .onError(error -> handleError(error, event)) .onSuccess(members -> handleTopHelpers(topHelpers, members, timeRange, event)); } + private void assignTopHelpers(SlashCommandInteractionEvent event) { + event.reply("Automatic Top Helper assignment dialog has started") + .setEphemeral(true) + .queue(); + assignmentRoutine.startDialogFor(Objects.requireNonNull(event.getGuild())); + } + private static Month computeMonth(@Nullable OptionMapping atMonthData) { if (atMonthData != null) { return Month.valueOf(atMonthData.getAsString()); @@ -110,43 +131,14 @@ private static Month computeMonth(@Nullable OptionMapping atMonthData) { return Instant.now().atZone(ZoneOffset.UTC).minusMonths(1).getMonth(); } - private static TimeRange computeTimeRange(Month atMonth) { - ZonedDateTime now = Instant.now().atZone(ZoneOffset.UTC); - - int atYear = now.getYear(); - // E.g. using November, while it is March 2022, should use November 2021 - if (atMonth.compareTo(now.getMonth()) > 0) { - atYear--; - } - YearMonth atYearMonth = YearMonth.of(atYear, atMonth); - - Instant start = atYearMonth.atDay(1).atTime(LocalTime.MIN).toInstant(ZoneOffset.UTC); - Instant end = atYearMonth.atEndOfMonth().atTime(LocalTime.MAX).toInstant(ZoneOffset.UTC); - String description = "%s %d" - .formatted(atMonth.getDisplayName(TextStyle.FULL_STANDALONE, Locale.US), atYear); - - return new TimeRange(start, end, description); - } - - private List computeTopHelpersDescending(long guildId, TimeRange timeRange) { - return database.read(context -> context - .select(HELP_CHANNEL_MESSAGES.AUTHOR_ID, DSL.sum(HELP_CHANNEL_MESSAGES.MESSAGE_LENGTH)) - .from(HELP_CHANNEL_MESSAGES) - .where(HELP_CHANNEL_MESSAGES.GUILD_ID.eq(guildId) - .and(HELP_CHANNEL_MESSAGES.SENT_AT.between(timeRange.start(), timeRange.end()))) - .groupBy(HELP_CHANNEL_MESSAGES.AUTHOR_ID) - .orderBy(DSL.two().desc()) - .limit(TOP_HELPER_LIMIT) - .fetch(Records.mapping(TopHelperResult::new))); - } - private static void handleError(Throwable error, IDeferrableCallback event) { logger.warn("Failed to compute top-helpers", error); event.getHook().editOriginal("Sorry, something went wrong.").queue(); } - private static void handleTopHelpers(Collection topHelpers, - Collection members, TimeRange timeRange, IDeferrableCallback event) { + private static void handleTopHelpers(Collection topHelpers, + Collection members, TopHelpersService.TimeRange timeRange, + IDeferrableCallback event) { Map userIdToMember = members.stream().collect(Collectors.toMap(Member::getIdLong, Function.identity())); @@ -164,7 +156,7 @@ private static void handleTopHelpers(Collection topHelpers, event.getHook().editOriginal(message).queue(); } - private static List topHelperToDataRow(TopHelperResult topHelper, + private static List topHelperToDataRow(TopHelpersService.TopHelperResult topHelper, @Nullable Member member) { String id = Long.toString(topHelper.authorId()); String name = MessageUtils.abbreviate( @@ -197,12 +189,6 @@ private static String dataTableToAsciiTable(Collection> dataTable, return AsciiTable.getTable(AsciiTable.BASIC_ASCII_NO_DATA_SEPARATORS, dataTable, columns); } - private record TimeRange(Instant start, Instant end, String description) { - } - - private record TopHelperResult(long authorId, BigDecimal messageLengths) { - } - private record ColumnSetting(String headerName, HorizontalAlign alignment) { } } diff --git a/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersService.java b/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersService.java new file mode 100644 index 0000000000..a2d4fbc484 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersService.java @@ -0,0 +1,72 @@ +package org.togetherjava.tjbot.features.tophelper; + +import org.jooq.Records; +import org.jooq.impl.DSL; + +import org.togetherjava.tjbot.db.Database; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalTime; +import java.time.Month; +import java.time.YearMonth; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.time.format.TextStyle; +import java.util.List; +import java.util.Locale; + +import static org.togetherjava.tjbot.db.generated.tables.HelpChannelMessages.HELP_CHANNEL_MESSAGES; + +// TODO Javadoc everywhere +public final class TopHelpersService { + private static final int TOP_HELPER_LIMIT = 18; + + private final Database database; + + /** + * Creates a new instance. + * + * @param database the database containing the message records of top helpers + */ + public TopHelpersService(Database database) { + this.database = database; + } + + public List computeTopHelpersDescending(long guildId, Instant start, + Instant end) { + return database.read(context -> context + .select(HELP_CHANNEL_MESSAGES.AUTHOR_ID, DSL.sum(HELP_CHANNEL_MESSAGES.MESSAGE_LENGTH)) + .from(HELP_CHANNEL_MESSAGES) + .where(HELP_CHANNEL_MESSAGES.GUILD_ID.eq(guildId) + .and(HELP_CHANNEL_MESSAGES.SENT_AT.between(start, end))) + .groupBy(HELP_CHANNEL_MESSAGES.AUTHOR_ID) + .orderBy(DSL.two().desc()) + .limit(TOP_HELPER_LIMIT) + .fetch(Records.mapping(TopHelperResult::new))); + } + + public record TopHelperResult(long authorId, BigDecimal messageLengths) { + } + + public record TimeRange(Instant start, Instant end, String description) { + public static TimeRange fromMonth(Month atMonth) { + ZonedDateTime now = Instant.now().atZone(ZoneOffset.UTC); + + int atYear = now.getYear(); + // E.g. using November, while it is March 2022, should use November 2021 + if (atMonth.compareTo(now.getMonth()) > 0) { + atYear--; + } + YearMonth atYearMonth = YearMonth.of(atYear, atMonth); + + Instant start = atYearMonth.atDay(1).atTime(LocalTime.MIN).toInstant(ZoneOffset.UTC); + Instant end = + atYearMonth.atEndOfMonth().atTime(LocalTime.MAX).toInstant(ZoneOffset.UTC); + String description = "%s %d" + .formatted(atMonth.getDisplayName(TextStyle.FULL_STANDALONE, Locale.US), atYear); + + return new TimeRange(start, end, description); + } + } +} From d14820d2b998189dcc9a6cea825e6996d70bc507 Mon Sep 17 00:00:00 2001 From: Zabuzard Date: Sat, 16 Aug 2025 13:32:05 +0200 Subject: [PATCH 02/14] select menu --- .../TopHelpersAssignmentRoutine.java | 109 ++++++++++++++++- .../features/tophelper/TopHelpersCommand.java | 67 +---------- .../features/tophelper/TopHelpersService.java | 112 ++++++++++++++++-- 3 files changed, 208 insertions(+), 80 deletions(-) diff --git a/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersAssignmentRoutine.java b/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersAssignmentRoutine.java index a625531010..b9d73453cf 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersAssignmentRoutine.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersAssignmentRoutine.java @@ -2,35 +2,72 @@ import net.dv8tion.jda.api.JDA; import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.Member; import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; +import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent; +import net.dv8tion.jda.api.events.interaction.component.StringSelectInteractionEvent; +import net.dv8tion.jda.api.interactions.components.buttons.Button; +import net.dv8tion.jda.api.interactions.components.selections.SelectOption; +import net.dv8tion.jda.api.interactions.components.selections.StringSelectMenu; +import net.dv8tion.jda.api.utils.messages.MessageCreateBuilder; +import net.dv8tion.jda.api.utils.messages.MessageCreateData; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.togetherjava.tjbot.config.Config; import org.togetherjava.tjbot.features.Routine; +import org.togetherjava.tjbot.features.UserInteractionType; +import org.togetherjava.tjbot.features.UserInteractor; +import org.togetherjava.tjbot.features.componentids.ComponentIdGenerator; +import org.togetherjava.tjbot.features.componentids.ComponentIdInteractor; + +import javax.annotation.Nullable; import java.time.Instant; import java.time.Month; import java.time.ZoneOffset; +import java.util.Collection; import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.function.Function; import java.util.function.Predicate; import java.util.regex.Pattern; +import java.util.stream.Collectors; // TODO Javadoc everywhere -public final class TopHelpersAssignmentRoutine implements Routine { +public final class TopHelpersAssignmentRoutine implements Routine, UserInteractor { private static final Logger logger = LoggerFactory.getLogger(TopHelpersAssignmentRoutine.class); private static final int CHECK_AT_HOUR = 12; private final Config config; private final TopHelpersService service; private final Predicate assignmentChannelNamePredicate; + private final ComponentIdInteractor componentIdInteractor; public TopHelpersAssignmentRoutine(Config config, TopHelpersService service) { this.config = config; this.service = service; + assignmentChannelNamePredicate = Pattern.compile(config.getTopHelperAssignmentChannelPattern()).asMatchPredicate(); + + componentIdInteractor = new ComponentIdInteractor(getInteractionType(), getName()); + } + + @Override + public String getName() { + return "top-helper-assignment"; + } + + @Override + public UserInteractionType getInteractionType() { + return UserInteractionType.OTHER; + } + + @Override + public void acceptComponentIdGenerator(ComponentIdGenerator generator) { + componentIdInteractor.acceptComponentIdGenerator(generator); } @Override @@ -60,11 +97,13 @@ public void startDialogFor(Guild guild) { } private void startDialogIn(TextChannel channel) { + Guild guild = channel.getGuild(); + Month previousMonth = Instant.now().atZone(ZoneOffset.UTC).minusMonths(1).getMonth(); TopHelpersService.TimeRange timeRange = TopHelpersService.TimeRange.fromMonth(previousMonth); - List topHelpers = service.computeTopHelpersDescending( - channel.getGuild().getIdLong(), timeRange.start(), timeRange.end()); + List topHelpers = service + .computeTopHelpersDescending(guild.getIdLong(), timeRange.start(), timeRange.end()); if (topHelpers.isEmpty()) { channel.sendMessage( @@ -74,6 +113,68 @@ private void startDialogIn(TextChannel channel) { return; } - logger.error("TODO Implement me"); + service.retrieveTopHelperMembers(topHelpers, guild) + .onError(error -> handleError(error, channel)) + .onSuccess(members -> sendSelectionMenu(topHelpers, members, timeRange, channel)); + } + + private static void handleError(Throwable error, TextChannel channel) { + logger.warn("Failed to compute top-helpers for automatic assignment", error); + channel.sendMessage("Wanted to assign Top Helpers, but something went wrong.").queue(); + } + + private void sendSelectionMenu(Collection topHelpers, + Collection members, TopHelpersService.TimeRange timeRange, + TextChannel channel) { + String content = """ + Starting assignment of Top Helpers for %s: + ```java + %s + ```""".formatted(timeRange.description(), + service.asAsciiTable(topHelpers, members, false)); + + StringSelectMenu.Builder menu = + StringSelectMenu.create(componentIdInteractor.generateComponentId("foo")) + .setPlaceholder("Select the Top Helpers") + .setMinValues(1) + .setMaxValues(topHelpers.size()); + + Map userIdToMember = + members.stream().collect(Collectors.toMap(Member::getIdLong, Function.identity())); + topHelpers.stream() + .map(topHelper -> topHelperToSelectOption(topHelper, + userIdToMember.get(topHelper.authorId()))) + .forEach(menu::addOptions); + + MessageCreateData message = new MessageCreateBuilder().setContent(content) + .addActionRow(menu.build()) + .addActionRow( + Button.danger(componentIdInteractor.generateComponentId("cancel"), "Cancel")) + .build(); + + channel.sendMessage(message).queue(); + } + + private static SelectOption topHelperToSelectOption(TopHelpersService.TopHelperResult topHelper, + @Nullable Member member) { + String id = Long.toString(topHelper.authorId()); + + String name = TopHelpersService.getUsernameDisplay(member); + long messageLengths = topHelper.messageLengths().longValue(); + String label = messageLengths + " - " + name; + + return SelectOption.of(label, id); + } + + @Override + public void onButtonClick(ButtonInteractionEvent event, List args) { + // TODO Implement + logger.error("TODO Button clicked: {}", args); + } + + @Override + public void onStringSelectSelection(StringSelectInteractionEvent event, List args) { + // TODO Implement + logger.error("TODO Menu used: {}", args); } } diff --git a/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersCommand.java b/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersCommand.java index 499da16e9b..2610162c04 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersCommand.java @@ -1,9 +1,5 @@ package org.togetherjava.tjbot.features.tophelper; -import com.github.freva.asciitable.AsciiTable; -import com.github.freva.asciitable.Column; -import com.github.freva.asciitable.ColumnData; -import com.github.freva.asciitable.HorizontalAlign; import net.dv8tion.jda.api.entities.Member; import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; import net.dv8tion.jda.api.interactions.callbacks.IDeferrableCallback; @@ -16,7 +12,6 @@ import org.togetherjava.tjbot.features.CommandVisibility; import org.togetherjava.tjbot.features.SlashCommandAdapter; -import org.togetherjava.tjbot.features.utils.MessageUtils; import javax.annotation.Nullable; @@ -28,12 +23,7 @@ import java.util.Collection; import java.util.List; import java.util.Locale; -import java.util.Map; import java.util.Objects; -import java.util.function.Function; -import java.util.function.IntFunction; -import java.util.stream.Collectors; -import java.util.stream.IntStream; /** * Command that displays the top helpers of a given time range. @@ -48,8 +38,6 @@ public final class TopHelpersCommand extends SlashCommandAdapter { private static final String SUBCOMMAND_ASSIGN_NAME = "assign"; private static final String MONTH_OPTION = "at-month"; - private static final int MAX_USER_NAME_LIMIT = 15; - private final TopHelpersService service; private final TopHelpersAssignmentRoutine assignmentRoutine; @@ -107,10 +95,7 @@ private void showTopHelpers(SlashCommandInteractionEvent event) { } event.deferReply().queue(); - List topHelperIds = - topHelpers.stream().map(TopHelpersService.TopHelperResult::authorId).toList(); - event.getGuild() - .retrieveMembersByIds(topHelperIds) + service.retrieveTopHelperMembers(topHelpers, event.getGuild()) .onError(error -> handleError(error, event)) .onSuccess(members -> handleTopHelpers(topHelpers, members, timeRange, event)); } @@ -136,59 +121,15 @@ private static void handleError(Throwable error, IDeferrableCallback event) { event.getHook().editOriginal("Sorry, something went wrong.").queue(); } - private static void handleTopHelpers(Collection topHelpers, + private void handleTopHelpers(Collection topHelpers, Collection members, TopHelpersService.TimeRange timeRange, IDeferrableCallback event) { - Map userIdToMember = - members.stream().collect(Collectors.toMap(Member::getIdLong, Function.identity())); - - List> topHelpersDataTable = topHelpers.stream() - .map(topHelper -> topHelperToDataRow(topHelper, - userIdToMember.get(topHelper.authorId()))) - .toList(); - String message = """ ```java // for %s %s - ```""".formatted(timeRange.description(), dataTableToString(topHelpersDataTable)); - + ```""".formatted(timeRange.description(), + service.asAsciiTable(topHelpers, members, true)); event.getHook().editOriginal(message).queue(); } - - private static List topHelperToDataRow(TopHelpersService.TopHelperResult topHelper, - @Nullable Member member) { - String id = Long.toString(topHelper.authorId()); - String name = MessageUtils.abbreviate( - member == null ? "UNKNOWN_USER" : member.getEffectiveName(), MAX_USER_NAME_LIMIT); - String messageLengths = Long.toString(topHelper.messageLengths().longValue()); - - return List.of(id, name, messageLengths); - } - - private static String dataTableToString(Collection> dataTable) { - return dataTableToAsciiTable(dataTable, - List.of(new ColumnSetting("Id", HorizontalAlign.RIGHT), - new ColumnSetting("Name", HorizontalAlign.RIGHT), - new ColumnSetting("Message lengths", HorizontalAlign.RIGHT))); - } - - private static String dataTableToAsciiTable(Collection> dataTable, - List columnSettings) { - IntFunction headerToAlignment = i -> columnSettings.get(i).headerName(); - IntFunction indexToAlignment = i -> columnSettings.get(i).alignment(); - - IntFunction>> indexToColumn = - i -> new Column().header(headerToAlignment.apply(i)) - .dataAlign(indexToAlignment.apply(i)) - .with(row -> row.get(i)); - - List>> columns = - IntStream.range(0, columnSettings.size()).mapToObj(indexToColumn).toList(); - - return AsciiTable.getTable(AsciiTable.BASIC_ASCII_NO_DATA_SEPARATORS, dataTable, columns); - } - - private record ColumnSetting(String headerName, HorizontalAlign alignment) { - } } diff --git a/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersService.java b/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersService.java index a2d4fbc484..b2c9f3e220 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersService.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersService.java @@ -1,9 +1,17 @@ package org.togetherjava.tjbot.features.tophelper; -import org.jooq.Records; -import org.jooq.impl.DSL; +import com.github.freva.asciitable.AsciiTable; +import com.github.freva.asciitable.Column; +import com.github.freva.asciitable.ColumnData; +import com.github.freva.asciitable.HorizontalAlign; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.utils.concurrent.Task; import org.togetherjava.tjbot.db.Database; +import org.togetherjava.tjbot.features.utils.MessageUtils; + +import javax.annotation.Nullable; import java.math.BigDecimal; import java.time.Instant; @@ -13,14 +21,20 @@ import java.time.ZoneOffset; import java.time.ZonedDateTime; import java.time.format.TextStyle; +import java.util.ArrayList; +import java.util.Collection; import java.util.List; import java.util.Locale; - -import static org.togetherjava.tjbot.db.generated.tables.HelpChannelMessages.HELP_CHANNEL_MESSAGES; +import java.util.Map; +import java.util.function.Function; +import java.util.function.IntFunction; +import java.util.stream.Collectors; +import java.util.stream.IntStream; // TODO Javadoc everywhere public final class TopHelpersService { private static final int TOP_HELPER_LIMIT = 18; + private static final int MAX_USER_NAME_LIMIT = 15; private final Database database; @@ -35,15 +49,87 @@ public TopHelpersService(Database database) { public List computeTopHelpersDescending(long guildId, Instant start, Instant end) { - return database.read(context -> context - .select(HELP_CHANNEL_MESSAGES.AUTHOR_ID, DSL.sum(HELP_CHANNEL_MESSAGES.MESSAGE_LENGTH)) - .from(HELP_CHANNEL_MESSAGES) - .where(HELP_CHANNEL_MESSAGES.GUILD_ID.eq(guildId) - .and(HELP_CHANNEL_MESSAGES.SENT_AT.between(start, end))) - .groupBy(HELP_CHANNEL_MESSAGES.AUTHOR_ID) - .orderBy(DSL.two().desc()) - .limit(TOP_HELPER_LIMIT) - .fetch(Records.mapping(TopHelperResult::new))); + // TODO Undo after testing! + /* + * return database.read(context -> context .select(HELP_CHANNEL_MESSAGES.AUTHOR_ID, + * DSL.sum(HELP_CHANNEL_MESSAGES.MESSAGE_LENGTH)) .from(HELP_CHANNEL_MESSAGES) + * .where(HELP_CHANNEL_MESSAGES.GUILD_ID.eq(guildId) + * .and(HELP_CHANNEL_MESSAGES.SENT_AT.between(start, end))) + * .groupBy(HELP_CHANNEL_MESSAGES.AUTHOR_ID) .orderBy(DSL.two().desc()) + * .limit(TOP_HELPER_LIMIT) .fetch(Records.mapping(TopHelperResult::new))); + */ + return List.of(new TopHelperResult(1014989258165072028L, BigDecimal.valueOf(500)), + new TopHelperResult(257500867568205824L, BigDecimal.valueOf(400)), + new TopHelperResult(238042761490595843L, BigDecimal.valueOf(300)), + new TopHelperResult(905767721814351892L, BigDecimal.valueOf(200)), + new TopHelperResult(157994153806921728L, BigDecimal.valueOf(100))); + } + + public Task> retrieveTopHelperMembers(List topHelpers, + Guild guild) { + List topHelperIds = topHelpers.stream().map(TopHelperResult::authorId).toList(); + return guild.retrieveMembersByIds(topHelperIds); + } + + public String asAsciiTable(Collection topHelpers, + Collection members, boolean includeIds) { + Map userIdToMember = + members.stream().collect(Collectors.toMap(Member::getIdLong, Function.identity())); + + List> topHelpersDataTable = topHelpers.stream() + .map(topHelper -> topHelperToDataRow(topHelper, + userIdToMember.get(topHelper.authorId()), includeIds)) + .toList(); + + return dataTableToString(topHelpersDataTable, includeIds); + } + + public static String getUsernameDisplay(@Nullable Member member) { + return MessageUtils.abbreviate(member == null ? "UNKNOWN_USER" : member.getEffectiveName(), + MAX_USER_NAME_LIMIT); + } + + private static List topHelperToDataRow(TopHelpersService.TopHelperResult topHelper, + @Nullable Member member, boolean includeIds) { + String id = Long.toString(topHelper.authorId()); + String name = getUsernameDisplay(member); + String messageLengths = Long.toString(topHelper.messageLengths().longValue()); + + if (includeIds) { + return List.of(id, name, messageLengths); + } else { + return List.of(name, messageLengths); + } + } + + private static String dataTableToString(Collection> dataTable, + boolean includeIds) { + List settings = new ArrayList<>(); + if (includeIds) { + settings.add(new ColumnSetting("Id", HorizontalAlign.RIGHT)); + } + settings.add(new ColumnSetting("Name", HorizontalAlign.RIGHT)); + settings.add(new ColumnSetting("Message lengths", HorizontalAlign.RIGHT)); + return dataTableToAsciiTable(dataTable, settings); + } + + private static String dataTableToAsciiTable(Collection> dataTable, + List columnSettings) { + IntFunction headerToAlignment = i -> columnSettings.get(i).headerName(); + IntFunction indexToAlignment = i -> columnSettings.get(i).alignment(); + + IntFunction>> indexToColumn = + i -> new Column().header(headerToAlignment.apply(i)) + .dataAlign(indexToAlignment.apply(i)) + .with(row -> row.get(i)); + + List>> columns = + IntStream.range(0, columnSettings.size()).mapToObj(indexToColumn).toList(); + + return AsciiTable.getTable(AsciiTable.BASIC_ASCII_NO_DATA_SEPARATORS, dataTable, columns); + } + + private record ColumnSetting(String headerName, HorizontalAlign alignment) { } public record TopHelperResult(long authorId, BigDecimal messageLengths) { From c7ea0f9058e3b2b5a9acd65107e85d5f0b3cf2a0 Mon Sep 17 00:00:00 2001 From: Zabuzard Date: Sat, 16 Aug 2025 17:10:23 +0200 Subject: [PATCH 03/14] role management and more flow --- application/config.json.template | 12 +- .../org/togetherjava/tjbot/config/Config.java | 17 ++- .../tjbot/config/TopHelpersConfig.java | 58 ++++++++++ .../TopHelpersAssignmentRoutine.java | 108 ++++++++++++++++-- 4 files changed, 170 insertions(+), 25 deletions(-) create mode 100644 application/src/main/java/org/togetherjava/tjbot/config/TopHelpersConfig.java diff --git a/application/config.json.template b/application/config.json.template index dd895a8a69..7e2ca378a2 100644 --- a/application/config.json.template +++ b/application/config.json.template @@ -10,8 +10,8 @@ "mutedRolePattern": "Muted", "heavyModerationRolePattern": "Moderator", "softModerationRolePattern": "Moderator|Community Ambassador", - "tagManageRolePattern": "Moderator|Community Ambassador|Top Helpers .+", - "excludeCodeAutoDetectionRolePattern": "Top Helpers .+|Moderator|Community Ambassador|Expert", + "tagManageRolePattern": "Moderator|Community Ambassador|Top Helper.*", + "excludeCodeAutoDetectionRolePattern": "Top Helper.*|Moderator|Community Ambassador|Expert", "suggestions": { "channelPattern": "tj-suggestions", "upVoteEmoteName": "peepo_yes", @@ -22,7 +22,7 @@ "mode": "AUTO_DELETE_BUT_APPROVE_QUARANTINE", "reportChannelPattern": "commands", "botTrapChannelPattern": "bot-trap", - "trustedUserRolePattern": "Top Helpers .+|Moderator|Community Ambassador|Expert", + "trustedUserRolePattern": "Top Helper.*|Moderator|Community Ambassador|Expert", "suspiciousKeywords": [ "nitro", "boob", @@ -190,5 +190,9 @@ "pollIntervalInMinutes": 10 }, "memberCountCategoryPattern": "Info", - "topHelperAssignmentChannelPattern": "commands" + "topHelpers": { + "rolePattern": "Top Helper.*", + "assignmentChannelPattern": "commands", + "announcementChannelPattern": "hall-of-fame" + } } diff --git a/application/src/main/java/org/togetherjava/tjbot/config/Config.java b/application/src/main/java/org/togetherjava/tjbot/config/Config.java index 2685801933..60e6622cbc 100644 --- a/application/src/main/java/org/togetherjava/tjbot/config/Config.java +++ b/application/src/main/java/org/togetherjava/tjbot/config/Config.java @@ -48,7 +48,7 @@ public final class Config { private final RSSFeedsConfig rssFeedsConfig; private final String selectRolesChannelPattern; private final String memberCountCategoryPattern; - private final String topHelperAssignmentChannelPattern; + private final TopHelpersConfig topHelpers; @SuppressWarnings("ConstructorWithTooManyParameters") @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) @@ -102,8 +102,7 @@ private Config(@JsonProperty(value = "token", required = true) String token, @JsonProperty(value = "rssConfig", required = true) RSSFeedsConfig rssFeedsConfig, @JsonProperty(value = "selectRolesChannelPattern", required = true) String selectRolesChannelPattern, - @JsonProperty(value = "topHelperAssignmentChannelPattern", - required = true) String topHelperAssignmentChannelPattern) { + @JsonProperty(value = "topHelpers", required = true) TopHelpersConfig topHelpers) { this.token = Objects.requireNonNull(token); this.githubApiKey = Objects.requireNonNull(githubApiKey); this.databasePath = Objects.requireNonNull(databasePath); @@ -138,8 +137,7 @@ private Config(@JsonProperty(value = "token", required = true) String token, this.featureBlacklistConfig = Objects.requireNonNull(featureBlacklistConfig); this.rssFeedsConfig = Objects.requireNonNull(rssFeedsConfig); this.selectRolesChannelPattern = Objects.requireNonNull(selectRolesChannelPattern); - this.topHelperAssignmentChannelPattern = - Objects.requireNonNull(topHelperAssignmentChannelPattern); + this.topHelpers = Objects.requireNonNull(topHelpers); } /** @@ -452,12 +450,11 @@ public RSSFeedsConfig getRSSFeedsConfig() { } /** - * Gets the REGEX pattern used to identify the channel where Top Helper Assignments are - * automatically executed. + * Gets the config for the Top Helpers system. * - * @return the channel name pattern + * @return the configuration */ - public String getTopHelperAssignmentChannelPattern() { - return topHelperAssignmentChannelPattern; + public TopHelpersConfig getTopHelpers() { + return topHelpers; } } diff --git a/application/src/main/java/org/togetherjava/tjbot/config/TopHelpersConfig.java b/application/src/main/java/org/togetherjava/tjbot/config/TopHelpersConfig.java new file mode 100644 index 0000000000..6f4b20d9db --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/config/TopHelpersConfig.java @@ -0,0 +1,58 @@ +package org.togetherjava.tjbot.config; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonRootName; + +import java.util.Objects; + +/** + * Configuration for the top helper system, see + * {@link org.togetherjava.tjbot.features.tophelper.TopHelpersCommand}. + */ +@JsonRootName("topHelpers") +public final class TopHelpersConfig { + private final String rolePattern; + private final String assignmentChannelPattern; + private final String announcementChannelPattern; + + @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) + private TopHelpersConfig( + @JsonProperty(value = "rolePattern", required = true) String rolePattern, + @JsonProperty(value = "assignmentChannelPattern", + required = true) String assignmentChannelPattern, + @JsonProperty(value = "announcementChannelPattern", + required = true) String announcementChannelPattern) { + this.rolePattern = Objects.requireNonNull(rolePattern); + this.assignmentChannelPattern = Objects.requireNonNull(assignmentChannelPattern); + this.announcementChannelPattern = Objects.requireNonNull(announcementChannelPattern); + } + + /** + * Gets the REGEX pattern matching the role used to represent Top Helpers. + * + * @return the role name pattern + */ + public String getRolePattern() { + return rolePattern; + } + + /** + * Gets the REGEX pattern used to identify the channel where Top Helper assignments are + * automatically executed. + * + * @return the channel name pattern + */ + public String getAssignmentChannelPattern() { + return assignmentChannelPattern; + } + + /** + * Gets the REGEX pattern used to identify the channel where Top Helper announcements are send. + * + * @return the channel name pattern + */ + public String getAnnouncementChannelPattern() { + return announcementChannelPattern; + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersAssignmentRoutine.java b/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersAssignmentRoutine.java index b9d73453cf..34bd6e115e 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersAssignmentRoutine.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersAssignmentRoutine.java @@ -3,10 +3,14 @@ import net.dv8tion.jda.api.JDA; import net.dv8tion.jda.api.entities.Guild; import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.Role; +import net.dv8tion.jda.api.entities.UserSnowflake; import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent; import net.dv8tion.jda.api.events.interaction.component.StringSelectInteractionEvent; +import net.dv8tion.jda.api.interactions.components.ComponentInteraction; import net.dv8tion.jda.api.interactions.components.buttons.Button; +import net.dv8tion.jda.api.interactions.components.buttons.ButtonStyle; import net.dv8tion.jda.api.interactions.components.selections.SelectOption; import net.dv8tion.jda.api.interactions.components.selections.StringSelectMenu; import net.dv8tion.jda.api.utils.messages.MessageCreateBuilder; @@ -15,6 +19,7 @@ import org.slf4j.LoggerFactory; import org.togetherjava.tjbot.config.Config; +import org.togetherjava.tjbot.config.TopHelpersConfig; import org.togetherjava.tjbot.features.Routine; import org.togetherjava.tjbot.features.UserInteractionType; import org.togetherjava.tjbot.features.UserInteractor; @@ -27,9 +32,12 @@ import java.time.Month; import java.time.ZoneOffset; import java.util.Collection; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; +import java.util.Set; import java.util.function.Function; import java.util.function.Predicate; import java.util.regex.Pattern; @@ -40,17 +48,22 @@ public final class TopHelpersAssignmentRoutine implements Routine, UserInteracto private static final Logger logger = LoggerFactory.getLogger(TopHelpersAssignmentRoutine.class); private static final int CHECK_AT_HOUR = 12; - private final Config config; + private final TopHelpersConfig config; private final TopHelpersService service; + private final Predicate roleNamePredicate; private final Predicate assignmentChannelNamePredicate; + private final Predicate announcementChannelNamePredicate; private final ComponentIdInteractor componentIdInteractor; public TopHelpersAssignmentRoutine(Config config, TopHelpersService service) { - this.config = config; + this.config = config.getTopHelpers(); this.service = service; + roleNamePredicate = Pattern.compile(this.config.getRolePattern()).asMatchPredicate(); assignmentChannelNamePredicate = - Pattern.compile(config.getTopHelperAssignmentChannelPattern()).asMatchPredicate(); + Pattern.compile(this.config.getAssignmentChannelPattern()).asMatchPredicate(); + announcementChannelNamePredicate = + Pattern.compile(this.config.getAnnouncementChannelPattern()).asMatchPredicate(); componentIdInteractor = new ComponentIdInteractor(getInteractionType(), getName()); } @@ -93,7 +106,7 @@ public void startDialogFor(Guild guild) { assignmentChannel.ifPresentOrElse(this::startDialogIn, () -> logger.warn( "Unable to assign Top Helpers, did not find an assignment channel matching the configured pattern '{}' for guild '{}'", - config.getTopHelperAssignmentChannelPattern(), guild.getName())); + config.getAssignmentChannelPattern(), guild.getName())); } private void startDialogIn(TextChannel channel) { @@ -134,7 +147,7 @@ private void sendSelectionMenu(Collection top service.asAsciiTable(topHelpers, members, false)); StringSelectMenu.Builder menu = - StringSelectMenu.create(componentIdInteractor.generateComponentId("foo")) + StringSelectMenu.create(componentIdInteractor.generateComponentId()) .setPlaceholder("Select the Top Helpers") .setMinValues(1) .setMaxValues(topHelpers.size()); @@ -148,8 +161,7 @@ private void sendSelectionMenu(Collection top MessageCreateData message = new MessageCreateBuilder().setContent(content) .addActionRow(menu.build()) - .addActionRow( - Button.danger(componentIdInteractor.generateComponentId("cancel"), "Cancel")) + .addActionRow(Button.danger(componentIdInteractor.generateComponentId(), "Cancel")) .build(); channel.sendMessage(message).queue(); @@ -168,13 +180,87 @@ private static SelectOption topHelperToSelectOption(TopHelpersService.TopHelperR @Override public void onButtonClick(ButtonInteractionEvent event, List args) { - // TODO Implement - logger.error("TODO Button clicked: {}", args); + ButtonStyle buttonStyle = event.getButton().getStyle(); + if (buttonStyle == ButtonStyle.DANGER) { + endFlow(event); + return; + } + + // TODO Positive case (send generic message to hall of fame) + logger.error("TODO Positive case (send generic message to hall of fame)"); } @Override public void onStringSelectSelection(StringSelectInteractionEvent event, List args) { - // TODO Implement - logger.error("TODO Menu used: {}", args); + Guild guild = Objects.requireNonNull(event.getGuild()); + Set selectedTopHelperIds = event.getSelectedOptions() + .stream() + .map(SelectOption::getValue) + .map(Long::parseLong) + .collect(Collectors.toSet()); + + Optional topHelperRole = guild.getRoles() + .stream() + .filter(role -> roleNamePredicate.test(role.getName())) + .findAny(); + if (topHelperRole.isEmpty()) { + logger.warn( + "Unable to assign Top Helpers, did not find a role matching the configured pattern '{}' for guild '{}'", + config.getRolePattern(), guild.getName()); + event.reply("Wanted to assign Top Helpers, but something went wrong.").queue(); + return; + } + + event.deferEdit().queue(); + // TODO Error case potentially needs to disable actions + guild.findMembersWithRoles(List.of(topHelperRole.orElseThrow())) + .onSuccess(currentTopHelpers -> manageTopHelperRole(currentTopHelpers, + selectedTopHelperIds, event, topHelperRole.orElseThrow())) + .onError(error -> handleError(error, event.getChannel().asTextChannel())); + } + + private void manageTopHelperRole(Collection currentTopHelpers, + Set selectedTopHelperIds, StringSelectInteractionEvent event, + Role topHelperRole) { + Guild guild = Objects.requireNonNull(event.getGuild()); + Set currentTopHelperIds = + currentTopHelpers.stream().map(Member::getIdLong).collect(Collectors.toSet()); + + Set usersToRemoveRoleFrom = new HashSet<>(currentTopHelperIds); + usersToRemoveRoleFrom.removeAll(selectedTopHelperIds); + + Set usersToAddRoleTo = new HashSet<>(selectedTopHelperIds); + usersToAddRoleTo.removeAll(currentTopHelperIds); + + for (long userToRemoveRoleFrom : usersToRemoveRoleFrom) { + guild.removeRoleFromMember(UserSnowflake.fromId(userToRemoveRoleFrom), topHelperRole) + .queue(); + } + for (long userToAddRoleTo : usersToAddRoleTo) { + guild.addRoleToMember(UserSnowflake.fromId(userToAddRoleTo), topHelperRole).queue(); + } + + reportRoleManageSuccess(event); + } + + private void reportRoleManageSuccess(StringSelectInteractionEvent event) { + String topHelperList = event.getSelectedOptions() + .stream() + .map(SelectOption::getLabel) + .map(label -> "* " + label) + .collect(Collectors.joining("\n")); + + String content = event.getMessage().getContentRaw() + "\nSelected Top Helpers:\n" + + topHelperList + "\nShould I send a generic announcement?"; + event.getHook() + .editOriginal(content) + .setActionRow(Button.success(componentIdInteractor.generateComponentId(), "Yes"), + Button.danger(componentIdInteractor.generateComponentId(), "No")) + .queue(); + } + + private void endFlow(ComponentInteraction event) { + String content = event.getMessage().getContentRaw() + "\nOkay, done. See you next time 👋"; + event.editMessage(content).setComponents().queue(); } } From 90d554448f567609540b8888e71510c296c4efd1 Mon Sep 17 00:00:00 2001 From: Zabuzard Date: Sat, 16 Aug 2025 20:21:54 +0200 Subject: [PATCH 04/14] announcement, failure logic --- .../TopHelpersAssignmentRoutine.java | 120 ++++++++++++++---- .../features/tophelper/TopHelpersCommand.java | 2 +- .../features/tophelper/TopHelpersService.java | 7 +- 3 files changed, 100 insertions(+), 29 deletions(-) diff --git a/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersAssignmentRoutine.java b/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersAssignmentRoutine.java index 34bd6e115e..9f76f59d2a 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersAssignmentRoutine.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersAssignmentRoutine.java @@ -10,7 +10,6 @@ import net.dv8tion.jda.api.events.interaction.component.StringSelectInteractionEvent; import net.dv8tion.jda.api.interactions.components.ComponentInteraction; import net.dv8tion.jda.api.interactions.components.buttons.Button; -import net.dv8tion.jda.api.interactions.components.buttons.ButtonStyle; import net.dv8tion.jda.api.interactions.components.selections.SelectOption; import net.dv8tion.jda.api.interactions.components.selections.StringSelectMenu; import net.dv8tion.jda.api.utils.messages.MessageCreateBuilder; @@ -29,9 +28,9 @@ import javax.annotation.Nullable; import java.time.Instant; -import java.time.Month; import java.time.ZoneOffset; import java.util.Collection; +import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Map; @@ -42,11 +41,15 @@ import java.util.function.Predicate; import java.util.regex.Pattern; import java.util.stream.Collectors; +import java.util.stream.Stream; // TODO Javadoc everywhere public final class TopHelpersAssignmentRoutine implements Routine, UserInteractor { private static final Logger logger = LoggerFactory.getLogger(TopHelpersAssignmentRoutine.class); private static final int CHECK_AT_HOUR = 12; + private static final String CANCEL_BUTTON_NAME = "cancel"; + private static final String YES_MESSAGE_BUTTON_NAME = "yes-message"; + private static final String NO_MESSAGE_BUTTON_NAME = "no-message"; private final TopHelpersConfig config; private final TopHelpersService service; @@ -112,9 +115,7 @@ public void startDialogFor(Guild guild) { private void startDialogIn(TextChannel channel) { Guild guild = channel.getGuild(); - Month previousMonth = Instant.now().atZone(ZoneOffset.UTC).minusMonths(1).getMonth(); - TopHelpersService.TimeRange timeRange = - TopHelpersService.TimeRange.fromMonth(previousMonth); + TopHelpersService.TimeRange timeRange = TopHelpersService.TimeRange.ofPreviousMonth(); List topHelpers = service .computeTopHelpersDescending(guild.getIdLong(), timeRange.start(), timeRange.end()); @@ -127,13 +128,12 @@ private void startDialogIn(TextChannel channel) { } service.retrieveTopHelperMembers(topHelpers, guild) - .onError(error -> handleError(error, channel)) - .onSuccess(members -> sendSelectionMenu(topHelpers, members, timeRange, channel)); - } - - private static void handleError(Throwable error, TextChannel channel) { - logger.warn("Failed to compute top-helpers for automatic assignment", error); - channel.sendMessage("Wanted to assign Top Helpers, but something went wrong.").queue(); + .onSuccess(members -> sendSelectionMenu(topHelpers, members, timeRange, channel)) + .onError(error -> { + logger.warn("Failed to compute top-helpers for automatic assignment", error); + channel.sendMessage("Wanted to assign Top Helpers, but something went wrong.") + .queue(); + }); } private void sendSelectionMenu(Collection topHelpers, @@ -161,7 +161,8 @@ private void sendSelectionMenu(Collection top MessageCreateData message = new MessageCreateBuilder().setContent(content) .addActionRow(menu.build()) - .addActionRow(Button.danger(componentIdInteractor.generateComponentId(), "Cancel")) + .addActionRow(Button + .danger(componentIdInteractor.generateComponentId(CANCEL_BUTTON_NAME), "Cancel")) .build(); channel.sendMessage(message).queue(); @@ -180,14 +181,15 @@ private static SelectOption topHelperToSelectOption(TopHelpersService.TopHelperR @Override public void onButtonClick(ButtonInteractionEvent event, List args) { - ButtonStyle buttonStyle = event.getButton().getStyle(); - if (buttonStyle == ButtonStyle.DANGER) { - endFlow(event); - return; - } + event.deferEdit().queue(); + String name = args.getFirst(); - // TODO Positive case (send generic message to hall of fame) - logger.error("TODO Positive case (send generic message to hall of fame)"); + switch (name) { + case CANCEL_BUTTON_NAME -> endFlow(event, "cancelled"); + case NO_MESSAGE_BUTTON_NAME -> endFlow(event, "not posting an announcement"); + case YES_MESSAGE_BUTTON_NAME -> prepareAnnouncement(event, args); + default -> throw new AssertionError("Unknown button name: " + name); + } } @Override @@ -212,11 +214,17 @@ public void onStringSelectSelection(StringSelectInteractionEvent event, List manageTopHelperRole(currentTopHelpers, selectedTopHelperIds, event, topHelperRole.orElseThrow())) - .onError(error -> handleError(error, event.getChannel().asTextChannel())); + .onError(error -> { + logger.warn("Failed to find existing top-helpers for automatic assignment", error); + event.getHook() + .editOriginal(event.getMessage() + + "\n❌ Sorry, something went wrong trying to find existing top-helpers.") + .setComponents() + .queue(); + }); } private void manageTopHelperRole(Collection currentTopHelpers, @@ -250,17 +258,75 @@ private void reportRoleManageSuccess(StringSelectInteractionEvent event) { .map(label -> "* " + label) .collect(Collectors.joining("\n")); + Stream topHelperIds = + event.getSelectedOptions().stream().map(SelectOption::getValue); + String[] successButtonArgs = Stream.concat(Stream.of(YES_MESSAGE_BUTTON_NAME), topHelperIds) + .toArray(String[]::new); + String content = event.getMessage().getContentRaw() + "\nSelected Top Helpers:\n" + topHelperList + "\nShould I send a generic announcement?"; event.getHook() .editOriginal(content) - .setActionRow(Button.success(componentIdInteractor.generateComponentId(), "Yes"), - Button.danger(componentIdInteractor.generateComponentId(), "No")) + .setActionRow( + Button.success(componentIdInteractor.generateComponentId(successButtonArgs), + "Yes"), + Button.danger(componentIdInteractor.generateComponentId(NO_MESSAGE_BUTTON_NAME), + "No")) .queue(); } - private void endFlow(ComponentInteraction event) { - String content = event.getMessage().getContentRaw() + "\nOkay, done. See you next time 👋"; - event.editMessage(content).setComponents().queue(); + private void prepareAnnouncement(ButtonInteractionEvent event, List args) { + List topHelperIds = args.stream().skip(1).map(Long::parseLong).toList(); + + event.getGuild() + .retrieveMembersByIds(topHelperIds) + .onSuccess(topHelpers -> postAnnouncement(event, topHelpers)) + .onError(error -> { + logger.warn("Failed to retrieve top-helper data for automatic assignment", error); + event.getHook() + .editOriginal(event.getMessage() + + "\n❌ Sorry, something went wrong trying to retrieve top-helper data.") + .setComponents() + .queue(); + }); + } + + private void postAnnouncement(ButtonInteractionEvent event, List topHelpers) { + Guild guild = Objects.requireNonNull(event.getGuild()); + Optional announcementChannel = guild.getTextChannelCache() + .stream() + .filter(channel -> announcementChannelNamePredicate.test(channel.getName())) + .findAny(); + + if (announcementChannel.isEmpty()) { + logger.warn( + "Unable to send a Top Helper announcement, did not find an announcement channel matching the configured pattern '{}' for guild '{}'", + config.getAnnouncementChannelPattern(), guild.getName()); + event.getHook() + .editOriginal(event.getMessage() + + "\n❌ Sorry, something went wrong trying to post the announcement.") + .setComponents() + .queue(); + return; + } + + Collections.shuffle(topHelpers); // for fairness + String topHelperList = topHelpers.stream() + .map(Member::getAsMention) + .map(mention -> "* " + mention) + .collect(Collectors.joining("\n")); + TopHelpersService.TimeRange timeRange = TopHelpersService.TimeRange.ofPreviousMonth(); + String announcement = "Thanks to the Top Helpers of %s 🎉%n%s" + .formatted(timeRange.description(), topHelperList); + + announcementChannel.orElseThrow().sendMessage(announcement).queue(); + + endFlow(event, "posted an announcement"); + } + + private void endFlow(ComponentInteraction event, String message) { + String content = event.getMessage().getContentRaw() + "\n✅ Okay, " + message + + ". See you next time 👋"; + event.getHook().editOriginal(content).setComponents().queue(); } } diff --git a/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersCommand.java b/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersCommand.java index 2610162c04..3294a968c5 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersCommand.java @@ -82,7 +82,7 @@ private void showTopHelpers(SlashCommandInteractionEvent event) { OptionMapping atMonthData = event.getOption(MONTH_OPTION); TopHelpersService.TimeRange timeRange = - TopHelpersService.TimeRange.fromMonth(computeMonth(atMonthData)); + TopHelpersService.TimeRange.ofMonth(computeMonth(atMonthData)); List topHelpers = service.computeTopHelpersDescending( event.getGuild().getIdLong(), timeRange.start(), timeRange.end()); diff --git a/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersService.java b/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersService.java index b2c9f3e220..55cab9f2a6 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersService.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersService.java @@ -136,7 +136,12 @@ public record TopHelperResult(long authorId, BigDecimal messageLengths) { } public record TimeRange(Instant start, Instant end, String description) { - public static TimeRange fromMonth(Month atMonth) { + public static TimeRange ofPreviousMonth() { + Month previousMonth = Instant.now().atZone(ZoneOffset.UTC).minusMonths(1).getMonth(); + return TimeRange.ofMonth(previousMonth); + } + + public static TimeRange ofMonth(Month atMonth) { ZonedDateTime now = Instant.now().atZone(ZoneOffset.UTC); int atYear = now.getYear(); From f9c3dea19309ae5d239af2d66e8426338a352bc0 Mon Sep 17 00:00:00 2001 From: Zabuzard Date: Sat, 16 Aug 2025 20:30:15 +0200 Subject: [PATCH 05/14] (fixed some comment that spotless wrapped ugly) --- .../java/org/togetherjava/tjbot/features/Routine.java | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/application/src/main/java/org/togetherjava/tjbot/features/Routine.java b/application/src/main/java/org/togetherjava/tjbot/features/Routine.java index 3f31ae7623..806618649d 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/Routine.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/Routine.java @@ -102,11 +102,9 @@ public static Schedule atFixedHour(int hourOfDay) { */ public static Schedule atFixedRateFromNextFixedTime(int periodStartHour, int periodHours) { // NOTE This scheduler could be improved, for example supporting arbitrary periods (not - // just - // hour-based). Also, it probably does not correctly handle all date/time-quirks, for - // example if a schedule would hit a time that does not exist for a specific date due to - // DST - // or similar issues. Those are minor though and can be ignored for now. + // just hour-based). Also, it probably does not correctly handle all date/time-quirks, + // for example if a schedule would hit a time that does not exist for a specific date + // due to DST or similar issues. Those are minor though and can be ignored for now. if (periodStartHour < 0 || periodStartHour >= HOURS_OF_DAY) { throw new IllegalArgumentException( "Schedule period start hour must be a valid hour of a day (0-23)"); From 121c02c231a5d968600503ab3e0cd167ebd7f16d Mon Sep 17 00:00:00 2001 From: Zabuzard Date: Mon, 18 Aug 2025 09:39:27 +0200 Subject: [PATCH 06/14] javadoc and some readability changes --- .../TopHelpersAssignmentRoutine.java | 134 ++++++++----- .../features/tophelper/TopHelpersCommand.java | 29 +-- .../features/tophelper/TopHelpersService.java | 189 +++++++++++++----- 3 files changed, 234 insertions(+), 118 deletions(-) diff --git a/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersAssignmentRoutine.java b/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersAssignmentRoutine.java index 9f76f59d2a..ddde0b25da 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersAssignmentRoutine.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersAssignmentRoutine.java @@ -6,6 +6,7 @@ import net.dv8tion.jda.api.entities.Role; import net.dv8tion.jda.api.entities.UserSnowflake; import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; +import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel; import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent; import net.dv8tion.jda.api.events.interaction.component.StringSelectInteractionEvent; import net.dv8tion.jda.api.interactions.components.ComponentInteraction; @@ -43,7 +44,18 @@ import java.util.stream.Collectors; import java.util.stream.Stream; -// TODO Javadoc everywhere +/** + * Semi-automatic routine for assigning and announcing Top Helpers of the month. + *

+ * The routine triggers once on the 1st of each month, but can also be started manually by calling + * {@link #startDialogFor(Guild)}. + *

+ * The routine starts a dialog in a configured channel, proposing a list of Top Helpers. A user can + * now choose from a list who to reward with the Top Helper role. These users are now assigned the + * configured Top Helper role, while users who had the role in the past have it removed from them. + * Afterward, one can select from the dialog whether a generic announcement that thanks the selected + * Top Helpers should be posted in a configured channel. + */ public final class TopHelpersAssignmentRoutine implements Routine, UserInteractor { private static final Logger logger = LoggerFactory.getLogger(TopHelpersAssignmentRoutine.class); private static final int CHECK_AT_HOUR = 12; @@ -58,6 +70,12 @@ public final class TopHelpersAssignmentRoutine implements Routine, UserInteracto private final Predicate announcementChannelNamePredicate; private final ComponentIdInteractor componentIdInteractor; + /** + * Creates a new instance. + * + * @param config the config to use + * @param service the service to use to compute Top Helpers + */ public TopHelpersAssignmentRoutine(Config config, TopHelpersService service) { this.config = config.getTopHelpers(); this.service = service; @@ -88,19 +106,30 @@ public void acceptComponentIdGenerator(ComponentIdGenerator generator) { @Override public Schedule createSchedule() { + // Routine schedules are hour-based. Ensuring it only triggers once per a month is done + // inside the routine as early-check instead. return Schedule.atFixedHour(CHECK_AT_HOUR); } @Override public void runRoutine(JDA jda) { - int dayOfMonth = Instant.now().atOffset(ZoneOffset.UTC).getDayOfMonth(); - if (dayOfMonth != 1) { + if (!isFirstDayOfMonth()) { return; } jda.getGuilds().forEach(this::startDialogFor); } + private static boolean isFirstDayOfMonth() { + return Instant.now().atOffset(ZoneOffset.UTC).getDayOfMonth() == 1; + } + + /** + * Starts a dialog for semi-automatic Top Helper assignment and announcement. See + * {@link TopHelpersAssignmentRoutine} for details. + * + * @param guild to start the dialog and compute Top Helpers for + */ public void startDialogFor(Guild guild) { Optional assignmentChannel = guild.getTextChannelCache() .stream() @@ -116,8 +145,8 @@ private void startDialogIn(TextChannel channel) { Guild guild = channel.getGuild(); TopHelpersService.TimeRange timeRange = TopHelpersService.TimeRange.ofPreviousMonth(); - List topHelpers = service - .computeTopHelpersDescending(guild.getIdLong(), timeRange.start(), timeRange.end()); + List topHelpers = + service.computeTopHelpersDescending(guild, timeRange); if (topHelpers.isEmpty()) { channel.sendMessage( @@ -127,24 +156,21 @@ private void startDialogIn(TextChannel channel) { return; } - service.retrieveTopHelperMembers(topHelpers, guild) - .onSuccess(members -> sendSelectionMenu(topHelpers, members, timeRange, channel)) - .onError(error -> { - logger.warn("Failed to compute top-helpers for automatic assignment", error); - channel.sendMessage("Wanted to assign Top Helpers, but something went wrong.") - .queue(); - }); + TopHelpersService.retrieveTopHelperMembers(topHelpers, guild).onError(error -> { + logger.warn("Failed to compute top-helpers for automatic assignment", error); + channel.sendMessage("Wanted to assign Top Helpers, but something went wrong.").queue(); + }).onSuccess(members -> sendSelectionMenu(topHelpers, members, timeRange, channel)); } - private void sendSelectionMenu(Collection topHelpers, + private void sendSelectionMenu(Collection topHelpers, Collection members, TopHelpersService.TimeRange timeRange, - TextChannel channel) { + MessageChannel channel) { String content = """ Starting assignment of Top Helpers for %s: ```java %s ```""".formatted(timeRange.description(), - service.asAsciiTable(topHelpers, members, false)); + TopHelpersService.asAsciiTable(topHelpers, members)); StringSelectMenu.Builder menu = StringSelectMenu.create(componentIdInteractor.generateComponentId()) @@ -168,7 +194,7 @@ private void sendSelectionMenu(Collection top channel.sendMessage(message).queue(); } - private static SelectOption topHelperToSelectOption(TopHelpersService.TopHelperResult topHelper, + private static SelectOption topHelperToSelectOption(TopHelpersService.TopHelperStats topHelper, @Nullable Member member) { String id = Long.toString(topHelper.authorId()); @@ -179,19 +205,6 @@ private static SelectOption topHelperToSelectOption(TopHelpersService.TopHelperR return SelectOption.of(label, id); } - @Override - public void onButtonClick(ButtonInteractionEvent event, List args) { - event.deferEdit().queue(); - String name = args.getFirst(); - - switch (name) { - case CANCEL_BUTTON_NAME -> endFlow(event, "cancelled"); - case NO_MESSAGE_BUTTON_NAME -> endFlow(event, "not posting an announcement"); - case YES_MESSAGE_BUTTON_NAME -> prepareAnnouncement(event, args); - default -> throw new AssertionError("Unknown button name: " + name); - } - } - @Override public void onStringSelectSelection(StringSelectInteractionEvent event, List args) { Guild guild = Objects.requireNonNull(event.getGuild()); @@ -214,17 +227,16 @@ public void onStringSelectSelection(StringSelectInteractionEvent event, List { + logger.warn("Failed to find existing top-helpers for automatic assignment", error); + event.getHook() + .editOriginal(event.getMessage() + + "\n❌ Sorry, something went wrong trying to find existing top-helpers.") + .setComponents() + .queue(); + }) .onSuccess(currentTopHelpers -> manageTopHelperRole(currentTopHelpers, - selectedTopHelperIds, event, topHelperRole.orElseThrow())) - .onError(error -> { - logger.warn("Failed to find existing top-helpers for automatic assignment", error); - event.getHook() - .editOriginal(event.getMessage() - + "\n❌ Sorry, something went wrong trying to find existing top-helpers.") - .setComponents() - .queue(); - }); + selectedTopHelperIds, event, topHelperRole.orElseThrow())); } private void manageTopHelperRole(Collection currentTopHelpers, @@ -275,20 +287,30 @@ private void reportRoleManageSuccess(StringSelectInteractionEvent event) { .queue(); } + @Override + public void onButtonClick(ButtonInteractionEvent event, List args) { + event.deferEdit().queue(); + String name = args.getFirst(); + + switch (name) { + case YES_MESSAGE_BUTTON_NAME -> prepareAnnouncement(event, args); + case NO_MESSAGE_BUTTON_NAME -> reportFlowFinished(event, "not posting an announcement"); + case CANCEL_BUTTON_NAME -> reportFlowFinished(event, "cancelled"); + default -> throw new AssertionError("Unknown button name: " + name); + } + } + private void prepareAnnouncement(ButtonInteractionEvent event, List args) { List topHelperIds = args.stream().skip(1).map(Long::parseLong).toList(); - event.getGuild() - .retrieveMembersByIds(topHelperIds) - .onSuccess(topHelpers -> postAnnouncement(event, topHelpers)) - .onError(error -> { - logger.warn("Failed to retrieve top-helper data for automatic assignment", error); - event.getHook() - .editOriginal(event.getMessage() - + "\n❌ Sorry, something went wrong trying to retrieve top-helper data.") - .setComponents() - .queue(); - }); + event.getGuild().retrieveMembersByIds(topHelperIds).onError(error -> { + logger.warn("Failed to retrieve top-helper data for automatic assignment", error); + event.getHook() + .editOriginal(event.getMessage() + + "\n❌ Sorry, something went wrong trying to retrieve top-helper data.") + .setComponents() + .queue(); + }).onSuccess(topHelpers -> postAnnouncement(event, topHelpers)); } private void postAnnouncement(ButtonInteractionEvent event, List topHelpers) { @@ -310,7 +332,9 @@ private void postAnnouncement(ButtonInteractionEvent event, List "* " + mention) @@ -321,12 +345,12 @@ private void postAnnouncement(ButtonInteractionEvent event, List * Top helpers are measured by their message length in help channels, as set by * {@link TopHelpersMessageListener}. @@ -57,12 +59,12 @@ public TopHelpersCommand(TopHelpersService service, .forEach(month -> monthData.addChoice( month.getDisplayName(TextStyle.FULL_STANDALONE, Locale.US), month.name())); - getData().addSubcommands( - new SubcommandData(SUBCOMMAND_SHOW_NAME, - "Lists top helpers for the last month, or a given month") - .addOptions(monthData), - new SubcommandData(SUBCOMMAND_ASSIGN_NAME, - "Automatically assigns top helpers for the last month")); + var showCommand = new SubcommandData(SUBCOMMAND_SHOW_NAME, + "Lists top helpers for the last month, or a given month") + .addOptions(monthData); + var assignCommand = new SubcommandData(SUBCOMMAND_ASSIGN_NAME, + "Automatically assigns top helpers for the last month"); + getData().addSubcommands(showCommand, assignCommand); this.service = service; this.assignmentRoutine = assignmentRoutine; @@ -80,11 +82,12 @@ public void onSlashCommand(SlashCommandInteractionEvent event) { private void showTopHelpers(SlashCommandInteractionEvent event) { OptionMapping atMonthData = event.getOption(MONTH_OPTION); + Guild guild = Objects.requireNonNull(event.getGuild()); TopHelpersService.TimeRange timeRange = - TopHelpersService.TimeRange.ofMonth(computeMonth(atMonthData)); - List topHelpers = service.computeTopHelpersDescending( - event.getGuild().getIdLong(), timeRange.start(), timeRange.end()); + TopHelpersService.TimeRange.ofPastMonth(computeMonth(atMonthData)); + List topHelpers = + service.computeTopHelpersDescending(guild, timeRange); if (topHelpers.isEmpty()) { event @@ -95,7 +98,7 @@ private void showTopHelpers(SlashCommandInteractionEvent event) { } event.deferReply().queue(); - service.retrieveTopHelperMembers(topHelpers, event.getGuild()) + TopHelpersService.retrieveTopHelperMembers(topHelpers, guild) .onError(error -> handleError(error, event)) .onSuccess(members -> handleTopHelpers(topHelpers, members, timeRange, event)); } @@ -121,7 +124,7 @@ private static void handleError(Throwable error, IDeferrableCallback event) { event.getHook().editOriginal("Sorry, something went wrong.").queue(); } - private void handleTopHelpers(Collection topHelpers, + private void handleTopHelpers(Collection topHelpers, Collection members, TopHelpersService.TimeRange timeRange, IDeferrableCallback event) { String message = """ @@ -129,7 +132,7 @@ private void handleTopHelpers(Collection topH // for %s %s ```""".formatted(timeRange.description(), - service.asAsciiTable(topHelpers, members, true)); + TopHelpersService.asAsciiTableWithIds(topHelpers, members)); event.getHook().editOriginal(message).queue(); } } diff --git a/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersService.java b/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersService.java index 55cab9f2a6..2c8f007a2a 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersService.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersService.java @@ -7,6 +7,8 @@ import net.dv8tion.jda.api.entities.Guild; import net.dv8tion.jda.api.entities.Member; import net.dv8tion.jda.api.utils.concurrent.Task; +import org.jooq.Records; +import org.jooq.impl.DSL; import org.togetherjava.tjbot.db.Database; import org.togetherjava.tjbot.features.utils.MessageUtils; @@ -31,7 +33,14 @@ import java.util.stream.Collectors; import java.util.stream.IntStream; -// TODO Javadoc everywhere +import static org.togetherjava.tjbot.db.generated.tables.HelpChannelMessages.HELP_CHANNEL_MESSAGES; + +/** + * Service used to compute Top Helpers of a given time range, see + * {@link #computeTopHelpersDescending(Guild, TimeRange)}. + *

+ * Also offers utility to process or display Top Helper results. + */ public final class TopHelpersService { private static final int TOP_HELPER_LIMIT = 18; private static final int MAX_USER_NAME_LIMIT = 15; @@ -47,31 +56,134 @@ public TopHelpersService(Database database) { this.database = database; } - public List computeTopHelpersDescending(long guildId, Instant start, - Instant end) { - // TODO Undo after testing! - /* - * return database.read(context -> context .select(HELP_CHANNEL_MESSAGES.AUTHOR_ID, - * DSL.sum(HELP_CHANNEL_MESSAGES.MESSAGE_LENGTH)) .from(HELP_CHANNEL_MESSAGES) - * .where(HELP_CHANNEL_MESSAGES.GUILD_ID.eq(guildId) - * .and(HELP_CHANNEL_MESSAGES.SENT_AT.between(start, end))) - * .groupBy(HELP_CHANNEL_MESSAGES.AUTHOR_ID) .orderBy(DSL.two().desc()) - * .limit(TOP_HELPER_LIMIT) .fetch(Records.mapping(TopHelperResult::new))); + /** + * Stats for a single Top Helper, computed for a specific time range. See + * {@link #computeTopHelpersDescending(Guild, TimeRange)}. + * + * @param authorId ID of the Top Helper + * @param messageLengths lengths of messages send in the time range, the more, the better + */ + public record TopHelperStats(long authorId, BigDecimal messageLengths) { + } + + + /** + * Represents a time range with a defined start and end. + * + * @param start the inclusive start of the range + * @param end the exclusive end of the range + * @param description used for visual representation e.g., 'July 2025' + */ + public record TimeRange(Instant start, Instant end, String description) { + /** + * Creates a time range representing the previous month (assuming UTC). For example if the + * current month is April, this will return a time range for March. + * + * @return time range for the previous month */ - return List.of(new TopHelperResult(1014989258165072028L, BigDecimal.valueOf(500)), - new TopHelperResult(257500867568205824L, BigDecimal.valueOf(400)), - new TopHelperResult(238042761490595843L, BigDecimal.valueOf(300)), - new TopHelperResult(905767721814351892L, BigDecimal.valueOf(200)), - new TopHelperResult(157994153806921728L, BigDecimal.valueOf(100))); + public static TimeRange ofPreviousMonth() { + Month previousMonth = Instant.now().atZone(ZoneOffset.UTC).minusMonths(1).getMonth(); + return TimeRange.ofPastMonth(previousMonth); + } + + /** + * Creates a time range representing the given month (assuming UTC). If the month lies in + * the future for the current year, the past year will be used instead. For example, if the + * current month is April 2025: + *

    + *
  • {@code ofPastMonth(Month.APRIL)} returns April 2025
  • + *
  • {@code ofPastMonth(Month.MARCH)} returns March 2025
  • + *
  • {@code ofPastMonth(Month.JUNE)} returns June 2024
  • + *
+ * + * @param atMonth the month to represent + * @return Time range representing the given month, either for the current or the previous + * year. + */ + public static TimeRange ofPastMonth(Month atMonth) { + ZonedDateTime now = Instant.now().atZone(ZoneOffset.UTC); + + int atYear = now.getYear(); + // E.g. using November, while it is March 2022, should use November 2021 + if (atMonth.compareTo(now.getMonth()) > 0) { + atYear--; + } + YearMonth atYearMonth = YearMonth.of(atYear, atMonth); + + Instant start = atYearMonth.atDay(1).atTime(LocalTime.MIN).toInstant(ZoneOffset.UTC); + Instant end = + atYearMonth.atEndOfMonth().atTime(LocalTime.MAX).toInstant(ZoneOffset.UTC); + String description = "%s %d" + .formatted(atMonth.getDisplayName(TextStyle.FULL_STANDALONE, Locale.US), atYear); + + return new TimeRange(start, end, description); + } } - public Task> retrieveTopHelperMembers(List topHelpers, + /** + * Computes the Top Helpers of the given time range. + * + * @param guild to compute Top Helpers for + * @param range of the time to compute results for + * @return list of top helpers, descending with the user who helped the most first + */ + public List computeTopHelpersDescending(Guild guild, TimeRange range) { + return database.read(context -> context + .select(HELP_CHANNEL_MESSAGES.AUTHOR_ID, DSL.sum(HELP_CHANNEL_MESSAGES.MESSAGE_LENGTH)) + .from(HELP_CHANNEL_MESSAGES) + .where(HELP_CHANNEL_MESSAGES.GUILD_ID.eq(guild.getIdLong()) + .and(HELP_CHANNEL_MESSAGES.SENT_AT.between(range.start(), range.end()))) + .groupBy(HELP_CHANNEL_MESSAGES.AUTHOR_ID) + .orderBy(DSL.two().desc()) + .limit(TOP_HELPER_LIMIT) + .fetch(Records.mapping(TopHelperStats::new))); + } + + /** + * Retrieves the member-data to a given list of top helpers. + *

+ * The resulting list is in the same order as the given list and will contain {@code null} for + * any Top Helper who is not member of the guild anymore. + * + * @param topHelpers the list of top helpers to retrieve member-data for + * @param guild the guild the top helpers are members of + * @return list of member-data for each top helper, same size and same order. + */ + public static Task> retrieveTopHelperMembers(List topHelpers, Guild guild) { - List topHelperIds = topHelpers.stream().map(TopHelperResult::authorId).toList(); + List topHelperIds = topHelpers.stream().map(TopHelperStats::authorId).toList(); return guild.retrieveMembersByIds(topHelperIds); } - public String asAsciiTable(Collection topHelpers, + /** + * Visual representation of the given Top Helpers as ASCII table. The table includes columns ID, + * Name and Message lengths. + * + * @param topHelpers the list of top helpers to represent + * @param members the list of member-data that lines up with the topHelpers, for example given + * by {@link #retrieveTopHelperMembers(List, Guild)} + * @return ASCII table representing the Top Helpers + */ + public static String asAsciiTableWithIds(Collection topHelpers, + Collection members) { + return asAsciiTable(topHelpers, members, true); + } + + /** + * Visual representation of the given Top Helpers as ASCII table. The table includes columns + * Name and Message lengths. + * + * @param topHelpers the list of top helpers to represent + * @param members the list of member-data that lines up with the topHelpers, for example given + * by {@link #retrieveTopHelperMembers(List, Guild)} + * @return ASCII table representing the Top Helpers + */ + public static String asAsciiTable(Collection topHelpers, + Collection members) { + return asAsciiTable(topHelpers, members, false); + } + + private static String asAsciiTable(Collection topHelpers, Collection members, boolean includeIds) { Map userIdToMember = members.stream().collect(Collectors.toMap(Member::getIdLong, Function.identity())); @@ -84,12 +196,18 @@ public String asAsciiTable(Collection topHelpers, return dataTableToString(topHelpersDataTable, includeIds); } + /** + * Visual representation of the given members name as it should be used for display purposes. + * + * @param member to get name of, {@code null} will be represented with a placeholder + * @return name of the user for display purposes + */ public static String getUsernameDisplay(@Nullable Member member) { return MessageUtils.abbreviate(member == null ? "UNKNOWN_USER" : member.getEffectiveName(), MAX_USER_NAME_LIMIT); } - private static List topHelperToDataRow(TopHelpersService.TopHelperResult topHelper, + private static List topHelperToDataRow(TopHelperStats topHelper, @Nullable Member member, boolean includeIds) { String id = Long.toString(topHelper.authorId()); String name = getUsernameDisplay(member); @@ -106,7 +224,7 @@ private static String dataTableToString(Collection> dataTable, boolean includeIds) { List settings = new ArrayList<>(); if (includeIds) { - settings.add(new ColumnSetting("Id", HorizontalAlign.RIGHT)); + settings.add(new ColumnSetting("ID", HorizontalAlign.RIGHT)); } settings.add(new ColumnSetting("Name", HorizontalAlign.RIGHT)); settings.add(new ColumnSetting("Message lengths", HorizontalAlign.RIGHT)); @@ -131,33 +249,4 @@ private static String dataTableToAsciiTable(Collection> dataTable, private record ColumnSetting(String headerName, HorizontalAlign alignment) { } - - public record TopHelperResult(long authorId, BigDecimal messageLengths) { - } - - public record TimeRange(Instant start, Instant end, String description) { - public static TimeRange ofPreviousMonth() { - Month previousMonth = Instant.now().atZone(ZoneOffset.UTC).minusMonths(1).getMonth(); - return TimeRange.ofMonth(previousMonth); - } - - public static TimeRange ofMonth(Month atMonth) { - ZonedDateTime now = Instant.now().atZone(ZoneOffset.UTC); - - int atYear = now.getYear(); - // E.g. using November, while it is March 2022, should use November 2021 - if (atMonth.compareTo(now.getMonth()) > 0) { - atYear--; - } - YearMonth atYearMonth = YearMonth.of(atYear, atMonth); - - Instant start = atYearMonth.atDay(1).atTime(LocalTime.MIN).toInstant(ZoneOffset.UTC); - Instant end = - atYearMonth.atEndOfMonth().atTime(LocalTime.MAX).toInstant(ZoneOffset.UTC); - String description = "%s %d" - .formatted(atMonth.getDisplayName(TextStyle.FULL_STANDALONE, Locale.US), atYear); - - return new TimeRange(start, end, description); - } - } } From 69bc71b088a4d74f22deba24458f87a54e49e326 Mon Sep 17 00:00:00 2001 From: Zabuzard Date: Mon, 18 Aug 2025 11:30:31 +0200 Subject: [PATCH 07/14] fixed some wrong javadoc --- .../features/tophelper/TopHelpersService.java | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersService.java b/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersService.java index 2c8f007a2a..6c5a38375c 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersService.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersService.java @@ -141,13 +141,13 @@ public List computeTopHelpersDescending(Guild guild, TimeRange r /** * Retrieves the member-data to a given list of top helpers. - *

- * The resulting list is in the same order as the given list and will contain {@code null} for - * any Top Helper who is not member of the guild anymore. * * @param topHelpers the list of top helpers to retrieve member-data for * @param guild the guild the top helpers are members of - * @return list of member-data for each top helper, same size and same order. + * @return list of member-data for each top helper. If a Top helper is not member of the guild + * anymore, it will not be contained in the list of members. Also, the order is not + * given and in particular does not have to line up with the original order of Top + * Helpers. */ public static Task> retrieveTopHelperMembers(List topHelpers, Guild guild) { @@ -160,8 +160,8 @@ public static Task> retrieveTopHelperMembers(List t * Name and Message lengths. * * @param topHelpers the list of top helpers to represent - * @param members the list of member-data that lines up with the topHelpers, for example given - * by {@link #retrieveTopHelperMembers(List, Guild)} + * @param members the list of available member-data, for example given by + * {@link #retrieveTopHelperMembers(List, Guild)} * @return ASCII table representing the Top Helpers */ public static String asAsciiTableWithIds(Collection topHelpers, @@ -174,8 +174,8 @@ public static String asAsciiTableWithIds(Collection topHelpers, * Name and Message lengths. * * @param topHelpers the list of top helpers to represent - * @param members the list of member-data that lines up with the topHelpers, for example given - * by {@link #retrieveTopHelperMembers(List, Guild)} + * @param members the list of available member-data, for example given by + * {@link #retrieveTopHelperMembers(List, Guild)} * @return ASCII table representing the Top Helpers */ public static String asAsciiTable(Collection topHelpers, From e2d030863e5231057fc3418929318b891dae5260 Mon Sep 17 00:00:00 2001 From: Zabuzard Date: Mon, 18 Aug 2025 12:15:33 +0200 Subject: [PATCH 08/14] (minor description tweak) --- .../org/togetherjava/tjbot/features/tophelper/package-info.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/src/main/java/org/togetherjava/tjbot/features/tophelper/package-info.java b/application/src/main/java/org/togetherjava/tjbot/features/tophelper/package-info.java index 3f8aa7dff5..c509f9aa23 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/tophelper/package-info.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/tophelper/package-info.java @@ -1,5 +1,5 @@ /** - * This packages offers all the functionality for the top-helpers command system. The core class is + * This packages offers all the functionality for the top-helpers system. The core class is * {@link org.togetherjava.tjbot.features.tophelper.TopHelpersCommand}. */ @MethodsReturnNonnullByDefault From e938b0d09239698a7813be2579953293f39fb9d0 Mon Sep 17 00:00:00 2001 From: Zabuzard Date: Sat, 23 Aug 2025 09:19:19 +0200 Subject: [PATCH 09/14] CR: better default channel --- application/config.json.template | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/config.json.template b/application/config.json.template index 7e2ca378a2..5884522c60 100644 --- a/application/config.json.template +++ b/application/config.json.template @@ -192,7 +192,7 @@ "memberCountCategoryPattern": "Info", "topHelpers": { "rolePattern": "Top Helper.*", - "assignmentChannelPattern": "commands", + "assignmentChannelPattern": "community-commands", "announcementChannelPattern": "hall-of-fame" } } From 31e2624f7a8e89ac4209efd67ad55c550ef31608 Mon Sep 17 00:00:00 2001 From: Zabuzard Date: Sat, 23 Aug 2025 09:25:47 +0200 Subject: [PATCH 10/14] CR message format improvement --- .../tjbot/features/tophelper/TopHelpersAssignmentRoutine.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersAssignmentRoutine.java b/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersAssignmentRoutine.java index ddde0b25da..4596ef4a2c 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersAssignmentRoutine.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersAssignmentRoutine.java @@ -166,8 +166,9 @@ private void sendSelectionMenu(Collection topH Collection members, TopHelpersService.TimeRange timeRange, MessageChannel channel) { String content = """ - Starting assignment of Top Helpers for %s: + Starting assignment of Top Helpers: ```java + // %s %s ```""".formatted(timeRange.description(), TopHelpersService.asAsciiTable(topHelpers, members)); From fa617d133c2d0200a015a2e4811fa3ae71da45f8 Mon Sep 17 00:00:00 2001 From: Zabuzard Date: Sat, 23 Aug 2025 09:26:04 +0200 Subject: [PATCH 11/14] CR added mapUserIdToMember utility to service --- .../tophelper/TopHelpersAssignmentRoutine.java | 4 +--- .../tjbot/features/tophelper/TopHelpersService.java | 13 +++++++++++-- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersAssignmentRoutine.java b/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersAssignmentRoutine.java index 4596ef4a2c..b7e685acae 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersAssignmentRoutine.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersAssignmentRoutine.java @@ -38,7 +38,6 @@ import java.util.Objects; import java.util.Optional; import java.util.Set; -import java.util.function.Function; import java.util.function.Predicate; import java.util.regex.Pattern; import java.util.stream.Collectors; @@ -179,8 +178,7 @@ private void sendSelectionMenu(Collection topH .setMinValues(1) .setMaxValues(topHelpers.size()); - Map userIdToMember = - members.stream().collect(Collectors.toMap(Member::getIdLong, Function.identity())); + Map userIdToMember = TopHelpersService.mapUserIdToMember(members); topHelpers.stream() .map(topHelper -> topHelperToSelectOption(topHelper, userIdToMember.get(topHelper.authorId()))) diff --git a/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersService.java b/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersService.java index 6c5a38375c..73cba41b02 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersService.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersService.java @@ -185,8 +185,7 @@ public static String asAsciiTable(Collection topHelpers, private static String asAsciiTable(Collection topHelpers, Collection members, boolean includeIds) { - Map userIdToMember = - members.stream().collect(Collectors.toMap(Member::getIdLong, Function.identity())); + Map userIdToMember = mapUserIdToMember(members); List> topHelpersDataTable = topHelpers.stream() .map(topHelper -> topHelperToDataRow(topHelper, @@ -196,6 +195,16 @@ private static String asAsciiTable(Collection topHelpers, return dataTableToString(topHelpersDataTable, includeIds); } + /** + * Given a list of members, maps them by their user ID. + * + * @param members the members to map + * @return a map of user ID to corresponding member + */ + public static Map mapUserIdToMember(Collection members) { + return members.stream().collect(Collectors.toMap(Member::getIdLong, Function.identity())); + } + /** * Visual representation of the given members name as it should be used for display purposes. * From 21a1880d8f0dcc18eb6ce3155fb77e9edf638389 Mon Sep 17 00:00:00 2001 From: Zabuzard Date: Sat, 23 Aug 2025 09:42:48 +0200 Subject: [PATCH 12/14] Added Guilds, a helper with role and channel find methods --- .../FileSharingMessageListener.java | 5 +- .../tjbot/features/help/HelpSystemHelper.java | 7 ++- .../features/moderation/ModerationUtils.java | 8 +-- .../features/moderation/scam/ScamBlocker.java | 10 ++-- .../moderation/scam/ScamDetector.java | 5 +- .../TopHelpersAssignmentRoutine.java | 6 +- .../tjbot/features/utils/Guilds.java | 56 +++++++++++++++++++ .../tjbot/features/utils/MessageUtils.java | 2 +- 8 files changed, 76 insertions(+), 23 deletions(-) create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/utils/Guilds.java diff --git a/application/src/main/java/org/togetherjava/tjbot/features/filesharing/FileSharingMessageListener.java b/application/src/main/java/org/togetherjava/tjbot/features/filesharing/FileSharingMessageListener.java index b330960475..c040eaf065 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/filesharing/FileSharingMessageListener.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/filesharing/FileSharingMessageListener.java @@ -2,7 +2,6 @@ import net.dv8tion.jda.api.entities.Member; import net.dv8tion.jda.api.entities.Message; -import net.dv8tion.jda.api.entities.Role; import net.dv8tion.jda.api.entities.User; import net.dv8tion.jda.api.entities.channel.ChannelType; import net.dv8tion.jda.api.entities.channel.concrete.ThreadChannel; @@ -21,6 +20,7 @@ import org.togetherjava.tjbot.features.UserInteractor; import org.togetherjava.tjbot.features.componentids.ComponentIdGenerator; import org.togetherjava.tjbot.features.componentids.ComponentIdInteractor; +import org.togetherjava.tjbot.features.utils.Guilds; import java.io.IOException; import java.io.InputStream; @@ -99,8 +99,7 @@ public void onMessageReceived(MessageReceivedEvent event) { public void onButtonClick(ButtonInteractionEvent event, List args) { Member interactionUser = event.getMember(); String gistAuthorId = args.getFirst(); - boolean hasSoftModPermissions = - interactionUser.getRoles().stream().map(Role::getName).anyMatch(isSoftModRole); + boolean hasSoftModPermissions = Guilds.hasMemberRole(interactionUser, isSoftModRole); if (!gistAuthorId.equals(interactionUser.getId()) && !hasSoftModPermissions) { event.reply("You do not have permission for this action.").setEphemeral(true).queue(); diff --git a/application/src/main/java/org/togetherjava/tjbot/features/help/HelpSystemHelper.java b/application/src/main/java/org/togetherjava/tjbot/features/help/HelpSystemHelper.java index 222a8bfb66..621f1b5937 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/help/HelpSystemHelper.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/help/HelpSystemHelper.java @@ -27,6 +27,7 @@ import org.togetherjava.tjbot.features.chatgpt.ChatGptCommand; import org.togetherjava.tjbot.features.chatgpt.ChatGptService; import org.togetherjava.tjbot.features.componentids.ComponentIdInteractor; +import org.togetherjava.tjbot.features.utils.Guilds; import java.awt.Color; import java.time.Instant; @@ -57,7 +58,7 @@ public final class HelpSystemHelper { static final Color AMBIENT_COLOR = new Color(255, 255, 165); - private final Predicate hasTagManageRole; + private final Predicate isTagManageRole; private final Predicate isHelpForumName; private final String helpForumPattern; /** @@ -88,7 +89,7 @@ public HelpSystemHelper(Config config, Database database, ChatGptService chatGpt this.database = database; this.chatGptService = chatGptService; - hasTagManageRole = Pattern.compile(config.getTagManageRolePattern()).asMatchPredicate(); + isTagManageRole = Pattern.compile(config.getTagManageRolePattern()).asMatchPredicate(); helpForumPattern = helpConfig.getHelpForumPattern(); isHelpForumName = Pattern.compile(helpForumPattern).asMatchPredicate(); @@ -344,7 +345,7 @@ private static ForumTag requireTag(String tagName, ForumChannel forumChannel) { } boolean hasTagManageRole(Member member) { - return member.getRoles().stream().map(Role::getName).anyMatch(hasTagManageRole); + return Guilds.hasMemberRole(member, isTagManageRole); } boolean isHelpForumName(String channelName) { diff --git a/application/src/main/java/org/togetherjava/tjbot/features/moderation/ModerationUtils.java b/application/src/main/java/org/togetherjava/tjbot/features/moderation/ModerationUtils.java index d6aba6dc68..5378b1a606 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/moderation/ModerationUtils.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/moderation/ModerationUtils.java @@ -18,6 +18,7 @@ import org.togetherjava.tjbot.config.Config; import org.togetherjava.tjbot.features.moderation.modmail.ModMailCommand; +import org.togetherjava.tjbot.features.utils.Guilds; import org.togetherjava.tjbot.features.utils.MessageUtils; import javax.annotation.Nullable; @@ -321,7 +322,7 @@ public static Predicate getIsMutedRolePredicate(Config config) { */ public static Optional getMutedRole(Guild guild, Config config) { Predicate isMutedRole = getIsMutedRolePredicate(config); - return guild.getRoles().stream().filter(role -> isMutedRole.test(role.getName())).findAny(); + return Guilds.findRole(guild, isMutedRole); } /** @@ -343,10 +344,7 @@ public static Predicate getIsQuarantinedRolePredicate(Config config) { */ public static Optional getQuarantinedRole(Guild guild, Config config) { Predicate isQuarantinedRole = getIsQuarantinedRolePredicate(config); - return guild.getRoles() - .stream() - .filter(role -> isQuarantinedRole.test(role.getName())) - .findAny(); + return Guilds.findRole(guild, isQuarantinedRole); } /** diff --git a/application/src/main/java/org/togetherjava/tjbot/features/moderation/scam/ScamBlocker.java b/application/src/main/java/org/togetherjava/tjbot/features/moderation/scam/ScamBlocker.java index 730d7eef14..5c3564e447 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/moderation/scam/ScamBlocker.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/moderation/scam/ScamBlocker.java @@ -6,7 +6,6 @@ import net.dv8tion.jda.api.entities.Member; import net.dv8tion.jda.api.entities.Message; import net.dv8tion.jda.api.entities.MessageEmbed; -import net.dv8tion.jda.api.entities.Role; import net.dv8tion.jda.api.entities.SelfUser; import net.dv8tion.jda.api.entities.User; import net.dv8tion.jda.api.entities.channel.concrete.PrivateChannel; @@ -34,6 +33,7 @@ import org.togetherjava.tjbot.features.moderation.ModerationActionsStore; import org.togetherjava.tjbot.features.moderation.ModerationUtils; import org.togetherjava.tjbot.features.moderation.modmail.ModMailCommand; +import org.togetherjava.tjbot.features.utils.Guilds; import org.togetherjava.tjbot.features.utils.MessageUtils; import org.togetherjava.tjbot.logging.LogMarkers; @@ -41,6 +41,7 @@ import java.util.Collection; import java.util.EnumSet; import java.util.List; +import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.function.Consumer; @@ -71,7 +72,7 @@ public final class ScamBlocker extends MessageReceiverAdapter implements UserInt private final Config config; private final ModerationActionsStore actionsStore; private final ScamHistoryStore scamHistoryStore; - private final Predicate hasRequiredRole; + private final Predicate isRequiredRole; private final ComponentIdInteractor componentIdInteractor; @@ -100,7 +101,7 @@ public ScamBlocker(ModerationActionsStore actionsStore, ScamHistoryStore scamHis Pattern.compile(botTrapChannelPattern).asMatchPredicate(); isBotTrapChannel = channel -> isBotTrapChannelName.test(channel.getName()); - hasRequiredRole = Pattern.compile(config.getSoftModerationRolePattern()).asMatchPredicate(); + isRequiredRole = Pattern.compile(config.getSoftModerationRolePattern()).asMatchPredicate(); componentIdInteractor = new ComponentIdInteractor(getInteractionType(), getName()); } @@ -316,7 +317,8 @@ private String generateComponentId(ComponentIdArguments args) { @Override public void onButtonClick(ButtonInteractionEvent event, List argsRaw) { ComponentIdArguments args = ComponentIdArguments.fromList(argsRaw); - if (event.getMember().getRoles().stream().map(Role::getName).noneMatch(hasRequiredRole)) { + if (Guilds.doesMemberNotHaveRole(Objects.requireNonNull(event.getMember()), + isRequiredRole)) { event.reply( "You can not handle scam in this guild, since you do not have the required role.") .setEphemeral(true) diff --git a/application/src/main/java/org/togetherjava/tjbot/features/moderation/scam/ScamDetector.java b/application/src/main/java/org/togetherjava/tjbot/features/moderation/scam/ScamDetector.java index df2272e795..21f3eebcef 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/moderation/scam/ScamDetector.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/moderation/scam/ScamDetector.java @@ -2,10 +2,10 @@ import net.dv8tion.jda.api.entities.Member; import net.dv8tion.jda.api.entities.Message; -import net.dv8tion.jda.api.entities.Role; import org.togetherjava.tjbot.config.Config; import org.togetherjava.tjbot.config.ScamBlockerConfig; +import org.togetherjava.tjbot.features.utils.Guilds; import java.util.Collection; import java.util.List; @@ -51,8 +51,7 @@ public ScamDetector(Config config) { */ public boolean isScam(Message message) { Member author = message.getMember(); - boolean isTrustedUser = author != null - && author.getRoles().stream().map(Role::getName).anyMatch(hasTrustedRole); + boolean isTrustedUser = author != null && Guilds.hasMemberRole(author, hasTrustedRole); if (isTrustedUser) { return false; } diff --git a/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersAssignmentRoutine.java b/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersAssignmentRoutine.java index b7e685acae..78a52bced9 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersAssignmentRoutine.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersAssignmentRoutine.java @@ -25,6 +25,7 @@ import org.togetherjava.tjbot.features.UserInteractor; import org.togetherjava.tjbot.features.componentids.ComponentIdGenerator; import org.togetherjava.tjbot.features.componentids.ComponentIdInteractor; +import org.togetherjava.tjbot.features.utils.Guilds; import javax.annotation.Nullable; @@ -213,10 +214,7 @@ public void onStringSelectSelection(StringSelectInteractionEvent event, List topHelperRole = guild.getRoles() - .stream() - .filter(role -> roleNamePredicate.test(role.getName())) - .findAny(); + Optional topHelperRole = Guilds.findRole(guild, roleNamePredicate); if (topHelperRole.isEmpty()) { logger.warn( "Unable to assign Top Helpers, did not find a role matching the configured pattern '{}' for guild '{}'", diff --git a/application/src/main/java/org/togetherjava/tjbot/features/utils/Guilds.java b/application/src/main/java/org/togetherjava/tjbot/features/utils/Guilds.java new file mode 100644 index 0000000000..943de33824 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/utils/Guilds.java @@ -0,0 +1,56 @@ +package org.togetherjava.tjbot.features.utils; + +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.Role; + +import java.util.Optional; +import java.util.function.Predicate; + +/** + * Utility methods for working with {@link Guild}s. + *

+ * This class is meant to contain all utility methods for {@link Guild}s that can be used on all + * other commands to avoid similar methods appearing everywhere. + */ +public final class Guilds { + private Guilds() { + throw new UnsupportedOperationException("Utility class, construction not supported"); + } + + /** + * Finds any role in the guild whose name matches the given predicate. + * + * @param guild guild to search the role in + * @param isRoleName a predicate matching the name of the role to search + * @return the matched role, if any + */ + public static Optional findRole(Guild guild, Predicate isRoleName) { + return guild.getRoles().stream().filter(role -> isRoleName.test(role.getName())).findAny(); + } + + /** + * Checks whether a given member has a role whose name matches a given predicate. + * + * @param member the member to check + * @param isRoleName a predicate matching the name of the role to search + * @return {@code true} if the member has a matching role, {@code false} otherwise + * @see #doesMemberNotHaveRole(Member, Predicate) + */ + public static boolean hasMemberRole(Member member, Predicate isRoleName) { + return member.getRoles().stream().map(Role::getName).anyMatch(isRoleName); + } + + /** + * Checks whether a given member does not have a role whose name matches a given predicate. + * + * @param member the member to check + * @param isRoleName a predicate matching the name of the role to search + * @return {@code true} if the member does not have any matching role, {@code false} otherwise + * @see #hasMemberRole(Member, Predicate) + */ + public static boolean doesMemberNotHaveRole(Member member, + Predicate isRoleName) { + return member.getRoles().stream().map(Role::getName).noneMatch(isRoleName); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/utils/MessageUtils.java b/application/src/main/java/org/togetherjava/tjbot/features/utils/MessageUtils.java index 615e379a3c..89cc140549 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/utils/MessageUtils.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/utils/MessageUtils.java @@ -19,7 +19,7 @@ * This class is meant to contain all utility methods for {@link Message} that can be used on all * other commands to avoid similar methods appearing everywhere. */ -public class MessageUtils { +public final class MessageUtils { public static final int MAXIMUM_VISIBLE_EMBEDS = 25; public static final String ABBREVIATION = "..."; private static final String CODE_FENCE_SYMBOL = "```"; From 9fadf60bfe68b48e7ee41e923d7a4d1974c981f7 Mon Sep 17 00:00:00 2001 From: Zabuzard Date: Sat, 23 Aug 2025 09:47:12 +0200 Subject: [PATCH 13/14] CR readability with split args --- .../features/tophelper/TopHelpersAssignmentRoutine.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersAssignmentRoutine.java b/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersAssignmentRoutine.java index 78a52bced9..95f04582de 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersAssignmentRoutine.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersAssignmentRoutine.java @@ -288,9 +288,10 @@ private void reportRoleManageSuccess(StringSelectInteractionEvent event) { public void onButtonClick(ButtonInteractionEvent event, List args) { event.deferEdit().queue(); String name = args.getFirst(); + List otherArgs = args.subList(1, args.size()); switch (name) { - case YES_MESSAGE_BUTTON_NAME -> prepareAnnouncement(event, args); + case YES_MESSAGE_BUTTON_NAME -> prepareAnnouncement(event, otherArgs); case NO_MESSAGE_BUTTON_NAME -> reportFlowFinished(event, "not posting an announcement"); case CANCEL_BUTTON_NAME -> reportFlowFinished(event, "cancelled"); default -> throw new AssertionError("Unknown button name: " + name); @@ -298,7 +299,7 @@ public void onButtonClick(ButtonInteractionEvent event, List args) { } private void prepareAnnouncement(ButtonInteractionEvent event, List args) { - List topHelperIds = args.stream().skip(1).map(Long::parseLong).toList(); + List topHelperIds = args.stream().map(Long::parseLong).toList(); event.getGuild().retrieveMembersByIds(topHelperIds).onError(error -> { logger.warn("Failed to retrieve top-helper data for automatic assignment", error); From e29b12484af39c2068c9154577a9ec36da03d527 Mon Sep 17 00:00:00 2001 From: Zabuzard Date: Sat, 23 Aug 2025 09:55:45 +0200 Subject: [PATCH 14/14] Added channel find methods to Guilds helper (findTextChannel etc) --- .../tjbot/features/help/HelpSystemHelper.java | 6 +--- .../moderation/audit/ModAuditLogWriter.java | 12 +++---- .../features/moderation/scam/ScamBlocker.java | 8 ++--- .../TopHelpersAssignmentRoutine.java | 12 +++---- .../tjbot/features/utils/Guilds.java | 34 +++++++++++++++++++ 5 files changed, 47 insertions(+), 25 deletions(-) diff --git a/application/src/main/java/org/togetherjava/tjbot/features/help/HelpSystemHelper.java b/application/src/main/java/org/togetherjava/tjbot/features/help/HelpSystemHelper.java index 621f1b5937..dbb6ed55e2 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/help/HelpSystemHelper.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/help/HelpSystemHelper.java @@ -361,11 +361,7 @@ Optional handleRequireHelpForum(Guild guild, Predicate isChannelName = this::isHelpForumName; String channelPattern = getHelpForumPattern(); - Optional maybeChannel = guild.getForumChannelCache() - .stream() - .filter(channel -> isChannelName.test(channel.getName())) - .findAny(); - + Optional maybeChannel = Guilds.findForumChannel(guild, isChannelName); if (maybeChannel.isEmpty()) { consumeChannelPatternIfNotFound.accept(channelPattern); } diff --git a/application/src/main/java/org/togetherjava/tjbot/features/moderation/audit/ModAuditLogWriter.java b/application/src/main/java/org/togetherjava/tjbot/features/moderation/audit/ModAuditLogWriter.java index b458017732..b0aebee61b 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/moderation/audit/ModAuditLogWriter.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/moderation/audit/ModAuditLogWriter.java @@ -10,6 +10,7 @@ import org.slf4j.LoggerFactory; import org.togetherjava.tjbot.config.Config; +import org.togetherjava.tjbot.features.utils.Guilds; import javax.annotation.Nullable; @@ -34,7 +35,7 @@ public final class ModAuditLogWriter { private final Config config; - private final Predicate auditLogChannelNamePredicate; + private final Predicate isAuditLogChannelName; /** * Creates a new instance. @@ -43,7 +44,7 @@ public final class ModAuditLogWriter { */ public ModAuditLogWriter(Config config) { this.config = config; - auditLogChannelNamePredicate = + isAuditLogChannelName = Pattern.compile(config.getModAuditLogChannelPattern()).asMatchPredicate(); } @@ -90,11 +91,8 @@ public void write(String title, String description, @Nullable User author, * @return the channel used for moderation audit logs, if present */ public Optional getAndHandleModAuditLogChannel(Guild guild) { - Optional auditLogChannel = guild.getTextChannelCache() - .stream() - .filter(channel -> auditLogChannelNamePredicate.test(channel.getName())) - .findAny(); - + Optional auditLogChannel = + Guilds.findTextChannel(guild, isAuditLogChannelName); if (auditLogChannel.isEmpty()) { logger.warn( "Unable to log moderation events, did not find a mod audit log channel matching the configured pattern '{}' for guild '{}'", diff --git a/application/src/main/java/org/togetherjava/tjbot/features/moderation/scam/ScamBlocker.java b/application/src/main/java/org/togetherjava/tjbot/features/moderation/scam/ScamBlocker.java index 5c3564e447..1c37fe8f3f 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/moderation/scam/ScamBlocker.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/moderation/scam/ScamBlocker.java @@ -66,7 +66,7 @@ public final class ScamBlocker extends MessageReceiverAdapter implements UserInt private final ScamBlockerConfig.Mode mode; private final String reportChannelPattern; private final String botTrapChannelPattern; - private final Predicate isReportChannel; + private final Predicate isReportChannelName; private final Predicate isBotTrapChannel; private final ScamDetector scamDetector; private final Config config; @@ -92,9 +92,7 @@ public ScamBlocker(ModerationActionsStore actionsStore, ScamHistoryStore scamHis scamDetector = new ScamDetector(config); reportChannelPattern = config.getScamBlocker().getReportChannelPattern(); - Predicate isReportChannelName = - Pattern.compile(reportChannelPattern).asMatchPredicate(); - isReportChannel = channel -> isReportChannelName.test(channel.getName()); + isReportChannelName = Pattern.compile(reportChannelPattern).asMatchPredicate(); botTrapChannelPattern = config.getScamBlocker().getBotTrapChannelPattern(); Predicate isBotTrapChannelName = @@ -297,7 +295,7 @@ If you think this was a mistake (for example, your account was hacked, but you g } private Optional getReportChannel(Guild guild) { - return guild.getTextChannelCache().stream().filter(isReportChannel).findAny(); + return Guilds.findTextChannel(guild, isReportChannelName); } private List