diff --git a/application/build.gradle b/application/build.gradle index ce7e55194a..99a6e20355 100644 --- a/application/build.gradle +++ b/application/build.gradle @@ -60,10 +60,11 @@ dependencies { implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-csv:2.13.0' implementation 'com.fasterxml.jackson.core:jackson-databind:2.13.0' - testImplementation 'org.mockito:mockito-core:3.12.4' - testRuntimeOnly 'org.mockito:mockito-core:3.12.4' + implementation 'com.github.freva:ascii-table:1.2.0' + testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.1' testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.8.1' + testImplementation 'org.mockito:mockito-core:4.0.0' } application { diff --git a/application/src/main/java/org/togetherjava/tjbot/Application.java b/application/src/main/java/org/togetherjava/tjbot/Application.java index 60a1d84b28..f748929fc0 100644 --- a/application/src/main/java/org/togetherjava/tjbot/Application.java +++ b/application/src/main/java/org/togetherjava/tjbot/Application.java @@ -8,6 +8,7 @@ import org.togetherjava.tjbot.commands.system.CommandSystem; import org.togetherjava.tjbot.config.Config; import org.togetherjava.tjbot.db.Database; +import org.togetherjava.tjbot.listener.TopHelpersMetadataListener; import javax.security.auth.login.LoginException; import java.io.IOException; @@ -76,7 +77,8 @@ public static void runBot(String token, Path databasePath) { Database database = new Database("jdbc:sqlite:" + databasePath.toAbsolutePath()); JDA jda = JDABuilder.createDefault(token) - .addEventListeners(new CommandSystem(database)) + .addEventListeners(new CommandSystem(database), + new TopHelpersMetadataListener(database)) .build(); jda.awaitReady(); logger.info("Bot is ready"); diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/Commands.java b/application/src/main/java/org/togetherjava/tjbot/commands/Commands.java index 3961b19ed8..c7d52a7ef9 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/Commands.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/Commands.java @@ -41,6 +41,6 @@ public enum Commands { // available. return List.of(new PingCommand(), new DatabaseCommand(database), new TeXCommand(), new TagCommand(tagSystem), new TagManageCommand(tagSystem), - new TagsCommand(tagSystem)); + new TagsCommand(tagSystem), new TopHelpers(database)); } } diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/TopHelpers.java b/application/src/main/java/org/togetherjava/tjbot/commands/TopHelpers.java new file mode 100644 index 0000000000..44ebe38b27 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/TopHelpers.java @@ -0,0 +1,102 @@ +package org.togetherjava.tjbot.commands; + +import com.github.freva.asciitable.HorizontalAlign; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.events.interaction.SlashCommandEvent; +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.jetbrains.annotations.Nullable; +import org.jooq.Records; +import org.togetherjava.tjbot.db.Database; +import org.togetherjava.tjbot.util.PresentationUtils; + +import javax.annotation.ParametersAreNonnullByDefault; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static org.jooq.impl.DSL.*; +import static org.togetherjava.tjbot.db.generated.tables.MessageMetadata.MESSAGE_METADATA; + +/** + * Command to retrieve top helpers for last 30 days. + */ +@ParametersAreNonnullByDefault +public final class TopHelpers extends SlashCommandAdapter { + + public static final String PLAINTEXT_MESSAGE_TEMPLATE = "```\n%s\n```"; + private static final String COUNT_OPTION = "count"; + private static final String NO_ENTRIES = "No entries"; + + private static final long MIN_COUNT = 10L; + private static final long MAX_COUNT = 30L; + + private record TopHelperRow(Integer serialId, Long userId, Long messageCount) { + } + + private final Database database; + + /** + * Initializes TopHelpers with a database. + * + * @param database the database to store the key-value pairs in + */ + public TopHelpers(Database database) { + super("tophelpers", "Find top helpers for last 30 days", SlashCommandVisibility.GUILD); + this.database = database; + getData().addOptions(new OptionData(OptionType.INTEGER, COUNT_OPTION, + "Count of top helpers to be retrieved (default is 10 and capped at 30)", false)); + } + + @SuppressWarnings("squid:S1192") + @Override + public void onSlashCommand(SlashCommandEvent event) { + long guildId = event.getGuild().getIdLong(); + long count = getBoundedCountAsLong(event.getOption(COUNT_OPTION)); + database.read(dsl -> { + List records = dsl.with("TOPHELPERS") + .as(select(MESSAGE_METADATA.USER_ID, count().as("COUNT")).from(MESSAGE_METADATA) + .where(MESSAGE_METADATA.GUILD_ID.eq(guildId)) + .groupBy(MESSAGE_METADATA.USER_ID) + .orderBy(count().desc()) + .limit(count)) + .select(rowNumber().over(orderBy(field(name("COUNT")).desc())).as("#"), + field(name("USER_ID"), Long.class), field(name("COUNT"), Long.class)) + .from(table(name("TOPHELPERS"))) + .fetch(Records.mapping(TopHelperRow::new)); + generateResponse(event, records); + }); + } + + private static long getBoundedCountAsLong(@Nullable OptionMapping count) { + return count == null ? MIN_COUNT : Math.min(count.getAsLong(), MAX_COUNT); + } + + private static String prettyFormatOutput(List> dataFrame) { + return String.format(PLAINTEXT_MESSAGE_TEMPLATE, + dataFrame.isEmpty() ? NO_ENTRIES + : PresentationUtils.dataFrameToAsciiTable(dataFrame, + new String[] {"#", "Name", "Message Count (in the last 30 days)"}, + new HorizontalAlign[] {HorizontalAlign.RIGHT, HorizontalAlign.LEFT, + HorizontalAlign.RIGHT})); + } + + private static void generateResponse(SlashCommandEvent event, + List records) { + List userIds = records.stream().map(TopHelperRow::userId).toList(); + event.getGuild().retrieveMembersByIds(userIds).onSuccess(members -> { + Map activeUserIdToEffectiveNames = members.stream() + .collect(Collectors.toMap(Member::getIdLong, Member::getEffectiveName)); + List> topHelpersDataframe = records.stream() + .map(topHelperRow -> List.of(topHelperRow.serialId.toString(), + activeUserIdToEffectiveNames.getOrDefault(topHelperRow.userId, + // Any user who is no more a part of the guild is marked as + // [UNKNOWN] + "[UNKNOWN]"), + topHelperRow.messageCount.toString())) + .toList(); + event.reply(prettyFormatOutput(topHelpersDataframe)).queue(); + }); + } +} 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 88097957f4..50a4726be0 100644 --- a/application/src/main/java/org/togetherjava/tjbot/config/Config.java +++ b/application/src/main/java/org/togetherjava/tjbot/config/Config.java @@ -23,16 +23,19 @@ public final class Config { private final String databasePath; private final String projectWebsite; private final String discordGuildInvite; + private final String helpChannelRegex; @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) private Config(@JsonProperty("token") String token, - @JsonProperty("databasePath") String databasePath, - @JsonProperty("projectWebsite") String projectWebsite, - @JsonProperty("discordGuildInvite") String discordGuildInvite) { + @JsonProperty("databasePath") String databasePath, + @JsonProperty("projectWebsite") String projectWebsite, + @JsonProperty("discordGuildInvite") String discordGuildInvite, + @JsonProperty("helpChannelRegex") String helpChannelRegex) { this.token = token; this.databasePath = databasePath; this.projectWebsite = projectWebsite; this.discordGuildInvite = discordGuildInvite; + this.helpChannelRegex = helpChannelRegex; } /** @@ -94,4 +97,11 @@ public String getProjectWebsite() { public String getDiscordGuildInvite() { return discordGuildInvite; } + + /** + * Gets regex pattern to identify help channels. + * + * @return string representation of regex pattern to identify help channels + */ + public String getHelpChannelRegex() { return helpChannelRegex; } } diff --git a/application/src/main/java/org/togetherjava/tjbot/listener/TopHelpersMetadataListener.java b/application/src/main/java/org/togetherjava/tjbot/listener/TopHelpersMetadataListener.java new file mode 100644 index 0000000000..880418c4bc --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/listener/TopHelpersMetadataListener.java @@ -0,0 +1,70 @@ +package org.togetherjava.tjbot.listener; + +import net.dv8tion.jda.api.events.message.guild.GuildMessageReceivedEvent; +import net.dv8tion.jda.api.hooks.ListenerAdapter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.togetherjava.tjbot.db.Database; +import org.togetherjava.tjbot.util.JdaUtils; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; + +import static org.togetherjava.tjbot.db.generated.tables.MessageMetadata.MESSAGE_METADATA; + +/** + * Listener responsible for persistence of text message metadata. + */ +public final class TopHelpersMetadataListener extends ListenerAdapter { + private static final Logger logger = LoggerFactory.getLogger(TopHelpersMetadataListener.class); + + private static final int MESSAGE_METADATA_ARCHIVAL_DAYS = 30; + + private final Database database; + + /** + * Creates a new message metadata listener, using the given database. + * + * @param database the database to store message metadata. + */ + public TopHelpersMetadataListener(Database database) { + this.database = database; + } + + /** + * Stores the relevant message metadata for on message received event. + * + * @param event incoming message received event. + */ + @Override + public void onGuildMessageReceived(GuildMessageReceivedEvent event) { + var channel = event.getChannel(); + if (!event.getAuthor().isBot() && !event.isWebhookMessage() + && JdaUtils.isHelpChannel(channel)) { + var messageId = event.getMessage().getIdLong(); + var guildId = event.getGuild().getIdLong(); + var channelId = channel.getIdLong(); + var userId = event.getAuthor().getIdLong(); + var createTimestamp = event.getMessage().getTimeCreated().toEpochSecond(); + database.write(dsl -> { + dsl.newRecord(MESSAGE_METADATA) + .setMessageId(messageId) + .setGuildId(guildId) + .setChannelId(channelId) + .setUserId(userId) + .setCreateTimestamp(createTimestamp) + .insert(); + int noOfRowsDeleted = dsl.deleteFrom(MESSAGE_METADATA) + .where(MESSAGE_METADATA.CREATE_TIMESTAMP.le(Instant.now() + .minus(MESSAGE_METADATA_ARCHIVAL_DAYS, ChronoUnit.DAYS) + .getEpochSecond())) + .execute(); + if (noOfRowsDeleted > 0) { + logger.debug( + "{} old records have been deleted based on archival criteria of {} days.", + noOfRowsDeleted, MESSAGE_METADATA_ARCHIVAL_DAYS); + } + }); + } + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/listener/package-info.java b/application/src/main/java/org/togetherjava/tjbot/listener/package-info.java new file mode 100644 index 0000000000..cbef12ab08 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/listener/package-info.java @@ -0,0 +1,4 @@ +/** + * This package contains non-user interactive event listeners. + */ +package org.togetherjava.tjbot.listener; diff --git a/application/src/main/java/org/togetherjava/tjbot/util/JDAUtils.java b/application/src/main/java/org/togetherjava/tjbot/util/JDAUtils.java new file mode 100644 index 0000000000..133fe5b89f --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/util/JDAUtils.java @@ -0,0 +1,24 @@ +package org.togetherjava.tjbot.util; + +import net.dv8tion.jda.api.entities.TextChannel; + +import java.util.regex.Pattern; + +/** + * Miscellaneous utilities for JDA entities. + */ +public final class JDAUtils { + private static final Pattern HELP_CHANNEL_PATTERN = Pattern.compile(".*\\Qhelp\\E.*"); + + private JDAUtils() {} + + /** + * Checks if a provided channel is a help channel. + * + * @param textChannel provided channel. + * @return true if the provided channel is a help channel, false otherwise. + */ + public static boolean isAHelpChannel(TextChannel textChannel) { + return HELP_CHANNEL_PATTERN.matcher(textChannel.getName()).matches(); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/util/JdaUtils.java b/application/src/main/java/org/togetherjava/tjbot/util/JdaUtils.java new file mode 100644 index 0000000000..cba72bb7f4 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/util/JdaUtils.java @@ -0,0 +1,24 @@ +package org.togetherjava.tjbot.util; + +import net.dv8tion.jda.api.entities.TextChannel; + +import java.util.regex.Pattern; + +/** + * Miscellaneous utilities for JDA entities. + */ +public final class JdaUtils { + private static final Pattern HELP_CHANNEL_PATTERN = Pattern.compile(".*\\Qhelp\\E.*"); + + private JdaUtils() {} + + /** + * Checks if a provided channel is a help channel. + * + * @param textChannel provided channel. + * @return true if the provided channel is a help channel, false otherwise. + */ + public static boolean isHelpChannel(TextChannel textChannel) { + return HELP_CHANNEL_PATTERN.matcher(textChannel.getName()).matches(); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/util/PresentationUtils.java b/application/src/main/java/org/togetherjava/tjbot/util/PresentationUtils.java new file mode 100644 index 0000000000..bf23fcd894 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/util/PresentationUtils.java @@ -0,0 +1,60 @@ +package org.togetherjava.tjbot.util; + +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 org.jetbrains.annotations.NotNull; + +import java.util.List; +import java.util.Objects; +import java.util.stream.IntStream; + +/** + * Utility class for representation of data in various formats + */ +public final class PresentationUtils { + private PresentationUtils() {} + + /** + * Flattens a dataFrame to String representation of a table. + * + * eg. + *
+     * {@code
+     *     var dataframe = List.of(List.of("Apple", "Fruit"), List.of("Potato", "Vegetable"));
+     *     var columnHeaders = new String[] {"Item", "Category"};
+     *     var horizontalAlignment = new HorizontalAlign[] {HorizontalAlign.LEFT, HorizontalAlign.LEFT};
+     *     dataFrameToAsciiTable(dataframe, columnHeaders, horizontalAlignment);
+     * }
+     * 
+ * will return: + *
+     * {@code
+     *  Item   | Category
+     * --------+-----------
+     *  Apple  | Fruit
+     *  Potato | Vegetable
+     * }
+     * 
+ * + * @param dataFrame dataframe represented as List> where List represents a + * single row + * @param headers column headers for the table + * @param horizontalAligns column alignment for the table + * @return String representation of the dataFrame in tabular form + */ + public static String dataFrameToAsciiTable(@NotNull List> dataFrame, + @NotNull String[] headers, @NotNull HorizontalAlign[] horizontalAligns) { + Objects.requireNonNull(dataFrame, "DataFrame cannot be null"); + Objects.requireNonNull(headers, "Headers cannot be null"); + Objects.requireNonNull(horizontalAligns, "HorizontalAligns cannot be null"); + Character[] characters = AsciiTable.BASIC_ASCII_NO_DATA_SEPARATORS_NO_OUTSIDE_BORDER; + List>> columnData = IntStream.range(0, headers.length) + .mapToObj(i -> new Column().header(headers[i]) + .dataAlign(horizontalAligns[i]) + .>with(row -> row.get(i))) + .toList(); + return AsciiTable.getTable(characters, dataFrame, columnData); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/util/package-info.java b/application/src/main/java/org/togetherjava/tjbot/util/package-info.java new file mode 100644 index 0000000000..6937afec0a --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/util/package-info.java @@ -0,0 +1,4 @@ +/** + * This package contains miscellaneous utilities. + */ +package org.togetherjava.tjbot.util; diff --git a/application/src/main/resources/db/V3__Create_Table_Message_Metadata.sql b/application/src/main/resources/db/V3__Create_Table_Message_Metadata.sql new file mode 100644 index 0000000000..8f5052f3c6 --- /dev/null +++ b/application/src/main/resources/db/V3__Create_Table_Message_Metadata.sql @@ -0,0 +1,7 @@ +CREATE TABLE message_metadata( + message_id BIGINT NOT NULL PRIMARY KEY, + guild_id BIGINT NOT NULL, + channel_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + create_timestamp BIGINT NOT NULL +)