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 08a63930e4..401171192a 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/Features.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/Features.java @@ -111,6 +111,7 @@ public enum Features { features.add(new AskCommand(config, helpSystemHelper)); features.add(new CloseCommand(helpSystemHelper)); features.add(new ChangeHelpCategoryCommand(config, helpSystemHelper)); + features.add(new ChangeHelpTitleCommand(helpSystemHelper)); // Mixtures features.add(new HelpThreadOverviewUpdater(config, helpSystemHelper)); 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 95b69fbcae..3d9f72270b 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 @@ -16,6 +16,8 @@ import org.togetherjava.tjbot.commands.SlashCommandVisibility; import org.togetherjava.tjbot.config.Config; +import java.util.Optional; + import static org.togetherjava.tjbot.commands.help.HelpSystemHelper.TITLE_COMPACT_LENGTH_MAX; import static org.togetherjava.tjbot.commands.help.HelpSystemHelper.TITLE_COMPACT_LENGTH_MIN; @@ -83,8 +85,14 @@ public void onSlashCommand(@NotNull SlashCommandInteractionEvent event) { return; } - TextChannel helpStagingChannel = event.getTextChannel(); - helpStagingChannel.createThreadChannel("[%s] %s".formatted(category, title)) + Optional maybeOverviewChannel = + helper.handleRequireOverviewChannelForAsk(event.getGuild(), event.getChannel()); + if (maybeOverviewChannel.isEmpty()) { + return; + } + TextChannel overviewChannel = maybeOverviewChannel.orElseThrow(); + + overviewChannel.createThreadChannel("[%s] %s".formatted(category, title)) .flatMap(threadChannel -> handleEvent(event, threadChannel, event.getMember(), title, category)) .queue(any -> { diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/help/ChangeHelpCategoryCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/help/ChangeHelpCategoryCommand.java index 6522473a27..576890ecb3 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/help/ChangeHelpCategoryCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/help/ChangeHelpCategoryCommand.java @@ -18,6 +18,7 @@ import java.time.Instant; import java.time.temporal.ChronoUnit; +import java.util.Locale; import java.util.Optional; import java.util.concurrent.TimeUnit; @@ -34,8 +35,8 @@ public final class ChangeHelpCategoryCommand extends SlashCommandAdapter { private static final String CATEGORY_OPTION = "category"; - private static final int COOLDOWN_DURATION_VALUE = 1; - private static final ChronoUnit COOLDOWN_DURATION_UNIT = ChronoUnit.HOURS; + private static final int COOLDOWN_DURATION_VALUE = 30; + private static final ChronoUnit COOLDOWN_DURATION_UNIT = ChronoUnit.MINUTES; private final HelpSystemHelper helper; private final Cache helpThreadIdToLastCategoryChange; @@ -82,8 +83,9 @@ public void onSlashCommand(@NotNull SlashCommandInteractionEvent event) { if (isHelpThreadOnCooldown(helpThread)) { event - .reply("Please wait a bit, this command can only be used once per %d %s." - .formatted(COOLDOWN_DURATION_VALUE, COOLDOWN_DURATION_UNIT)) + .reply("Please wait a bit, this command can only be used once per %d %s.".formatted( + COOLDOWN_DURATION_VALUE, + COOLDOWN_DURATION_UNIT.toString().toLowerCase(Locale.US))) .setEphemeral(true) .queue(); return; @@ -92,7 +94,7 @@ public void onSlashCommand(@NotNull SlashCommandInteractionEvent event) { event.deferReply().queue(); - helper.renameChannelToCategoryTitle(helpThread, category) + helper.renameChannelToCategory(helpThread, category) .flatMap(any -> sendCategoryChangedMessage(helpThread.getGuild(), event.getHook(), helpThread, category)) .queue(); diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/help/ChangeHelpTitleCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/help/ChangeHelpTitleCommand.java new file mode 100644 index 0000000000..a94471c103 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/help/ChangeHelpTitleCommand.java @@ -0,0 +1,91 @@ +package org.togetherjava.tjbot.commands.help; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import net.dv8tion.jda.api.entities.ThreadChannel; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import org.jetbrains.annotations.NotNull; +import org.togetherjava.tjbot.commands.SlashCommandAdapter; +import org.togetherjava.tjbot.commands.SlashCommandVisibility; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Locale; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +/** + * Implements the {@code /change-help-title} command, which is able to change the title of a help + * thread. + *

+ * This is to adjust a bad title in hindsight, for example if it was automatically created by + * {@link ImplicitAskListener}. + */ +public final class ChangeHelpTitleCommand extends SlashCommandAdapter { + private static final String TITLE_OPTION = "title"; + + private static final int COOLDOWN_DURATION_VALUE = 30; + private static final ChronoUnit COOLDOWN_DURATION_UNIT = ChronoUnit.MINUTES; + + private final HelpSystemHelper helper; + private final Cache helpThreadIdToLastTitleChange; + + /** + * Creates a new instance. + * + * @param helper the helper to use + */ + public ChangeHelpTitleCommand(@NotNull HelpSystemHelper helper) { + super("change-help-title", "changes the title of a help thread", + SlashCommandVisibility.GUILD); + + getData().addOption(OptionType.STRING, TITLE_OPTION, "short and to the point", true); + + helpThreadIdToLastTitleChange = Caffeine.newBuilder() + .maximumSize(1_000) + .expireAfterAccess(COOLDOWN_DURATION_VALUE, TimeUnit.of(COOLDOWN_DURATION_UNIT)) + .build(); + + this.helper = helper; + } + + @Override + public void onSlashCommand(@NotNull SlashCommandInteractionEvent event) { + String title = event.getOption(TITLE_OPTION).getAsString(); + + if (!helper.handleIsHelpThread(event)) { + return; + } + + ThreadChannel helpThread = event.getThreadChannel(); + if (helpThread.isArchived()) { + event.reply("This thread is already closed.").setEphemeral(true).queue(); + return; + } + + if (isHelpThreadOnCooldown(helpThread)) { + event + .reply("Please wait a bit, this command can only be used once per %d %s.".formatted( + COOLDOWN_DURATION_VALUE, + COOLDOWN_DURATION_UNIT.toString().toLowerCase(Locale.US))) + .setEphemeral(true) + .queue(); + return; + } + helpThreadIdToLastTitleChange.put(helpThread.getIdLong(), Instant.now()); + + helper.renameChannelToTitle(helpThread, title) + .flatMap(any -> event.reply("Changed the title to **%s**.".formatted(title))) + .queue(); + } + + private boolean isHelpThreadOnCooldown(@NotNull ThreadChannel helpThread) { + return Optional + .ofNullable(helpThreadIdToLastTitleChange.getIfPresent(helpThread.getIdLong())) + .map(lastCategoryChange -> lastCategoryChange.plus(COOLDOWN_DURATION_VALUE, + COOLDOWN_DURATION_UNIT)) + .filter(Instant.now()::isBefore) + .isPresent(); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/help/CloseCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/help/CloseCommand.java index 857015aa83..14106631ee 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/help/CloseCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/help/CloseCommand.java @@ -12,6 +12,7 @@ import java.time.Instant; import java.time.temporal.ChronoUnit; +import java.util.Locale; import java.util.Optional; import java.util.concurrent.TimeUnit; @@ -22,8 +23,8 @@ * use. Meant to be used once a question has been resolved. */ public final class CloseCommand extends SlashCommandAdapter { - private static final int COOLDOWN_DURATION_VALUE = 1; - private static final ChronoUnit COOLDOWN_DURATION_UNIT = ChronoUnit.HOURS; + private static final int COOLDOWN_DURATION_VALUE = 30; + private static final ChronoUnit COOLDOWN_DURATION_UNIT = ChronoUnit.MINUTES; private final HelpSystemHelper helper; private final Cache helpThreadIdToLastClose; @@ -58,8 +59,9 @@ public void onSlashCommand(@NotNull SlashCommandInteractionEvent event) { if (isHelpThreadOnCooldown(helpThread)) { event - .reply("Please wait a bit, this command can only be used once per %d %s." - .formatted(COOLDOWN_DURATION_VALUE, COOLDOWN_DURATION_UNIT)) + .reply("Please wait a bit, this command can only be used once per %d %s.".formatted( + COOLDOWN_DURATION_VALUE, + COOLDOWN_DURATION_UNIT.toString().toLowerCase(Locale.US))) .setEphemeral(true) .queue(); return; 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 d30c8c8000..b7f5e77f1a 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 @@ -31,8 +31,8 @@ public final class HelpSystemHelper { private static final String CODE_SYNTAX_EXAMPLE_PATH = "codeSyntaxExample.png"; 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_CATEGORY_TITLE_PATTERN = Pattern + .compile("(?:\\[(?<%s>[^\\[]+)] )?(?<%s>.+)".formatted(CATEGORY_GROUP, TITLE_GROUP)); private static final Pattern TITLE_COMPACT_REMOVAL_PATTERN = Pattern.compile("\\W"); static final int TITLE_COMPACT_LENGTH_MIN = 2; @@ -110,7 +110,7 @@ boolean handleIsHelpThread(@NotNull IReplyCallback event) { if (event.getChannelType() == ChannelType.GUILD_PUBLIC_THREAD) { ThreadChannel thread = event.getThreadChannel(); - if (isStagingChannelName.test(thread.getParentChannel().getName())) { + if (isOverviewChannelName.test(thread.getParentChannel().getName())) { return true; } } @@ -136,30 +136,24 @@ Optional handleFindRoleForCategory(@NotNull String category, @NotNull Guil @NotNull Optional getCategoryOfChannel(@NotNull Channel channel) { - Matcher matcher = EXTRACT_CATEGORY_TITLE_PATTERN.matcher(channel.getName()); - if (!matcher.find()) { - return Optional.empty(); - } - - return Optional.ofNullable(matcher.group(CATEGORY_GROUP)); + return Optional.ofNullable(HelpThreadName.ofChannelName(channel.getName()).category); } @NotNull - RestAction renameChannelToCategoryTitle(@NotNull GuildChannel channel, + RestAction renameChannelToCategory(@NotNull GuildChannel channel, @NotNull String category) { - String currentTitle = channel.getName(); - Matcher matcher = EXTRACT_CATEGORY_TITLE_PATTERN.matcher(currentTitle); + HelpThreadName currentName = HelpThreadName.ofChannelName(channel.getName()); + HelpThreadName changedName = new HelpThreadName(category, currentName.title); - if (!matcher.matches()) { - throw new AssertionError("Pattern must match any thread name"); - } - boolean hasCategoryInTitle = matcher.group(CATEGORY_GROUP) != null; - String titleWithoutCategory = - hasCategoryInTitle ? matcher.group(TITLE_GROUP) : currentTitle; + return channel.getManager().setName(changedName.toChannelName()); + } - String titleWithCategory = "[%s] %s".formatted(category, titleWithoutCategory); + @NotNull + RestAction renameChannelToTitle(@NotNull GuildChannel channel, @NotNull String title) { + HelpThreadName currentName = HelpThreadName.ofChannelName(channel.getName()); + HelpThreadName changedName = new HelpThreadName(currentName.category, title); - return channel.getManager().setName(titleWithCategory); + return channel.getManager().setName(changedName.toChannelName()); } boolean isOverviewChannelName(@NotNull String channelName) { @@ -186,4 +180,46 @@ static boolean isTitleValid(@NotNull CharSequence title) { return titleCompact.length() >= TITLE_COMPACT_LENGTH_MIN && titleCompact.length() <= TITLE_COMPACT_LENGTH_MAX; } + + @NotNull + Optional handleRequireOverviewChannelForAsk(@NotNull Guild guild, + @NotNull MessageChannel respondTo) { + Predicate isChannelName = this::isOverviewChannelName; + String channelPattern = this.getOverviewChannelPattern(); + + Optional maybeChannel = guild.getTextChannelCache() + .stream() + .filter(channel -> isChannelName.test(channel.getName())) + .findAny(); + + if (maybeChannel.isEmpty()) { + logger.warn( + "Attempted to create a help thread, did not find the overview channel matching the configured pattern '{}' for guild '{}'", + channelPattern, guild.getName()); + + respondTo.sendMessage( + "Sorry, I was unable to locate the overview channel. The server seems wrongly configured, please contact a moderator.") + .queue(); + return Optional.empty(); + } + + return maybeChannel; + } + + private record HelpThreadName(@Nullable String category, @NotNull String title) { + static @NotNull HelpThreadName ofChannelName(@NotNull CharSequence channelName) { + Matcher matcher = EXTRACT_CATEGORY_TITLE_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)); + } + + @NotNull + String toChannelName() { + return category == null ? title : "[%s] %s".formatted(category, title); + } + } } 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 3ffe0b6396..0a79750c1a 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 @@ -1,7 +1,7 @@ package org.togetherjava.tjbot.commands.help; -import net.dv8tion.jda.api.EmbedBuilder; import net.dv8tion.jda.api.JDA; +import net.dv8tion.jda.api.MessageBuilder; import net.dv8tion.jda.api.entities.*; import net.dv8tion.jda.api.events.message.MessageReceivedEvent; import net.dv8tion.jda.api.requests.RestAction; @@ -31,7 +31,7 @@ public final class HelpThreadOverviewUpdater extends MessageReceiverAdapter implements Routine { private static final Logger logger = LoggerFactory.getLogger(HelpThreadOverviewUpdater.class); - private static final String STATUS_TITLE = "Active questions"; + private static final String STATUS_TITLE = "## __**Active questions**__ ##"; private static final int OVERVIEW_QUESTION_LIMIT = 150; private final HelpSystemHelper helper; @@ -47,7 +47,7 @@ public final class HelpThreadOverviewUpdater extends MessageReceiverAdapter impl * @param helper the helper to use */ public HelpThreadOverviewUpdater(@NotNull Config config, @NotNull HelpSystemHelper helper) { - super(Pattern.compile(config.getHelpSystem().getStagingChannelPattern())); + super(Pattern.compile(config.getHelpSystem().getOverviewChannelPattern())); allCategories = config.getHelpSystem().getCategories(); this.helper = helper; @@ -82,28 +82,18 @@ public void onMessageReceived(@NotNull MessageReceivedEvent event) { } private void updateOverviewForGuild(@NotNull Guild guild) { - Optional maybeStagingChannel = - handleRequireChannel(ChannelType.STAGING, guild); - Optional maybeOverviewChannel = - handleRequireChannel(ChannelType.OVERVIEW, guild); + Optional maybeOverviewChannel = handleRequireOverviewChannel(guild); - if (maybeStagingChannel.isEmpty() || maybeOverviewChannel.isEmpty()) { + if (maybeOverviewChannel.isEmpty()) { return; } - updateOverview(maybeStagingChannel.orElseThrow(), maybeOverviewChannel.orElseThrow()); + updateOverview(maybeOverviewChannel.orElseThrow()); } - private @NotNull Optional handleRequireChannel(@NotNull ChannelType channelType, - @NotNull Guild guild) { - Predicate isChannelName = switch (channelType) { - case OVERVIEW -> helper::isOverviewChannelName; - case STAGING -> helper::isStagingChannelName; - }; - String channelPattern = switch (channelType) { - case OVERVIEW -> helper.getOverviewChannelPattern(); - case STAGING -> helper.getStagingChannelPattern(); - }; + private @NotNull Optional handleRequireOverviewChannel(@NotNull Guild guild) { + Predicate isChannelName = helper::isOverviewChannelName; + String channelPattern = helper.getOverviewChannelPattern(); Optional maybeChannel = guild.getTextChannelCache() .stream() @@ -113,37 +103,35 @@ 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, channelPattern, guild.getName()); + ChannelType.OVERVIEW, channelPattern, guild.getName()); return Optional.empty(); } return maybeChannel; } - private void updateOverview(@NotNull IThreadContainer stagingChannel, - @NotNull MessageChannel overviewChannel) { + private void updateOverview(@NotNull TextChannel overviewChannel) { logger.debug("Updating overview of active questions"); - List activeThreads = stagingChannel.getThreadChannels() + List activeThreads = overviewChannel.getThreadChannels() .stream() .filter(Predicate.not(ThreadChannel::isArchived)) .toList(); logger.debug("Found {} active questions", activeThreads.size()); - MessageEmbed embed = new EmbedBuilder().setTitle(STATUS_TITLE) - .setDescription(createDescription(activeThreads)) - .setColor(HelpSystemHelper.AMBIENT_COLOR) + Message message = new MessageBuilder() + .setContent(STATUS_TITLE + "\n\n" + createDescription(activeThreads)) .build(); getStatusMessage(overviewChannel).flatMap(maybeStatusMessage -> { logger.debug("Sending the updated question overview"); if (maybeStatusMessage.isEmpty()) { - return overviewChannel.sendMessageEmbeds(embed); + return overviewChannel.sendMessage(message); } String statusMessageId = maybeStatusMessage.orElseThrow().getId(); - return overviewChannel.editMessageEmbedsById(statusMessageId, embed); + return overviewChannel.editMessageById(statusMessageId, message); }).queue(); } @@ -186,13 +174,8 @@ private static boolean isStatusMessage(@NotNull Message message) { return false; } - List embeds = message.getEmbeds(); - if (embeds.isEmpty()) { - return false; - } - - MessageEmbed embed = embeds.get(0); - return STATUS_TITLE.equals(embed.getTitle()); + String content = message.getContentRaw(); + return content.startsWith(STATUS_TITLE); } private enum ChannelType { 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 d0b124b608..5af0f1ae68 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 @@ -44,7 +44,7 @@ public final class ImplicitAskListener extends MessageReceiverAdapter { private static final Logger logger = LoggerFactory.getLogger(ImplicitAskListener.class); - private static final int TITLE_MAX_LENGTH = 30; + private static final int TITLE_MAX_LENGTH = 50; private static final int COOLDOWN_DURATION_VALUE = 15; private static final ChronoUnit COOLDOWN_DURATION_UNIT = ChronoUnit.SECONDS; @@ -85,8 +85,14 @@ public void onMessageReceived(@NotNull MessageReceivedEvent event) { String title = createTitle(message.getContentDisplay()); - TextChannel helpStagingChannel = event.getTextChannel(); - helpStagingChannel.createThreadChannel(title) + Optional maybeOverviewChannel = + helper.handleRequireOverviewChannelForAsk(event.getGuild(), event.getChannel()); + if (maybeOverviewChannel.isEmpty()) { + return; + } + TextChannel overviewChannel = maybeOverviewChannel.orElseThrow(); + + overviewChannel.createThreadChannel(title) .flatMap(threadChannel -> handleEvent(threadChannel, message, title)) .queue(any -> { }, ImplicitAskListener::handleFailure); @@ -143,7 +149,6 @@ private Optional getLastHelpThreadIfOnCooldown(long userId) { } return HelpSystemHelper.isTitleValid(titleCandidate) ? titleCandidate : "Untitled"; - } private @NotNull RestAction handleEvent(@NotNull ThreadChannel threadChannel, 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 index db10bf7527..5d43a1d68e 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/tophelper/TopHelpersMessageListener.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/tophelper/TopHelpersMessageListener.java @@ -21,6 +21,7 @@ public final class TopHelpersMessageListener extends MessageReceiverAdapter { private final Database database; private final Predicate isStagingChannelName; + private final Predicate isOverviewChannelName; /** * Creates a new listener to receive all message sent in help channels. @@ -35,6 +36,8 @@ public TopHelpersMessageListener(@NotNull Database database, @NotNull Config con isStagingChannelName = Pattern.compile(config.getHelpSystem().getStagingChannelPattern()) .asMatchPredicate(); + isOverviewChannelName = Pattern.compile(config.getHelpSystem().getOverviewChannelPattern()) + .asMatchPredicate(); } @Override @@ -56,7 +59,9 @@ private boolean isHelpThread(@NotNull MessageReceivedEvent event) { } ThreadChannel thread = event.getThreadChannel(); - return isStagingChannelName.test(thread.getParentChannel().getName()); + String rootChannelName = thread.getParentChannel().getName(); + return isStagingChannelName.test(rootChannelName) + || isOverviewChannelName.test(rootChannelName); } private void addMessageRecord(@NotNull MessageReceivedEvent event) {