diff --git a/application/build.gradle b/application/build.gradle index b9b1f3e3aa..a86b8a66a2 100644 --- a/application/build.gradle +++ b/application/build.gradle @@ -53,6 +53,7 @@ dependencies { implementation 'org.scilab.forge:jlatexmath-font-cyrillic:1.0.7' implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-csv:2.13.0' + implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.13.0' implementation 'com.fasterxml.jackson.core:jackson-databind:2.13.0' implementation 'com.github.freva:ascii-table:1.2.0' diff --git a/application/config.json.template b/application/config.json.template index 58bb204c19..e0f9271047 100644 --- a/application/config.json.template +++ b/application/config.json.template @@ -10,6 +10,8 @@ "tagManageRolePattern": "Moderator|Staff Assistant|Top Helpers .+", "freeCommand": [ { + "inactiveChannelDuration": "PT2H", + "messageRetrieveLimit": 10, "statusChannel": , "monitoredChannels": [ 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 095ee74662..e8528391c0 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/Features.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/Features.java @@ -6,6 +6,8 @@ import org.togetherjava.tjbot.commands.basic.RoleSelectCommand; import org.togetherjava.tjbot.commands.basic.SuggestionsUpDownVoter; import org.togetherjava.tjbot.commands.basic.VcActivityCommand; +import org.togetherjava.tjbot.commands.free.AutoFreeRoutine; +import org.togetherjava.tjbot.commands.free.FreeChannelMonitor; import org.togetherjava.tjbot.commands.free.FreeCommand; import org.togetherjava.tjbot.commands.mathcommands.TeXCommand; import org.togetherjava.tjbot.commands.moderation.*; @@ -59,6 +61,7 @@ public enum Features { ModerationActionsStore actionsStore = new ModerationActionsStore(database); ModAuditLogWriter modAuditLogWriter = new ModAuditLogWriter(config); ScamHistoryStore scamHistoryStore = new ScamHistoryStore(database); + FreeChannelMonitor freeChannelMonitor = new FreeChannelMonitor(config); // NOTE The system can add special system relevant commands also by itself, // hence this list may not necessarily represent the full list of all commands actually @@ -71,6 +74,7 @@ public enum Features { features.add(new TopHelpersPurgeMessagesRoutine(database)); features.add(new RemindRoutine(database)); features.add(new ScamHistoryPurgeRoutine(scamHistoryStore)); + features.add(new AutoFreeRoutine(freeChannelMonitor)); // Message receivers features.add(new TopHelpersMessageListener(database, config)); @@ -100,9 +104,10 @@ public enum Features { features.add(new RemindCommand(database)); features.add(new QuarantineCommand(actionsStore, config)); features.add(new UnquarantineCommand(actionsStore, config)); + features.add(new WhoIsCommand()); // Mixtures - features.add(new FreeCommand(config)); + features.add(new FreeCommand(config, freeChannelMonitor)); return features; } diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/free/AutoFreeRoutine.java b/application/src/main/java/org/togetherjava/tjbot/commands/free/AutoFreeRoutine.java new file mode 100644 index 0000000000..bafe787745 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/free/AutoFreeRoutine.java @@ -0,0 +1,55 @@ +package org.togetherjava.tjbot.commands.free; + +import net.dv8tion.jda.api.JDA; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.TextChannel; +import org.jetbrains.annotations.NotNull; +import org.togetherjava.tjbot.commands.Routine; + +import java.util.Collection; +import java.util.Objects; +import java.util.concurrent.TimeUnit; + +/** + * Routine that automatically marks busy help channels free after a certain time without any + * activity. + */ +public final class AutoFreeRoutine implements Routine { + private final FreeChannelMonitor channelMonitor; + + /** + * Creates a new instance. + * + * @param channelMonitor used to monitor and control the free-status of channels + */ + public AutoFreeRoutine(@NotNull FreeChannelMonitor channelMonitor) { + this.channelMonitor = channelMonitor; + } + + @Override + public void runRoutine(@NotNull JDA jda) { + channelMonitor.guildIds() + .map(jda::getGuildById) + .filter(Objects::nonNull) + .forEach(this::processGuild); + } + + private void processGuild(@NotNull Guild guild) { + // Mark inactive channels free + Collection inactiveChannels = channelMonitor.freeInactiveChannels(guild); + + // Then update the status + channelMonitor.displayStatus(guild); + + // Finally, send the messages (the order is important to ensure sane behavior in case of + // crashes) + inactiveChannels.forEach(inactiveChannel -> inactiveChannel + .sendMessage(UserStrings.AUTO_MARK_AS_FREE.message()) + .queue()); + } + + @Override + public @NotNull Schedule createSchedule() { + return new Schedule(ScheduleMode.FIXED_RATE, 1, 5, TimeUnit.MINUTES); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/free/ChannelStatus.java b/application/src/main/java/org/togetherjava/tjbot/commands/free/ChannelStatus.java index 41eb9528fe..6a025e39e3 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/free/ChannelStatus.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/free/ChannelStatus.java @@ -101,11 +101,11 @@ private void setName(@NotNull final String name) { * * @param guild the {@link Guild} that the channel belongs to, to retrieve its name from. * @throws IllegalArgumentException if the guild has not been added, see - * {@link ChannelMonitor#addChannelForStatus(TextChannel)} + * {@link FreeChannelMonitor#addChannelForStatus(TextChannel)} * @throws IllegalStateException if a channel was added, see - * {@link ChannelMonitor#addChannelToMonitor(long)}, that is not a {@link TextChannel}. - * Since addChannelToMonitor does not access the {@link JDA} the entry can only be - * validated before use instead of on addition. + * {@link FreeChannelMonitor#addChannelToMonitor(long)}, that is not a + * {@link TextChannel}. Since addChannelToMonitor does not access the {@link JDA} the + * entry can only be validated before use instead of on addition. */ public void updateChannelName(@NotNull final Guild guild) { GuildChannel channel = guild.getGuildChannelById(channelId); diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/free/ChannelMonitor.java b/application/src/main/java/org/togetherjava/tjbot/commands/free/FreeChannelMonitor.java similarity index 60% rename from application/src/main/java/org/togetherjava/tjbot/commands/free/ChannelMonitor.java rename to application/src/main/java/org/togetherjava/tjbot/commands/free/FreeChannelMonitor.java index 7eec12bb24..0f74b8ac48 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/free/ChannelMonitor.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/free/FreeChannelMonitor.java @@ -1,38 +1,56 @@ package org.togetherjava.tjbot.commands.free; -import net.dv8tion.jda.api.entities.Category; -import net.dv8tion.jda.api.entities.Guild; -import net.dv8tion.jda.api.entities.GuildChannel; -import net.dv8tion.jda.api.entities.TextChannel; +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.entities.*; +import net.dv8tion.jda.api.requests.RestAction; +import net.dv8tion.jda.api.utils.TimeUtil; import org.jetbrains.annotations.NotNull; +import org.togetherjava.tjbot.config.Config; +import org.togetherjava.tjbot.config.FreeCommandConfig; +import java.awt.Color; +import java.time.Instant; import java.util.*; +import java.util.stream.LongStream; import java.util.stream.Stream; /** * A class responsible for monitoring the status of channels and reporting on their busy/free status * for use by {@link FreeCommand}. - * + *

* Channels for monitoring are added via {@link #addChannelToMonitor(long)} however the monitoring * will not be accessible/visible until a channel in the same {@link Guild} is registered for the * output via {@link #addChannelForStatus(TextChannel)}. This will all happen automatically for any * channels listed in {@link org.togetherjava.tjbot.config.FreeCommandConfig}. - * + *

* When a status channel is added for a guild, all monitored channels for that guild are tested and * an {@link IllegalStateException} is thrown if any of them are not {@link TextChannel}s. - * + *

* After successful configuration, any changes in busy/free status will automatically be displayed * in the configured {@code Status Channel} for that guild. */ -final class ChannelMonitor { +public final class FreeChannelMonitor { // Map to store channel ID's, use Guild.getChannels() to guarantee order for display private final Map channelsToMonitorById; private final Map guildIdToStatusChannel; + private final Map channelIdToMessageIdForStatus; - ChannelMonitor() { + private static final String STATUS_TITLE = "**__CHANNEL STATUS__**\n\n"; + private static final Color MESSAGE_HIGHLIGHT_COLOR = Color.decode("#CCCC00"); + + private final Config config; + + /** + * Creates a new instance. + * + * @param config the config to use + */ + public FreeChannelMonitor(@NotNull Config config) { guildIdToStatusChannel = new HashMap<>(); // JDA required to populate map channelsToMonitorById = new HashMap<>(); + channelIdToMessageIdForStatus = new HashMap<>(); + this.config = config; } /** @@ -48,7 +66,7 @@ public void addChannelToMonitor(final long channelId) { * Method for adding the channel that the status will be printed in. Even though the method only * stores the long id it requires, the method requires the actual {@link TextChannel} to be * passed because it needs to verify it as well as store the guild id. - * + *

* This method also calls a method which updates the status of the channels in the * {@link Guild}. So always add the status channel after you have added all * monitored channels for the guild, see {@link #addChannelToMonitor(long)}. @@ -57,14 +75,14 @@ public void addChannelToMonitor(final long channelId) { */ public void addChannelForStatus(@NotNull final TextChannel channel) { guildIdToStatusChannel.put(channel.getGuild().getIdLong(), channel.getIdLong()); - updateStatusFor(channel.getGuild()); + freeInactiveChannels(channel.getGuild()); } /** * This method tests whether a guild id is configured for monitoring in the free command system. * To add a guild for monitoring see {@link org.togetherjava.tjbot.config.FreeCommandConfig} or * {@link #addChannelForStatus(TextChannel)}. - * + * * @param guildId the id of the guild to test. * @return whether the guild is configured in the free command system or not. */ @@ -77,7 +95,7 @@ public boolean isMonitoringGuild(final long guildId) { * system. To add a channel for monitoring see * {@link org.togetherjava.tjbot.config.FreeCommandConfig} or * {@link #addChannelToMonitor(long)}. - * + * * @param channelId the id of the channel to test. * @return {@code true} if the channel is configured in the system, {@code false} otherwise. */ @@ -107,36 +125,38 @@ public boolean isChannelBusy(final long channelId) { /** * This method tests if a channel is currently active by fetching the latest message and testing - * if it was posted more recently than the configured time limit, see - * {@link FreeUtil#inactiveTimeLimit()} and - * {@link org.togetherjava.tjbot.config.FreeCommandConfig#INACTIVE_DURATION}, - * {@link org.togetherjava.tjbot.config.FreeCommandConfig#INACTIVE_UNIT}. + * if it was posted more recently than the configured time limit. * * @param channel the channel to test. + * @param when the reference moment, usually "now" * @return {@code true} if the channel is inactive, false if it has received messages more * recently than the configured duration. * @throws IllegalArgumentException if the channel passed is not monitored. See * {@link #addChannelToMonitor(long)} */ - public boolean isChannelInactive(@NotNull final TextChannel channel) { + public boolean isChannelInactive(@NotNull final TextChannel channel, @NotNull Instant when) { requiresIsMonitored(channel.getIdLong()); - // TODO change the entire inactive test to work via rest-actions - return FreeUtil.getLastMessageId(channel) - // black magic to convert OptionalLong into Optional because OptionalLong does not - // have .map + OptionalLong maybeLastMessageId = FreeUtil.getLastMessageId(channel); + if (maybeLastMessageId.isEmpty()) { + return true; + } + + FreeCommandConfig configForChannel = config.getFreeCommandConfig() .stream() - .boxed() - .findFirst() - .map(FreeUtil::timeFromId) - .map(createdTime -> createdTime.isBefore(FreeUtil.inactiveTimeLimit())) - .orElse(true); // if no channel history could be fetched assume channel is free + .filter(freeConfig -> freeConfig.getMonitoredChannels().contains(channel.getIdLong())) + .findAny() + .orElseThrow(); + + return TimeUtil.getTimeCreated(maybeLastMessageId.orElseThrow()) + .toInstant() + .isBefore(when.minus(configForChannel.getInactiveChannelDuration())); } /** * This method sets the channel's status to 'busy' see {@link ChannelStatus#setBusy(long)} for * details. - * + * * @param channelId the id for the channel status to modify. * @param userId the id of the user changing the status to busy. * @throws IllegalArgumentException if the channel passed is not monitored. See @@ -149,7 +169,7 @@ public void setChannelBusy(final long channelId, final long userId) { /** * This method sets the channel's status to 'free', see {@link ChannelStatus#setFree()} for * details. - * + * * @param channelId the id for the channel status to modify. * @throws IllegalArgumentException if the channel passed is not monitored. See * {@link #addChannelToMonitor(long)} @@ -171,7 +191,7 @@ public void setChannelFree(final long channelId) { /** * This method provides a stream of the id's for channels where statuses are displayed. This is * streamed purely as a simple method of encapsulation. - * + * * @return a stream of channel id's */ public @NotNull Stream statusIds() { @@ -187,6 +207,18 @@ public void setChannelFree(final long channelId) { .toList(); } + /** + * Gets a stream with IDs of all monitored channels that are currently marked busy. + * + * @return stream with IDs of all busy channels + */ + public LongStream getBusyChannelIds() { + return channelsToMonitorById.values() + .stream() + .filter(ChannelStatus::isBusy) + .mapToLong(ChannelStatus::getChannelId); + } + /** * Creates the status message (specific to the guild specified) that shows which channels are * busy/free. @@ -194,7 +226,7 @@ public void setChannelFree(final long channelId) { * It first updates the channel names, order and grouping(categories) according to * {@link net.dv8tion.jda.api.JDA} for the monitored channels. So that the output is always * consistent with remote changes. - * + * * @param guild the guild the message is intended for. * @return a string representing the busy/free status of channels in this guild. The String * includes emojis and other discord specific markup. Attempting to display this @@ -232,26 +264,30 @@ public String statusMessage(@NotNull final Guild guild) { /** * This method checks all channels in a guild that are currently being monitored and are busy - * and determines if the last time it was updated is more recent than the configured time see - * {@link org.togetherjava.tjbot.config.FreeCommandConfig#INACTIVE_UNIT}. If so it changes the - * channel's status to free, see {@link ChannelMonitor#isChannelInactive(TextChannel)}. + * and determines if the last time it was updated is more recent than the configured time. If so + * it changes the channel's status to free, see + * {@link FreeChannelMonitor#isChannelInactive(TextChannel, Instant)}. *

- * This method is run automatically during startup and should be run on a set schedule, as - * defined in {@link org.togetherjava.tjbot.config.FreeCommandConfig}. The scheduled execution - * is not currently implemented - * + * This method is run automatically during startup and on a set schedule, as defined in + * {@link FreeCommandConfig}. + * * @param guild the guild for which to test the channel statuses of. + * @return all inactive channels that have been updated */ - public void updateStatusFor(@NotNull Guild guild) { - // TODO add automation after Routine support (#235) is pushed - guildMonitoredChannelsList(guild).parallelStream() + public @NotNull Collection freeInactiveChannels(@NotNull Guild guild) { + Instant now = Instant.now(); + + List inactiveChannels = guildMonitoredChannelsList(guild).parallelStream() .filter(ChannelStatus::isBusy) .map(ChannelStatus::getChannelId) .map(guild::getTextChannelById) .filter(Objects::nonNull) // pointless, added for warnings - .filter(this::isChannelInactive) - .map(TextChannel::getIdLong) - .forEach(this::setChannelFree); + .filter(busyChannel -> isChannelInactive(busyChannel, now)) + .toList(); + + inactiveChannels.stream().map(TextChannel::getIdLong).forEach(this::setChannelFree); + + return inactiveChannels; } /** @@ -281,10 +317,106 @@ public void updateStatusFor(@NotNull Guild guild) { return channel; } + /** + * Displays the message that will be displayed for users. + *

+ * This method detects if any messages have been posted in the channel below the status message. + * If that is the case this will delete the existing status message and post another one so that + * it's the last message in the channel. + *

+ * If it cannot find an existing status message it will create a new one. + *

+ * Otherwise it will edit the existing message. + * + * @param guild the guild to display the status in. + */ + public void displayStatus(@NotNull Guild guild) { + TextChannel channel = getStatusChannelFor(guild); + + String messageTxt = buildStatusMessage(guild); + MessageEmbed embed = new EmbedBuilder().setTitle(STATUS_TITLE) + .setDescription(messageTxt) + .setFooter(channel.getJDA().getSelfUser().getName()) + .setTimestamp(Instant.now()) + .setColor(MESSAGE_HIGHLIGHT_COLOR) + .build(); + + getStatusMessageIn(channel).flatMap(this::deleteIfNotLatest) + .ifPresentOrElse(message -> message.editMessageEmbeds(embed).queue(), + () -> channel.sendMessageEmbeds(embed) + .queue(message -> channelIdToMessageIdForStatus.put(channel.getIdLong(), + message.getIdLong()))); + } + + private @NotNull Optional deleteIfNotLatest(@NotNull Message message) { + OptionalLong lastId = FreeUtil.getLastMessageId(message.getTextChannel()); + if (lastId.isPresent() && lastId.getAsLong() != message.getIdLong()) { + message.delete().queue(); + return Optional.empty(); + } + + return Optional.of(message); + } + + private @NotNull Optional getStatusMessageIn(@NotNull TextChannel channel) { + if (!channelIdToMessageIdForStatus.containsKey(channel.getIdLong())) { + return findExistingStatusMessage(channel); + } + return Optional.ofNullable(channelIdToMessageIdForStatus.get(channel.getIdLong())) + .map(channel::retrieveMessageById) + .map(RestAction::complete); + } + + private @NotNull Optional findExistingStatusMessage(@NotNull TextChannel channel) { + // will only run when bot starts, afterwards its stored in a map + + FreeCommandConfig configForChannel = config.getFreeCommandConfig() + .stream() + .filter(freeConfig -> freeConfig.getStatusChannel() == channel.getIdLong()) + .findAny() + .orElseThrow(); + + Optional statusMessage = FreeUtil + .getChannelHistory(channel, configForChannel.getMessageRetrieveLimit()) + .flatMap(history -> history.stream() + .filter(message -> !message.getEmbeds().isEmpty()) + .filter(message -> message.getAuthor().equals(channel.getJDA().getSelfUser())) + // TODO the equals is not working, i believe its because there is no getTitleRaw() + // .filter(message -> STATUS_TITLE.equals(message.getEmbeds().get(0).getTitle())) + .findFirst()); + + channelIdToMessageIdForStatus.put(channel.getIdLong(), + statusMessage.map(Message::getIdLong).orElse(null)); + return statusMessage; + } + + /** + * Method for creating the message that shows the channel statuses for the specified guild. + *

+ * This method dynamically builds the status message as per the current values on the guild, + * including the channel categories. This method will detect any changes made on the guild and + * represent those changes in the status message. + * + * @param guild the guild that the message is required for. + * @return the message to display showing the channel statuses. Includes Discord specific + * formatting, trying to display elsewhere may have unpredictable results. + * @throws IllegalArgumentException if the guild passed in is not configured in the free command + * system, see {@link FreeChannelMonitor#addChannelForStatus(TextChannel)}. + */ + public @NotNull String buildStatusMessage(@NotNull Guild guild) { + if (!isMonitoringGuild(guild.getIdLong())) { + throw new IllegalArgumentException( + "The guild '%s(%s)' is not configured in the free command system" + .formatted(guild.getName(), guild.getIdLong())); + } + + return statusMessage(guild); + } + /** * The toString method for this class, it generates a human-readable text string of the * currently monitored channels and the channels the status are printed in. - * + * * @return the human-readable text string that describes this class. */ @Override diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/free/FreeCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/free/FreeCommand.java index 3814855c94..7f2dcd86d1 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/free/FreeCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/free/FreeCommand.java @@ -1,16 +1,12 @@ package org.togetherjava.tjbot.commands.free; -import net.dv8tion.jda.api.EmbedBuilder; import net.dv8tion.jda.api.JDA; import net.dv8tion.jda.api.entities.Guild; -import net.dv8tion.jda.api.entities.Message; -import net.dv8tion.jda.api.entities.MessageEmbed; import net.dv8tion.jda.api.entities.TextChannel; import net.dv8tion.jda.api.events.GenericEvent; import net.dv8tion.jda.api.events.ReadyEvent; import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; import net.dv8tion.jda.api.events.message.MessageReceivedEvent; -import net.dv8tion.jda.api.requests.RestAction; import org.jetbrains.annotations.NotNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -20,9 +16,8 @@ import org.togetherjava.tjbot.config.Config; import org.togetherjava.tjbot.config.FreeCommandConfig; -import java.awt.*; -import java.time.Instant; -import java.util.*; +import java.util.Collection; +import java.util.stream.Collectors; // TODO (can SlashCommandVisibility be narrower than GUILD?) // TODO monitor all channels when list is empty? monitor none? @@ -34,6 +29,7 @@ // TODO add scheduled tasks to check last message every predefined duration and mark as free if // applicable + /** * Implementation of the free command. It is used to monitor a predefined list of channels and show * users which ones are available for use and which are not. @@ -57,15 +53,12 @@ public final class FreeCommand extends SlashCommandAdapter implements EventReceiver { private static final Logger logger = LoggerFactory.getLogger(FreeCommand.class); - private static final String STATUS_TITLE = "**__CHANNEL STATUS__**\n\n"; private static final String COMMAND_NAME = "free"; - private static final Color MESSAGE_HIGHLIGHT_COLOR = Color.decode("#CCCC00"); private final Config config; // Map to store channel ID's, use Guild.getChannels() to guarantee order for display - private final ChannelMonitor channelMonitor; - private final Map channelIdToMessageIdForStatus; + private final FreeChannelMonitor channelMonitor; private volatile boolean isReady; @@ -75,16 +68,16 @@ public final class FreeCommand extends SlashCommandAdapter implements EventRecei *

* This fetches configuration information from a json configuration file (see * {@link FreeCommandConfig}) for further details. - * + * * @param config the config to use for this + * @param channelMonitor used to monitor and control the free-status of channels */ - public FreeCommand(@NotNull Config config) { + public FreeCommand(@NotNull Config config, @NotNull FreeChannelMonitor channelMonitor) { super(COMMAND_NAME, "Marks this channel as free for another user to ask a question", SlashCommandVisibility.GUILD); this.config = config; - channelIdToMessageIdForStatus = new HashMap<>(); - channelMonitor = new ChannelMonitor(); + this.channelMonitor = channelMonitor; isReady = false; } @@ -113,7 +106,9 @@ public void onReady(@NotNull final ReadyEvent event) { channelMonitor.statusIds() .map(id -> requiresTextChannel(jda, id)) - .forEach(this::displayStatus); + .map(TextChannel::getGuild) + .collect(Collectors.toSet()) + .forEach(channelMonitor::displayStatus); isReady = true; } @@ -124,7 +119,7 @@ public void onReady(@NotNull final ReadyEvent event) { *

* If this is called on from a channel that was not configured for monitoring (see * {@link FreeCommandConfig}) the user will receive an ephemeral message stating such. - * + * * @param event the event that triggered this * @throws IllegalStateException if this method is called for a Global Slash Command */ @@ -144,7 +139,7 @@ public void onSlashCommand(@NotNull final SlashCommandInteractionEvent event) { } // TODO check if /free called by original author, if not put message asking if he approves channelMonitor.setChannelFree(id); - displayStatus(channelMonitor.getStatusChannelFor(requiresGuild(event))); + channelMonitor.displayStatus(requiresGuild(event)); event.reply(UserStrings.MARK_AS_FREE.message()).queue(); } @@ -187,52 +182,10 @@ private boolean handleShouldBeProcessed(@NotNull final SlashCommandInteractionEv return true; } - /** - * Displays the message that will be displayed for users. - *

- * This method detects if any messages have been posted in the channel below the status message. - * If that is the case this will delete the existing status message and post another one so that - * it's the last message in the channel. - *

- * If it cannot find an existing status message it will create a new one. - *

- * Otherwise it will edit the existing message. - * - * @param channel the text channel the status message will be posted in. - */ - public void displayStatus(@NotNull TextChannel channel) { - final Guild guild = channel.getGuild(); - - String messageTxt = buildStatusMessage(guild); - MessageEmbed embed = new EmbedBuilder().setTitle(STATUS_TITLE) - .setDescription(messageTxt) - .setFooter(channel.getJDA().getSelfUser().getName()) - .setTimestamp(Instant.now()) - .setColor(MESSAGE_HIGHLIGHT_COLOR) - .build(); - - getStatusMessageIn(channel).flatMap(this::deleteIfNotLatest) - .ifPresentOrElse(message -> message.editMessageEmbeds(embed).queue(), - () -> channel.sendMessageEmbeds(embed) - .queue(message -> channelIdToMessageIdForStatus.put(channel.getIdLong(), - message.getIdLong()))); - } - - private @NotNull Optional deleteIfNotLatest(@NotNull Message message) { - - OptionalLong lastId = FreeUtil.getLastMessageId(message.getTextChannel()); - if (lastId.isPresent() && lastId.getAsLong() != message.getIdLong()) { - message.delete().queue(); - return Optional.empty(); - } - - return Optional.of(message); - } - private void checkBusyStatusAllChannels(@NotNull JDA jda) { channelMonitor.guildIds() .map(id -> requiresGuild(jda, id)) - .forEach(channelMonitor::updateStatusFor); + .forEach(channelMonitor::freeInactiveChannels); } private @NotNull Guild requiresGuild(@NotNull JDA jda, long id) { @@ -255,29 +208,6 @@ private void checkBusyStatusAllChannels(@NotNull JDA jda) { return guild; } - /** - * Method for creating the message that shows the channel statuses for the specified guild. - *

- * This method dynamically builds the status message as per the current values on the guild, - * including the channel categories. This method will detect any changes made on the guild and - * represent those changes in the status message. - * - * @param guild the guild that the message is required for. - * @return the message to display showing the channel statuses. Includes Discord specific - * formatting, trying to display elsewhere may have unpredictable results. - * @throws IllegalArgumentException if the guild passed in is not configured in the free command - * system, see {@link ChannelMonitor#addChannelForStatus(TextChannel)}. - */ - public @NotNull String buildStatusMessage(@NotNull Guild guild) { - if (!channelMonitor.isMonitoringGuild(guild.getIdLong())) { - throw new IllegalArgumentException( - "The guild '%s(%s)' is not configured in the free command system" - .formatted(guild.getName(), guild.getIdLong())); - } - - return channelMonitor.statusMessage(guild); - } - /** * Method for responding to 'onGuildMessageReceived' this will need to be replaced by a more * appropriate method when the bot has more functionality. @@ -288,8 +218,8 @@ private void checkBusyStatusAllChannels(@NotNull JDA jda) { * @param event the generic event that includes the 'onGuildMessageReceived'. */ @SuppressWarnings("squid:S2583") // False-positive about the if-else-instanceof, sonar thinks - // the second case is unreachable; but it passes without - // pattern-matching. Probably a bug in SonarLint with Java 17. + // the second case is unreachable; but it passes without + // pattern-matching. Probably a bug in SonarLint with Java 17. @Override public void onEvent(@NotNull GenericEvent event) { if (event instanceof ReadyEvent readyEvent) { @@ -316,37 +246,11 @@ public void onEvent(@NotNull GenericEvent event) { } channelMonitor.setChannelBusy(messageEvent.getChannel().getIdLong(), messageEvent.getAuthor().getIdLong()); - displayStatus(channelMonitor.getStatusChannelFor(messageEvent.getGuild())); + channelMonitor.displayStatus(messageEvent.getGuild()); messageEvent.getMessage().reply(UserStrings.NEW_QUESTION.message()).queue(); } } - private @NotNull Optional getStatusMessageIn(@NotNull TextChannel channel) { - if (!channelIdToMessageIdForStatus.containsKey(channel.getIdLong())) { - return findExistingStatusMessage(channel); - } - return Optional.ofNullable(channelIdToMessageIdForStatus.get(channel.getIdLong())) - .map(channel::retrieveMessageById) - .map(RestAction::complete); - } - - private @NotNull Optional findExistingStatusMessage(@NotNull TextChannel channel) { - // will only run when bot starts, afterwards its stored in a map - - Optional statusMessage = FreeUtil - .getChannelHistory(channel, FreeCommandConfig.MESSAGE_RETRIEVE_LIMIT) - .flatMap(history -> history.stream() - .filter(message -> !message.getEmbeds().isEmpty()) - .filter(message -> message.getAuthor().equals(channel.getJDA().getSelfUser())) - // TODO the equals is not working, i believe its because there is no getTitleRaw() - // .filter(message -> STATUS_TITLE.equals(message.getEmbeds().get(0).getTitle())) - .findFirst()); - - channelIdToMessageIdForStatus.put(channel.getIdLong(), - statusMessage.map(Message::getIdLong).orElse(null)); - return statusMessage; - } - private void initChannelsToMonitor() { config.getFreeCommandConfig() .stream() diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/free/FreeUtil.java b/application/src/main/java/org/togetherjava/tjbot/commands/free/FreeUtil.java index 0ab0711a4c..bf61ad7bc8 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/free/FreeUtil.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/free/FreeUtil.java @@ -3,13 +3,10 @@ import net.dv8tion.jda.api.entities.Message; import net.dv8tion.jda.api.entities.TextChannel; import net.dv8tion.jda.api.interactions.callbacks.IReplyCallback; -import net.dv8tion.jda.api.utils.TimeUtil; import org.jetbrains.annotations.NotNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.togetherjava.tjbot.config.FreeCommandConfig; -import java.time.OffsetDateTime; import java.util.List; import java.util.Optional; import java.util.OptionalLong; @@ -65,7 +62,6 @@ public static void sendErrorMessage(@NotNull IReplyCallback interaction, * @return the id of the latest message or empty if it could not be retrieved. */ public static @NotNull OptionalLong getLastMessageId(@NotNull TextChannel channel) { - // black magic to convert Optional into OptionalLong because Optional does not have // .mapToLong return getChannelHistory(channel, 1).stream() @@ -73,26 +69,4 @@ public static void sendErrorMessage(@NotNull IReplyCallback interaction, .mapToLong(Message::getIdLong) .findFirst(); } - - /** - * Method that returns the time data from a discord snowflake. - * - * @param id the snowflake containing the time desired - * @return the creation time of the entity the id represents - */ - public static @NotNull OffsetDateTime timeFromId(long id) { - return TimeUtil.getTimeCreated(id); - } - - /** - * Method that calculates a time value a specific duration before now. The duration is - * configured in {@link FreeCommandConfig#INACTIVE_UNIT} and - * {@link FreeCommandConfig#INACTIVE_DURATION}. - * - * @return the time value a set duration before now. - */ - public static @NotNull OffsetDateTime inactiveTimeLimit() { - return OffsetDateTime.now() - .minus(FreeCommandConfig.INACTIVE_DURATION, FreeCommandConfig.INACTIVE_UNIT); - } } diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/free/UserStrings.java b/application/src/main/java/org/togetherjava/tjbot/commands/free/UserStrings.java index 8f8b619151..b10db9bf10 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/free/UserStrings.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/free/UserStrings.java @@ -18,6 +18,10 @@ enum UserStrings { MARK_AS_FREE(""" This channel is now available for a question to be asked. """), + AUTO_MARK_AS_FREE( + """ + This channel seems to be inactive and was now marked available for a question to be asked. + """), ALREADY_FREE_ERROR(""" This channel is already free, no changes made. """), diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/mathcommands/TeXCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/mathcommands/TeXCommand.java index 1f5be8d3ea..00d1037c57 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/mathcommands/TeXCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/mathcommands/TeXCommand.java @@ -2,8 +2,10 @@ 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.IDeferrableCallback; import net.dv8tion.jda.api.interactions.commands.OptionType; import net.dv8tion.jda.api.interactions.components.buttons.Button; +import net.dv8tion.jda.api.interactions.components.buttons.ButtonStyle; import org.jetbrains.annotations.NotNull; import org.scilab.forge.jlatexmath.ParseException; import org.scilab.forge.jlatexmath.TeXConstants; @@ -21,6 +23,8 @@ import java.io.IOException; import java.util.List; import java.util.Objects; +import java.util.regex.Matcher; +import java.util.regex.Pattern; /** * Implementation of a tex command which takes a string and renders an image corresponding to the @@ -31,11 +35,18 @@ * message. */ -public class TeXCommand extends SlashCommandAdapter { - - private static final String LATEX_OPTION = "latex"; +public final class TeXCommand extends SlashCommandAdapter { + static final String LATEX_OPTION = "latex"; + // Matches regions between two dollars, like '$foo$'. + private static final String MATH_REGION = "(\\$[^$]+\\$)"; + private static final String TEXT_REGION = "([^$]+)"; + private static final Pattern INLINE_LATEX_REPLACEMENT = + Pattern.compile(MATH_REGION + "|" + TEXT_REGION); private static final String RENDERING_ERROR = "There was an error generating the image"; - private static final float DEFAULT_IMAGE_SIZE = 40F; + static final String BAD_LATEX_ERROR_PREFIX = "That is an invalid latex: "; + static final String INVALID_INLINE_FORMAT_ERROR_MESSAGE = + "The amount of $-symbols must be divisible by two. Did you forget to close an expression?"; + private static final float DEFAULT_IMAGE_SIZE = 40.0F; private static final Color BACKGROUND_COLOR = Color.decode("#36393F"); private static final Color FOREGROUND_COLOR = Color.decode("#FFFFFF"); private static final Logger logger = LoggerFactory.getLogger(TeXCommand.class); @@ -44,8 +55,7 @@ public class TeXCommand extends SlashCommandAdapter { * Creates a new Instance. */ public TeXCommand() { - super("tex", - "This command accepts a latex expression and generates an image corresponding to it.", + super("tex", "Renders LaTeX, also supports inline $-regions like 'see this $\frac{x}{2}$'.", SlashCommandVisibility.GUILD); getData().addOption(OptionType.STRING, LATEX_OPTION, "The latex which is rendered as an image", true); @@ -56,42 +66,102 @@ public void onSlashCommand(@NotNull final SlashCommandInteractionEvent event) { String latex = Objects.requireNonNull(event.getOption(LATEX_OPTION)).getAsString(); String userID = (Objects.requireNonNull(event.getMember()).getId()); TeXFormula formula; + try { + if (latex.contains("$")) { + latex = convertInlineLatexToFull(latex); + } formula = new TeXFormula(latex); } catch (ParseException e) { - event.reply("That is an invalid latex: " + e.getMessage()).setEphemeral(true).queue(); + event.reply(BAD_LATEX_ERROR_PREFIX + e.getMessage()).setEphemeral(true).queue(); return; } + event.deferReply().queue(); - Image image = formula.createBufferedImage(TeXConstants.STYLE_DISPLAY, DEFAULT_IMAGE_SIZE, - FOREGROUND_COLOR, BACKGROUND_COLOR); - if (image.getWidth(null) == -1 || image.getHeight(null) == -1) { - event.getHook().setEphemeral(true).editOriginal(RENDERING_ERROR).queue(); - logger.warn( - "Unable to render latex, image does not have an accessible width or height. Formula was {}", - latex); - return; - } - BufferedImage renderedTextImage = new BufferedImage(image.getWidth(null), - image.getHeight(null), BufferedImage.TYPE_4BYTE_ABGR); - renderedTextImage.getGraphics().drawImage(image, 0, 0, null); - ByteArrayOutputStream renderedTextImageStream = new ByteArrayOutputStream(); try { - ImageIO.write(renderedTextImage, "png", renderedTextImageStream); + Image image = renderImage(formula); + sendImage(event, userID, image); } catch (IOException e) { - event.getHook().setEphemeral(true).editOriginal(RENDERING_ERROR).queue(); + event.getHook().editOriginal(RENDERING_ERROR).queue(); logger.warn( "Unable to render latex, could not convert the image into an attachable form. Formula was {}", latex, e); - return; + + } catch (IllegalStateException e) { + event.getHook().editOriginal(RENDERING_ERROR).queue(); + + logger.warn( + "Unable to render latex, image does not have an accessible width or height. Formula was {}", + latex, e); } + } + + private @NotNull Image renderImage(@NotNull TeXFormula formula) { + Image image = formula.createBufferedImage(TeXConstants.STYLE_DISPLAY, DEFAULT_IMAGE_SIZE, + FOREGROUND_COLOR, BACKGROUND_COLOR); + + if (image.getWidth(null) == -1 || image.getHeight(null) == -1) { + throw new IllegalStateException("Image has no height or width"); + } + return image; + } + + private void sendImage(@NotNull IDeferrableCallback event, @NotNull String userID, + @NotNull Image image) throws IOException { + ByteArrayOutputStream renderedTextImageStream = getRenderedTextImageStream(image); event.getHook() .editOriginal(renderedTextImageStream.toByteArray(), "tex.png") - .setActionRow(Button.danger(generateComponentId(userID), "Delete")) + .setActionRow(Button.of(ButtonStyle.DANGER, generateComponentId(userID), "Delete")) .queue(); } + @NotNull + private ByteArrayOutputStream getRenderedTextImageStream(@NotNull Image image) + throws IOException { + BufferedImage renderedTextImage = new BufferedImage(image.getWidth(null), + image.getHeight(null), BufferedImage.TYPE_4BYTE_ABGR); + + renderedTextImage.getGraphics().drawImage(image, 0, 0, null); + ByteArrayOutputStream renderedTextImageStream = new ByteArrayOutputStream(); + + ImageIO.write(renderedTextImage, "png", renderedTextImageStream); + + return renderedTextImageStream; + } + + /** + * Converts inline latex like: {@code hello $\frac{x}{2}$ world} to full latex + * {@code \text{hello}\frac{x}{2}\text{ world}}. + * + * @param latex the latex to convert + * @return the converted latex + */ + @NotNull + private String convertInlineLatexToFull(@NotNull String latex) { + if (isInvalidInlineFormat(latex)) { + throw new ParseException(INVALID_INLINE_FORMAT_ERROR_MESSAGE); + } + + Matcher matcher = INLINE_LATEX_REPLACEMENT.matcher(latex); + StringBuilder sb = new StringBuilder(latex.length()); + + while (matcher.find()) { + boolean isInsideMathRegion = matcher.group(1) != null; + if (isInsideMathRegion) { + sb.append(matcher.group(1).replace("$", "")); + } else { + sb.append("\\text{").append(matcher.group(2)).append("}"); + } + } + + return sb.toString(); + } + + private boolean isInvalidInlineFormat(@NotNull String latex) { + return latex.chars().filter(charAsInt -> charAsInt == '$').count() % 2 == 1; + } + @Override public void onButtonClick(@NotNull final ButtonInteractionEvent event, @NotNull final List args) { diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/WhoIsCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/WhoIsCommand.java new file mode 100644 index 0000000000..4403353880 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/WhoIsCommand.java @@ -0,0 +1,252 @@ +package org.togetherjava.tjbot.commands.moderation; + +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.entities.*; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.Interaction; +import net.dv8tion.jda.api.interactions.callbacks.IReplyCallback; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.requests.restaction.interactions.ReplyCallbackAction; +import org.jetbrains.annotations.NotNull; +import org.togetherjava.tjbot.commands.SlashCommandAdapter; +import org.togetherjava.tjbot.commands.SlashCommandVisibility; +import org.togetherjava.tjbot.commands.utils.DiscordClientAction; + +import javax.annotation.CheckReturnValue; +import java.awt.Color; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Collection; +import java.util.Objects; +import java.util.stream.Collectors; + +/** + * This command allows you to look up user (or member) info. + */ +@SuppressWarnings("ClassWithoutLogger") +public final class WhoIsCommand extends SlashCommandAdapter { + private static final String USER_OPTION = "user"; + private static final String SHOW_SERVER_INFO_OPTION = "show_server_specific_info"; + + private static final String USER_PROFILE_PICTURE_SIZE = "4096"; + + // Sun, December 11, 2016, 13:36:30 + private static final DateTimeFormatter DATE_TIME_FORMAT = + DateTimeFormatter.ofPattern("E, MMMM d, u, HH:mm:ss"); + + /** + * Creates an instance. + */ + public WhoIsCommand() { + super("whois", "Provides info about the given user", SlashCommandVisibility.GUILD); + + getData().addOption(OptionType.USER, USER_OPTION, "the user to look up", true) + .addOption(OptionType.BOOLEAN, SHOW_SERVER_INFO_OPTION, + "Whenever to show info that is specific to this server, such as their roles. This is true by default.", + false); + } + + @Override + public void onSlashCommand(@NotNull final SlashCommandInteractionEvent event) { + OptionMapping userOption = Objects.requireNonNull(event.getOption(USER_OPTION), + "The given user option cannot be null"); + OptionMapping showServerSpecificInfoOption = event.getOption(SHOW_SERVER_INFO_OPTION); + + User user = userOption.getAsUser(); + Member member = userOption.getAsMember(); + + boolean showServerSpecificInfo = null != member && (null == showServerSpecificInfoOption + || showServerSpecificInfoOption.getAsBoolean()); + + user.retrieveProfile().flatMap((User.Profile profile) -> { + if (showServerSpecificInfo) { + return handleWhoIsMember(event, member, profile); + } else { + return handleWhoIsUser(event, user, profile); + } + }).queue(); + } + + @CheckReturnValue + private static @NotNull ReplyCallbackAction handleWhoIsUser(final @NotNull IReplyCallback event, + final @NotNull User user, final @NotNull User.Profile profile) { + String description = userIdentificationToStringItem(user) + "\n**Is bot:** " + user.isBot() + + userFlagsToStringItem(user.getFlags()) + "\n**Registration date:** " + + DATE_TIME_FORMAT.format(user.getTimeCreated()); + + EmbedBuilder embedBuilder = + generateEmbedBuilder(event, user, profile, profile.getAccentColor()).setAuthor( + user.getName(), user.getEffectiveAvatarUrl(), user.getEffectiveAvatarUrl()) + .setDescription(description); + + return sendEmbedWithProfileAction(event, embedBuilder.build(), user.getId()); + } + + @CheckReturnValue + private static @NotNull ReplyCallbackAction handleWhoIsMember( + final @NotNull IReplyCallback event, final @NotNull Member member, + final @NotNull User.Profile profile) { + User user = member.getUser(); + + Color memberColor = member.getColor(); + Color effectiveColor = (null == memberColor) ? profile.getAccentColor() : memberColor; + + String description = userIdentificationToStringItem(user) + voiceStateToStringItem(member) + + "\n**Is bot:** " + user.isBot() + possibleBoosterToStringItem(member) + + userFlagsToStringItem(user.getFlags()) + "\n**Join date:** " + + DATE_TIME_FORMAT.format(member.getTimeJoined()) + "\n**Registration date:** " + + DATE_TIME_FORMAT.format(user.getTimeCreated()) + "\n**Roles:** " + + formatRoles(member); + + EmbedBuilder embedBuilder = generateEmbedBuilder(event, user, profile, effectiveColor) + .setAuthor(member.getEffectiveName(), member.getEffectiveAvatarUrl(), + member.getEffectiveAvatarUrl()) + .setDescription(description); + + return sendEmbedWithProfileAction(event, embedBuilder.build(), user.getId()); + } + + private static @NotNull ReplyCallbackAction sendEmbedWithProfileAction( + final @NotNull IReplyCallback event, @NotNull MessageEmbed embed, + @NotNull String userId) { + return event.replyEmbeds(embed) + .addActionRow( + DiscordClientAction.General.USER.asLinkButton("Click to see profile!", userId)); + } + + private static @NotNull String voiceStateToStringItem(@NotNull final Member member) { + GuildVoiceState voiceState = Objects.requireNonNull(member.getVoiceState(), + "The given voiceState cannot be null"); + + if (!voiceState.inAudioChannel()) { + return ""; + } + + return "\n**In voicechannel:** " + (voiceState.getChannel().getAsMention()); + } + + + /** + * Generates whois embed based on the given parameters. + * + * @param event the {@link SlashCommandInteractionEvent} + * @param user the {@link User} getting whois'd + * @param profile the {@link net.dv8tion.jda.api.entities.User.Profile} of the whois'd user + * @param effectiveColor the {@link Color} that the embed will become + * @return the generated {@link EmbedBuilder} + */ + private static @NotNull EmbedBuilder generateEmbedBuilder(@NotNull final Interaction event, + @NotNull final User user, final @NotNull User.Profile profile, + final Color effectiveColor) { + + EmbedBuilder embedBuilder = new EmbedBuilder().setThumbnail(user.getEffectiveAvatarUrl()) + .setColor(effectiveColor) + .setFooter("Requested by " + event.getUser().getAsTag(), + event.getMember().getEffectiveAvatarUrl()) + .setTimestamp(Instant.now()); + + if (null != profile.getBannerId()) { + embedBuilder.setImage(profile.getBannerUrl() + "?size=" + USER_PROFILE_PICTURE_SIZE); + } + + return embedBuilder; + } + + /** + * Handles boosting properties of a {@link Member} + * + * @param member the {@link Member} to take the booster properties from + * @return user readable {@link String} + */ + private static @NotNull String possibleBoosterToStringItem(final @NotNull Member member) { + OffsetDateTime timeBoosted = member.getTimeBoosted(); + + if (null == timeBoosted) { + return "\n**Is booster:** false"; + } + + return "\n**Is booster:** true \n**Boosting since:** " + + DATE_TIME_FORMAT.format(timeBoosted); + } + + /** + * Handles the user's identifying properties (such as ID, tag) + * + * @param user the {@link User} to take the identifiers from + * @return user readable {@link String} + */ + private static @NotNull String userIdentificationToStringItem(final @NotNull User user) { + return "**Mention:** " + user.getAsMention() + "\n**Tag:** " + user.getAsTag() + + "\n**ID:** " + user.getId(); + } + + /** + * Formats the roles into a user readable {@link String} + * + * @param member member to take the Roles from + * @return user readable {@link String} of the roles + */ + private static String formatRoles(final @NotNull Member member) { + return member.getRoles().stream().map(Role::getAsMention).collect(Collectors.joining(", ")); + } + + /** + * Formats Hypesquad and the flags + * + * @param flags the {@link Collection} of {@link net.dv8tion.jda.api.entities.User.UserFlag} + * (recommend {@link java.util.EnumSet} + * @return user readable {@link StringBuilder} + */ + private static @NotNull StringBuilder userFlagsToStringItem( + final @NotNull Collection flags) { + String formattedFlags = formatUserFlags(flags); + StringBuilder result = hypeSquadToStringItem(flags); + + if (!formattedFlags.isBlank()) { + result.append("\n**Flags:** ").append(formattedFlags); + } + + return result; + } + + /** + * Formats user readable Hypesquad item + * + * @param flags the {@link Collection} of {@link net.dv8tion.jda.api.entities.User.UserFlag} + * (recommend {@link java.util.EnumSet} + * @return user readable {@link StringBuilder} + */ + private static @NotNull StringBuilder hypeSquadToStringItem( + final @NotNull Collection flags) { + StringBuilder stringBuilder = new StringBuilder("**\nHypesquad:** "); + + if (flags.contains(User.UserFlag.HYPESQUAD_BALANCE)) { + stringBuilder.append(User.UserFlag.HYPESQUAD_BALANCE.getName()); + } else if (flags.contains(User.UserFlag.HYPESQUAD_BRAVERY)) { + stringBuilder.append(User.UserFlag.HYPESQUAD_BRAVERY.getName()); + } else if (flags.contains(User.UserFlag.HYPESQUAD_BRILLIANCE)) { + stringBuilder.append(User.UserFlag.HYPESQUAD_BRILLIANCE.getName()); + } else { + stringBuilder.append("joined none"); + } + + return stringBuilder; + } + + /** + * Formats the flags into a user readable {@link String}, filters Hypesquad relating flags + * + * @param flags the {@link Collection} of {@link net.dv8tion.jda.api.entities.User.UserFlag} + * (recommend {@link java.util.EnumSet} + * @return the user readable string + */ + @NotNull + private static String formatUserFlags(final @NotNull Collection flags) { + return flags.stream() + .map(User.UserFlag::getName) + .filter(name -> (name.contains("Hypesquad"))) + .collect(Collectors.joining(", ")); + } +} 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 index 664bd69306..ef1128f035 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/reminder/RemindCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/reminder/RemindCommand.java @@ -1,5 +1,6 @@ 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; @@ -25,7 +26,7 @@ * *

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

@@ -33,16 +34,16 @@ */ 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"; + 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 MAX_PENDING_REMINDERS_PER_USER = 100; + static final int MAX_PENDING_REMINDERS_PER_USER = 100; private final Database database; @@ -78,11 +79,12 @@ public void onSlashCommand(@NotNull SlashCommandInteractionEvent event) { Instant remindAt = parseWhen(timeAmount, timeUnit); User author = event.getUser(); + Guild guild = event.getGuild(); if (!handleIsRemindAtWithinLimits(remindAt, event)) { return; } - if (!handleIsUserBelowMaxPendingReminders(author, event)) { + if (!handleIsUserBelowMaxPendingReminders(author, guild, event)) { return; } @@ -92,7 +94,7 @@ public void onSlashCommand(@NotNull SlashCommandInteractionEvent event) { database.write(context -> context.newRecord(PENDING_REMINDERS) .setCreatedAt(Instant.now()) - .setGuildId(event.getGuild().getIdLong()) + .setGuildId(guild.getIdLong()) .setChannelId(event.getChannel().getIdLong()) .setAuthorId(author.getIdLong()) .setRemindAt(remindAt) @@ -133,9 +135,10 @@ private static boolean handleIsRemindAtWithinLimits(@NotNull Instant remindAt, } private boolean handleIsUserBelowMaxPendingReminders(@NotNull ISnowflake author, - @NotNull IReplyCallback event) { + @NotNull ISnowflake guild, @NotNull IReplyCallback event) { int pendingReminders = database.read(context -> context.fetchCount(PENDING_REMINDERS, - PENDING_REMINDERS.AUTHOR_ID.equal(author.getIdLong()))); + PENDING_REMINDERS.AUTHOR_ID.equal(author.getIdLong()) + .and(PENDING_REMINDERS.GUILD_ID.equal(guild.getIdLong())))); if (pendingReminders < MAX_PENDING_REMINDERS_PER_USER) { return true; 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 4e0e167b42..563231afb3 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 @@ -27,7 +27,7 @@ * Reminders can be set by using {@link RemindCommand}. */ public final class RemindRoutine implements Routine { - private static final Logger logger = LoggerFactory.getLogger(RemindRoutine.class); + 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; @@ -71,7 +71,7 @@ private static void sendReminder(@NotNull JDA jda, long id, long channelId, long 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); + MessageChannel channel = jda.getChannelById(MessageChannel.class, channelId); if (channel != null) { return createGuildReminderRoute(jda, authorId, channel); } @@ -81,7 +81,7 @@ private static RestAction computeReminderRoute(@NotNull JDA jda, } private static @NotNull RestAction createGuildReminderRoute(@NotNull JDA jda, - long authorId, @NotNull TextChannel channel) { + long authorId, @NotNull MessageChannel channel) { return jda.retrieveUserById(authorId) .onErrorMap(error -> null) .map(author -> ReminderRoute.toPublic(channel, author)); @@ -128,7 +128,7 @@ Failed to send a reminder (id '{}'), skipping it. This can be due to a network i private record ReminderRoute(@NotNull MessageChannel channel, @Nullable User target, @Nullable String description) { - static ReminderRoute toPublic(@NotNull TextChannel channel, @Nullable User target) { + static ReminderRoute toPublic(@NotNull MessageChannel channel, @Nullable User target) { return new ReminderRoute(channel, target, target == null ? null : target.getAsMention()); } diff --git a/application/src/main/java/org/togetherjava/tjbot/config/Config.java b/application/src/main/java/org/togetherjava/tjbot/config/Config.java index 0581e329cf..a6deae76f7 100644 --- a/application/src/main/java/org/togetherjava/tjbot/config/Config.java +++ b/application/src/main/java/org/togetherjava/tjbot/config/Config.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import org.jetbrains.annotations.NotNull; import java.io.IOException; @@ -70,7 +71,8 @@ private Config(@JsonProperty("token") String token, * @throws IOException if the file could not be loaded */ public static Config load(Path path) throws IOException { - return new ObjectMapper().readValue(path.toFile(), Config.class); + return new ObjectMapper().registerModule(new JavaTimeModule()) + .readValue(path.toFile(), Config.class); } /** diff --git a/application/src/main/java/org/togetherjava/tjbot/config/FreeCommandConfig.java b/application/src/main/java/org/togetherjava/tjbot/config/FreeCommandConfig.java index e1cc6bd01c..03fab031c4 100644 --- a/application/src/main/java/org/togetherjava/tjbot/config/FreeCommandConfig.java +++ b/application/src/main/java/org/togetherjava/tjbot/config/FreeCommandConfig.java @@ -5,7 +5,7 @@ import com.fasterxml.jackson.annotation.JsonRootName; import org.jetbrains.annotations.NotNull; -import java.time.temporal.ChronoUnit; +import java.time.Duration; import java.util.Collection; import java.util.Collections; import java.util.List; @@ -19,6 +19,8 @@ *

  * "freeCommand": [
  *   {
+ *       "inactiveChannelDuration": duration,
+ *       "messageRetrieveLimit": int_number,
  *       "statusChannel": long_number,
  *       "monitoredChannels": [long_number, long_number]
  *   }]
@@ -31,21 +33,20 @@
 @SuppressWarnings("ClassCanBeRecord")
 @JsonRootName("freeCommand")
 public final class FreeCommandConfig {
-    // TODO make constants configurable via config file once config templating (#234) is pushed
-    public static final long INACTIVE_DURATION = 1;
-    public static final ChronoUnit INACTIVE_UNIT = ChronoUnit.HOURS;
-    public static final long INACTIVE_TEST_INTERVAL = 15;
-    public static final ChronoUnit INACTIVE_TEST_UNIT = ChronoUnit.MINUTES;
-    public static final int MESSAGE_RETRIEVE_LIMIT = 10;
-
     private final long statusChannel;
     private final List monitoredChannels;
+    private final Duration inactiveChannelDuration;
+    private final int messageRetrieveLimit;
 
     @JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
     private FreeCommandConfig(@JsonProperty("statusChannel") long statusChannel,
-            @JsonProperty("monitoredChannels") List monitoredChannels) {
+            @JsonProperty("monitoredChannels") List monitoredChannels,
+            @JsonProperty("inactiveChannelDuration") Duration inactiveChannelDuration,
+            @JsonProperty("messageRetrieveLimit") int messageRetrieveLimit) {
         this.statusChannel = statusChannel;
         this.monitoredChannels = Collections.unmodifiableList(monitoredChannels);
+        this.messageRetrieveLimit = messageRetrieveLimit;
+        this.inactiveChannelDuration = inactiveChannelDuration;
     }
 
     /**
@@ -66,4 +67,22 @@ public long getStatusChannel() {
     public @NotNull Collection getMonitoredChannels() {
         return monitoredChannels; // already unmodifiable
     }
+
+    /**
+     * Gets the duration of inactivity after which a channel is considered inactive.
+     * 
+     * @return inactivity duration
+     */
+    public @NotNull Duration getInactiveChannelDuration() {
+        return inactiveChannelDuration;
+    }
+
+    /**
+     * Gets the limit of messages to retrieve when searching for previous status messages.
+     * 
+     * @return the message retrieve limit
+     */
+    public int getMessageRetrieveLimit() {
+        return messageRetrieveLimit;
+    }
 }
diff --git a/application/src/test/java/org/togetherjava/tjbot/commands/mathcommands/TeXCommandTest.java b/application/src/test/java/org/togetherjava/tjbot/commands/mathcommands/TeXCommandTest.java
new file mode 100644
index 0000000000..be2dd18c0e
--- /dev/null
+++ b/application/src/test/java/org/togetherjava/tjbot/commands/mathcommands/TeXCommandTest.java
@@ -0,0 +1,109 @@
+package org.togetherjava.tjbot.commands.mathcommands;
+
+import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent;
+import org.jetbrains.annotations.NotNull;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.togetherjava.tjbot.commands.SlashCommand;
+import org.togetherjava.tjbot.jda.JdaTester;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.description;
+import static org.mockito.Mockito.verify;
+
+final class TeXCommandTest {
+    private JdaTester jdaTester;
+    private SlashCommand command;
+
+    @BeforeEach
+    void setUp() {
+        jdaTester = new JdaTester();
+        command = jdaTester.spySlashCommand(new TeXCommand());
+    }
+
+    private @NotNull SlashCommandInteractionEvent triggerSlashCommand(@NotNull String latex) {
+        SlashCommandInteractionEvent event = jdaTester.createSlashCommandInteractionEvent(command)
+            .setOption(TeXCommand.LATEX_OPTION, latex)
+            .build();
+
+        command.onSlashCommand(event);
+        return event;
+    }
+
+    private void verifySuccessfulResponse(@NotNull SlashCommandInteractionEvent event,
+            @NotNull String query) {
+        verify(jdaTester.getInteractionHookMock(), description("Testing query: " + query))
+            .editOriginal(any(byte[].class), eq("tex.png"));
+    }
+
+    private static List provideSupportedQueries() {
+        List fullLatex = List.of("\\frac{x}{2}", "f \\in \\mathcal{O}(n^2)",
+                "a^{\\varphi(n)} \\equiv 1\\ (\\textrm{mod}\\ n)", "\\textrm{I like } \\xi");
+
+        List inlineLatex = List.of("$\\frac{x}{2}$", "$x$ hello", "hello $x$",
+                "hello $x$ world $y$", "$x$$y$$z$", "$x \\cdot y$");
+
+        List edgeCases = List.of("", "   ", " \n ");
+
+        List allQueries = new ArrayList<>();
+        allQueries.addAll(fullLatex);
+        allQueries.addAll(inlineLatex);
+        allQueries.addAll(edgeCases);
+
+        return allQueries;
+    }
+
+    @ParameterizedTest
+    @MethodSource("provideSupportedQueries")
+    @DisplayName("The command supports and renders all supported latex queries")
+    void canRenderSupportedQuery(@NotNull String supportedQuery) {
+        // GIVEN a supported latex query
+
+        // WHEN triggering the command
+        SlashCommandInteractionEvent event = triggerSlashCommand(supportedQuery);
+
+        // THEN the command send a successful response
+        verifySuccessfulResponse(event, supportedQuery);
+    }
+
+    private static List provideBadInlineQueries() {
+        return List.of("hello $x world", "$", "  $  ", "hello $x$ world$", "$$$$$", "$x$$y$$z");
+    }
+
+    @ParameterizedTest
+    @MethodSource("provideBadInlineQueries")
+    @DisplayName("The command does not support bad inline latex queries, for example with missing dollars")
+    void failsOnBadInlineQuery(@NotNull String badInlineQuery) {
+        // GIVEN a bad inline latex query
+
+        // WHEN triggering the command
+        SlashCommandInteractionEvent event = triggerSlashCommand(badInlineQuery);
+
+        // THEN the command send a failure response
+        verify(event, description("Testing query: " + badInlineQuery))
+            .reply(contains(TeXCommand.INVALID_INLINE_FORMAT_ERROR_MESSAGE));
+    }
+
+    private static List provideBadQueries() {
+        return List.of("__", "\\foo", "\\left(x + y)");
+    }
+
+    @ParameterizedTest
+    @MethodSource("provideBadQueries")
+    @DisplayName("The command does not support bad latex queries, for example with unknown symbols or incomplete braces")
+    void failsOnBadQuery(@NotNull String badQuery) {
+        // GIVEN a bad inline latex query
+
+        // WHEN triggering the command
+        SlashCommandInteractionEvent event = triggerSlashCommand(badQuery);
+
+        // THEN the command send a failure response
+        verify(event, description("Testing query: " + badQuery))
+            .reply(startsWith(TeXCommand.BAD_LATEX_ERROR_PREFIX));
+    }
+}
diff --git a/application/src/test/java/org/togetherjava/tjbot/commands/reminder/RawReminderTestHelper.java b/application/src/test/java/org/togetherjava/tjbot/commands/reminder/RawReminderTestHelper.java
new file mode 100644
index 0000000000..14125f5b76
--- /dev/null
+++ b/application/src/test/java/org/togetherjava/tjbot/commands/reminder/RawReminderTestHelper.java
@@ -0,0 +1,67 @@
+package org.togetherjava.tjbot.commands.reminder;
+
+import net.dv8tion.jda.api.entities.Member;
+import net.dv8tion.jda.api.entities.TextChannel;
+import org.jetbrains.annotations.NotNull;
+import org.togetherjava.tjbot.db.Database;
+import org.togetherjava.tjbot.db.generated.Tables;
+import org.togetherjava.tjbot.db.generated.tables.records.PendingRemindersRecord;
+import org.togetherjava.tjbot.jda.JdaTester;
+
+import java.time.Instant;
+import java.util.List;
+
+import static org.togetherjava.tjbot.db.generated.tables.PendingReminders.PENDING_REMINDERS;
+
+final class RawReminderTestHelper {
+    private Database database;
+    private JdaTester jdaTester;
+
+    RawReminderTestHelper(@NotNull Database database, @NotNull JdaTester jdaTester) {
+        this.database = database;
+        this.jdaTester = jdaTester;
+    }
+
+    void insertReminder(@NotNull String content, @NotNull Instant remindAt) {
+        insertReminder(content, remindAt, jdaTester.getMemberSpy(), jdaTester.getTextChannelSpy());
+    }
+
+    void insertReminder(@NotNull String content, @NotNull Instant remindAt,
+            @NotNull Member author) {
+        insertReminder(content, remindAt, author, jdaTester.getTextChannelSpy());
+    }
+
+    void insertReminder(@NotNull String content, @NotNull Instant remindAt, @NotNull Member author,
+            @NotNull TextChannel channel) {
+        long channelId = channel.getIdLong();
+        long guildId = channel.getGuild().getIdLong();
+        long authorId = author.getIdLong();
+
+        database.write(context -> context.newRecord(Tables.PENDING_REMINDERS)
+            .setCreatedAt(Instant.now())
+            .setGuildId(guildId)
+            .setChannelId(channelId)
+            .setAuthorId(authorId)
+            .setRemindAt(remindAt)
+            .setContent(content)
+            .insert());
+    }
+
+    @NotNull
+    List readReminders() {
+        return readReminders(jdaTester.getMemberSpy());
+    }
+
+    @NotNull
+    List readReminders(@NotNull Member author) {
+        long guildId = jdaTester.getTextChannelSpy().getGuild().getIdLong();
+        long authorId = author.getIdLong();
+
+        return database.read(context -> context.selectFrom(PENDING_REMINDERS)
+            .where(PENDING_REMINDERS.AUTHOR_ID.eq(authorId)
+                .and(PENDING_REMINDERS.GUILD_ID.eq(guildId)))
+            .stream()
+            .map(PendingRemindersRecord::getContent)
+            .toList());
+    }
+}
diff --git a/application/src/test/java/org/togetherjava/tjbot/commands/reminder/RemindCommandTest.java b/application/src/test/java/org/togetherjava/tjbot/commands/reminder/RemindCommandTest.java
new file mode 100644
index 0000000000..697e96853c
--- /dev/null
+++ b/application/src/test/java/org/togetherjava/tjbot/commands/reminder/RemindCommandTest.java
@@ -0,0 +1,137 @@
+package org.togetherjava.tjbot.commands.reminder;
+
+import net.dv8tion.jda.api.entities.Member;
+import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent;
+import org.jetbrains.annotations.NotNull;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.function.Executable;
+import org.togetherjava.tjbot.commands.SlashCommand;
+import org.togetherjava.tjbot.db.Database;
+import org.togetherjava.tjbot.jda.JdaTester;
+
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.startsWith;
+import static org.mockito.Mockito.verify;
+import static org.togetherjava.tjbot.db.generated.tables.PendingReminders.PENDING_REMINDERS;
+
+final class RemindCommandTest {
+    private SlashCommand command;
+    private JdaTester jdaTester;
+    private RawReminderTestHelper rawReminders;
+
+    @BeforeEach
+    void setUp() {
+        Database database = Database.createMemoryDatabase(PENDING_REMINDERS);
+        command = new RemindCommand(database);
+        jdaTester = new JdaTester();
+        rawReminders = new RawReminderTestHelper(database, jdaTester);
+    }
+
+    private @NotNull SlashCommandInteractionEvent triggerSlashCommand(int timeAmount,
+            @NotNull String timeUnit, @NotNull String content) {
+        return triggerSlashCommand(timeAmount, timeUnit, content, jdaTester.getMemberSpy());
+    }
+
+    private @NotNull SlashCommandInteractionEvent triggerSlashCommand(int timeAmount,
+            @NotNull String timeUnit, @NotNull String content, @NotNull Member author) {
+        SlashCommandInteractionEvent event = jdaTester.createSlashCommandInteractionEvent(command)
+            .setOption(RemindCommand.TIME_AMOUNT_OPTION, timeAmount)
+            .setOption(RemindCommand.TIME_UNIT_OPTION, timeUnit)
+            .setOption(RemindCommand.CONTENT_OPTION, content)
+            .setUserWhoTriggered(author)
+            .build();
+
+        command.onSlashCommand(event);
+        return event;
+    }
+
+    @Test
+    @DisplayName("Throws an exception if the time unit is not supported, i.e. not part of the actual choice dialog")
+    void throwsWhenGivenUnsupportedUnit() {
+        // GIVEN
+        // WHEN triggering /remind with the unsupported time unit 'nanoseconds'
+        Executable triggerRemind = () -> triggerSlashCommand(10, "nanoseconds", "foo");
+
+        // THEN command throws, no reminder was created
+        Assertions.assertThrows(IllegalArgumentException.class, triggerRemind);
+        assertTrue(rawReminders.readReminders().isEmpty());
+    }
+
+    @Test
+    @DisplayName("Rejects a reminder time that is set too far in the future and responds accordingly")
+    void doesNotSupportDatesTooFarInFuture() {
+        // GIVEN
+        // WHEN triggering /remind too far in the future
+        SlashCommandInteractionEvent event = triggerSlashCommand(10, "years", "foo");
+
+        // THEN rejects and responds accordingly, no reminder was created
+        verify(event).reply(startsWith("The reminder is set too far in the future"));
+        assertTrue(rawReminders.readReminders().isEmpty());
+    }
+
+    @Test
+    @DisplayName("Rejects a reminder if a user has too many reminders still pending")
+    void userIsLimitedIfTooManyPendingReminders() {
+        // GIVEN a user with too many reminders still pending
+        Instant remindAt = Instant.now().plus(100, ChronoUnit.DAYS);
+        for (int i = 0; i < RemindCommand.MAX_PENDING_REMINDERS_PER_USER; i++) {
+            rawReminders.insertReminder("foo " + i, remindAt);
+        }
+
+        // WHEN triggering another reminder
+        SlashCommandInteractionEvent event = triggerSlashCommand(5, "minutes", "foo");
+
+        // THEN rejects and responds accordingly, no new reminder was created
+        verify(event)
+            .reply(startsWith("You have reached the maximum amount of pending reminders per user"));
+        assertEquals(RemindCommand.MAX_PENDING_REMINDERS_PER_USER,
+                rawReminders.readReminders().size());
+    }
+
+    @Test
+    @DisplayName("Does not limit a user if another user has too many reminders still pending, i.e. the limit is per user")
+    void userIsNotLimitedIfOtherUserHasTooManyPendingReminders() {
+        // GIVEN a user with too many reminders still pending,
+        // and a second user with no reminders yet
+        Member firstUser = jdaTester.createMemberSpy(1);
+        Instant remindAt = Instant.now().plus(100, ChronoUnit.DAYS);
+        for (int i = 0; i < RemindCommand.MAX_PENDING_REMINDERS_PER_USER; i++) {
+            rawReminders.insertReminder("foo " + i, remindAt, firstUser);
+        }
+
+        Member secondUser = jdaTester.createMemberSpy(2);
+
+        // WHEN the second user triggers another reminder
+        SlashCommandInteractionEvent event = triggerSlashCommand(5, "minutes", "foo", secondUser);
+
+        // THEN accepts the reminder and responds accordingly
+        verify(event).reply("Will remind you about 'foo' in 5 minutes.");
+
+        List remindersOfSecondUser = rawReminders.readReminders(secondUser);
+        assertEquals(1, remindersOfSecondUser.size());
+        assertEquals("foo", remindersOfSecondUser.get(0));
+    }
+
+    @Test
+    @DisplayName("The command can create a reminder, the regular base case")
+    void canCreateReminders() {
+        // GIVEN
+        // WHEN triggering the /remind command
+        SlashCommandInteractionEvent event = triggerSlashCommand(5, "minutes", "foo");
+
+        // THEN accepts the reminder and responds accordingly
+        verify(event).reply("Will remind you about 'foo' in 5 minutes.");
+
+        List pendingReminders = rawReminders.readReminders();
+        assertEquals(1, pendingReminders.size());
+        assertEquals("foo", pendingReminders.get(0));
+    }
+}
diff --git a/application/src/test/java/org/togetherjava/tjbot/commands/reminder/RemindRoutineTest.java b/application/src/test/java/org/togetherjava/tjbot/commands/reminder/RemindRoutineTest.java
new file mode 100644
index 0000000000..01271f94ab
--- /dev/null
+++ b/application/src/test/java/org/togetherjava/tjbot/commands/reminder/RemindRoutineTest.java
@@ -0,0 +1,181 @@
+package org.togetherjava.tjbot.commands.reminder;
+
+import net.dv8tion.jda.api.entities.*;
+import net.dv8tion.jda.api.requests.ErrorResponse;
+import net.dv8tion.jda.api.requests.RestAction;
+import org.jetbrains.annotations.NotNull;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.ArgumentMatchers;
+import org.togetherjava.tjbot.commands.Routine;
+import org.togetherjava.tjbot.db.Database;
+import org.togetherjava.tjbot.jda.JdaTester;
+
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.*;
+import static org.togetherjava.tjbot.db.generated.tables.PendingReminders.PENDING_REMINDERS;
+
+final class RemindRoutineTest {
+    private Routine routine;
+    private JdaTester jdaTester;
+    private RawReminderTestHelper rawReminders;
+
+    @BeforeEach
+    void setUp() {
+        Database database = Database.createMemoryDatabase(PENDING_REMINDERS);
+        routine = new RemindRoutine(database);
+        jdaTester = new JdaTester();
+        rawReminders = new RawReminderTestHelper(database, jdaTester);
+    }
+
+    private void triggerRoutine() {
+        routine.runRoutine(jdaTester.getJdaMock());
+    }
+
+    private static @NotNull MessageEmbed getLastMessageFrom(@NotNull MessageChannel channel) {
+        ArgumentCaptor responseCaptor = ArgumentCaptor.forClass(MessageEmbed.class);
+        verify(channel).sendMessageEmbeds(responseCaptor.capture());
+        return responseCaptor.getValue();
+    }
+
+    private @NotNull Member createAndSetupUnknownMember() {
+        int unknownMemberId = 2;
+
+        Member member = jdaTester.createMemberSpy(unknownMemberId);
+
+        RestAction unknownMemberAction = jdaTester.createFailedActionMock(
+                jdaTester.createErrorResponseException(ErrorResponse.UNKNOWN_USER));
+        when(jdaTester.getJdaMock().retrieveUserById(unknownMemberId))
+            .thenReturn(unknownMemberAction);
+
+        RestAction unknownPrivateChannelAction = jdaTester.createFailedActionMock(
+                jdaTester.createErrorResponseException(ErrorResponse.UNKNOWN_USER));
+        when(jdaTester.getJdaMock().openPrivateChannelById(anyLong()))
+            .thenReturn(unknownPrivateChannelAction);
+        when(jdaTester.getJdaMock().openPrivateChannelById(anyString()))
+            .thenReturn(unknownPrivateChannelAction);
+
+        return member;
+    }
+
+    private @NotNull TextChannel createAndSetupUnknownChannel() {
+        long unknownChannelId = 2;
+
+        TextChannel channel = jdaTester.createTextChannelSpy(unknownChannelId);
+        when(jdaTester.getJdaMock()
+            .getChannelById(ArgumentMatchers.>any(), eq(unknownChannelId)))
+                .thenReturn(null);
+
+        return channel;
+    }
+
+    @Test
+    @DisplayName("Sends out a pending reminder to a guild channel, the base case")
+    void sendsPendingReminderChannelFoundAuthorFound() {
+        // GIVEN a pending reminder
+        Instant remindAt = Instant.now();
+        String reminderContent = "foo";
+        Member author = jdaTester.getMemberSpy();
+        rawReminders.insertReminder("foo", remindAt, author);
+
+        // WHEN running the routine
+        triggerRoutine();
+
+        // THEN the reminder is sent out and deleted from the database
+        assertTrue(rawReminders.readReminders().isEmpty());
+
+        MessageEmbed lastMessage = getLastMessageFrom(jdaTester.getTextChannelSpy());
+        assertEquals(reminderContent, lastMessage.getDescription());
+        assertSimilar(remindAt, lastMessage.getTimestamp().toInstant());
+        assertEquals(author.getUser().getAsTag(), lastMessage.getAuthor().getName());
+    }
+
+    @Test
+    @DisplayName("Sends out a pending reminder to a guild channel, even if the author could not be retrieved anymore")
+    void sendsPendingReminderChannelFoundAuthorNotFound() {
+        // GIVEN a pending reminder from an unknown user
+        Instant remindAt = Instant.now();
+        String reminderContent = "foo";
+        Member unknownAuthor = createAndSetupUnknownMember();
+        rawReminders.insertReminder("foo", remindAt, unknownAuthor);
+
+        // WHEN running the routine
+        triggerRoutine();
+
+        // THEN the reminder is sent out and deleted from the database
+        assertTrue(rawReminders.readReminders().isEmpty());
+
+        MessageEmbed lastMessage = getLastMessageFrom(jdaTester.getTextChannelSpy());
+        assertEquals(reminderContent, lastMessage.getDescription());
+        assertSimilar(remindAt, lastMessage.getTimestamp().toInstant());
+        assertEquals("Unknown user", lastMessage.getAuthor().getName());
+    }
+
+    @Test
+    @DisplayName("Sends out a pending reminder via DM, even if the channel could not be retrieved anymore")
+    void sendsPendingReminderChannelNotFoundAuthorFound() {
+        // GIVEN a pending reminder from an unknown channel
+        Instant remindAt = Instant.now();
+        String reminderContent = "foo";
+        Member author = jdaTester.getMemberSpy();
+        TextChannel unknownChannel = createAndSetupUnknownChannel();
+        rawReminders.insertReminder("foo", remindAt, author, unknownChannel);
+
+        // WHEN running the routine
+        triggerRoutine();
+
+        // THEN the reminder is sent out and deleted from the database
+        assertTrue(rawReminders.readReminders().isEmpty());
+
+        MessageEmbed lastMessage = getLastMessageFrom(jdaTester.getPrivateChannelSpy());
+        assertEquals(reminderContent, lastMessage.getDescription());
+        assertSimilar(remindAt, lastMessage.getTimestamp().toInstant());
+        assertEquals(author.getUser().getAsTag(), lastMessage.getAuthor().getName());
+    }
+
+    @Test
+    @DisplayName("Skips a pending reminder if sending it out resulted in an error")
+    void skipPendingReminderOnErrorChannelNotFoundAuthorNotFound() {
+        // GIVEN a pending reminder and from an unknown channel and author
+        Instant remindAt = Instant.now();
+        String reminderContent = "foo";
+        Member unknownAuthor = createAndSetupUnknownMember();
+        TextChannel unknownChannel = createAndSetupUnknownChannel();
+        rawReminders.insertReminder("foo", remindAt, unknownAuthor, unknownChannel);
+
+        // WHEN running the routine
+        triggerRoutine();
+
+        // THEN the reminder is skipped and deleted from the database
+        assertTrue(rawReminders.readReminders().isEmpty());
+    }
+
+    @Test
+    @DisplayName("A reminder that is not pending yet, is not send out")
+    void reminderIsNotSendIfNotPending() {
+        // GIVEN a reminder that is not pending yet
+        Instant remindAt = Instant.now().plus(1, ChronoUnit.HOURS);
+        String reminderContent = "foo";
+        rawReminders.insertReminder("foo", remindAt);
+
+        // WHEN running the routine
+        triggerRoutine();
+
+        // THEN the reminder is not send yet and still in the database
+        assertEquals(1, rawReminders.readReminders().size());
+        verify(jdaTester.getTextChannelSpy(), never()).sendMessageEmbeds(any(MessageEmbed.class));
+    }
+
+    private static void assertSimilar(@NotNull Instant expected, @NotNull Instant actual) {
+        // NOTE For some reason, the instant ends up in the database slightly wrong already (about
+        // half a second), seems to be an issue with jOOQ
+        assertEquals(expected.toEpochMilli(), actual.toEpochMilli(), TimeUnit.SECONDS.toMillis(1));
+    }
+}
diff --git a/application/src/test/java/org/togetherjava/tjbot/jda/JdaTester.java b/application/src/test/java/org/togetherjava/tjbot/jda/JdaTester.java
index 4c96a216ae..ab2428770e 100644
--- a/application/src/test/java/org/togetherjava/tjbot/jda/JdaTester.java
+++ b/application/src/test/java/org/togetherjava/tjbot/jda/JdaTester.java
@@ -1,11 +1,13 @@
 package org.togetherjava.tjbot.jda;
 
 import net.dv8tion.jda.api.AccountType;
+import net.dv8tion.jda.api.JDA;
 import net.dv8tion.jda.api.Permission;
 import net.dv8tion.jda.api.entities.*;
 import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent;
 import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent;
 import net.dv8tion.jda.api.exceptions.ErrorResponseException;
+import net.dv8tion.jda.api.interactions.InteractionHook;
 import net.dv8tion.jda.api.interactions.callbacks.IReplyCallback;
 import net.dv8tion.jda.api.interactions.components.ItemComponent;
 import net.dv8tion.jda.api.requests.ErrorResponse;
@@ -20,6 +22,7 @@
 import net.dv8tion.jda.internal.requests.Requester;
 import net.dv8tion.jda.internal.requests.restaction.AuditableRestActionImpl;
 import net.dv8tion.jda.internal.requests.restaction.MessageActionImpl;
+import net.dv8tion.jda.internal.requests.restaction.WebhookMessageUpdateActionImpl;
 import net.dv8tion.jda.internal.requests.restaction.interactions.ReplyCallbackActionImpl;
 import net.dv8tion.jda.internal.utils.config.AuthorizationConfig;
 import org.jetbrains.annotations.NotNull;
@@ -36,6 +39,7 @@
 import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.ScheduledThreadPoolExecutor;
 import java.util.function.Consumer;
+import java.util.function.Function;
 import java.util.function.Supplier;
 import java.util.function.UnaryOperator;
 
@@ -83,8 +87,11 @@ public final class JdaTester {
     private final ReplyCallbackActionImpl replyAction;
     private final AuditableRestActionImpl auditableRestAction;
     private final MessageActionImpl messageAction;
+    private final WebhookMessageUpdateActionImpl webhookMessageUpdateAction;
     private final TextChannelImpl textChannel;
     private final PrivateChannelImpl privateChannel;
+    private final InteractionHook interactionHook;
+    private final ReplyCallbackAction replyCallbackAction;
 
     /**
      * Creates a new instance. The instance uses a fresh and isolated mocked JDA setup.
@@ -108,6 +115,8 @@ public JdaTester() {
         textChannel = spy(new TextChannelImpl(TEXT_CHANNEL_ID, guild));
         privateChannel = spy(new PrivateChannelImpl(jda, PRIVATE_CHANNEL_ID, user));
         messageAction = mock(MessageActionImpl.class);
+        webhookMessageUpdateAction = mock(WebhookMessageUpdateActionImpl.class);
+        replyCallbackAction = mock(ReplyCallbackAction.class);
         EntityBuilder entityBuilder = mock(EntityBuilder.class);
         Role everyoneRole = new RoleImpl(GUILD_ID, guild);
 
@@ -123,6 +132,9 @@ public JdaTester() {
         doReturn(APPLICATION_ID).when(selfUser).getApplicationIdLong();
         doReturn(selfUser).when(jda).getSelfUser();
         when(jda.getGuildChannelById(anyLong())).thenReturn(textChannel);
+        when(jda.getTextChannelById(anyLong())).thenReturn(textChannel);
+        when(jda.getChannelById(ArgumentMatchers.>any(), anyLong()))
+            .thenReturn(textChannel);
         when(jda.getPrivateChannelById(anyLong())).thenReturn(privateChannel);
         when(jda.getGuildById(anyLong())).thenReturn(guild);
         when(jda.getEntityBuilder()).thenReturn(entityBuilder);
@@ -133,8 +145,6 @@ public JdaTester() {
         doReturn(new Requester(jda, new AuthorizationConfig(TEST_TOKEN))).when(jda).getRequester();
         when(jda.getAccountType()).thenReturn(AccountType.BOT);
 
-        doReturn(messageAction).when(privateChannel).sendMessage(anyString());
-
         replyAction = mock(ReplyCallbackActionImpl.class);
         when(replyAction.setEphemeral(anyBoolean())).thenReturn(replyAction);
         when(replyAction.addActionRow(anyCollection())).thenReturn(replyAction);
@@ -148,13 +158,39 @@ public JdaTester() {
         auditableRestAction = (AuditableRestActionImpl) mock(AuditableRestActionImpl.class);
         doNothing().when(auditableRestAction).queue();
 
-        doNothing().when(messageAction).queue();
+        doNothing().when(webhookMessageUpdateAction).queue();
+        doReturn(webhookMessageUpdateAction).when(webhookMessageUpdateAction)
+            .setActionRow(any(ItemComponent.class));
 
         doReturn(everyoneRole).when(guild).getPublicRole();
         doReturn(selfMember).when(guild).getMember(selfUser);
         doReturn(member).when(guild).getMember(not(eq(selfUser)));
 
+        RestAction userAction = createSucceededActionMock(member.getUser());
+        when(jda.retrieveUserById(anyLong())).thenReturn(userAction);
+
         doReturn(null).when(textChannel).retrieveMessageById(any());
+
+        interactionHook = mock(InteractionHook.class);
+        when(interactionHook.editOriginal(anyString())).thenReturn(webhookMessageUpdateAction);
+        when(interactionHook.editOriginal(any(Message.class)))
+            .thenReturn(webhookMessageUpdateAction);
+        when(interactionHook.editOriginal(any(byte[].class), any(), any()))
+            .thenReturn(webhookMessageUpdateAction);
+
+        doReturn(messageAction).when(textChannel).sendMessageEmbeds(any(), any());
+        doReturn(messageAction).when(textChannel).sendMessageEmbeds(any());
+
+        doNothing().when(messageAction).queue();
+        when(messageAction.content(any())).thenReturn(messageAction);
+
+        RestAction privateChannelAction = createSucceededActionMock(privateChannel);
+        when(jda.openPrivateChannelById(anyLong())).thenReturn(privateChannelAction);
+        when(jda.openPrivateChannelById(anyString())).thenReturn(privateChannelAction);
+        doReturn(null).when(privateChannel).retrieveMessageById(any());
+        doReturn(messageAction).when(privateChannel).sendMessage(anyString());
+        doReturn(messageAction).when(privateChannel).sendMessageEmbeds(any(), any());
+        doReturn(messageAction).when(privateChannel).sendMessageEmbeds(any());
     }
 
     /**
@@ -229,6 +265,8 @@ public JdaTester() {
 
     /**
      * Creates a Mockito spy for a member with the given user id.
+     * 

+ * See {@link #getMemberSpy()} to get the default member used by this tester. * * @param userId the id of the member to create * @return the created spy @@ -238,6 +276,18 @@ public JdaTester() { return spy(new MemberImpl(guild, user)); } + /** + * Creates a Mockito spy for a text channel with the given channel id. + *

+ * See {@link #getTextChannelSpy()} to get the default text channel used by this tester. + * + * @param channelId the id of the text channel to create + * @return the created spy + */ + public @NotNull TextChannel createTextChannelSpy(long channelId) { + return spy(new TextChannelImpl(channelId, guild)); + } + /** * Gets the Mockito mock used as universal reply action by all mocks created by this tester * instance. @@ -251,6 +301,19 @@ public JdaTester() { return replyAction; } + /** + * Gets the Mockito mock used as universal interaction hook by all mocks created by this tester + * instance. + *

+ * For example the events created by {@link #createSlashCommandInteractionEvent(SlashCommand)} + * will return this mock on several of their methods. + * + * @return the interaction hook mock used by this tester + */ + public @NotNull InteractionHook getInteractionHookMock() { + return interactionHook; + } + /** * Gets the text channel spy used as universal text channel by all mocks created by this tester * instance. @@ -264,6 +327,42 @@ public JdaTester() { return textChannel; } + /** + * Gets the private channel spy used as universal private channel by all mocks created by this + * tester instance. + *

+ * For example {@link JDA#openPrivateChannelById(long)} will return this spy if used on the + * instance returned by {@link #getJdaMock()}. + * + * @return the private channel spy used by this tester + */ + public @NotNull PrivateChannel getPrivateChannelSpy() { + return privateChannel; + } + + /** + * Gets the member spy used as universal member by all mocks created by this tester instance. + *

+ * For example the events created by {@link #createSlashCommandInteractionEvent(SlashCommand)} + * will return this spy on several of their methods. + *

+ * See {@link #createMemberSpy(long)} to create other members. + * + * @return the member spy used by this tester + */ + public @NotNull Member getMemberSpy() { + return member; + } + + /** + * Gets the JDA mock used as universal instance by all mocks created by this tester instance. + * + * @return the JDA mock used by this tester + */ + public @NotNull JDA getJdaMock() { + return jda; + } + /** * Creates a mocked action that always succeeds and consumes the given object. *

@@ -295,11 +394,25 @@ public JdaTester() { successConsumer.accept(t); return null; }; + Answer> mapExecution = invocation -> { + Function mapFunction = invocation.getArgument(0); + Object result = mapFunction.apply(t); + return createSucceededActionMock(result); + }; + Answer> flatMapExecution = invocation -> { + Function> flatMapFunction = invocation.getArgument(0); + return flatMapFunction.apply(t); + }; doNothing().when(action).queue(); doAnswer(successExecution).when(action).queue(any()); doAnswer(successExecution).when(action).queue(any(), any()); + when(action.onErrorMap(any())).thenReturn(action); + when(action.onErrorMap(any(), any())).thenReturn(action); + + doAnswer(mapExecution).when(action).map(any()); + doAnswer(flatMapExecution).when(action).flatMap(any()); return action; } @@ -336,11 +449,27 @@ public JdaTester() { return null; }; + Answer> errorMapExecution = invocation -> { + Function mapFunction = invocation.getArgument(0); + Object result = mapFunction.apply(failureReason); + return createSucceededActionMock(result); + }; + + Answer> mapExecution = invocation -> createFailedActionMock(failureReason); + Answer> flatMapExecution = + invocation -> createFailedActionMock(failureReason); + doNothing().when(action).queue(); doNothing().when(action).queue(any()); + doAnswer(errorMapExecution).when(action).onErrorMap(any()); + doAnswer(errorMapExecution).when(action).onErrorMap(any(), any()); + doAnswer(failureExecution).when(action).queue(any(), any()); + doAnswer(mapExecution).when(action).map(any()); + doAnswer(flatMapExecution).when(action).flatMap(any()); + return action; } @@ -371,6 +500,10 @@ private void mockInteraction(@NotNull IReplyCallback interaction) { doReturn(textChannel).when(interaction).getTextChannel(); doReturn(textChannel).when(interaction).getGuildChannel(); doReturn(privateChannel).when(interaction).getPrivateChannel(); + + doReturn(interactionHook).when(interaction).getHook(); + doReturn(replyCallbackAction).when(interaction).deferReply(); + doReturn(replyCallbackAction).when(interaction).deferReply(anyBoolean()); } private void mockButtonClickEvent(@NotNull ButtonInteractionEvent event) { diff --git a/application/src/test/java/org/togetherjava/tjbot/jda/SlashCommandInteractionEventBuilder.java b/application/src/test/java/org/togetherjava/tjbot/jda/SlashCommandInteractionEventBuilder.java index ec414445dd..4f4323944d 100644 --- a/application/src/test/java/org/togetherjava/tjbot/jda/SlashCommandInteractionEventBuilder.java +++ b/application/src/test/java/org/togetherjava/tjbot/jda/SlashCommandInteractionEventBuilder.java @@ -2,9 +2,9 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; -import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; import net.dv8tion.jda.api.entities.Member; import net.dv8tion.jda.api.entities.User; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; 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; @@ -103,6 +103,26 @@ public final class SlashCommandInteractionEventBuilder { return this; } + /** + * Sets the given option, overriding an existing value under the same name. + *

+ * If {@link #setSubcommand(String)} is set, this option will be interpreted as option to the + * subcommand. + *

+ * Use {@link #clearOptions()} to clear any set options. + * + * @param name the name of the option + * @param value the value of the option + * @return this builder instance for chaining + * @throws IllegalArgumentException if the option does not exist in the corresponding command, + * as specified by its {@link SlashCommand#getData()} + */ + public @NotNull SlashCommandInteractionEventBuilder setOption(@NotNull String name, + long value) { + putOptionRaw(name, value, OptionType.INTEGER); + return this; + } + /** * Sets the given option, overriding an existing value under the same name. *

@@ -292,6 +312,14 @@ private SlashCommandInteractionEvent spySlashCommandEvent(String jsonData) { @NotNull OptionType type) { if (type == OptionType.STRING) { return (String) value; + } else if (type == OptionType.INTEGER) { + if (value instanceof Long asLong) { + return value.toString(); + } + + throw new IllegalArgumentException( + "Expected a long, since the type was set to INTEGER. But got '%s'" + .formatted(value.getClass())); } else if (type == OptionType.USER) { if (value instanceof User user) { return user.getId();