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 7a00a2a8da..b371bf6520 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/Features.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/Features.java @@ -10,6 +10,8 @@ import org.togetherjava.tjbot.commands.mathcommands.TeXCommand; import org.togetherjava.tjbot.commands.moderation.*; 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.system.BotCore; import org.togetherjava.tjbot.commands.tags.TagCommand; import org.togetherjava.tjbot.commands.tags.TagManageCommand; @@ -61,6 +63,7 @@ public enum Features { features.add(new ModAuditLogRoutine(database, config)); features.add(new TemporaryModerationRoutine(jda, actionsStore, config)); features.add(new TopHelpersPurgeMessagesRoutine(database)); + features.add(new RemindRoutine(database)); // Message receivers features.add(new TopHelpersMessageListener(database, config)); @@ -86,6 +89,7 @@ public enum Features { features.add(new TopHelpersCommand(database, config)); features.add(new RoleSelectCommand()); features.add(new NoteCommand(actionsStore, config)); + features.add(new RemindCommand(database)); // Mixtures features.add(new FreeCommand(config)); 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 366aca3aa1..9a8c67e2d3 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 @@ -92,6 +92,7 @@ public AuditCommand(@NotNull ModerationActionsStore actionsStore, @NotNull Confi .getDateTimeString(action.actionExpiresAt().atOffset(ZoneOffset.UTC))); return jda.retrieveUserById(action.authorId()) + .onErrorMap(error -> null) .map(author -> new EmbedBuilder().setTitle(action.actionType().name()) .setAuthor(author == null ? "(unknown user)" : author.getAsTag(), null, author == null ? null : author.getAvatarUrl()) diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/ModerationUtils.java b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/ModerationUtils.java index 04d8ae5fd8..ec4c9bfb3c 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/ModerationUtils.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/ModerationUtils.java @@ -369,8 +369,7 @@ public static Predicate getIsMutedRolePredicate(@NotNull Config config) case "minute", "minutes" -> ChronoUnit.MINUTES; case "hour", "hours" -> ChronoUnit.HOURS; case "day", "days" -> ChronoUnit.DAYS; - default -> throw new IllegalArgumentException( - "Unsupported mute duration: " + durationText); + default -> throw new IllegalArgumentException("Unsupported duration: " + durationText); }; return Optional.of(new TemporaryData(Instant.now().plus(duration, unit), durationText)); 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 new file mode 100644 index 0000000000..72396dfd37 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/reminder/RemindCommand.java @@ -0,0 +1,152 @@ +package org.togetherjava.tjbot.commands.reminder; + +import net.dv8tion.jda.api.entities.ISnowflake; +import net.dv8tion.jda.api.entities.User; +import net.dv8tion.jda.api.events.interaction.SlashCommandEvent; +import net.dv8tion.jda.api.interactions.Interaction; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; +import org.jetbrains.annotations.NotNull; +import org.togetherjava.tjbot.commands.SlashCommandAdapter; +import org.togetherjava.tjbot.commands.SlashCommandVisibility; +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 amount: 5 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"; + private static final String TIME_AMOUNT_OPTION = "time-amount"; + private static final String TIME_UNIT_OPTION = "time-unit"; + private 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 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(@NotNull Database database) { + super(COMMAND_NAME, "Reminds you after a given time period has passed (e.g. in 5 weeks)", + SlashCommandVisibility.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(@NotNull SlashCommandEvent 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(); + + if (!handleIsRemindAtWithinLimits(remindAt, event)) { + return; + } + if (!handleIsUserBelowMaxPendingReminders(author, 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(event.getGuild().getIdLong()) + .setChannelId(event.getChannel().getIdLong()) + .setAuthorId(author.getIdLong()) + .setRemindAt(remindAt) + .setContent(content) + .insert()); + } + + private static @NotNull Instant parseWhen(int whenAmount, @NotNull 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(@NotNull Instant remindAt, + @NotNull Interaction 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(@NotNull ISnowflake author, + @NotNull Interaction event) { + int pendingReminders = database.read(context -> context.fetchCount(PENDING_REMINDERS, + PENDING_REMINDERS.AUTHOR_ID.equal(author.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 new file mode 100644 index 0000000000..4e0e167b42 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/reminder/RemindRoutine.java @@ -0,0 +1,142 @@ +package org.togetherjava.tjbot.commands.reminder; + +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.JDA; +import net.dv8tion.jda.api.entities.*; +import net.dv8tion.jda.api.requests.RestAction; +import net.dv8tion.jda.api.requests.restaction.MessageAction; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.togetherjava.tjbot.commands.Routine; +import org.togetherjava.tjbot.db.Database; + +import java.awt.*; +import java.time.Instant; +import java.time.temporal.TemporalAccessor; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import java.util.function.Function; + +import static org.togetherjava.tjbot.db.generated.Tables.PENDING_REMINDERS; + +/** + * Routine that processes and sends pending reminders. + *

+ * Reminders can be set by using {@link RemindCommand}. + */ +public final class RemindRoutine implements Routine { + private static final Logger logger = LoggerFactory.getLogger(RemindRoutine.class); + private static final Color AMBIENT_COLOR = Color.decode("#F7F492"); + private static final int SCHEDULE_INTERVAL_SECONDS = 30; + private final Database database; + + /** + * Creates a new instance. + * + * @param database the database that contains the pending reminders to send. + */ + public RemindRoutine(@NotNull Database database) { + this.database = database; + } + + @Override + public @NotNull Schedule createSchedule() { + return new Schedule(ScheduleMode.FIXED_RATE, 0, SCHEDULE_INTERVAL_SECONDS, + TimeUnit.SECONDS); + } + + @Override + public void runRoutine(@NotNull JDA jda) { + Instant now = Instant.now(); + database.write(context -> context.selectFrom(PENDING_REMINDERS) + .where(PENDING_REMINDERS.REMIND_AT.lessOrEqual(now)) + .stream() + .forEach(pendingReminder -> { + sendReminder(jda, pendingReminder.getId(), pendingReminder.getChannelId(), + pendingReminder.getAuthorId(), pendingReminder.getContent(), + pendingReminder.getCreatedAt()); + + pendingReminder.delete(); + })); + } + + private static void sendReminder(@NotNull JDA jda, long id, long channelId, long authorId, + @NotNull CharSequence content, @NotNull TemporalAccessor createdAt) { + RestAction route = computeReminderRoute(jda, channelId, authorId); + sendReminderViaRoute(route, id, content, createdAt); + } + + private static RestAction computeReminderRoute(@NotNull JDA jda, long channelId, + long authorId) { + // If guild channel can still be found, send there + TextChannel channel = jda.getTextChannelById(channelId); + if (channel != null) { + return createGuildReminderRoute(jda, authorId, channel); + } + + // Otherwise, attempt to DM the user directly + return createDmReminderRoute(jda, authorId); + } + + private static @NotNull RestAction createGuildReminderRoute(@NotNull JDA jda, + long authorId, @NotNull TextChannel channel) { + return jda.retrieveUserById(authorId) + .onErrorMap(error -> null) + .map(author -> ReminderRoute.toPublic(channel, author)); + } + + private static @NotNull RestAction createDmReminderRoute(@NotNull JDA jda, + long authorId) { + return jda.openPrivateChannelById(authorId).map(ReminderRoute::toPrivate); + } + + private static void sendReminderViaRoute(@NotNull RestAction routeAction, + long id, @NotNull CharSequence content, @NotNull TemporalAccessor createdAt) { + Function sendMessage = route -> route.channel + .sendMessageEmbeds(createReminderEmbed(content, createdAt, route.target())) + .content(route.description()); + + Consumer logFailure = failure -> logger.warn( + """ + Failed to send a reminder (id '{}'), skipping it. This can be due to a network issue, \ + but also happen if the bot disconnected from the target guild and the \ + user has disabled DMs or has been deleted.""", + id); + + routeAction.flatMap(sendMessage).queue(doNothing(), logFailure); + } + + private static @NotNull MessageEmbed createReminderEmbed(@NotNull CharSequence content, + @NotNull TemporalAccessor createdAt, @Nullable User author) { + String authorName = author == null ? "Unknown user" : author.getAsTag(); + String authorIconUrl = author == null ? null : author.getAvatarUrl(); + + return new EmbedBuilder().setAuthor(authorName, null, authorIconUrl) + .setDescription(content) + .setFooter("reminder from") + .setTimestamp(createdAt) + .setColor(AMBIENT_COLOR) + .build(); + } + + private static @NotNull Consumer doNothing() { + return a -> { + }; + } + + private record ReminderRoute(@NotNull MessageChannel channel, @Nullable User target, + @Nullable String description) { + static ReminderRoute toPublic(@NotNull TextChannel channel, @Nullable User target) { + return new ReminderRoute(channel, target, + target == null ? null : target.getAsMention()); + } + + static ReminderRoute toPrivate(@NotNull PrivateChannel channel) { + return new ReminderRoute(channel, channel.getUser(), + "(Sending your reminder directly, because I was unable to locate" + + " the original channel you wanted it to be send to)"); + } + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/reminder/package-info.java b/application/src/main/java/org/togetherjava/tjbot/commands/reminder/package-info.java new file mode 100644 index 0000000000..a7f65b1c69 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/reminder/package-info.java @@ -0,0 +1,5 @@ +/** + * This packages offers all the functionality for the remind-command. The core class is + * {@link org.togetherjava.tjbot.commands.reminder.RemindCommand}. + */ +package org.togetherjava.tjbot.commands.reminder; diff --git a/application/src/main/java/org/togetherjava/tjbot/routines/ModAuditLogRoutine.java b/application/src/main/java/org/togetherjava/tjbot/routines/ModAuditLogRoutine.java index d6f23d1bbd..2e9cb9f95f 100644 --- a/application/src/main/java/org/togetherjava/tjbot/routines/ModAuditLogRoutine.java +++ b/application/src/main/java/org/togetherjava/tjbot/routines/ModAuditLogRoutine.java @@ -95,6 +95,7 @@ private static RestAction getTargetTagFromEntry(@NotNull AuditLogEntry e // If the target is null, the user got deleted in the meantime return entry.getJDA() .retrieveUserById(entry.getTargetIdLong()) + .onErrorMap(error -> null) .map(target -> target == null ? "(user unknown)" : target.getAsTag()); } diff --git a/application/src/main/resources/db/V8__Add_Pending_Reminders.sql b/application/src/main/resources/db/V8__Add_Pending_Reminders.sql new file mode 100644 index 0000000000..af2512ad40 --- /dev/null +++ b/application/src/main/resources/db/V8__Add_Pending_Reminders.sql @@ -0,0 +1,10 @@ +CREATE TABLE pending_reminders +( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + created_at TIMESTAMP NOT NULL, + guild_id BIGINT NOT NULL, + channel_id BIGINT NOT NULL, + author_id BIGINT NOT NULL, + remind_at TIMESTAMP NOT NULL, + content TEXT NOT NULL +) \ No newline at end of file