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 28c4f1284f..2e1ef3eb26 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/Features.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/Features.java @@ -126,7 +126,7 @@ public static Collection createFeatures(JDA jda, Database database, Con features.add(new UnquarantineCommand(actionsStore, config)); features.add(new WhoIsCommand()); features.add(new WolframAlphaCommand(config)); - features.add(new AskCommand(config, helpSystemHelper)); + features.add(new AskCommand(config, helpSystemHelper, database)); features.add(new ModMailCommand(jda, config)); features.add(new HelpThreadCommand(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 2b0c253e09..ed4e0c518e 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 @@ -1,9 +1,8 @@ package org.togetherjava.tjbot.commands.help; -import net.dv8tion.jda.api.entities.Guild; -import net.dv8tion.jda.api.entities.IMentionable; -import net.dv8tion.jda.api.entities.Member; -import net.dv8tion.jda.api.entities.Message; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import net.dv8tion.jda.api.entities.*; import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; import net.dv8tion.jda.api.entities.channel.concrete.ThreadChannel; import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; @@ -19,12 +18,25 @@ import org.togetherjava.tjbot.commands.CommandVisibility; import org.togetherjava.tjbot.commands.SlashCommandAdapter; +import org.togetherjava.tjbot.commands.utils.MessageUtils; import org.togetherjava.tjbot.config.Config; +import org.togetherjava.tjbot.db.Database; +import org.togetherjava.tjbot.db.generated.tables.records.HelpThreadsRecord; +import javax.annotation.Nullable; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; import java.util.Optional; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; import static org.togetherjava.tjbot.commands.help.HelpSystemHelper.TITLE_COMPACT_LENGTH_MAX; import static org.togetherjava.tjbot.commands.help.HelpSystemHelper.TITLE_COMPACT_LENGTH_MIN; +import static org.togetherjava.tjbot.commands.help.HelpThreadCommand.CHANGE_CATEGORY_SUBCOMMAND; +import static org.togetherjava.tjbot.commands.help.HelpThreadCommand.CHANGE_TITLE_SUBCOMMAND; +import static org.togetherjava.tjbot.db.generated.Tables.HELP_THREADS; /** * Implements the {@code /ask} command, which is the main way of asking questions. The command can @@ -47,19 +59,27 @@ * */ public final class AskCommand extends SlashCommandAdapter { + private static final int COOLDOWN_DURATION_VALUE = 5; + private static final ChronoUnit COOLDOWN_DURATION_UNIT = ChronoUnit.MINUTES; private static final Logger logger = LoggerFactory.getLogger(AskCommand.class); public static final String COMMAND_NAME = "ask"; private static final String TITLE_OPTION = "title"; private static final String CATEGORY_OPTION = "category"; + private final Cache userToLastAsk = Caffeine.newBuilder() + .maximumSize(1_000) + .expireAfterAccess(COOLDOWN_DURATION_VALUE, TimeUnit.of(COOLDOWN_DURATION_UNIT)) + .build(); private final HelpSystemHelper helper; + private final Database database; /** * Creates a new instance. * * @param config the config to use * @param helper the helper to use + * @param database the database to get help threads from */ - public AskCommand(Config config, HelpSystemHelper helper) { + public AskCommand(Config config, HelpSystemHelper helper, Database database) { super("ask", "Ask a question - use this in the staging channel", CommandVisibility.GUILD); OptionData title = @@ -73,10 +93,16 @@ public AskCommand(Config config, HelpSystemHelper helper) { getData().addOptions(title, category); this.helper = helper; + this.database = database; } @Override public void onSlashCommand(SlashCommandInteractionEvent event) { + if (isUserOnCooldown(event.getUser())) { + sendCooldownResponse(event); + return; + } + String title = event.getOption(TITLE_OPTION).getAsString(); String category = event.getOption(CATEGORY_OPTION).getAsString(); @@ -102,8 +128,67 @@ public void onSlashCommand(SlashCommandInteractionEvent event) { overviewChannel.createThreadChannel(name.toChannelName()) .flatMap(threadChannel -> handleEvent(eventHook, threadChannel, author, title, category, guild)) - .queue(any -> { - }, e -> handleFailure(e, eventHook)); + .queue(any -> userToLastAsk.put(event.getUser().getIdLong(), Instant.now()), + e -> handleFailure(e, eventHook)); + } + + private boolean isUserOnCooldown(User user) { + return Optional.ofNullable(userToLastAsk.getIfPresent(user.getIdLong())) + .map(lastAction -> lastAction.plus(COOLDOWN_DURATION_VALUE, COOLDOWN_DURATION_UNIT)) + .filter(Instant.now()::isBefore) + .isPresent(); + } + + private void sendCooldownResponse(SlashCommandInteractionEvent event) { + User user = event.getUser(); + Guild guild = event.getGuild(); + + HelpThreadsRecord lastThreadByAuthor = getLatestHelpThread(user); + + String cooldownDuration = + COOLDOWN_DURATION_VALUE + " " + COOLDOWN_DURATION_UNIT.name().toLowerCase(); + + if (lastThreadByAuthor == null) { + logger.warn("Can't find the last help thread created by the user with id ({})", + user.getId()); + event + .reply("Sorry, something went wrong. Please try again after %s." + .formatted(cooldownDuration)) + .setEphemeral(true) + .queue(); + return; + } + + Function, String> formatMessage = commandMentions -> { + String message = + """ + Sorry, you can only create a single help thread every %s. Please use your existing thread %s instead. + If you made a typo or similar, you can adjust the title using the command %s and the category with %s 👌"""; + + String lastThreadMention = + MessageUtils.mentionChannelById(lastThreadByAuthor.getChannelId()); + + return message.formatted(cooldownDuration, lastThreadMention, commandMentions.get(0), + commandMentions.get(1)); + }; + + RestAction changeTitle = mentionHelpChangeCommand(guild, CHANGE_TITLE_SUBCOMMAND); + RestAction changeCategory = + mentionHelpChangeCommand(guild, CHANGE_CATEGORY_SUBCOMMAND); + + RestAction.allOf(changeCategory, changeTitle) + .map(formatMessage) + .flatMap(text -> event.reply(text).setEphemeral(true)) + .queue(); + } + + @Nullable + private HelpThreadsRecord getLatestHelpThread(User user) { + return database.read(context -> context.selectFrom(HELP_THREADS) + .where(HELP_THREADS.AUTHOR_ID.eq(user.getIdLong())) + .orderBy(HELP_THREADS.CREATED_AT.desc()) + .limit(1) + .fetchOne()); } private boolean handleIsValidTitle(CharSequence title, IReplyCallback event) { @@ -173,4 +258,9 @@ private static void handleFailure(Throwable exception, InteractionHook eventHook logger.error("Attempted to create a help thread, but failed", exception); } + + private static RestAction mentionHelpChangeCommand(Guild guild, String subcommand) { + return MessageUtils.mentionGuildSlashCommand(guild, HelpThreadCommand.COMMAND_NAME, + HelpThreadCommand.CHANGE_SUBCOMMAND_GROUP, subcommand); + } } diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/help/HelpThreadCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/help/HelpThreadCommand.java index b4e52339f6..9d708a90cc 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/help/HelpThreadCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/help/HelpThreadCommand.java @@ -41,10 +41,10 @@ public final class HelpThreadCommand extends SlashCommandAdapter { private static final int COOLDOWN_DURATION_VALUE = 30; private static final ChronoUnit COOLDOWN_DURATION_UNIT = ChronoUnit.MINUTES; - private static final String CHANGE_CATEGORY_SUBCOMMAND = "category"; + public static final String CHANGE_CATEGORY_SUBCOMMAND = "category"; private static final String CHANGE_CATEGORY_OPTION = "category"; + public static final String CHANGE_TITLE_SUBCOMMAND = "title"; private static final String CHANGE_TITLE_OPTION = "title"; - private static final String CHANGE_TITLE_SUBCOMMAND = "title"; public static final String CHANGE_SUBCOMMAND_GROUP = "change"; public static final String COMMAND_NAME = "help-thread"; diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/utils/MessageUtils.java b/application/src/main/java/org/togetherjava/tjbot/commands/utils/MessageUtils.java index d92e7d4e00..68c505e7b3 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/utils/MessageUtils.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/utils/MessageUtils.java @@ -141,4 +141,16 @@ public static String abbreviate(String text, int maxLength) { return text.substring(0, maxLength - ABBREVIATION.length()) + ABBREVIATION; } + /** + * Mentions a guild channel by its id. If the given channelId is unknown, the formatted text + * will say #deleted-channel in Discord. + * + * @param channelId the ID of the channel to mention + * @return the channel as formatted string which Discord interprets as clickable mention + */ + public static String mentionChannelById(long channelId) { + // Clone of JDAs Channel#getAsMention, but unfortunately channel instances can not be + // created out of just an ID, unlike User#fromId + return "<#%d>".formatted(channelId); + } }