diff --git a/application/build.gradle b/application/build.gradle index 44cd02255d..3ed6987a3f 100644 --- a/application/build.gradle +++ b/application/build.gradle @@ -59,6 +59,8 @@ dependencies { implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-csv:2.13.0' implementation 'com.fasterxml.jackson.core:jackson-databind:2.13.0' + implementation 'com.github.freva:ascii-table:1.2.0' + implementation 'com.github.ben-manes.caffeine:caffeine:3.0.4' testImplementation 'org.mockito:mockito-core:4.0.0' diff --git a/application/config.json.template b/application/config.json.template index 363599f186..6ebd7715db 100644 --- a/application/config.json.template +++ b/application/config.json.template @@ -15,5 +15,6 @@ ] } - ] -} \ No newline at end of file + ], + "helpChannelPattern": "([a-zA-Z_]+_)?help(_\\d+)?" +} diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/Features.java b/application/src/main/java/org/togetherjava/tjbot/commands/Features.java index 6960cca19f..393172ed99 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/Features.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/Features.java @@ -13,6 +13,9 @@ import org.togetherjava.tjbot.commands.tags.TagManageCommand; import org.togetherjava.tjbot.commands.tags.TagSystem; import org.togetherjava.tjbot.commands.tags.TagsCommand; +import org.togetherjava.tjbot.commands.tophelper.TopHelpersCommand; +import org.togetherjava.tjbot.commands.tophelper.TopHelpersMessageListener; +import org.togetherjava.tjbot.commands.tophelper.TopHelpersPurgeMessagesRoutine; import org.togetherjava.tjbot.db.Database; import org.togetherjava.tjbot.routines.ModAuditLogRoutine; @@ -53,8 +56,10 @@ public enum Features { // Routines features.add(new ModAuditLogRoutine(database)); features.add(new TemporaryModerationRoutine(jda, actionsStore)); + features.add(new TopHelpersPurgeMessagesRoutine(database)); // Message receivers + features.add(new TopHelpersMessageListener(database)); // Event receivers features.add(new RejoinMuteListener(actionsStore)); @@ -73,6 +78,7 @@ public enum Features { features.add(new AuditCommand(actionsStore)); features.add(new MuteCommand(actionsStore)); features.add(new UnmuteCommand(actionsStore)); + features.add(new TopHelpersCommand(database)); // Mixtures features.add(new FreeCommand()); diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/tophelper/TopHelpersCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/tophelper/TopHelpersCommand.java new file mode 100644 index 0000000000..876ce3dcb2 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/tophelper/TopHelpersCommand.java @@ -0,0 +1,194 @@ +package org.togetherjava.tjbot.commands.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.entities.Role; +import net.dv8tion.jda.api.events.interaction.SlashCommandEvent; +import net.dv8tion.jda.api.interactions.Interaction; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.jooq.Records; +import org.jooq.impl.DSL; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.togetherjava.tjbot.commands.SlashCommandAdapter; +import org.togetherjava.tjbot.commands.SlashCommandVisibility; +import org.togetherjava.tjbot.config.Config; +import org.togetherjava.tjbot.db.Database; + +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.time.format.TextStyle; +import java.time.temporal.TemporalAdjusters; +import java.util.Collection; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.function.Function; +import java.util.function.IntFunction; +import java.util.function.Predicate; +import java.util.regex.Pattern; +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. + *

+ * Top helpers are measured by their message count in help channels, as set by + * {@link TopHelpersMessageListener}. + */ +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 int TOP_HELPER_LIMIT = 20; + + private final Database database; + private final Predicate hasRequiredRole; + + /** + * Creates a new instance. + * + * @param database the database containing the message counts of top helpers + */ + public TopHelpersCommand(@NotNull Database database) { + super(COMMAND_NAME, "Lists top helpers for the last month", SlashCommandVisibility.GUILD); + // TODO Add options to optionally pick a time range once JDA/Discord offers a date-picker + hasRequiredRole = Pattern.compile(Config.getInstance().getSoftModerationRolePattern()) + .asMatchPredicate(); + this.database = database; + } + + @Override + public void onSlashCommand(@NotNull SlashCommandEvent event) { + if (!handleHasAuthorRole(event.getMember(), event)) { + return; + } + + TimeRange timeRange = computeDefaultTimeRange(); + List topHelpers = + computeTopHelpersDescending(event.getGuild().getIdLong(), timeRange); + + if (topHelpers.isEmpty()) { + event + .reply("No entries for the selected time range (%s)." + .formatted(timeRange.description())) + .queue(); + return; + } + event.deferReply().queue(); + + List topHelperIds = topHelpers.stream().map(TopHelperResult::authorId).toList(); + event.getGuild() + .retrieveMembersByIds(topHelperIds) + .onError(error -> handleError(error, event)) + .onSuccess(members -> handleTopHelpers(topHelpers, members, timeRange, event)); + } + + @SuppressWarnings("BooleanMethodNameMustStartWithQuestion") + private boolean handleHasAuthorRole(@NotNull Member author, @NotNull Interaction event) { + if (author.getRoles().stream().map(Role::getName).anyMatch(hasRequiredRole)) { + return true; + } + event.reply("You can not compute the top-helpers since you do not have the required role.") + .setEphemeral(true) + .queue(); + return false; + } + + private static @NotNull TimeRange computeDefaultTimeRange() { + // Last month + ZonedDateTime start = Instant.now() + .atZone(ZoneOffset.UTC) + .minusMonths(1) + .with(TemporalAdjusters.firstDayOfMonth()); + ZonedDateTime end = start.with(TemporalAdjusters.lastDayOfMonth()); + String description = start.getMonth().getDisplayName(TextStyle.FULL_STANDALONE, Locale.US); + + return new TimeRange(start.toInstant(), end.toInstant(), description); + } + + private @NotNull List computeTopHelpersDescending(long guildId, + @NotNull TimeRange timeRange) { + return database.read(context -> context.select(HELP_CHANNEL_MESSAGES.AUTHOR_ID, DSL.count()) + .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.count().desc()) + .limit(TOP_HELPER_LIMIT) + .fetch(Records.mapping(TopHelperResult::new))); + } + + private static void handleError(@NotNull Throwable error, @NotNull Interaction event) { + logger.warn("Failed to compute top-helpers", error); + event.getHook().editOriginal("Sorry, something went wrong.").queue(); + } + + private static void handleTopHelpers(@NotNull Collection topHelpers, + @NotNull Collection members, @NotNull TimeRange timeRange, + @NotNull Interaction 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%n%s%n```".formatted(dataTableToString(topHelpersDataTable, timeRange)); + + event.getHook().editOriginal(message).queue(); + } + + private static @NotNull List topHelperToDataRow(@NotNull TopHelperResult topHelper, + @Nullable Member member) { + String id = Long.toString(topHelper.authorId()); + String name = member == null ? "UNKNOWN_USER" : member.getEffectiveName(); + String messageCount = Integer.toString(topHelper.messageCount()); + + return List.of(id, name, messageCount); + } + + private static @NotNull String dataTableToString(@NotNull Collection> dataTable, + @NotNull TimeRange timeRange) { + return dataTableToAsciiTable(dataTable, + List.of(new ColumnSetting("Id", HorizontalAlign.RIGHT), + new ColumnSetting("Name", HorizontalAlign.RIGHT), + new ColumnSetting( + "Message count (for %s)".formatted(timeRange.description()), + HorizontalAlign.RIGHT))); + } + + private static @NotNull String dataTableToAsciiTable( + @NotNull Collection> dataTable, + @NotNull 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 TimeRange(Instant start, Instant end, String description) { + } + + private record TopHelperResult(long authorId, int messageCount) { + } + + private record ColumnSetting(String headerName, HorizontalAlign alignment) { + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/tophelper/TopHelpersMessageListener.java b/application/src/main/java/org/togetherjava/tjbot/commands/tophelper/TopHelpersMessageListener.java new file mode 100644 index 0000000000..fadb5b50ff --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/tophelper/TopHelpersMessageListener.java @@ -0,0 +1,48 @@ +package org.togetherjava.tjbot.commands.tophelper; + +import net.dv8tion.jda.api.events.message.guild.GuildMessageReceivedEvent; +import org.jetbrains.annotations.NotNull; +import org.togetherjava.tjbot.commands.MessageReceiverAdapter; +import org.togetherjava.tjbot.config.Config; +import org.togetherjava.tjbot.db.Database; + +import java.util.regex.Pattern; + +import static org.togetherjava.tjbot.db.generated.tables.HelpChannelMessages.HELP_CHANNEL_MESSAGES; + +/** + * Listener that receives all sent help messages and puts them into the database for + * {@link TopHelpersCommand} to pick them up. + */ +public final class TopHelpersMessageListener extends MessageReceiverAdapter { + private final Database database; + + /** + * Creates a new listener to receive all message sent in help channels. + * + * @param database to store message meta-data in + */ + public TopHelpersMessageListener(@NotNull Database database) { + super(Pattern.compile(Config.getInstance().getHelpChannelPattern())); + this.database = database; + } + + @Override + public void onMessageReceived(@NotNull GuildMessageReceivedEvent event) { + if (event.getAuthor().isBot() || event.isWebhookMessage()) { + return; + } + + addMessageRecord(event); + } + + private void addMessageRecord(@NotNull GuildMessageReceivedEvent event) { + database.write(context -> context.newRecord(HELP_CHANNEL_MESSAGES) + .setMessageId(event.getMessage().getIdLong()) + .setGuildId(event.getGuild().getIdLong()) + .setChannelId(event.getChannel().getIdLong()) + .setAuthorId(event.getAuthor().getIdLong()) + .setSentAt(event.getMessage().getTimeCreated().toInstant()) + .insert()); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/tophelper/TopHelpersPurgeMessagesRoutine.java b/application/src/main/java/org/togetherjava/tjbot/commands/tophelper/TopHelpersPurgeMessagesRoutine.java new file mode 100644 index 0000000000..527c908f1d --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/tophelper/TopHelpersPurgeMessagesRoutine.java @@ -0,0 +1,54 @@ +package org.togetherjava.tjbot.commands.tophelper; + +import net.dv8tion.jda.api.JDA; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.togetherjava.tjbot.commands.Routine; +import org.togetherjava.tjbot.db.Database; + +import java.time.Instant; +import java.time.Period; +import java.util.concurrent.TimeUnit; + +import static org.togetherjava.tjbot.db.generated.tables.HelpChannelMessages.HELP_CHANNEL_MESSAGES; + +/** + * Cleanup routine to get rid of old database top-helper message entries. + */ +public final class TopHelpersPurgeMessagesRoutine implements Routine { + private static final Logger logger = + LoggerFactory.getLogger(TopHelpersPurgeMessagesRoutine.class); + private static final Period DELETE_MESSAGE_RECORDS_AFTER = Period.ofDays(90); + + private final Database database; + + /** + * Creates a new cleanup routine. + * + * @param database the database that contains the messages to purge + */ + public TopHelpersPurgeMessagesRoutine(@NotNull Database database) { + this.database = database; + } + + @Override + public @NotNull Schedule createSchedule() { + return new Schedule(ScheduleMode.FIXED_RATE, 0, 4, TimeUnit.HOURS); + } + + @Override + public void runRoutine(@NotNull JDA jda) { + int recordsDeleted = + database.writeAndProvide(context -> context.deleteFrom(HELP_CHANNEL_MESSAGES) + .where(HELP_CHANNEL_MESSAGES.SENT_AT + .lessOrEqual(Instant.now().minus(DELETE_MESSAGE_RECORDS_AFTER))) + .execute()); + + if (recordsDeleted > 0) { + logger.debug( + "{} old help message records have been deleted because they are older than {}.", + recordsDeleted, DELETE_MESSAGE_RECORDS_AFTER); + } + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/tophelper/package-info.java b/application/src/main/java/org/togetherjava/tjbot/commands/tophelper/package-info.java new file mode 100644 index 0000000000..61969abb57 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/tophelper/package-info.java @@ -0,0 +1,5 @@ +/** + * This packages offers all the functionality for the top-helpers command system. The core class is + * {@link org.togetherjava.tjbot.commands.tophelper.TopHelpersCommand}. + */ +package org.togetherjava.tjbot.commands.tophelper; 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 35c077dc13..470758b199 100644 --- a/application/src/main/java/org/togetherjava/tjbot/config/Config.java +++ b/application/src/main/java/org/togetherjava/tjbot/config/Config.java @@ -32,8 +32,8 @@ public final class Config { private final String heavyModerationRolePattern; private final String softModerationRolePattern; private final String tagManageRolePattern; - private final List freeCommand; + private final String helpChannelPattern; @SuppressWarnings("ConstructorWithTooManyParameters") @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) @@ -46,7 +46,8 @@ private Config(@JsonProperty("token") String token, @JsonProperty("heavyModerationRolePattern") String heavyModerationRolePattern, @JsonProperty("softModerationRolePattern") String softModerationRolePattern, @JsonProperty("tagManageRolePattern") String tagManageRolePattern, - @JsonProperty("freeCommand") List freeCommand) { + @JsonProperty("freeCommand") List freeCommand, + @JsonProperty("helpChannelPattern") String helpChannelPattern) { this.token = token; this.databasePath = databasePath; this.projectWebsite = projectWebsite; @@ -57,6 +58,7 @@ private Config(@JsonProperty("token") String token, this.softModerationRolePattern = softModerationRolePattern; this.tagManageRolePattern = tagManageRolePattern; this.freeCommand = Collections.unmodifiableList(freeCommand); + this.helpChannelPattern = helpChannelPattern; } /** @@ -178,4 +180,14 @@ public String getTagManageRolePattern() { public @NotNull Collection getFreeCommandConfig() { return freeCommand; // already unmodifiable } + + /** + * Gets the REGEX pattern used to identify channels that are used for helping people with their + * questions. + * + * @return the channel name pattern + */ + public String getHelpChannelPattern() { + return helpChannelPattern; + } } diff --git a/application/src/main/resources/db/V7__Add_Top_Helper_System.sql b/application/src/main/resources/db/V7__Add_Top_Helper_System.sql new file mode 100644 index 0000000000..ab0b05b552 --- /dev/null +++ b/application/src/main/resources/db/V7__Add_Top_Helper_System.sql @@ -0,0 +1,8 @@ +CREATE TABLE help_channel_messages +( + message_id BIGINT NOT NULL PRIMARY KEY, + guild_id BIGINT NOT NULL, + channel_id BIGINT NOT NULL, + author_id BIGINT NOT NULL, + sent_at TIMESTAMP NOT NULL +)