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 401171192a..f82b777473 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/Features.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/Features.java @@ -75,6 +75,7 @@ public enum Features { features.add(new RemindRoutine(database)); features.add(new ScamHistoryPurgeRoutine(scamHistoryStore)); features.add(new BotMessageCleanup(config)); + features.add(new HelpThreadActivityUpdater(helpSystemHelper)); // Message receivers features.add(new TopHelpersMessageListener(database, config)); diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/help/AskCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/help/AskCommand.java index 63f0b23951..d29f72807b 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/help/AskCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/help/AskCommand.java @@ -97,7 +97,10 @@ public void onSlashCommand(@NotNull SlashCommandInteractionEvent event) { Guild guild = event.getGuild(); event.deferReply(true).queue(); - overviewChannel.createThreadChannel("[%s] %s".formatted(category, title)) + HelpSystemHelper.HelpThreadName name = new HelpSystemHelper.HelpThreadName( + HelpSystemHelper.ThreadActivity.NEEDS_HELP, category, title); + + overviewChannel.createThreadChannel(name.toChannelName()) .flatMap(threadChannel -> handleEvent(eventHook, threadChannel, author, title, category, guild)) .queue(any -> { diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/help/BotMessageCleanup.java b/application/src/main/java/org/togetherjava/tjbot/commands/help/BotMessageCleanup.java index 5678072773..8584b29430 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/help/BotMessageCleanup.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/help/BotMessageCleanup.java @@ -117,7 +117,7 @@ private static boolean shouldMessageBeCleanedUp(@NotNull Message message) { logger.debug("Found {} messages to delete", messageIdsToDelete.size()); if (messageIdsToDelete.isEmpty()) { - return new CompletedRestAction<>(channel.getJDA(), null, null); + return new CompletedRestAction<>(channel.getJDA(), null); } if (messageIdsToDelete.size() == 1) { diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/help/HelpSystemHelper.java b/application/src/main/java/org/togetherjava/tjbot/commands/help/HelpSystemHelper.java index c6bbd74544..e9c00a7460 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/help/HelpSystemHelper.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/help/HelpSystemHelper.java @@ -5,6 +5,7 @@ import net.dv8tion.jda.api.interactions.callbacks.IReplyCallback; import net.dv8tion.jda.api.requests.RestAction; import net.dv8tion.jda.api.requests.restaction.MessageAction; +import net.dv8tion.jda.internal.requests.CompletedRestAction; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.slf4j.Logger; @@ -19,6 +20,7 @@ import java.util.function.Predicate; import java.util.regex.Matcher; import java.util.regex.Pattern; +import java.util.stream.Stream; /** * Helper class offering certain methods used by the help system. @@ -29,10 +31,13 @@ public final class HelpSystemHelper { static final Color AMBIENT_COLOR = new Color(255, 255, 165); private static final String CODE_SYNTAX_EXAMPLE_PATH = "codeSyntaxExample.png"; + + private static final String ACTIVITY_GROUP = "activity"; private static final String CATEGORY_GROUP = "category"; private static final String TITLE_GROUP = "title"; - private static final Pattern EXTRACT_CATEGORY_TITLE_PATTERN = Pattern - .compile("(?:\\[(?<%s>[^\\[]+)] )?(?<%s>.+)".formatted(CATEGORY_GROUP, TITLE_GROUP)); + private static final Pattern EXTRACT_HELP_NAME_PATTERN = + Pattern.compile("(?:(?<%s>\\W) )?(?:\\[(?<%s>[^\\[]+)] )?(?<%s>.+)" + .formatted(ACTIVITY_GROUP, CATEGORY_GROUP, TITLE_GROUP)); private static final Pattern TITLE_COMPACT_REMOVAL_PATTERN = Pattern.compile("\\W"); static final int TITLE_COMPACT_LENGTH_MIN = 2; @@ -143,17 +148,40 @@ Optional getCategoryOfChannel(@NotNull Channel channel) { RestAction renameChannelToCategory(@NotNull GuildChannel channel, @NotNull String category) { HelpThreadName currentName = HelpThreadName.ofChannelName(channel.getName()); - HelpThreadName changedName = new HelpThreadName(category, currentName.title); + HelpThreadName nextName = + new HelpThreadName(currentName.activity, category, currentName.title); - return channel.getManager().setName(changedName.toChannelName()); + return renameChannel(channel, currentName, nextName); } @NotNull RestAction renameChannelToTitle(@NotNull GuildChannel channel, @NotNull String title) { HelpThreadName currentName = HelpThreadName.ofChannelName(channel.getName()); - HelpThreadName changedName = new HelpThreadName(currentName.category, title); + HelpThreadName nextName = + new HelpThreadName(currentName.activity, currentName.category, title); + + return renameChannel(channel, currentName, nextName); + } + + @NotNull + RestAction renameChannelToActivity(@NotNull GuildChannel channel, + @NotNull ThreadActivity activity) { + HelpThreadName currentName = HelpThreadName.ofChannelName(channel.getName()); + HelpThreadName nextName = + new HelpThreadName(activity, currentName.category, currentName.title); - return channel.getManager().setName(changedName.toChannelName()); + return renameChannel(channel, currentName, nextName); + } + + @NotNull + private RestAction renameChannel(@NotNull GuildChannel channel, + @NotNull HelpThreadName currentName, @NotNull HelpThreadName nextName) { + if (currentName.equals(nextName)) { + // Do not stress rate limits if no actual change is done + return new CompletedRestAction<>(channel.getJDA(), null); + } + + return channel.getManager().setName(nextName.toChannelName()); } boolean isOverviewChannelName(@NotNull String channelName) { @@ -185,7 +213,7 @@ static boolean isTitleValid(@NotNull CharSequence title) { Optional handleRequireOverviewChannelForAsk(@NotNull Guild guild, @NotNull MessageChannel respondTo) { Predicate isChannelName = this::isOverviewChannelName; - String channelPattern = this.getOverviewChannelPattern(); + String channelPattern = getOverviewChannelPattern(); Optional maybeChannel = guild.getTextChannelCache() .stream() @@ -206,20 +234,55 @@ Optional handleRequireOverviewChannelForAsk(@NotNull Guild guild, return maybeChannel; } - private record HelpThreadName(@Nullable String category, @NotNull String title) { + record HelpThreadName(@Nullable ThreadActivity activity, @Nullable String category, + @NotNull String title) { static @NotNull HelpThreadName ofChannelName(@NotNull CharSequence channelName) { - Matcher matcher = EXTRACT_CATEGORY_TITLE_PATTERN.matcher(channelName); + Matcher matcher = EXTRACT_HELP_NAME_PATTERN.matcher(channelName); if (!matcher.matches()) { throw new AssertionError("Pattern must match any thread name"); } - return new HelpThreadName(matcher.group(CATEGORY_GROUP), matcher.group(TITLE_GROUP)); + String activityText = matcher.group(ACTIVITY_GROUP); + + ThreadActivity activity = + activityText == null ? null : ThreadActivity.ofSymbol(activityText); + String category = matcher.group(CATEGORY_GROUP); + String title = matcher.group(TITLE_GROUP); + + return new HelpThreadName(activity, category, title); } @NotNull String toChannelName() { - return category == null ? title : "[%s] %s".formatted(category, title); + String activityText = activity == null ? "" : activity.getSymbol() + " "; + String categoryText = category == null ? "" : "[%s] ".formatted(category); + + return activityText + categoryText + title; + } + } + + enum ThreadActivity { + NEEDS_HELP("🔻"), + LIKELY_NEEDS_HELP("🔸"), + SEEMS_GOOD("🔹"); + + private final String symbol; + + ThreadActivity(@NotNull String symbol) { + this.symbol = symbol; + } + + public @NotNull String getSymbol() { + return symbol; + } + + static @NotNull ThreadActivity ofSymbol(@NotNull String symbol) { + return Stream.of(values()) + .filter(activity -> activity.getSymbol().equals(symbol)) + .findAny() + .orElseThrow(() -> new IllegalArgumentException( + "Unknown thread activity symbol: " + symbol)); } } } diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/help/HelpThreadActivityUpdater.java b/application/src/main/java/org/togetherjava/tjbot/commands/help/HelpThreadActivityUpdater.java new file mode 100644 index 0000000000..3114410ff4 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/help/HelpThreadActivityUpdater.java @@ -0,0 +1,120 @@ +package org.togetherjava.tjbot.commands.help; + +import net.dv8tion.jda.api.JDA; +import net.dv8tion.jda.api.entities.*; +import net.dv8tion.jda.api.requests.RestAction; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.togetherjava.tjbot.commands.Routine; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.TimeUnit; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +/** + * Routine that periodically checks all help threads and updates their activity based on heuristics. + *

+ * The activity indicates to helpers which channels are in most need of help and which likely + * already received attention by helpers. + */ +public final class HelpThreadActivityUpdater implements Routine { + private static final Logger logger = LoggerFactory.getLogger(HelpThreadActivityUpdater.class); + private static final int SCHEDULE_MINUTES = 30; + private static final int ACTIVITY_DETERMINE_MESSAGE_LIMIT = 11; + + private final HelpSystemHelper helper; + + /** + * Creates a new instance. + * + * @param helper the helper to use + */ + public HelpThreadActivityUpdater(@NotNull HelpSystemHelper helper) { + this.helper = helper; + } + + @Override + public @NotNull Schedule createSchedule() { + return new Schedule(ScheduleMode.FIXED_RATE, 1, SCHEDULE_MINUTES, TimeUnit.MINUTES); + } + + @Override + public void runRoutine(@NotNull JDA jda) { + jda.getGuildCache().forEach(this::updateActivityForGuild); + } + + private void updateActivityForGuild(@NotNull Guild guild) { + Optional maybeOverviewChannel = handleRequireOverviewChannel(guild); + + if (maybeOverviewChannel.isEmpty()) { + return; + } + + logger.debug("Updating activities of active questions"); + + List activeThreads = maybeOverviewChannel.orElseThrow() + .getThreadChannels() + .stream() + .filter(Predicate.not(ThreadChannel::isArchived)) + .toList(); + + logger.debug("Found {} active questions", activeThreads.size()); + + activeThreads.forEach(this::updateActivityForThread); + } + + private @NotNull Optional handleRequireOverviewChannel(@NotNull Guild guild) { + Predicate isChannelName = helper::isOverviewChannelName; + String channelPattern = helper.getOverviewChannelPattern(); + + Optional maybeChannel = guild.getTextChannelCache() + .stream() + .filter(channel -> isChannelName.test(channel.getName())) + .findAny(); + + if (maybeChannel.isEmpty()) { + logger.warn( + "Unable to update help thread overview, did not find an overview channel matching the configured pattern '{}' for guild '{}'", + channelPattern, guild.getName()); + return Optional.empty(); + } + + return maybeChannel; + } + + private void updateActivityForThread(@NotNull ThreadChannel threadChannel) { + determineActivity(threadChannel) + .flatMap( + threadActivity -> helper.renameChannelToActivity(threadChannel, threadActivity)) + .queue(); + } + + private static @NotNull RestAction determineActivity( + MessageChannel channel) { + return channel.getHistory().retrievePast(ACTIVITY_DETERMINE_MESSAGE_LIMIT).map(messages -> { + if (messages.size() >= ACTIVITY_DETERMINE_MESSAGE_LIMIT) { + // There are likely even more messages, but we hit the limit + return HelpSystemHelper.ThreadActivity.SEEMS_GOOD; + } + + Map> authorToMessages = messages.stream() + .filter(Predicate.not(HelpThreadActivityUpdater::isBotMessage)) + .collect(Collectors.groupingBy(Message::getAuthor)); + + boolean isThereActivity = authorToMessages.size() >= 2 && authorToMessages.values() + .stream() + .anyMatch(messagesByAuthor -> messagesByAuthor.size() >= 2); + + return isThereActivity ? HelpSystemHelper.ThreadActivity.LIKELY_NEEDS_HELP + : HelpSystemHelper.ThreadActivity.NEEDS_HELP; + }); + } + + private static boolean isBotMessage(@NotNull Message message) { + return message.getAuthor().equals(message.getJDA().getSelfUser()); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/help/HelpThreadOverviewUpdater.java b/application/src/main/java/org/togetherjava/tjbot/commands/help/HelpThreadOverviewUpdater.java index 8e09234b0e..2841c0af4b 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/help/HelpThreadOverviewUpdater.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/help/HelpThreadOverviewUpdater.java @@ -108,8 +108,8 @@ private void updateOverviewForGuild(@NotNull Guild guild) { if (maybeChannel.isEmpty()) { logger.warn( - "Unable to update help thread overview, did not find a {} channel matching the configured pattern '{}' for guild '{}'", - ChannelType.OVERVIEW, channelPattern, guild.getName()); + "Unable to update help thread overview, did not find an overview channel matching the configured pattern '{}' for guild '{}'", + channelPattern, guild.getName()); return Optional.empty(); } @@ -195,7 +195,7 @@ private static boolean isStatusMessage(@NotNull Message message) { logger.info( "Failed to locate the question overview ({} times), trying again next time.", currentFailures); - return new CompletedRestAction<>(overviewChannel.getJDA(), null, null); + return new CompletedRestAction<>(overviewChannel.getJDA(), null); } FIND_STATUS_MESSAGE_CONSECUTIVE_FAILURES.set(0); @@ -203,11 +203,6 @@ private static boolean isStatusMessage(@NotNull Message message) { return overviewChannel.editMessageById(statusMessageId, updatedStatusMessage); } - private enum ChannelType { - OVERVIEW, - STAGING - } - private record CategoryWithThreads(@NotNull String category, @NotNull List threads) { diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/help/ImplicitAskListener.java b/application/src/main/java/org/togetherjava/tjbot/commands/help/ImplicitAskListener.java index 5af0f1ae68..3652ef0a4a 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/help/ImplicitAskListener.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/help/ImplicitAskListener.java @@ -92,7 +92,10 @@ public void onMessageReceived(@NotNull MessageReceivedEvent event) { } TextChannel overviewChannel = maybeOverviewChannel.orElseThrow(); - overviewChannel.createThreadChannel(title) + HelpSystemHelper.HelpThreadName name = new HelpSystemHelper.HelpThreadName( + HelpSystemHelper.ThreadActivity.NEEDS_HELP, null, title); + + overviewChannel.createThreadChannel(name.toChannelName()) .flatMap(threadChannel -> handleEvent(threadChannel, message, title)) .queue(any -> { }, ImplicitAskListener::handleFailure);