-
-
Notifications
You must be signed in to change notification settings - Fork 89
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
Changes from all commits
Commits
Show all changes
16 commits
Select commit
Hold shift + click to select a range
1603907
Pushing the remider command, which works while the bot is online.
DevSerendipity c7a8781
Reworking first part of remind command
Zabuzard 039fed7
Adding proper "when" selection for /remind
Zabuzard 88b1b26
Adding remind routine to send reminders
Zabuzard ae2e679
Javadoc and cleanup for /remind
Zabuzard f928715
Mention the user in the reminder
Zabuzard e797550
Bugfix with user not found
Zabuzard cce771f
Adding max-pending-reminders-per-user limit
Zabuzard c4ddb87
Send reminder per DM if failing to send in guild
Zabuzard 0f9fafa
Improvements from CR
Zabuzard e5bb21c
UX renamings of the remind options
Zabuzard f35a82f
ReminderRoute factories for readability
Zabuzard ca51485
UX swapped whenUnit and whenAmount
Zabuzard f9b107a
Multiline log message
Zabuzard dc670b2
Renamings from CR
Zabuzard 658cae9
Simplified DM route creation
Zabuzard File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
152 changes: 152 additions & 0 deletions
152
application/src/main/java/org/togetherjava/tjbot/commands/reminder/RemindCommand.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
142 changes: 142 additions & 0 deletions
142
application/src/main/java/org/togetherjava/tjbot/commands/reminder/RemindRoutine.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
Zabuzard marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
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)"); | ||
} | ||
} | ||
} |
5 changes: 5 additions & 0 deletions
5
application/src/main/java/org/togetherjava/tjbot/commands/reminder/package-info.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
10 changes: 10 additions & 0 deletions
10
application/src/main/resources/db/V8__Add_Pending_Reminders.sql
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
) |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.