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 RestAction handleEvent(@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 Optional getLastHelpThreadIfOnCooldown(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,