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 581296a77d..d6f08a617f 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/Features.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/Features.java @@ -17,8 +17,8 @@ import org.togetherjava.tjbot.commands.moderation.scam.ScamHistoryPurgeRoutine; import org.togetherjava.tjbot.commands.moderation.scam.ScamHistoryStore; import org.togetherjava.tjbot.commands.moderation.temp.TemporaryModerationRoutine; -import org.togetherjava.tjbot.commands.reminder.RemindCommand; import org.togetherjava.tjbot.commands.reminder.RemindRoutine; +import org.togetherjava.tjbot.commands.reminder.ReminderCommand; import org.togetherjava.tjbot.commands.system.BotCore; import org.togetherjava.tjbot.commands.system.LogLevelCommand; import org.togetherjava.tjbot.commands.tags.TagCommand; @@ -120,7 +120,7 @@ public static Collection createFeatures(JDA jda, Database database, Con features.add(new TopHelpersCommand(database)); features.add(new RoleSelectCommand()); features.add(new NoteCommand(actionsStore)); - features.add(new RemindCommand(database)); + features.add(new ReminderCommand(database)); features.add(new QuarantineCommand(actionsStore, config)); features.add(new UnquarantineCommand(actionsStore, config)); features.add(new WhoIsCommand()); diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/AuditCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/AuditCommand.java index 0a173c63ed..d490713b2d 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/AuditCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/AuditCommand.java @@ -21,6 +21,7 @@ import org.togetherjava.tjbot.commands.CommandVisibility; import org.togetherjava.tjbot.commands.SlashCommandAdapter; +import org.togetherjava.tjbot.commands.utils.Pagination; import javax.annotation.Nullable; @@ -106,7 +107,7 @@ private > RestAction auditUser( if (pageNumber == -1) { pageNumberInLimits = totalPages; } else { - pageNumberInLimits = clamp(1, pageNumber, totalPages); + pageNumberInLimits = Pagination.clamp(1, pageNumber, totalPages); } return jda.retrieveUserById(targetId) @@ -130,10 +131,6 @@ private List> groupActionsByPages(List actions) return groupedActions; } - private static int clamp(int minInclusive, int value, int maxInclusive) { - return Math.min(Math.max(minInclusive, value), maxInclusive); - } - private static EmbedBuilder createSummaryEmbed(User user, Collection actions) { return new EmbedBuilder().setTitle("Audit log of **%s**".formatted(user.getAsTag())) .setAuthor(user.getName(), null, user.getAvatarUrl()) diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/reminder/RemindCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/reminder/RemindCommand.java deleted file mode 100644 index 97ab7d02cd..0000000000 --- a/application/src/main/java/org/togetherjava/tjbot/commands/reminder/RemindCommand.java +++ /dev/null @@ -1,154 +0,0 @@ -package org.togetherjava.tjbot.commands.reminder; - -import net.dv8tion.jda.api.entities.Guild; -import net.dv8tion.jda.api.entities.ISnowflake; -import net.dv8tion.jda.api.entities.User; -import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; -import net.dv8tion.jda.api.interactions.callbacks.IReplyCallback; -import net.dv8tion.jda.api.interactions.commands.OptionType; -import net.dv8tion.jda.api.interactions.commands.build.OptionData; - -import org.togetherjava.tjbot.commands.CommandVisibility; -import org.togetherjava.tjbot.commands.SlashCommandAdapter; -import org.togetherjava.tjbot.db.Database; - -import java.time.*; -import java.time.temporal.TemporalAmount; -import java.util.List; - -import static org.togetherjava.tjbot.db.generated.Tables.PENDING_REMINDERS; - -/** - * Implements the '/remind' command which can be used to automatically send reminders to oneself at - * a future date. - *

- * Example usage: - * - *

- * {@code
- * /remind time-amount: 5 time-unit: weeks content: Hello World!
- * }
- * 
- *

- * Pending reminders are processed and send by {@link RemindRoutine}. - */ -public final class RemindCommand extends SlashCommandAdapter { - private static final String COMMAND_NAME = "remind"; - static final String TIME_AMOUNT_OPTION = "time-amount"; - static final String TIME_UNIT_OPTION = "time-unit"; - static final String CONTENT_OPTION = "content"; - - private static final int MIN_TIME_AMOUNT = 1; - private static final int MAX_TIME_AMOUNT = 1_000; - private static final List TIME_UNITS = - List.of("minutes", "hours", "days", "weeks", "months", "years"); - private static final Period MAX_TIME_PERIOD = Period.ofYears(3); - static final int MAX_PENDING_REMINDERS_PER_USER = 100; - - private final Database database; - - /** - * Creates an instance of the command. - * - * @param database to store and fetch the reminders from - */ - public RemindCommand(Database database) { - super(COMMAND_NAME, "Reminds you after a given time period has passed (e.g. in 5 weeks)", - CommandVisibility.GUILD); - - // TODO As soon as JDA offers date/time selector input, this should also offer - // "/remind at" next to "/remind in" and use subcommands then - OptionData timeAmount = new OptionData(OptionType.INTEGER, TIME_AMOUNT_OPTION, - "period to remind you in, the amount of time (e.g. [5] weeks)", true) - .setRequiredRange(MIN_TIME_AMOUNT, MAX_TIME_AMOUNT); - OptionData timeUnit = new OptionData(OptionType.STRING, TIME_UNIT_OPTION, - "period to remind you in, the unit of time (e.g. 5 [weeks])", true); - TIME_UNITS.forEach(unit -> timeUnit.addChoice(unit, unit)); - - getData().addOptions(timeUnit, timeAmount) - .addOption(OptionType.STRING, CONTENT_OPTION, "what to remind you about", true); - - this.database = database; - } - - @Override - public void onSlashCommand(SlashCommandInteractionEvent event) { - int timeAmount = Math.toIntExact(event.getOption(TIME_AMOUNT_OPTION).getAsLong()); - String timeUnit = event.getOption(TIME_UNIT_OPTION).getAsString(); - String content = event.getOption(CONTENT_OPTION).getAsString(); - - Instant remindAt = parseWhen(timeAmount, timeUnit); - User author = event.getUser(); - Guild guild = event.getGuild(); - - if (!handleIsRemindAtWithinLimits(remindAt, event)) { - return; - } - if (!handleIsUserBelowMaxPendingReminders(author, guild, event)) { - return; - } - - event.reply("Will remind you about '%s' in %d %s.".formatted(content, timeAmount, timeUnit)) - .setEphemeral(true) - .queue(); - - database.write(context -> context.newRecord(PENDING_REMINDERS) - .setCreatedAt(Instant.now()) - .setGuildId(guild.getIdLong()) - .setChannelId(event.getChannel().getIdLong()) - .setAuthorId(author.getIdLong()) - .setRemindAt(remindAt) - .setContent(content) - .insert()); - } - - private static Instant parseWhen(int whenAmount, String whenUnit) { - TemporalAmount period = switch (whenUnit) { - case "second", "seconds" -> Duration.ofSeconds(whenAmount); - case "minute", "minutes" -> Duration.ofMinutes(whenAmount); - case "hour", "hours" -> Duration.ofHours(whenAmount); - case "day", "days" -> Period.ofDays(whenAmount); - case "week", "weeks" -> Period.ofWeeks(whenAmount); - case "month", "months" -> Period.ofMonths(whenAmount); - case "year", "years" -> Period.ofYears(whenAmount); - default -> throw new IllegalArgumentException("Unsupported unit, was: " + whenUnit); - }; - - return ZonedDateTime.now(ZoneOffset.UTC).plus(period).toInstant(); - } - - private static boolean handleIsRemindAtWithinLimits(Instant remindAt, IReplyCallback event) { - ZonedDateTime maxWhen = ZonedDateTime.now(ZoneOffset.UTC).plus(MAX_TIME_PERIOD); - - if (remindAt.atZone(ZoneOffset.UTC).isBefore(maxWhen)) { - return true; - } - - event - .reply("The reminder is set too far in the future. The maximal allowed period is '%s'." - .formatted(MAX_TIME_PERIOD)) - .setEphemeral(true) - .queue(); - - return false; - } - - private boolean handleIsUserBelowMaxPendingReminders(ISnowflake author, ISnowflake guild, - IReplyCallback event) { - int pendingReminders = database.read(context -> context.fetchCount(PENDING_REMINDERS, - PENDING_REMINDERS.AUTHOR_ID.equal(author.getIdLong()) - .and(PENDING_REMINDERS.GUILD_ID.equal(guild.getIdLong())))); - - if (pendingReminders < MAX_PENDING_REMINDERS_PER_USER) { - return true; - } - - event.reply( - "You have reached the maximum amount of pending reminders per user (%s). Please wait until some of them have been sent." - .formatted(MAX_PENDING_REMINDERS_PER_USER)) - .setEphemeral(true) - .queue(); - - return false; - } -} diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/reminder/RemindRoutine.java b/application/src/main/java/org/togetherjava/tjbot/commands/reminder/RemindRoutine.java index db09fe8ea4..b5bedd5906 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/reminder/RemindRoutine.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/reminder/RemindRoutine.java @@ -28,11 +28,11 @@ /** * Routine that processes and sends pending reminders. *

- * Reminders can be set by using {@link RemindCommand}. + * Reminders can be set by using {@link ReminderCommand}. */ public final class RemindRoutine implements Routine { static final Logger logger = LoggerFactory.getLogger(RemindRoutine.class); - private static final Color AMBIENT_COLOR = Color.decode("#F7F492"); + static final Color AMBIENT_COLOR = Color.decode("#F7F492"); private static final int SCHEDULE_INTERVAL_SECONDS = 30; private final Database database; diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/reminder/ReminderCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/reminder/ReminderCommand.java new file mode 100644 index 0000000000..a0bb1ee9a3 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/reminder/ReminderCommand.java @@ -0,0 +1,289 @@ +package org.togetherjava.tjbot.commands.reminder; + +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.ISnowflake; +import net.dv8tion.jda.api.entities.MessageEmbed; +import net.dv8tion.jda.api.entities.User; +import net.dv8tion.jda.api.entities.emoji.Emoji; +import net.dv8tion.jda.api.entities.emoji.EmojiUnion; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent; +import net.dv8tion.jda.api.interactions.callbacks.IReplyCallback; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; +import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; +import net.dv8tion.jda.api.interactions.components.buttons.Button; +import net.dv8tion.jda.api.utils.TimeFormat; +import net.dv8tion.jda.api.utils.messages.MessageCreateBuilder; +import net.dv8tion.jda.api.utils.messages.MessageCreateData; +import net.dv8tion.jda.api.utils.messages.MessageEditData; +import org.jooq.Result; + +import org.togetherjava.tjbot.commands.CommandVisibility; +import org.togetherjava.tjbot.commands.SlashCommandAdapter; +import org.togetherjava.tjbot.commands.utils.MessageUtils; +import org.togetherjava.tjbot.commands.utils.Pagination; +import org.togetherjava.tjbot.db.Database; +import org.togetherjava.tjbot.db.generated.tables.records.PendingRemindersRecord; + +import java.time.*; +import java.time.temporal.TemporalAmount; +import java.util.List; + +import static org.togetherjava.tjbot.db.generated.Tables.PENDING_REMINDERS; + +/** + * Implements the '/reminder' command which can be used to automatically send reminders to oneself + * at a future date. + *

+ * Example usage: + * + *

+ * {@code
+ * /reminder create time-amount: 5 time-unit: weeks content: Hello World!
+ * }
+ *
+ * {@code
+ * /reminder list
+ * }
+ * 
+ *

+ * Pending reminders are processed and send by {@link RemindRoutine}. + */ +public final class ReminderCommand extends SlashCommandAdapter { + private static final String COMMAND_NAME = "reminder"; + private static final String LIST_SUBCOMMAND = "list"; + static final String CREATE_SUBCOMMAND = "create"; + static final String TIME_AMOUNT_OPTION = "time-amount"; + static final String TIME_UNIT_OPTION = "time-unit"; + static final String CONTENT_OPTION = "content"; + + private static final int MIN_TIME_AMOUNT = 1; + private static final int MAX_TIME_AMOUNT = 1_000; + private static final List TIME_UNITS = + List.of("minutes", "hours", "days", "weeks", "months", "years"); + private static final Period MAX_TIME_PERIOD = Period.ofYears(3); + private static final int REMINDERS_PER_PAGE = 10; + private static final Emoji PREVIOUS_BUTTON_EMOJI = Emoji.fromUnicode("⬅"); + private static final Emoji NEXT_BUTTON_EMOJI = Emoji.fromUnicode("➡"); + static final int MAX_PENDING_REMINDERS_PER_USER = 100; + + private final Database database; + + /** + * Creates an instance of the command. + * + * @param database to store and fetch the reminders from + */ + public ReminderCommand(Database database) { + super(COMMAND_NAME, "Reminds you after a given time period has passed (e.g. in 5 weeks)", + CommandVisibility.GUILD); + + // TODO As soon as JDA offers date/time selector input, this should also offer + // "/remind at" next to "/remind in" and use subcommands then + OptionData timeAmount = new OptionData(OptionType.INTEGER, TIME_AMOUNT_OPTION, + "period to remind you in, the amount of time (e.g. [5] weeks)", true) + .setRequiredRange(MIN_TIME_AMOUNT, MAX_TIME_AMOUNT); + OptionData timeUnit = new OptionData(OptionType.STRING, TIME_UNIT_OPTION, + "period to remind you in, the unit of time (e.g. 5 [weeks])", true); + TIME_UNITS.forEach(unit -> timeUnit.addChoice(unit, unit)); + OptionData content = + new OptionData(OptionType.STRING, CONTENT_OPTION, "what to remind you about", true); + + getData().addSubcommands( + new SubcommandData(CREATE_SUBCOMMAND, "creates a reminder").addOptions(timeAmount, + timeUnit, content), + new SubcommandData(LIST_SUBCOMMAND, "shows all your currently pending reminders")); + + this.database = database; + } + + @Override + public void onSlashCommand(SlashCommandInteractionEvent event) { + switch (event.getSubcommandName()) { + case CREATE_SUBCOMMAND -> handleCreateCommand(event); + case LIST_SUBCOMMAND -> handleListCommand(event); + default -> throw new AssertionError( + "Unexpected Subcommand: " + event.getSubcommandName()); + } + } + + @Override + public void onButtonClick(ButtonInteractionEvent event, List args) { + int pageToShow = Integer.parseInt(args.get(0)); + + EmojiUnion emoji = event.getButton().getEmoji(); + if (PREVIOUS_BUTTON_EMOJI.equals(emoji)) { + pageToShow--; + } else if (NEXT_BUTTON_EMOJI.equals(emoji)) { + pageToShow++; + } + + Result pendingReminders = + getPendingReminders(event.getGuild(), event.getUser()); + + MessageCreateData message = createPendingRemindersPage(pendingReminders, pageToShow); + event.editMessage(MessageEditData.fromCreateData(message)).queue(); + } + + private void handleCreateCommand(SlashCommandInteractionEvent event) { + int timeAmount = Math.toIntExact(event.getOption(TIME_AMOUNT_OPTION).getAsLong()); + String timeUnit = event.getOption(TIME_UNIT_OPTION).getAsString(); + String content = event.getOption(CONTENT_OPTION).getAsString(); + + Instant remindAt = parseWhen(timeAmount, timeUnit); + User author = event.getUser(); + Guild guild = event.getGuild(); + + if (!handleIsRemindAtWithinLimits(remindAt, event)) { + return; + } + if (!handleIsUserBelowMaxPendingReminders(author, guild, event)) { + return; + } + + event.reply("Will remind you about '%s' in %d %s.".formatted(content, timeAmount, timeUnit)) + .setEphemeral(true) + .queue(); + + database.write(context -> context.newRecord(PENDING_REMINDERS) + .setCreatedAt(Instant.now()) + .setGuildId(guild.getIdLong()) + .setChannelId(event.getChannel().getIdLong()) + .setAuthorId(author.getIdLong()) + .setRemindAt(remindAt) + .setContent(content) + .insert()); + } + + private void handleListCommand(SlashCommandInteractionEvent event) { + Result pendingReminders = + getPendingReminders(event.getGuild(), event.getUser()); + + event.reply(createPendingRemindersPage(pendingReminders, 1)).setEphemeral(true).queue(); + } + + private Result getPendingReminders(Guild guild, User user) { + return database.read(context -> context.selectFrom(PENDING_REMINDERS) + .where(PENDING_REMINDERS.GUILD_ID.eq(guild.getIdLong()) + .and(PENDING_REMINDERS.AUTHOR_ID.eq(user.getIdLong()))) + .orderBy(PENDING_REMINDERS.REMIND_AT.asc()) + .fetch()); + } + + private MessageCreateData createPendingRemindersPage( + List pendingReminders, int pageToShow) { + // 12 reminders, 10 per page, ceil(12 / 10) = 2 + int totalPages = Math.ceilDiv(pendingReminders.size(), REMINDERS_PER_PAGE); + + pageToShow = Pagination.clamp(1, pageToShow, totalPages); + + EmbedBuilder remindersEmbed = new EmbedBuilder().setTitle("Pending reminders") + .setColor(RemindRoutine.AMBIENT_COLOR); + MessageCreateBuilder pendingRemindersPage = new MessageCreateBuilder(); + + if (pendingReminders.isEmpty()) { + remindersEmbed.setDescription("No pending reminders"); + } else { + if (totalPages > 1) { + pendingReminders = getPageEntries(pendingReminders, pageToShow); + remindersEmbed.setFooter("Page: %d/%d".formatted(pageToShow, totalPages)); + pendingRemindersPage.addActionRow(createPageTurnButtons(pageToShow, totalPages)); + } + pendingReminders.forEach(reminder -> addReminderAsField(reminder, remindersEmbed)); + } + + return pendingRemindersPage.addEmbeds(remindersEmbed.build()).build(); + } + + private List