Skip to content

Adding /remind command #373

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 16 commits into from
Feb 23, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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));
Expand All @@ -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));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -369,8 +369,7 @@ public static Predicate<String> 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));
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* Example usage:
*
* <pre>
* {@code
* /remind amount: 5 unit: weeks content: Hello World!
* }
* </pre>
* <p>
* 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<String> 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;
}
}
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* 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<ReminderRoute> route = computeReminderRoute(jda, channelId, authorId);
sendReminderViaRoute(route, id, content, createdAt);
}

private static RestAction<ReminderRoute> 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<ReminderRoute> 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<ReminderRoute> createDmReminderRoute(@NotNull JDA jda,
long authorId) {
return jda.openPrivateChannelById(authorId).map(ReminderRoute::toPrivate);
}

private static void sendReminderViaRoute(@NotNull RestAction<ReminderRoute> routeAction,
long id, @NotNull CharSequence content, @NotNull TemporalAccessor createdAt) {
Function<ReminderRoute, MessageAction> sendMessage = route -> route.channel
.sendMessageEmbeds(createReminderEmbed(content, createdAt, route.target()))
.content(route.description());

Consumer<Throwable> 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 <T> @NotNull Consumer<T> 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)");
}
}
}
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ private static RestAction<String> 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());
}

Expand Down
10 changes: 10 additions & 0 deletions application/src/main/resources/db/V8__Add_Pending_Reminders.sql
Original file line number Diff line number Diff line change
@@ -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
)