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 2d6af69d74..2b67bc6c85 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,18 +16,21 @@ import org.togetherjava.tjbot.commands.SlashCommandVisibility; import org.togetherjava.tjbot.config.Config; +import static org.togetherjava.tjbot.commands.help.HelpSystemHelper.TITLE_COMPACT_LENGTH_MAX; +import static org.togetherjava.tjbot.commands.help.HelpSystemHelper.TITLE_COMPACT_LENGTH_MIN; + /** * Implements the {@code /ask} command, which is the main way of asking questions. The command can * only be used in the staging channel. - * + *
* Upon use, it will create a new thread for the question and invite all helpers interested in the * given category to it. It will also introduce the user to the system and give a quick explanation * message. - * + *
* The other way to ask questions is by {@link ImplicitAskListener}. - * + *
* Example usage: - * + * *
* {@code * /ask title: How to send emails? category: Frameworks @@ -76,6 +79,10 @@ public void onSlashCommand(@NotNull SlashCommandInteractionEvent event) { return; } + if (!handleIsValidTitle(title, event)) { + return; + } + TextChannel helpStagingChannel = event.getTextChannel(); helpStagingChannel.createThreadChannel("[%s] %s".formatted(category, title)) .flatMap(threadChannel -> handleEvent(event, threadChannel, event.getMember(), title, @@ -96,6 +103,20 @@ private boolean handleIsStagingChannel(@NotNull IReplyCallback event) { return false; } + private boolean handleIsValidTitle(@NotNull CharSequence title, @NotNull IReplyCallback event) { + if (HelpSystemHelper.isTitleValid(title)) { + return true; + } + + event.reply( + "Sorry, but the titel length (after removal of special characters) has to be between %d and %d." + .formatted(TITLE_COMPACT_LENGTH_MIN, TITLE_COMPACT_LENGTH_MAX)) + .setEphemeral(true) + .queue(); + + return false; + } + private @NotNull RestActionhandleEvent(@NotNull IReplyCallback event, @NotNull ThreadChannel threadChannel, @NotNull Member author, @NotNull String title, @NotNull String category) { 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 67739b4ac7..6522473a27 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 @@ -1,5 +1,7 @@ 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.Guild; import net.dv8tion.jda.api.entities.Message; import net.dv8tion.jda.api.entities.Role; @@ -14,7 +16,10 @@ import org.togetherjava.tjbot.commands.SlashCommandVisibility; import org.togetherjava.tjbot.config.Config; +import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.Optional; +import java.util.concurrent.TimeUnit; /** * Implements the {@code /change-help-category} command, which is able to change the category of a @@ -29,7 +34,11 @@ 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 final HelpSystemHelper helper; + private final Cache helpThreadIdToLastCategoryChange; /** * Creates a new instance. @@ -49,6 +58,11 @@ public ChangeHelpCategoryCommand(@NotNull Config config, @NotNull HelpSystemHelp getData().addOptions(category); + helpThreadIdToLastCategoryChange = Caffeine.newBuilder() + .maximumSize(1_000) + .expireAfterAccess(COOLDOWN_DURATION_VALUE, TimeUnit.of(COOLDOWN_DURATION_UNIT)) + .build(); + this.helper = helper; } @@ -66,6 +80,16 @@ public void onSlashCommand(@NotNull SlashCommandInteractionEvent event) { 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)) + .setEphemeral(true) + .queue(); + return; + } + helpThreadIdToLastCategoryChange.put(helpThread.getIdLong(), Instant.now()); + event.deferReply().queue(); helper.renameChannelToCategoryTitle(helpThread, category) @@ -96,4 +120,13 @@ public void onSlashCommand(@NotNull SlashCommandInteractionEvent event) { return action.flatMap(any -> helpThread.sendMessage(headsUpWithoutRole) .flatMap(message -> message.editMessage(headsUpWithRole))); } + + private boolean isHelpThreadOnCooldown(@NotNull ThreadChannel helpThread) { + return Optional + .ofNullable(helpThreadIdToLastCategoryChange.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 00efae8fb2..857015aa83 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 @@ -1,5 +1,7 @@ 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.EmbedBuilder; import net.dv8tion.jda.api.entities.MessageEmbed; import net.dv8tion.jda.api.entities.ThreadChannel; @@ -8,6 +10,11 @@ import org.togetherjava.tjbot.commands.SlashCommandAdapter; import org.togetherjava.tjbot.commands.SlashCommandVisibility; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + /** * Implements the {@code /close} command to close question threads. * @@ -15,7 +22,11 @@ * 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 final HelpSystemHelper helper; + private final Cache
helpThreadIdToLastClose; /** * Creates a new instance. @@ -25,6 +36,11 @@ public final class CloseCommand extends SlashCommandAdapter { public CloseCommand(@NotNull HelpSystemHelper helper) { super("close", "Close this question thread", SlashCommandVisibility.GUILD); + helpThreadIdToLastClose = Caffeine.newBuilder() + .maximumSize(1_000) + .expireAfterAccess(COOLDOWN_DURATION_VALUE, TimeUnit.of(COOLDOWN_DURATION_UNIT)) + .build(); + this.helper = helper; } @@ -40,10 +56,28 @@ public void onSlashCommand(@NotNull SlashCommandInteractionEvent event) { 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)) + .setEphemeral(true) + .queue(); + return; + } + helpThreadIdToLastClose.put(helpThread.getIdLong(), Instant.now()); + MessageEmbed embed = new EmbedBuilder().setDescription("Closed the thread.") .setColor(HelpSystemHelper.AMBIENT_COLOR) .build(); event.replyEmbeds(embed).flatMap(any -> helpThread.getManager().setArchived(true)).queue(); } + + private boolean isHelpThreadOnCooldown(@NotNull ThreadChannel helpThread) { + return Optional.ofNullable(helpThreadIdToLastClose.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/HelpSystemHelper.java b/application/src/main/java/org/togetherjava/tjbot/commands/help/HelpSystemHelper.java index 8b835ed7ec..41fa901cc3 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 @@ -34,6 +34,10 @@ public final class HelpSystemHelper { 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; + static final int TITLE_COMPACT_LENGTH_MAX = 80; + private final Predicate isOverviewChannelName; private final String overviewChannelPattern; private final Predicate isStagingChannelName; @@ -175,4 +179,11 @@ boolean isStagingChannelName(@NotNull String channelName) { String getStagingChannelPattern() { return stagingChannelPattern; } + + static boolean isTitleValid(@NotNull CharSequence title) { + String titleCompact = TITLE_COMPACT_REMOVAL_PATTERN.matcher(title).replaceAll(""); + + return titleCompact.length() >= TITLE_COMPACT_LENGTH_MIN + && titleCompact.length() <= TITLE_COMPACT_LENGTH_MAX; + } } 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 4a63e73581..d0b124b608 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 @@ -27,11 +27,11 @@ * * Listens to plain messages in the staging channel, picks them up and transfers them into a proper * question thread. - * + *
* The system can handle spam appropriately and will not create multiple threads for each message. - * + *
* For example: - * + * *
* {@code * John sends: How to send emails? @@ -128,17 +128,22 @@ private OptionalgetLastHelpThreadIfOnCooldown(long userId) { } private static @NotNull String createTitle(@NotNull String message) { + String titleCandidate; if (message.length() < TITLE_MAX_LENGTH) { - return message; - } - // Attempt to end at the last word before hitting the limit - // e.g. "[foo bar] baz" for a limit somewhere in between "baz" - int lastWordEnd = message.lastIndexOf(' ', TITLE_MAX_LENGTH); - if (lastWordEnd == -1) { - lastWordEnd = TITLE_MAX_LENGTH; + titleCandidate = message; + } else { + // Attempt to end at the last word before hitting the limit + // e.g. "[foo bar] baz" for a limit somewhere in between "baz" + int lastWordEnd = message.lastIndexOf(' ', TITLE_MAX_LENGTH); + if (lastWordEnd == -1) { + lastWordEnd = TITLE_MAX_LENGTH; + } + + titleCandidate = message.substring(0, lastWordEnd); } - return message.substring(0, lastWordEnd); + return HelpSystemHelper.isTitleValid(titleCandidate) ? titleCandidate : "Untitled"; + } private @NotNull RestAction> handleEvent(@NotNull ThreadChannel threadChannel,