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..d5a965af5f 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)); @@ -102,7 +106,7 @@ public enum Features { features.add(new UnquarantineCommand(actionsStore, config)); // 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/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;
+    }
 }