diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/Commands.java b/application/src/main/java/org/togetherjava/tjbot/commands/Commands.java index de704a1f7a..0d6b32526e 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/Commands.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/Commands.java @@ -4,6 +4,7 @@ import org.togetherjava.tjbot.commands.basic.DatabaseCommand; import org.togetherjava.tjbot.commands.basic.PingCommand; import org.togetherjava.tjbot.commands.basic.VcActivityCommand; +import org.togetherjava.tjbot.commands.free.FreeCommand; import org.togetherjava.tjbot.commands.mathcommands.TeXCommand; import org.togetherjava.tjbot.commands.moderation.BanCommand; import org.togetherjava.tjbot.commands.moderation.KickCommand; @@ -55,6 +56,7 @@ public enum Commands { commands.add(new KickCommand()); commands.add(new BanCommand()); commands.add(new UnbanCommand()); + commands.add(new FreeCommand()); return commands; } diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/free/ChannelMonitor.java b/application/src/main/java/org/togetherjava/tjbot/commands/free/ChannelMonitor.java new file mode 100644 index 0000000000..9b600e79b6 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/free/ChannelMonitor.java @@ -0,0 +1,292 @@ +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 org.jetbrains.annotations.NotNull; + +import java.util.*; +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 { + // Map to store channel ID's, use Guild.getChannels() to guarantee order for display + private final Map channelsToMonitorById; + private final Map guildIdToStatusChannel; + + ChannelMonitor() { + guildIdToStatusChannel = new HashMap<>(); // JDA required to populate map + channelsToMonitorById = new HashMap<>(); + } + + /** + * Method for adding channels that need to be monitored. + * + * @param channelId the id of the channel to monitor + */ + public void addChannelToMonitor(final long channelId) { + channelsToMonitorById.put(channelId, new ChannelStatus(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)}. + * + * @param channel the channel the status message must be displayed in + */ + public void addChannelForStatus(@NotNull final TextChannel channel) { + guildIdToStatusChannel.put(channel.getGuild().getIdLong(), channel.getIdLong()); + updateStatusFor(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. + */ + public boolean isMonitoringGuild(final long guildId) { + return guildIdToStatusChannel.containsKey(guildId); + } + + /** + * This method tests whether a channel id is configured for monitoring in the free command + * 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. + */ + public boolean isMonitoringChannel(final long channelId) { + return channelsToMonitorById.containsKey(channelId); + } + + private ChannelStatus requiresIsMonitored(final long channelId) { + if (!channelsToMonitorById.containsKey(channelId)) { + throw new IllegalArgumentException( + "Channel with id: %s is not monitored by free channel".formatted(channelId)); + } + return channelsToMonitorById.get(channelId); + } + + /** + * This method tests if channel status to busy, see {@link ChannelStatus#isBusy()} for details. + * + * @param channelId the id for the channel to test. + * @return {@code true} if the channel is 'busy', false if the channel is 'free'. + * @throws IllegalArgumentException if the channel passed is not monitored. See + * {@link #addChannelToMonitor(long)} + */ + public boolean isChannelBusy(final long channelId) { + return requiresIsMonitored(channelId).isBusy(); + } + + /** + * 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}. + * + * @param channel the channel to test. + * @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) { + 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 + .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 + } + + /** + * 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 + * {@link #addChannelToMonitor(long)} + */ + public void setChannelBusy(final long channelId, final long userId) { + requiresIsMonitored(channelId).setBusy(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)} + */ + public void setChannelFree(final long channelId) { + requiresIsMonitored(channelId).setFree(); + } + + /** + * This method provides a stream of the id's for guilds that are currently being monitored. This + * is streamed purely as a simple method of encapsulation. + * + * @return a stream of guild id's + */ + public @NotNull Stream guildIds() { + return guildIdToStatusChannel.keySet().stream(); + } + + /** + * 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() { + return guildIdToStatusChannel.values().stream(); + } + + private @NotNull List guildMonitoredChannelsList(@NotNull final Guild guild) { + return guild.getChannels() + .stream() + .map(GuildChannel::getIdLong) + .filter(channelsToMonitorById::containsKey) + .map(channelsToMonitorById::get) + .toList(); + } + + /** + * Creates the status message (specific to the guild specified) that shows which channels are + * busy/free. + *

+ * 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 + * somewhere other than discord will lead to unexpected results. + */ + public String statusMessage(@NotNull final Guild guild) { + List statusFor = guildMonitoredChannelsList(guild); + + // update name so that current channel name is used + statusFor.forEach(channelStatus -> channelStatus.updateChannelName(guild)); + + // dynamically separate channels by channel categories + StringJoiner content = new StringJoiner("\n"); + String categoryName = ""; + for (ChannelStatus status : statusFor) { + TextChannel channel = guild.getTextChannelById(status.getChannelId()); + if (channel == null) { + // pointless ... added to remove warnings + continue; + } + Category category = channel.getParent(); + if (category != null && !category.getName().equals(categoryName)) { + categoryName = category.getName(); + // append the category name on a new line with markup for underlining + // TODO possible bug when not all channels are part of categories, may mistakenly + // include uncategorized channels inside previous category. will an uncategorized + // channel return an empty string or null? javadocs don't say. + content.add("\n__" + categoryName + "__"); + } + content.add(status.toDiscordContentRaw()); + } + + return content.toString(); + } + + /** + * 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)}. + *

+ * 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 + * + * @param guild the guild for which to test the channel statuses of. + */ + public void updateStatusFor(@NotNull Guild guild) { + // TODO add automation after Routine support (#235) is pushed + 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); + } + + /** + * This method returns the {@link TextChannel} that has been configured as the output of the + * status messages about busy/free for the specified guild. + * + * @param guild the {@link Guild} for which to retrieve the TextChannel for. + * @return the TextChannel where status messages are output in the specified guild. + * @throws IllegalArgumentException if the guild passed has not configured in the free command + * system, see {@link #addChannelForStatus(TextChannel)} + */ + public @NotNull TextChannel getStatusChannelFor(@NotNull final Guild guild) { + if (!guildIdToStatusChannel.containsKey(guild.getIdLong())) { + throw new IllegalArgumentException( + "Guild %s is not configured in the free command system." + .formatted(guild.getName())); + } + long channelId = guildIdToStatusChannel.get(guild.getIdLong()); + TextChannel channel = guild.getTextChannelById(channelId); + if (channel == null) + throw new IllegalStateException("Status channel %d does not exist in guild %s" + .formatted(channelId, guild.getName())); + return channel; + } + + /** + * 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 + public String toString() { + // This is called on boot as a debug level message by the logger + return "Monitoring Channels: %s%nDisplaying on Channels: %s" + .formatted(channelsToMonitorById, guildIdToStatusChannel); + } +} 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 new file mode 100644 index 0000000000..3dda6a17c8 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/free/ChannelStatus.java @@ -0,0 +1,206 @@ +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.GuildChannel; +import net.dv8tion.jda.api.entities.TextChannel; +import org.jetbrains.annotations.NotNull; + +import java.util.Objects; + +/** + * Class that tracks the current free/busy status of a channel that requires monitoring. + */ +final class ChannelStatus { + + private final long channelId; + private volatile long userId; + private volatile ChannelStatusType status; + private String name; + + /** + * Creates an instance of a Channel Status. + *

+ * This does not validate the id as that requires the JDA. Any ChannelStatus that gets created + * with an invalid id *should* be ignored and won't be invoked. (Since the Channel statuses are + * selected by retrieval of channels id's via the guild id, before retrieving the relevant + * ChannelStatuses). + * + * @param id the long id of the {@link net.dv8tion.jda.api.entities.TextChannel} to monitor. + */ + ChannelStatus(final long id) { + channelId = id; + status = ChannelStatusType.BUSY; + name = Long.toString(id); + } + + /** + * Retrieves whether the channel is currently busy/free. + *

+ * This value is volatile but is not thread safe in any other way. While statuses change + * frequently, each individual status instance *should* only be modified from a single source, + * since it represents only a single channel and modification will only be triggered by activity + * in that one channel. + * + * @return the current stored status related to the channel id. + */ + public synchronized boolean isBusy() { + return status.isBusy(); + } + + /** + * Method to test if an id is the same as the id of the help requester who most recently posted + * a question. + * + * @param userId the id to test + * @return {@code true} if the id value passed in is the same as the value of the user who most + * recently changed the status to 'busy'. {@code false} otherwise. + */ + public boolean isAsker(final long userId) { + return this.userId == userId; + } + + /** + * Retrieves the id for the {@link net.dv8tion.jda.api.entities.TextChannel} that this instance + * represents. There is no guarantee that the id is valid according to the {@link JDA}. + * + * @return the {@link net.dv8tion.jda.api.entities.TextChannel} id. + */ + public long getChannelId() { + return channelId; + } + + /** + * Retrieves the locally stored name of the {@link net.dv8tion.jda.api.entities.TextChannel} + * this represents. This value is initialised to the channel id and as such is never null. The + * name should first be set by retrieving the name the {@link JDA} currently uses, before + * calling this. + *

+ * The recommended value to use is {@link TextChannel#getAsMention()}. + * + * @return The currently stored name of the channel. + */ + public @NotNull String getName() { + return name; + } + + private void setName(@NotNull final String name) { + this.name = name; + } + + /** + * Method used to keep the channel name up to date with the {@link JDA}. This method is not + * called automatically. Manually update before using the value. + *

+ * The recommended value to use is {@link TextChannel#getAsMention()} + *

+ * This method is called in multithreaded context, however the value is not expected to change + * regularly and will not break anything if it is incorrect for a read or two, and it should be + * updated before use, which will happen in the using thread. So it has not been made thread + * safe. + * + * @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)} + * @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. + */ + public void updateChannelName(@NotNull final Guild guild) { + GuildChannel channel = guild.getGuildChannelById(channelId); + if (channel == null) { + throw new IllegalArgumentException( + "The guild passed in '%s' is not related to the channel this status is for: %s" + .formatted(guild.getName(), this)); + } + if (!(channel instanceof TextChannel textChannel)) { + throw new IllegalStateException("This channel status was created with the id for a" + + "non-text-channel and status cannot be monitored: '%s'".formatted(channelId)); + } else { + setName(textChannel.getAsMention()); + } + } + + /** + * Method to set the channel status to busy, a user id is passed in to keep track of the current + * user requesting help. This id will be used to confirm that the author is satisfied with the + * channel being marked as free. + *

+ * This functionality is not yet implemented so the id can be anything atm. Also note that on + * reboot the bot does not currently search for the author so the first time its marked as free + * there will be no confirmation. + * + * @param userId the id of the user who changed the status to 'busy' + */ + public synchronized void setBusy(final long userId) { + if (status.isFree()) { + status = ChannelStatusType.BUSY; + this.userId = userId; + } + } + + /** + * Method to set the channel status to free, the user id of the previous help requester is not + * overwritten by this method. So until another user changes the status to busy the old value + * will remain. + *

+ * The value will be 0 until the first time that the status is changed from free to busy. + *

+ * This functionality is not yet implemented so the id can be anything atm. + */ + public synchronized void setFree() { + status = ChannelStatusType.FREE; + } + + /** + * The identity of this object is solely based on the id value. Compares the long id's and + * determines if they are equal. + * + * @param o the other object to test against + * @return whether the objects have the same id or not. + */ + @Override + public boolean equals(final Object o) { + // TODO should I overload equals with equals(long) so that a Set may be used instead of a + // Map + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + + ChannelStatus channelStatus = (ChannelStatus) o; + return channelId == channelStatus.channelId; + } + + /** + * A String representation of the instance, gives the name and the current status. + * + * @return a String representation of the instance. + */ + @Override + public @NotNull String toString() { + return "ChannelStatus{ %s is %s }".formatted(name, status.description()); + } + + /** + * A {@link #toString()} method specially formatted for Discord ({@link JDA}. Uses emojis by + * string representation, that discord will automatically convert into images. Using this string + * outside of discord will display unexpected results. + * + * @return a String representation of ChannelStatus, formatted for Discord + */ + public @NotNull String toDiscordContentRaw() { + return "%s %s".formatted(status.toDiscordContentRaw(), name); + } + + /** + * The hash that represents the instance. It is based only on the id value. + * + * @return the instance's hash. + */ + @Override + public int hashCode() { + return Objects.hash(channelId); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/free/ChannelStatusType.java b/application/src/main/java/org/togetherjava/tjbot/commands/free/ChannelStatusType.java new file mode 100644 index 0000000000..8ebb09f4e5 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/free/ChannelStatusType.java @@ -0,0 +1,32 @@ +package org.togetherjava.tjbot.commands.free; + +import org.jetbrains.annotations.NotNull; + +enum ChannelStatusType { + FREE("free", ":green_circle:"), + BUSY("busy", ":red_circle:"); + + private final String description; + private final String emoji; + + ChannelStatusType(@NotNull String description, @NotNull String emoji) { + this.description = description; + this.emoji = emoji; + } + + public boolean isFree() { + return this == FREE; + } + + public boolean isBusy() { + return this == BUSY; + } + + public @NotNull String description() { + return description; + } + + public @NotNull String toDiscordContentRaw() { + return emoji; + } +} 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 new file mode 100644 index 0000000000..006f789b99 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/free/FreeCommand.java @@ -0,0 +1,362 @@ +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.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.SlashCommandEvent; +import net.dv8tion.jda.api.events.message.guild.GuildMessageReceivedEvent; +import net.dv8tion.jda.api.hooks.EventListener; +import net.dv8tion.jda.api.requests.RestAction; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.togetherjava.tjbot.commands.SlashCommandAdapter; +import org.togetherjava.tjbot.commands.SlashCommandVisibility; +import org.togetherjava.tjbot.commands.utils.MessageUtils; +import org.togetherjava.tjbot.config.Config; +import org.togetherjava.tjbot.config.FreeCommandConfig; + +import java.awt.*; +import java.util.*; + +// TODO (can SlashCommandVisibility be narrower than GUILD?) +// TODO monitor all channels when list is empty? monitor none? +// TODO (use other emojis? use images?) +// TODO add command to add/remove/status channels to monitor? +// TODO test if message is a reply and don't mark as busy if it is +// TODO add button query to confirm that message is new question not additional info for existing +// discussion before marking as busy +// 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. + *

+ * When a user posts a message in a channel that is being monitored that channel is automatically + * marked as busy until they post {@code /free} to notify the bot and other users that the channel + * is now available or after a preconfigured period of time has passed without any traffic. + *

+ * If any user posts a message that directly 'replies' to an existing message, in a monitored + * channel that is currently marked as free, the free status will remain. + *

+ * If a user starts typing in a channel where 2 or more users have posted multiple messages each, + * less than a configured time ago, they will receive an ephemeral message warning them that the + * channel is currently in use and that they should post in a free channel if they are trying to ask + * a question. + *

+ * A summary of the current status of those channels is displayed in a predefined channel. This + * channel may be one of the monitored channels however it is recommended that a different channel + * is used. + */ +public final class FreeCommand extends SlashCommandAdapter implements EventListener { + 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"); + + // Map to store channel ID's, use Guild.getChannels() to guarantee order for display + private final ChannelMonitor channelMonitor; + private final Map channelIdToMessageIdForStatus; + + private boolean isReady; + + + /** + * Creates an instance of FreeCommand. + *

+ * This fetches configuration information from a json configuration file (see + * {@link FreeCommandConfig}) for further details. + */ + public FreeCommand() { + super(COMMAND_NAME, "marks this channel as free for another user to ask a question", + SlashCommandVisibility.GUILD); + + channelIdToMessageIdForStatus = new HashMap<>(); + channelMonitor = new ChannelMonitor(); + + isReady = false; + } + + /** + * Reaction to the 'onReady' event. This method binds the configurables to the + * {@link net.dv8tion.jda.api.JDA} instance. Including fetching the names of the channels this + * command monitors. + *

+ * It also updates the Status messages in their relevant channels, so that the message is + * up-to-date. + *

+ * This also registers a new listener on the {@link net.dv8tion.jda.api.JDA}, this should be + * removed when the code base supports additional functionality + * + * @param event the event this method reacts to + */ + @Override + public void onReady(@NotNull final ReadyEvent event) { + final JDA jda = event.getJDA(); + // TODO remove this when onGuildMessageReceived has another access point + jda.addEventListener(this); + + initChannelsToMonitor(); + initStatusMessageChannels(jda); + logger.debug("Config loaded:\n{}", channelMonitor); + + checkBusyStatusAllChannels(jda); + + channelMonitor.statusIds() + .map(id -> requiresTextChannel(jda, id)) + .forEach(this::displayStatus); + + isReady = true; + } + + /** + * When triggered with {@code /free} this will mark a help channel as not busy (free for another + * person to use). + *

+ * 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 + */ + @Override + public void onSlashCommand(@NotNull final SlashCommandEvent event) { + logger.debug("/free used by {} on channel {}", event.getUser().getAsTag(), + event.getChannel().getName()); + if (!handleShouldBeProcessed(event)) { + return; + } + + long id = event.getChannel().getIdLong(); + // do not need to test if key is present, shouldHandle(event) already does. + if (!channelMonitor.isChannelBusy(id)) { + FreeUtil.sendErrorMessage(event, UserStrings.ALREADY_FREE_ERROR.message()); + return; + } + // TODO check if /free called by original author, if not put message asking if he approves + channelMonitor.setChannelFree(id); + displayStatus(channelMonitor.getStatusChannelFor(requiresGuild(event))); + event.reply(UserStrings.MARK_AS_FREE.message()).queue(); + } + + /** + * Method to test event to see if it should be processed. + *

+ * Will respond to users describing the problem if the event should not be processed. + *

+ * This checks if the command system is ready to process events, if the event was triggered in a + * monitored guild and in a monitored channel. + * + * @param event the event to test for validity. + * @return true if the event should be processed false otherwise. + */ + private boolean handleShouldBeProcessed(@NotNull final SlashCommandEvent event) { + if (!isReady) { + logger.debug( + "Slash command requested by {} in {}(channel: {}) before command is ready.", + event.getUser().getIdLong(), event.getGuild(), event.getChannel().getName()); + FreeUtil.sendErrorMessage(event, UserStrings.NOT_READY_ERROR.message()); + return false; + } + // checks if guild is null and throws IllegalStateException if it is + Guild guild = requiresGuild(event); + if (!channelMonitor.isMonitoringGuild(guild.getIdLong())) { + logger.error( + "Slash command used by {} in {}(channel: {}) when guild is not configured for Free Command", + event.getUser().getIdLong(), guild, event.getChannel().getName()); + FreeUtil.sendErrorMessage(event, + UserStrings.NOT_CONFIGURED_ERROR.formatted(guild.getName())); + return false; + } + if (!channelMonitor.isMonitoringChannel(event.getChannel().getIdLong())) { + logger.debug("'/free called in un-configured channel {}({})", guild.getName(), + event.getChannel().getName()); + FreeUtil.sendErrorMessage(event, UserStrings.NOT_MONITORED_ERROR.message()); + return false; + } + + 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 = MessageUtils.generateEmbed(STATUS_TITLE, messageTxt, + channel.getJDA().getSelfUser(), MESSAGE_HIGHLIGHT_COLOR); + + 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); + } + + private @NotNull Guild requiresGuild(@NotNull JDA jda, long id) { + Guild guild = jda.getGuildById(id); + if (guild == null) { + throw new IllegalStateException( + "The guild with id '%d' has been deleted since free command system was configured." + .formatted(id)); + } + return guild; + } + + private @NotNull Guild requiresGuild(SlashCommandEvent event) { + Guild guild = event.getGuild(); + if (guild == null) { + throw new IllegalStateException( + "A global slash command '%s' somehow got routed to the free system which requires a guild" + .formatted(event.getCommandString())); + } + 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. + *

+ * Marks channels as busy when a user posts a message in a monitored channel that is currently + * free. + * + * @param event the generic event that includes the 'onGuildMessageReceived'. + */ + @Override + public void onEvent(@NotNull GenericEvent event) { + if (event instanceof GuildMessageReceivedEvent guildEvent) { + if (guildEvent.isWebhookMessage() || guildEvent.getAuthor().isBot()) { + return; + } + if (!channelMonitor.isMonitoringChannel(guildEvent.getChannel().getIdLong())) { + logger.debug( + "Channel is not being monitored, ignoring message received in {} from {}", + guildEvent.getChannel().getName(), guildEvent.getAuthor()); + return; + } + if (channelMonitor.isChannelBusy(guildEvent.getChannel().getIdLong())) { + logger.debug( + "Channel status is currently busy, ignoring message received in {} from {}", + guildEvent.getChannel().getName(), guildEvent.getAuthor()); + return; + } + channelMonitor.setChannelBusy(guildEvent.getChannel().getIdLong(), + guildEvent.getAuthor().getIdLong()); + displayStatus(channelMonitor.getStatusChannelFor(guildEvent.getGuild())); + guildEvent.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.getInstance() + .getFreeCommandConfig() + .stream() + .map(FreeCommandConfig::getMonitoredChannels) + .flatMap(Collection::stream) + .forEach(channelMonitor::addChannelToMonitor); + } + + private void initStatusMessageChannels(@NotNull final JDA jda) { + Config.getInstance() + .getFreeCommandConfig() + .stream() + .map(FreeCommandConfig::getStatusChannel) + // throws IllegalStateException if the id's don't match TextChannels + .map(id -> requiresTextChannel(jda, id)) + .forEach(channelMonitor::addChannelForStatus); + } + + private @NotNull TextChannel requiresTextChannel(@NotNull JDA jda, long id) { + TextChannel channel = jda.getTextChannelById(id); + if (channel == null) { + throw new IllegalStateException( + "The id '%d' supplied in the config file, is not a valid id for a TextChannel" + .formatted(id)); + } + return channel; + } +} 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 new file mode 100644 index 0000000000..99a002c257 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/free/FreeUtil.java @@ -0,0 +1,99 @@ +package org.togetherjava.tjbot.commands.free; + +import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.entities.TextChannel; +import net.dv8tion.jda.api.interactions.Interaction; +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; + +/** + * A class containing helper methods required by the free package command. + */ +enum FreeUtil { + ; + private static final Logger logger = LoggerFactory.getLogger(FreeUtil.class); + + /** + * Helper method to easily send ephemeral messages to users. + * + * @param interaction The event or hook that this message is responding to + * @param message The text to be display for the user to read. + */ + public static void sendErrorMessage(@NotNull Interaction interaction, @NotNull String message) { + interaction.reply(message).setEphemeral(true).queue(); + } + + /** + * Method that provides the message history of a {@link TextChannel}. + *

+ *

+ * This method attempts to retrieve the message history, and logs any problems that occur in the + * attempt. + * + * @param channel the channel from which the history is required. + * @param limit the number of messages to retrieve. + * @return the requested message history or empty if unable to. + */ + public static @NotNull Optional> getChannelHistory(@NotNull TextChannel channel, + final int limit) { + return channel.getHistory().retrievePast(limit).mapToResult().map(listResult -> { + if (listResult.isFailure()) { + logger.error("Failed to retrieve messages from %s because of:" + .formatted(channel.getAsMention()), listResult.getFailure()); + return Optional.>empty(); + } + return Optional.of(listResult.get()); + }).complete(); + } + + /** + * Method that provides the id of the latest message in a {@link TextChannel}. + *

+ * This method tests for problems with retrieving the id like the latest message was deleted and + * the channel history being empty (or network trouble), etc. + * + * @param channel the channel from which the latest message is required. + * @return the id of the latest message or empty if it could not be retrieved. + */ + public static @NotNull OptionalLong getLastMessageId(@NotNull TextChannel channel) { + if (channel.hasLatestMessage()) { + return OptionalLong.of(channel.getLatestMessageIdLong()); + } + // black magic to convert Optional into OptionalLong because Optional does not have + // .mapToLong + return getChannelHistory(channel, 1).stream() + .flatMap(List::stream) + .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 new file mode 100644 index 0000000000..0a62023a50 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/free/UserStrings.java @@ -0,0 +1,62 @@ +package org.togetherjava.tjbot.commands.free; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Class containing all the strings sent to users during their interaction with the free command + * system. This does not include the logged strings or the exception strings. + */ +enum UserStrings { + NEW_QUESTION(""" + Thank you for asking a question in an available channel. + When a helper who can answer this question reads it they will help you. + Please be patient. + + __Please do not post your question in other channels__ + """), + MARK_AS_FREE(""" + This channel is now available for a question to be asked. + """), + ALREADY_FREE_ERROR(""" + This channel is already free, no changes made. + """), + NOT_READY_ERROR(""" + Command not ready please try again in a minute. + """), + NOT_MONITORED_ERROR("This channel is not being monitored for free/busy status. If you" + + "believe this channel should be part of the free/busy status system, please discuss it" + + "with a moderator"), + NOT_CONFIGURED_ERROR(""" + This guild (%s) is not configured to use the '/free' command. + Please add entries in the config, restart the bot and try again. + """); + + private final String message; + + UserStrings(@NotNull String message) { + this.message = message; + } + + /** + * Method to fetch the string that will be sent to a user in reaction to any event triggered by + * the free command system for that user. + * + * @return the string to send to a user to give them the specified response. + */ + public @NotNull String message() { + return message; + } + + /** + * Method to fetch the string that will be sent to a user in reaction to any event triggered by + * the free command system for that user. This can be used to add tagged values in the same way + * as {@link String#format(String, Object...)} + * + * @param args the replacement values for the specified tags. + * @return the string to send to a user to give them the specified response. + */ + public @NotNull String formatted(@Nullable Object... args) { + return message.formatted(args); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/free/package-info.java b/application/src/main/java/org/togetherjava/tjbot/commands/free/package-info.java new file mode 100644 index 0000000000..5fcca7cd69 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/free/package-info.java @@ -0,0 +1,5 @@ +/** + * This packages offers all the functionality for the free command system. Marking channels as + * free/busy and displaying a status message representing such + */ +package org.togetherjava.tjbot.commands.free; 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 bcfd9d9c05..35c077dc13 100644 --- a/application/src/main/java/org/togetherjava/tjbot/config/Config.java +++ b/application/src/main/java/org/togetherjava/tjbot/config/Config.java @@ -3,9 +3,13 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.ObjectMapper; +import org.jetbrains.annotations.NotNull; import java.io.IOException; import java.nio.file.Path; +import java.util.Collection; +import java.util.Collections; +import java.util.List; import java.util.Objects; /** @@ -29,6 +33,8 @@ public final class Config { private final String softModerationRolePattern; private final String tagManageRolePattern; + private final List freeCommand; + @SuppressWarnings("ConstructorWithTooManyParameters") @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) private Config(@JsonProperty("token") String token, @@ -39,7 +45,8 @@ private Config(@JsonProperty("token") String token, @JsonProperty("mutedRolePattern") String mutedRolePattern, @JsonProperty("heavyModerationRolePattern") String heavyModerationRolePattern, @JsonProperty("softModerationRolePattern") String softModerationRolePattern, - @JsonProperty("tagManageRolePattern") String tagManageRolePattern) { + @JsonProperty("tagManageRolePattern") String tagManageRolePattern, + @JsonProperty("freeCommand") List freeCommand) { this.token = token; this.databasePath = databasePath; this.projectWebsite = projectWebsite; @@ -49,6 +56,7 @@ private Config(@JsonProperty("token") String token, this.heavyModerationRolePattern = heavyModerationRolePattern; this.softModerationRolePattern = softModerationRolePattern; this.tagManageRolePattern = tagManageRolePattern; + this.freeCommand = Collections.unmodifiableList(freeCommand); } /** @@ -133,7 +141,7 @@ public String getDiscordGuildInvite() { /** * Gets the REGEX pattern used to identify roles that are allowed to use heavy moderation * commands, such as banning, based on role names. - * + * * @return the REGEX pattern */ public String getHeavyModerationRolePattern() { @@ -143,7 +151,7 @@ public String getHeavyModerationRolePattern() { /** * Gets the REGEX pattern used to identify roles that are allowed to use soft moderation * commands, such as kicking, muting or message deletion, based on role names. - * + * * @return the REGEX pattern */ public String getSoftModerationRolePattern() { @@ -159,4 +167,15 @@ public String getSoftModerationRolePattern() { public String getTagManageRolePattern() { return tagManageRolePattern; } + + /** + * Gets a List of channel id's required to configure the free command system see + * {@link FreeCommandConfig} + * + * @return a List of instances of FreeCommandConfig, each of the instances are separated by + * guild. + */ + public @NotNull Collection getFreeCommandConfig() { + return freeCommand; // already unmodifiable + } } diff --git a/application/src/main/java/org/togetherjava/tjbot/config/FreeCommandConfig.java b/application/src/main/java/org/togetherjava/tjbot/config/FreeCommandConfig.java new file mode 100644 index 0000000000..e1cc6bd01c --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/config/FreeCommandConfig.java @@ -0,0 +1,69 @@ +package org.togetherjava.tjbot.config; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonRootName; +import org.jetbrains.annotations.NotNull; + +import java.time.temporal.ChronoUnit; +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +/** + * Config instance for the Free Command System see + * {@link org.togetherjava.tjbot.commands.free.FreeCommand} + * + * The Json looks as follows: + * + *

+ * "freeCommand": [
+ *   {
+ *       "statusChannel": long_number,
+ *       "monitoredChannels": [long_number, long_number]
+ *   }]
+ * 
+ * + * Additional Guilds may add their settings by adding additional {@code {"statusChannel": ... } } + * + * The long channel ID can be found by right-clicking on the channel and selecting 'Copy ID' + */ +@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; + + @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) + private FreeCommandConfig(@JsonProperty("statusChannel") long statusChannel, + @JsonProperty("monitoredChannels") List monitoredChannels) { + this.statusChannel = statusChannel; + this.monitoredChannels = Collections.unmodifiableList(monitoredChannels); + } + + /** + * Retrieves the channelID where the status message will be displayed. + * + * @return the Channel ID where the Status Message is expected to be displayed + */ + public long getStatusChannel() { + return statusChannel; + } + + /** + * Retrieves a Collection of the channels that this guild wants to have registered for + * monitoring by the free/busy command system + * + * @return an Unmodifiable List of Channel ID's + */ + public @NotNull Collection getMonitoredChannels() { + return monitoredChannels; // already unmodifiable + } +}