Skip to content

Add top-helpers command #282

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Feb 2, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions application/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
5 changes: 3 additions & 2 deletions application/config.json.template
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,6 @@
<put_a_channel_id_here>
]
}
]
}
],
"helpChannelPattern": "([a-zA-Z_]+_)?help(_\\d+)?"
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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));
Expand All @@ -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());
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* 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<String> 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<TopHelperResult> 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<Long> 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<TopHelperResult> 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<TopHelperResult> topHelpers,
@NotNull Collection<? extends Member> members, @NotNull TimeRange timeRange,
@NotNull Interaction event) {
Map<Long, Member> userIdToMember =
members.stream().collect(Collectors.toMap(Member::getIdLong, Function.identity()));

List<List<String>> 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<String> 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<List<String>> 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<List<String>> dataTable,
@NotNull List<ColumnSetting> columnSettings) {
IntFunction<String> headerToAlignment = i -> columnSettings.get(i).headerName();
IntFunction<HorizontalAlign> indexToAlignment = i -> columnSettings.get(i).alignment();

IntFunction<ColumnData<List<String>>> indexToColumn =
i -> new Column().header(headerToAlignment.apply(i))
.dataAlign(indexToAlignment.apply(i))
.with(row -> row.get(i));

List<ColumnData<List<String>>> 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) {
}
}
Original file line number Diff line number Diff line change
@@ -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());
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ public final class Config {
private final String heavyModerationRolePattern;
private final String softModerationRolePattern;
private final String tagManageRolePattern;

private final List<FreeCommandConfig> freeCommand;
private final String helpChannelPattern;

@SuppressWarnings("ConstructorWithTooManyParameters")
@JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
Expand All @@ -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<FreeCommandConfig> freeCommand) {
@JsonProperty("freeCommand") List<FreeCommandConfig> freeCommand,
@JsonProperty("helpChannelPattern") String helpChannelPattern) {
this.token = token;
this.databasePath = databasePath;
this.projectWebsite = projectWebsite;
Expand All @@ -57,6 +58,7 @@ private Config(@JsonProperty("token") String token,
this.softModerationRolePattern = softModerationRolePattern;
this.tagManageRolePattern = tagManageRolePattern;
this.freeCommand = Collections.unmodifiableList(freeCommand);
this.helpChannelPattern = helpChannelPattern;
}

/**
Expand Down Expand Up @@ -178,4 +180,14 @@ public String getTagManageRolePattern() {
public @NotNull Collection<FreeCommandConfig> 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;
}
}
Loading