diff --git a/application/config.json.template b/application/config.json.template index 38136da3c3..0d3b1af6df 100644 --- a/application/config.json.template +++ b/application/config.json.template @@ -8,16 +8,6 @@ "heavyModerationRolePattern": "Moderator", "softModerationRolePattern": "Moderator|Staff Assistant", "tagManageRolePattern": "Moderator|Staff Assistant|Top Helpers .+", - "freeCommand": [ - { - "inactiveChannelDuration": "PT2H", - "messageRetrieveLimit": 10, - "statusChannel": , - "monitoredChannels": [ - - ] - } - ], "helpChannelPattern": "([a-zA-Z_]+_)?help(_\\d+)?", "suggestions": { "channelPattern": "tj_suggestions", @@ -33,5 +23,26 @@ "suspiciousHostKeywords": ["discord", "nitro", "premium"], "isHostSimilarToKeywordDistanceThreshold": 2 }, - "wolframAlphaAppId": "79J52T-6239TVXHR7" + "wolframAlphaAppId": "79J52T-6239TVXHR7", + "helpSystem": { + "stagingChannelPattern": "ask_here", + "overviewChannelPattern": "active_questions", + "categories": [ + "Java", + "Frameworks", + "JavaFX|Swing", + "IDE", + "Build Tools", + "Database", + "Android", + "C|C++", + "Algorithms", + "Math", + "Architecture", + "Code Review", + "Together Java Bot", + "Other" + ], + "categoryRoleSuffix": " - Helper" + } } 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 3d9c7aa88d..7a3da10a64 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/Features.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/Features.java @@ -6,9 +6,7 @@ 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.help.*; import org.togetherjava.tjbot.commands.mathcommands.TeXCommand; import org.togetherjava.tjbot.commands.mathcommands.wolframalpha.WolframAlphaCommand; import org.togetherjava.tjbot.commands.moderation.*; @@ -62,7 +60,7 @@ public enum Features { ModerationActionsStore actionsStore = new ModerationActionsStore(database); ModAuditLogWriter modAuditLogWriter = new ModAuditLogWriter(config); ScamHistoryStore scamHistoryStore = new ScamHistoryStore(database); - FreeChannelMonitor freeChannelMonitor = new FreeChannelMonitor(config); + HelpSystemHelper helpSystemHelper = new HelpSystemHelper(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 @@ -75,12 +73,13 @@ public enum Features { features.add(new TopHelpersPurgeMessagesRoutine(database)); features.add(new RemindRoutine(database)); features.add(new ScamHistoryPurgeRoutine(scamHistoryStore)); - features.add(new AutoFreeRoutine(freeChannelMonitor)); + features.add(new BotMessageCleanup(config)); // Message receivers features.add(new TopHelpersMessageListener(database, config)); features.add(new SuggestionsUpDownVoter(config)); features.add(new ScamBlocker(actionsStore, scamHistoryStore, config)); + features.add(new ImplicitAskListener(config, helpSystemHelper)); // Event receivers features.add(new RejoinModerationRoleListener(actionsStore, config)); @@ -107,9 +106,12 @@ public enum Features { features.add(new UnquarantineCommand(actionsStore, config)); features.add(new WhoIsCommand()); features.add(new WolframAlphaCommand(config)); + features.add(new AskCommand(config, helpSystemHelper)); + features.add(new CloseCommand(helpSystemHelper)); + features.add(new ChangeHelpCategoryCommand(config, helpSystemHelper)); // Mixtures - features.add(new FreeCommand(config, freeChannelMonitor)); + features.add(new HelpThreadOverviewUpdater(config, helpSystemHelper)); return features; } diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/Routine.java b/application/src/main/java/org/togetherjava/tjbot/commands/Routine.java index d4b69f55ac..10b8aa9d79 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/Routine.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/Routine.java @@ -16,13 +16,6 @@ * the schedule defined by {@link #createSchedule()}. */ public interface Routine extends Feature { - /** - * Triggered by the core system on the schedule defined by {@link #createSchedule()}. - * - * @param jda the JDA instance the bot is operating with - */ - void runRoutine(@NotNull JDA jda); - /** * Retrieves the schedule of this routine. Called by the core system once during the startup in * order to execute the routine accordingly. @@ -34,6 +27,13 @@ public interface Routine extends Feature { @NotNull Schedule createSchedule(); + /** + * Triggered by the core system on the schedule defined by {@link #createSchedule()}. + * + * @param jda the JDA instance the bot is operating with + */ + void runRoutine(@NotNull JDA jda); + /** * The schedule of routines. * 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 deleted file mode 100644 index bafe787745..0000000000 --- a/application/src/main/java/org/togetherjava/tjbot/commands/free/AutoFreeRoutine.java +++ /dev/null @@ -1,55 +0,0 @@ -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 deleted file mode 100644 index 6a025e39e3..0000000000 --- a/application/src/main/java/org/togetherjava/tjbot/commands/free/ChannelStatus.java +++ /dev/null @@ -1,208 +0,0 @@ -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 FreeChannelMonitor#addChannelForStatus(TextChannel)} - * @throws IllegalStateException if a channel was added, see - * {@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); - 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 deleted file mode 100644 index f4dca24d77..0000000000 --- a/application/src/main/java/org/togetherjava/tjbot/commands/free/ChannelStatusType.java +++ /dev/null @@ -1,32 +0,0 @@ -package org.togetherjava.tjbot.commands.free; - -import org.jetbrains.annotations.NotNull; - -enum ChannelStatusType { - FREE("free", ":white_check_mark:"), - BUSY("busy", ":x:"); - - 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/FreeChannelMonitor.java b/application/src/main/java/org/togetherjava/tjbot/commands/free/FreeChannelMonitor.java deleted file mode 100644 index 0f74b8ac48..0000000000 --- a/application/src/main/java/org/togetherjava/tjbot/commands/free/FreeChannelMonitor.java +++ /dev/null @@ -1,428 +0,0 @@ -package org.togetherjava.tjbot.commands.free; - -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. - */ -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; - - 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; - } - - /** - * 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()); - 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. - */ - 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. - * - * @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, @NotNull Instant when) { - requiresIsMonitored(channel.getIdLong()); - - OptionalLong maybeLastMessageId = FreeUtil.getLastMessageId(channel); - if (maybeLastMessageId.isEmpty()) { - return true; - } - - FreeCommandConfig configForChannel = config.getFreeCommandConfig() - .stream() - .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 - * {@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(); - } - - /** - * 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. - *

- * 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.getParentCategory(); - 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. If so - * it changes the channel's status to free, see - * {@link FreeChannelMonitor#isChannelInactive(TextChannel, Instant)}. - *

- * 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 @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(busyChannel -> isChannelInactive(busyChannel, now)) - .toList(); - - inactiveChannels.stream().map(TextChannel::getIdLong).forEach(this::setChannelFree); - - return inactiveChannels; - } - - /** - * 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; - } - - /** - * 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 - 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/FreeCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/free/FreeCommand.java deleted file mode 100644 index 7f2dcd86d1..0000000000 --- a/application/src/main/java/org/togetherjava/tjbot/commands/free/FreeCommand.java +++ /dev/null @@ -1,280 +0,0 @@ -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 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 org.jetbrains.annotations.NotNull; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.togetherjava.tjbot.commands.EventReceiver; -import org.togetherjava.tjbot.commands.SlashCommandAdapter; -import org.togetherjava.tjbot.commands.SlashCommandVisibility; -import org.togetherjava.tjbot.config.Config; -import org.togetherjava.tjbot.config.FreeCommandConfig; - -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? -// 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 EventReceiver { - private static final Logger logger = LoggerFactory.getLogger(FreeCommand.class); - - private static final String COMMAND_NAME = "free"; - - private final Config config; - - // Map to store channel ID's, use Guild.getChannels() to guarantee order for display - private final FreeChannelMonitor channelMonitor; - - private volatile boolean isReady; - - - /** - * Creates an instance of FreeCommand. - *

- * 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, @NotNull FreeChannelMonitor channelMonitor) { - super(COMMAND_NAME, "Marks this channel as free for another user to ask a question", - SlashCommandVisibility.GUILD); - - this.config = config; - this.channelMonitor = 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 - */ - public void onReady(@NotNull final ReadyEvent event) { - final JDA jda = event.getJDA(); - - initChannelsToMonitor(); - initStatusMessageChannels(jda); - logger.debug("Config loaded:\n{}", channelMonitor); - - checkBusyStatusAllChannels(jda); - - channelMonitor.statusIds() - .map(id -> requiresTextChannel(jda, id)) - .map(TextChannel::getGuild) - .collect(Collectors.toSet()) - .forEach(channelMonitor::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 SlashCommandInteractionEvent 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); - channelMonitor.displayStatus(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 SlashCommandInteractionEvent 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; - } - - private void checkBusyStatusAllChannels(@NotNull JDA jda) { - channelMonitor.guildIds() - .map(id -> requiresGuild(jda, id)) - .forEach(channelMonitor::freeInactiveChannels); - } - - 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(SlashCommandInteractionEvent 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 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'. - */ - @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. - @Override - public void onEvent(@NotNull GenericEvent event) { - if (event instanceof ReadyEvent readyEvent) { - onReady(readyEvent); - } else if (event instanceof MessageReceivedEvent messageEvent) { - if (!messageEvent.isFromGuild()) { - return; - } - - if (messageEvent.isWebhookMessage() || messageEvent.getAuthor().isBot()) { - return; - } - if (!channelMonitor.isMonitoringChannel(messageEvent.getChannel().getIdLong())) { - logger.debug( - "Channel is not being monitored, ignoring message received in {} from {}", - messageEvent.getChannel().getName(), messageEvent.getAuthor()); - return; - } - if (channelMonitor.isChannelBusy(messageEvent.getChannel().getIdLong())) { - logger.debug( - "Channel status is currently busy, ignoring message received in {} from {}", - messageEvent.getChannel().getName(), messageEvent.getAuthor()); - return; - } - channelMonitor.setChannelBusy(messageEvent.getChannel().getIdLong(), - messageEvent.getAuthor().getIdLong()); - channelMonitor.displayStatus(messageEvent.getGuild()); - messageEvent.getMessage().reply(UserStrings.NEW_QUESTION.message()).queue(); - } - } - - private void initChannelsToMonitor() { - config.getFreeCommandConfig() - .stream() - .map(FreeCommandConfig::getMonitoredChannels) - .flatMap(Collection::stream) - .forEach(channelMonitor::addChannelToMonitor); - } - - private void initStatusMessageChannels(@NotNull final JDA jda) { - config.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 deleted file mode 100644 index bf61ad7bc8..0000000000 --- a/application/src/main/java/org/togetherjava/tjbot/commands/free/FreeUtil.java +++ /dev/null @@ -1,72 +0,0 @@ -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.callbacks.IReplyCallback; -import org.jetbrains.annotations.NotNull; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -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 IReplyCallback 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) { - // 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(); - } -} 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 deleted file mode 100644 index b10db9bf10..0000000000 --- a/application/src/main/java/org/togetherjava/tjbot/commands/free/UserStrings.java +++ /dev/null @@ -1,66 +0,0 @@ -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 in an available channel. A helper will be with you shortly! - - __Do not post your question in other channels.__ - - Once your issue is resolved, feel free to /free the channel, thanks. - """), - 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. - """), - 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" - + " consult 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 deleted file mode 100644 index 5fcca7cd69..0000000000 --- a/application/src/main/java/org/togetherjava/tjbot/commands/free/package-info.java +++ /dev/null @@ -1,5 +0,0 @@ -/** - * 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/commands/help/AskCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/help/AskCommand.java new file mode 100644 index 0000000000..2d6af69d74 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/help/AskCommand.java @@ -0,0 +1,150 @@ +package org.togetherjava.tjbot.commands.help; + +import net.dv8tion.jda.api.entities.*; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.exceptions.ErrorResponseException; +import net.dv8tion.jda.api.interactions.callbacks.IReplyCallback; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; +import net.dv8tion.jda.api.requests.ErrorResponse; +import net.dv8tion.jda.api.requests.RestAction; +import net.dv8tion.jda.api.requests.restaction.interactions.ReplyCallbackAction; +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.config.Config; + +/** + * Implements the {@code /ask} command, which is the main way of asking questions. The command can + * only be used in the staging channel. + * + * Upon use, it will create a new thread for the question and invite all helpers interested in the + * given category to it. It will also introduce the user to the system and give a quick explanation + * message. + * + * The other way to ask questions is by {@link ImplicitAskListener}. + * + * Example usage: + * + *

+ * {@code
+ * /ask title: How to send emails? category: Frameworks
+ * // A thread with name "[Frameworks] How to send emails?" is created
+ * // The asker and all "Frameworks"-helpers are invited
+ * }
+ * 
+ */ +public final class AskCommand extends SlashCommandAdapter { + private static final Logger logger = LoggerFactory.getLogger(AskCommand.class); + + private static final String TITLE_OPTION = "title"; + private static final String CATEGORY_OPTION = "category"; + + private final HelpSystemHelper helper; + + /** + * Creates a new instance. + * + * @param config the config to use + * @param helper the helper to use + */ + public AskCommand(@NotNull Config config, @NotNull HelpSystemHelper helper) { + super("ask", "Ask a question - use this in the staging channel", + SlashCommandVisibility.GUILD); + + OptionData title = + new OptionData(OptionType.STRING, TITLE_OPTION, "short and to the point", true); + OptionData category = new OptionData(OptionType.STRING, CATEGORY_OPTION, + "select what describes your question the best", true); + config.getHelpSystem() + .getCategories() + .forEach(categoryText -> category.addChoice(categoryText, categoryText)); + + getData().addOptions(title, category); + + this.helper = helper; + } + + @Override + public void onSlashCommand(@NotNull SlashCommandInteractionEvent event) { + String title = event.getOption(TITLE_OPTION).getAsString(); + String category = event.getOption(CATEGORY_OPTION).getAsString(); + + if (!handleIsStagingChannel(event)) { + return; + } + + TextChannel helpStagingChannel = event.getTextChannel(); + helpStagingChannel.createThreadChannel("[%s] %s".formatted(category, title)) + .flatMap(threadChannel -> handleEvent(event, threadChannel, event.getMember(), title, + category)) + .queue(any -> { + }, e -> handleFailure(e, event)); + } + + private boolean handleIsStagingChannel(@NotNull IReplyCallback event) { + if (helper.isStagingChannelName(event.getChannel().getName())) { + return true; + } + + event.reply("Sorry, but this command can only be used in the help staging channel.") + .setEphemeral(true) + .queue(); + + return false; + } + + private @NotNull RestAction handleEvent(@NotNull IReplyCallback event, + @NotNull ThreadChannel threadChannel, @NotNull Member author, @NotNull String title, + @NotNull String category) { + return sendInitialMessage(event.getGuild(), threadChannel, author, title, category) + .flatMap(any -> notifyUser(event, threadChannel)) + .flatMap(any -> helper.sendExplanationMessage(threadChannel)); + } + + private RestAction sendInitialMessage(@NotNull Guild guild, + @NotNull ThreadChannel threadChannel, @NotNull Member author, @NotNull String title, + @NotNull String category) { + String roleMentionDescription = helper.handleFindRoleForCategory(category, guild) + .map(role -> " (%s)".formatted(role.getAsMention())) + .orElse(""); + + String contentPattern = "%s has a question about '**%s**'%%s and will send the details now." + .formatted(author.getAsMention(), title); + String contentWithoutRole = contentPattern.formatted(""); + String contentWithRole = contentPattern.formatted(roleMentionDescription); + + // We want to invite all members of a role, but without hard-pinging them. However, + // manually inviting them is cumbersome and can hit rate limits. + // Instead, we abuse the fact that a role-ping through an edit will not hard-ping users, + // but still invite them to a thread. + return threadChannel.sendMessage(contentWithoutRole) + .flatMap(message -> message.editMessage(contentWithRole)); + } + + private static @NotNull ReplyCallbackAction notifyUser(@NotNull IReplyCallback event, + @NotNull IMentionable threadChannel) { + return event.reply(""" + Created a thread for you: %s + Please ask your question there, thanks.""".formatted(threadChannel.getAsMention())) + .setEphemeral(true); + } + + private static void handleFailure(@NotNull Throwable exception, @NotNull IReplyCallback event) { + if (exception instanceof ErrorResponseException responseException) { + ErrorResponse response = responseException.getErrorResponse(); + if (response == ErrorResponse.MAX_CHANNELS + || response == ErrorResponse.MAX_ACTIVE_THREADS) { + event.reply( + "It seems that there are currently too many active questions, please try again in a few minutes.") + .setEphemeral(true) + .queue(); + return; + } + } + + logger.error("Attempted to create a help thread, but failed", exception); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/help/BotMessageCleanup.java b/application/src/main/java/org/togetherjava/tjbot/commands/help/BotMessageCleanup.java new file mode 100644 index 0000000000..d3c90980b4 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/help/BotMessageCleanup.java @@ -0,0 +1,126 @@ +package org.togetherjava.tjbot.commands.help; + +import net.dv8tion.jda.api.JDA; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.GuildMessageChannel; +import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.entities.TextChannel; +import net.dv8tion.jda.api.requests.RestAction; +import net.dv8tion.jda.internal.requests.CompletedRestAction; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.togetherjava.tjbot.commands.Routine; +import org.togetherjava.tjbot.config.Config; +import org.togetherjava.tjbot.config.HelpSystemConfig; + +import java.time.Duration; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.TimeUnit; +import java.util.function.Predicate; +import java.util.regex.Pattern; + +/** + * Routine that deletes all messages posted by the bot in the staging channel. + * + * This is mostly to cleanup the messages created by the fallback mechanism provided by + * {@link ImplicitAskListener}, since those messages can not be posted ephemeral. + * + * Messages are deleted after a certain amount of time. + */ +public final class BotMessageCleanup implements Routine { + private static final Logger logger = LoggerFactory.getLogger(BotMessageCleanup.class); + + private static final int MESSAGE_HISTORY_LIMIT = 50; + private static final Duration DELETE_MESSAGE_AFTER = Duration.ofMinutes(2); + + private final HelpSystemConfig config; + private final Predicate isStagingChannelName; + + /** + * Creates a new instance. + * + * @param config the config to use + */ + public BotMessageCleanup(@NotNull Config config) { + this.config = config.getHelpSystem(); + + isStagingChannelName = Pattern.compile(config.getHelpSystem().getStagingChannelPattern()) + .asMatchPredicate(); + } + + @Override + public @NotNull Schedule createSchedule() { + return new Schedule(ScheduleMode.FIXED_RATE, 1, 1, TimeUnit.MINUTES); + } + + @Override + public void runRoutine(@NotNull JDA jda) { + jda.getGuildCache().forEach(this::cleanupBotMessagesForGuild); + } + + private void cleanupBotMessagesForGuild(@NotNull Guild guild) { + Optional maybeStagingChannel = handleRequireStagingChannel(guild); + + if (maybeStagingChannel.isEmpty()) { + return; + } + + TextChannel stagingChannel = maybeStagingChannel.orElseThrow(); + + stagingChannel.getHistory() + .retrievePast(MESSAGE_HISTORY_LIMIT) + .flatMap(messages -> cleanupBotMessages(stagingChannel, messages)) + .queue(); + } + + private @NotNull Optional handleRequireStagingChannel(@NotNull Guild guild) { + Optional maybeChannel = guild.getTextChannelCache() + .stream() + .filter(channel -> isStagingChannelName.test(channel.getName())) + .findAny(); + + if (maybeChannel.isEmpty()) { + logger.warn( + "Unable to cleanup bot messages, did not find a the staging channel matching the configured pattern '{}' for guild '{}'", + config.getStagingChannelPattern(), guild.getName()); + return Optional.empty(); + } + + return maybeChannel; + } + + private static boolean shouldMessageBeCleanedUp(@NotNull Message message) { + if (!message.getAuthor().isBot()) { + return false; + } + + OffsetDateTime lastTouched = + message.isEdited() ? message.getTimeEdited() : message.getTimeCreated(); + Instant deleteWhen = lastTouched.toInstant().plus(DELETE_MESSAGE_AFTER); + + return deleteWhen.isBefore(Instant.now()); + } + + private static @NotNull RestAction cleanupBotMessages( + @NotNull GuildMessageChannel channel, @NotNull Collection messages) { + List messageIdsToDelete = messages.stream() + .filter(BotMessageCleanup::shouldMessageBeCleanedUp) + .map(Message::getId) + .toList(); + + if (messageIdsToDelete.isEmpty()) { + return new CompletedRestAction<>(channel.getJDA(), null, null); + } + + if (messageIdsToDelete.size() == 1) { + return channel.deleteMessageById(messageIdsToDelete.get(0)); + } + + return channel.deleteMessagesByIds(messageIdsToDelete); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/help/ChangeHelpCategoryCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/help/ChangeHelpCategoryCommand.java new file mode 100644 index 0000000000..67739b4ac7 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/help/ChangeHelpCategoryCommand.java @@ -0,0 +1,99 @@ +package org.togetherjava.tjbot.commands.help; + +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.entities.Role; +import net.dv8tion.jda.api.entities.ThreadChannel; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.InteractionHook; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; +import net.dv8tion.jda.api.requests.RestAction; +import org.jetbrains.annotations.NotNull; +import org.togetherjava.tjbot.commands.SlashCommandAdapter; +import org.togetherjava.tjbot.commands.SlashCommandVisibility; +import org.togetherjava.tjbot.config.Config; + +import java.util.Optional; + +/** + * Implements the {@code /change-help-category} command, which is able to change the category of a + * help thread. + *

+ * This is either used for threads that do not have categories yet (as created by + * {@link ImplicitAskListener}), or simply to adjust the category afterwards. + *

+ * Changing the category will invite all helpers interested into the corresponding category to the + * question thread. + */ +public final class ChangeHelpCategoryCommand extends SlashCommandAdapter { + private static final String CATEGORY_OPTION = "category"; + + private final HelpSystemHelper helper; + + /** + * Creates a new instance. + * + * @param config the config to use + * @param helper the helper to use + */ + public ChangeHelpCategoryCommand(@NotNull Config config, @NotNull HelpSystemHelper helper) { + super("change-help-category", "changes the category of a help thread", + SlashCommandVisibility.GUILD); + + OptionData category = new OptionData(OptionType.STRING, CATEGORY_OPTION, + "select what describes the question the best", true); + config.getHelpSystem() + .getCategories() + .forEach(categoryText -> category.addChoice(categoryText, categoryText)); + + getData().addOptions(category); + + this.helper = helper; + } + + @Override + public void onSlashCommand(@NotNull SlashCommandInteractionEvent event) { + String category = event.getOption(CATEGORY_OPTION).getAsString(); + + if (!helper.handleIsHelpThread(event)) { + return; + } + + ThreadChannel helpThread = event.getThreadChannel(); + if (helpThread.isArchived()) { + event.reply("This thread is already closed.").setEphemeral(true).queue(); + return; + } + + event.deferReply().queue(); + + helper.renameChannelToCategoryTitle(helpThread, category) + .flatMap(any -> sendCategoryChangedMessage(helpThread.getGuild(), event.getHook(), + helpThread, category)) + .queue(); + } + + private @NotNull RestAction sendCategoryChangedMessage(@NotNull Guild guild, + @NotNull InteractionHook hook, @NotNull ThreadChannel helpThread, + @NotNull String category) { + String changedContent = "Changed the category to **%s**.".formatted(category); + var action = hook.editOriginal(changedContent); + + Optional helperRole = helper.handleFindRoleForCategory(category, guild); + if (helperRole.isEmpty()) { + return action; + } + + // We want to invite all members of a role, but without hard-pinging them. However, + // manually inviting them is cumbersome and can hit rate limits. + // Instead, we abuse the fact that a role-ping through an edit will not hard-ping users, + // but still invite them to a thread. + String headsUpPattern = "%splease have a look, thanks."; + String headsUpWithoutRole = headsUpPattern.formatted(""); + String headsUpWithRole = + headsUpPattern.formatted(helperRole.orElseThrow().getAsMention() + " "); + return action.flatMap(any -> helpThread.sendMessage(headsUpWithoutRole) + .flatMap(message -> message.editMessage(headsUpWithRole))); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/help/CloseCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/help/CloseCommand.java new file mode 100644 index 0000000000..00efae8fb2 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/help/CloseCommand.java @@ -0,0 +1,49 @@ +package org.togetherjava.tjbot.commands.help; + +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.entities.MessageEmbed; +import net.dv8tion.jda.api.entities.ThreadChannel; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import org.jetbrains.annotations.NotNull; +import org.togetherjava.tjbot.commands.SlashCommandAdapter; +import org.togetherjava.tjbot.commands.SlashCommandVisibility; + +/** + * Implements the {@code /close} command to close question threads. + *

+ * Can be used in active (non-archived) question threads. Will close, i.e. archive, the thread upon + * use. Meant to be used once a question has been resolved. + */ +public final class CloseCommand extends SlashCommandAdapter { + private final HelpSystemHelper helper; + + /** + * Creates a new instance. + * + * @param helper the helper to use + */ + public CloseCommand(@NotNull HelpSystemHelper helper) { + super("close", "Close this question thread", SlashCommandVisibility.GUILD); + + this.helper = helper; + } + + @Override + public void onSlashCommand(@NotNull SlashCommandInteractionEvent event) { + if (!helper.handleIsHelpThread(event)) { + return; + } + + ThreadChannel helpThread = event.getThreadChannel(); + if (helpThread.isArchived()) { + event.reply("This thread is already closed.").setEphemeral(true).queue(); + return; + } + + MessageEmbed embed = new EmbedBuilder().setDescription("Closed the thread.") + .setColor(HelpSystemHelper.AMBIENT_COLOR) + .build(); + + event.replyEmbeds(embed).flatMap(any -> helpThread.getManager().setArchived(true)).queue(); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/help/HelpSystemHelper.java b/application/src/main/java/org/togetherjava/tjbot/commands/help/HelpSystemHelper.java new file mode 100644 index 0000000000..8b835ed7ec --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/help/HelpSystemHelper.java @@ -0,0 +1,178 @@ +package org.togetherjava.tjbot.commands.help; + +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.entities.*; +import net.dv8tion.jda.api.interactions.callbacks.IReplyCallback; +import net.dv8tion.jda.api.requests.RestAction; +import net.dv8tion.jda.api.requests.restaction.MessageAction; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.togetherjava.tjbot.config.Config; +import org.togetherjava.tjbot.config.HelpSystemConfig; + +import java.awt.Color; +import java.io.InputStream; +import java.util.List; +import java.util.Optional; +import java.util.function.Predicate; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Helper class offering certain methods used by the help system. + */ +public final class HelpSystemHelper { + private static final Logger logger = LoggerFactory.getLogger(HelpSystemHelper.class); + + static final Color AMBIENT_COLOR = new Color(255, 255, 165); + + private static final String CODE_SYNTAX_EXAMPLE_PATH = "codeSyntaxExample.png"; + private static final String CATEGORY_GROUP = "category"; + private static final String TITLE_GROUP = "title"; + private static final Pattern EXTRACT_CATEGORY_TITLE_PATTERN = + Pattern.compile("(?:\\[(?<%s>.+)] )?(?<%s>.+)".formatted(CATEGORY_GROUP, TITLE_GROUP)); + + private final Predicate isOverviewChannelName; + private final String overviewChannelPattern; + private final Predicate isStagingChannelName; + private final String stagingChannelPattern; + private final String categoryRoleSuffix; + + + /** + * Creates a new instance. + * + * @param config the config to use + */ + public HelpSystemHelper(@NotNull Config config) { + HelpSystemConfig helpConfig = config.getHelpSystem(); + + overviewChannelPattern = helpConfig.getOverviewChannelPattern(); + isOverviewChannelName = Pattern.compile(overviewChannelPattern).asMatchPredicate(); + + stagingChannelPattern = helpConfig.getStagingChannelPattern(); + isStagingChannelName = Pattern.compile(stagingChannelPattern).asMatchPredicate(); + + categoryRoleSuffix = helpConfig.getCategoryRoleSuffix(); + } + + RestAction sendExplanationMessage(@NotNull MessageChannel threadChannel) { + boolean useCodeSyntaxExampleImage = true; + InputStream codeSyntaxExampleData = + AskCommand.class.getResourceAsStream("/" + CODE_SYNTAX_EXAMPLE_PATH); + if (codeSyntaxExampleData == null) { + useCodeSyntaxExampleImage = false; + } + + String message = + "While you are waiting for getting help, here are some tips to improve your experience:"; + + List embeds = List.of(HelpSystemHelper.embedWith( + "Code is much easier to read if posted with **syntax highlighting** and proper formatting.", + useCodeSyntaxExampleImage ? "attachment://" + CODE_SYNTAX_EXAMPLE_PATH : null), + HelpSystemHelper.embedWith( + """ + If your code is **long**, or you have **multiple files** to share, consider posting it on sites \ + like https://pastebin.com/ and share the link instead, that is easier to browse for helpers."""), + HelpSystemHelper.embedWith( + """ + If nobody is calling back, that usually means that your question was **not well asked** and \ + hence nobody feels confident enough answering. Try to use your time to elaborate, \ + **provide details**, context, more code, examples and maybe some screenshots. \ + With enough info, someone knows the answer for sure .""")); + + MessageAction action = threadChannel.sendMessage(message); + if (useCodeSyntaxExampleImage) { + action = action.addFile(codeSyntaxExampleData, CODE_SYNTAX_EXAMPLE_PATH); + } + return action.setEmbeds(embeds); + } + + private static @NotNull MessageEmbed embedWith(@NotNull CharSequence message) { + return embedWith(message, null); + } + + private static @NotNull MessageEmbed embedWith(@NotNull CharSequence message, + @Nullable String imageUrl) { + return new EmbedBuilder().setColor(AMBIENT_COLOR) + .setDescription(message) + .setImage(imageUrl) + .build(); + } + + boolean handleIsHelpThread(@NotNull IReplyCallback event) { + if (event.getChannelType() == ChannelType.GUILD_PUBLIC_THREAD) { + ThreadChannel thread = event.getThreadChannel(); + + if (isStagingChannelName.test(thread.getParentChannel().getName())) { + return true; + } + } + + event.reply("Sorry, but this command can only be used in a help thread.") + .setEphemeral(true) + .queue(); + + return false; + } + + @NotNull + Optional handleFindRoleForCategory(@NotNull String category, @NotNull Guild guild) { + String roleName = category + categoryRoleSuffix; + Optional maybeHelperRole = guild.getRolesByName(roleName, true).stream().findAny(); + + if (maybeHelperRole.isEmpty()) { + logger.warn("Unable to find the helper role '{}'.", roleName); + } + + return maybeHelperRole; + } + + @NotNull + Optional getCategoryOfChannel(@NotNull Channel channel) { + Matcher matcher = EXTRACT_CATEGORY_TITLE_PATTERN.matcher(channel.getName()); + if (!matcher.find()) { + return Optional.empty(); + } + + return Optional.ofNullable(matcher.group(CATEGORY_GROUP)); + } + + @NotNull + RestAction renameChannelToCategoryTitle(@NotNull GuildChannel channel, + @NotNull String category) { + String currentTitle = channel.getName(); + Matcher matcher = EXTRACT_CATEGORY_TITLE_PATTERN.matcher(currentTitle); + + if (!matcher.matches()) { + throw new AssertionError("Pattern must match any thread name"); + } + boolean hasCategoryInTitle = matcher.group(CATEGORY_GROUP) != null; + String titleWithoutCategory = + hasCategoryInTitle ? matcher.group(TITLE_GROUP) : currentTitle; + + String titleWithCategory = "[%s] %s".formatted(category, titleWithoutCategory); + + return channel.getManager().setName(titleWithCategory); + } + + boolean isOverviewChannelName(@NotNull String channelName) { + return isOverviewChannelName.test(channelName); + } + + @NotNull + String getOverviewChannelPattern() { + return overviewChannelPattern; + } + + boolean isStagingChannelName(@NotNull String channelName) { + return isStagingChannelName.test(channelName); + } + + @NotNull + String getStagingChannelPattern() { + return stagingChannelPattern; + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/help/HelpThreadOverviewUpdater.java b/application/src/main/java/org/togetherjava/tjbot/commands/help/HelpThreadOverviewUpdater.java new file mode 100644 index 0000000000..ee947e2f56 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/help/HelpThreadOverviewUpdater.java @@ -0,0 +1,213 @@ +package org.togetherjava.tjbot.commands.help; + +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.JDA; +import net.dv8tion.jda.api.entities.*; +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; +import org.togetherjava.tjbot.commands.MessageReceiverAdapter; +import org.togetherjava.tjbot.commands.Routine; +import org.togetherjava.tjbot.config.Config; + +import java.util.*; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.function.Predicate; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +/** + * Provides and updates an overview of all active questions in an overview channel. + * + * The process runs on a schedule, but is also triggered whenever a new question has been asked in + * the staging channel. + * + * Active questions are automatically picked up and grouped by categories. + */ +public final class HelpThreadOverviewUpdater extends MessageReceiverAdapter implements Routine { + private static final Logger logger = LoggerFactory.getLogger(HelpThreadOverviewUpdater.class); + + private static final String STATUS_TITLE = "Active questions"; + + private final HelpSystemHelper helper; + private final List allCategories; + + private static final ScheduledExecutorService UPDATE_SERVICE = + Executors.newSingleThreadScheduledExecutor(); + + /** + * Creates a new instance. + * + * @param config the config to use + * @param helper the helper to use + */ + public HelpThreadOverviewUpdater(@NotNull Config config, @NotNull HelpSystemHelper helper) { + super(Pattern.compile(config.getHelpSystem().getStagingChannelPattern())); + + allCategories = config.getHelpSystem().getCategories(); + this.helper = helper; + } + + @Override + public @NotNull Schedule createSchedule() { + return new Schedule(ScheduleMode.FIXED_RATE, 1, 1, TimeUnit.MINUTES); + } + + @Override + public void runRoutine(@NotNull JDA jda) { + jda.getGuildCache().forEach(this::updateOverviewForGuild); + } + + @Override + public void onMessageReceived(@NotNull MessageReceivedEvent event) { + // Update whenever a thread was created + Message message = event.getMessage(); + if (message.getType() != MessageType.THREAD_CREATED) { + return; + } + + // Cleanup the status messages + message.delete().queue(); + + // Thread creation can sometimes take a bit longer than the actual message, so that + // "getThreadChannels()" + // would not pick it up, hence we execute the update with some slight delay. + UPDATE_SERVICE.schedule(() -> updateOverviewForGuild(event.getGuild()), 2, + TimeUnit.SECONDS); + } + + private void updateOverviewForGuild(@NotNull Guild guild) { + Optional maybeStagingChannel = + handleRequireChannel(ChannelType.STAGING, guild); + Optional maybeOverviewChannel = + handleRequireChannel(ChannelType.OVERVIEW, guild); + + if (maybeStagingChannel.isEmpty() || maybeOverviewChannel.isEmpty()) { + return; + } + + updateOverview(maybeStagingChannel.orElseThrow(), maybeOverviewChannel.orElseThrow()); + } + + private @NotNull Optional handleRequireChannel(@NotNull ChannelType channelType, + @NotNull Guild guild) { + Predicate isChannelName = switch (channelType) { + case OVERVIEW -> helper::isOverviewChannelName; + case STAGING -> helper::isStagingChannelName; + }; + String channelPattern = switch (channelType) { + case OVERVIEW -> helper.getOverviewChannelPattern(); + case STAGING -> helper.getStagingChannelPattern(); + }; + + Optional maybeChannel = guild.getTextChannelCache() + .stream() + .filter(channel -> isChannelName.test(channel.getName())) + .findAny(); + + if (maybeChannel.isEmpty()) { + logger.warn( + "Unable to update help thread overview, did not find a {} channel matching the configured pattern '{}' for guild '{}'", + channelType, channelPattern, guild.getName()); + return Optional.empty(); + } + + return maybeChannel; + } + + private void updateOverview(@NotNull IThreadContainer stagingChannel, + @NotNull MessageChannel overviewChannel) { + List activeThreads = stagingChannel.getThreadChannels() + .stream() + .filter(Predicate.not(ThreadChannel::isArchived)) + .toList(); + + MessageEmbed embed = new EmbedBuilder().setTitle(STATUS_TITLE) + .setDescription(createDescription(activeThreads)) + .setColor(HelpSystemHelper.AMBIENT_COLOR) + .build(); + + getStatusMessage(overviewChannel).flatMap(maybeStatusMessage -> { + if (maybeStatusMessage.isEmpty()) { + return overviewChannel.sendMessageEmbeds(embed); + } + + String statusMessageId = maybeStatusMessage.orElseThrow().getId(); + return overviewChannel.editMessageEmbedsById(statusMessageId, embed); + }).queue(); + } + + private @NotNull String createDescription(@NotNull Collection activeThreads) { + if (activeThreads.isEmpty()) { + return "Currently none."; + } + + return activeThreads.stream() + .sorted(Comparator.comparing(ThreadChannel::getTimeCreated).reversed()) + .collect(Collectors + .groupingBy(thread -> helper.getCategoryOfChannel(thread).orElse("Uncategorized"))) + .entrySet() + .stream() + .map(CategoryWithThreads::ofEntry) + .sorted(Comparator.comparingInt(categoryWithThreads -> { + // Order based on config, unknown categories last + int indexOfCategory = allCategories.indexOf(categoryWithThreads.category); + if (indexOfCategory == -1) { + return Integer.MAX_VALUE; + } + return indexOfCategory; + })) + .map(CategoryWithThreads::toDiscordString) + .collect(Collectors.joining("\n\n")); + } + + private static @NotNull RestAction> getStatusMessage( + @NotNull MessageChannel channel) { + return channel.getHistory() + .retrievePast(1) + .map(messages -> messages.stream() + .findFirst() + .filter(HelpThreadOverviewUpdater::isStatusMessage)); + } + + private static boolean isStatusMessage(@NotNull Message message) { + if (!message.getAuthor().equals(message.getJDA().getSelfUser())) { + return false; + } + + List embeds = message.getEmbeds(); + if (embeds.isEmpty()) { + return false; + } + + MessageEmbed embed = embeds.get(0); + return STATUS_TITLE.equals(embed.getTitle()); + } + + private enum ChannelType { + OVERVIEW, + STAGING + } + + private record CategoryWithThreads(@NotNull String category, + @NotNull List threads) { + + String toDiscordString() { + String threadListText = threads.stream() + .map(ThreadChannel::getAsMention) + .collect(Collectors.joining("\n• ", "• ", "")); + + return "**%s**:%n%s".formatted(category, threadListText); + } + + static @NotNull CategoryWithThreads ofEntry( + Map.@NotNull Entry> categoryAndThreads) { + return new CategoryWithThreads(categoryAndThreads.getKey(), + categoryAndThreads.getValue()); + } + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/help/ImplicitAskListener.java b/application/src/main/java/org/togetherjava/tjbot/commands/help/ImplicitAskListener.java new file mode 100644 index 0000000000..4a63e73581 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/help/ImplicitAskListener.java @@ -0,0 +1,208 @@ +package org.togetherjava.tjbot.commands.help; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.MessageBuilder; +import net.dv8tion.jda.api.entities.*; +import net.dv8tion.jda.api.events.message.MessageReceivedEvent; +import net.dv8tion.jda.api.exceptions.ErrorResponseException; +import net.dv8tion.jda.api.requests.ErrorResponse; +import net.dv8tion.jda.api.requests.RestAction; +import net.dv8tion.jda.api.requests.restaction.MessageAction; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.togetherjava.tjbot.commands.MessageReceiverAdapter; +import org.togetherjava.tjbot.config.Config; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Optional; +import java.util.concurrent.TimeUnit; +import java.util.regex.Pattern; + +/** + * Fallback approach for asking questions, next to the proper way of using {@link AskCommand}. + *

+ * Listens to plain messages in the staging channel, picks them up and transfers them into a proper + * question thread. + * + * The system can handle spam appropriately and will not create multiple threads for each message. + * + * For example: + * + *

+ * {@code
+ * John sends: How to send emails?
+ * // A thread with name "How to send emails?" is created
+ * // John gets an ephemeral message saying to move to the thread instead
+ * // Johns original message is deleted
+ * }
+ * 
+ */ +public final class ImplicitAskListener extends MessageReceiverAdapter { + private static final Logger logger = LoggerFactory.getLogger(ImplicitAskListener.class); + + private static final int TITLE_MAX_LENGTH = 30; + + private static final int COOLDOWN_DURATION_VALUE = 15; + private static final ChronoUnit COOLDOWN_DURATION_UNIT = ChronoUnit.SECONDS; + + private final Cache userIdToLastHelpThread; + private final HelpSystemHelper helper; + + /** + * Creates a new instance. + * + * @param config the config to use + * @param helper the helper to use + */ + public ImplicitAskListener(@NotNull Config config, @NotNull HelpSystemHelper helper) { + super(Pattern.compile(config.getHelpSystem().getStagingChannelPattern())); + + userIdToLastHelpThread = Caffeine.newBuilder() + .maximumSize(1_000) + .expireAfterAccess(COOLDOWN_DURATION_VALUE, TimeUnit.of(COOLDOWN_DURATION_UNIT)) + .build(); + + this.helper = helper; + } + + @Override + public void onMessageReceived(@NotNull MessageReceivedEvent event) { + // Only listen to regular messages from users + if (event.isWebhookMessage() || event.getMessage().getType() != MessageType.DEFAULT + || event.getAuthor().isBot()) { + return; + } + + Message message = event.getMessage(); + + if (!handleIsNotOnCooldown(message)) { + return; + } + + String title = createTitle(message.getContentDisplay()); + + TextChannel helpStagingChannel = event.getTextChannel(); + helpStagingChannel.createThreadChannel(title) + .flatMap(threadChannel -> handleEvent(threadChannel, message, title)) + .queue(any -> { + }, ImplicitAskListener::handleFailure); + } + + private boolean handleIsNotOnCooldown(@NotNull Message message) { + Member author = message.getMember(); + + Optional maybeLastHelpThread = + getLastHelpThreadIfOnCooldown(author.getIdLong()); + if (maybeLastHelpThread.isEmpty()) { + return true; + } + + ThreadChannel lastHelpThread = message.getGuild() + .getThreadChannelById(maybeLastHelpThread.orElseThrow().channelId); + String threadDescription = lastHelpThread == null ? "your previously created help thread" + : lastHelpThread.getAsMention(); + + message.getChannel() + .sendMessage(""" + %s Please use %s to follow up on your question, \ + or use `/ask` to ask a new questions, thanks.""" + .formatted(author.getAsMention(), threadDescription)) + .flatMap(any -> message.delete()) + .queue(); + return false; + } + + private Optional getLastHelpThreadIfOnCooldown(long userId) { + return Optional.ofNullable(userIdToLastHelpThread.getIfPresent(userId)) + .filter(lastHelpThread -> { + Instant cooldownExpiration = lastHelpThread.creationTime + .plus(COOLDOWN_DURATION_VALUE, COOLDOWN_DURATION_UNIT); + + // If user is on cooldown + return Instant.now().isBefore(cooldownExpiration); + }); + } + + private static @NotNull String createTitle(@NotNull String message) { + if (message.length() < TITLE_MAX_LENGTH) { + return message; + } + // Attempt to end at the last word before hitting the limit + // e.g. "[foo bar] baz" for a limit somewhere in between "baz" + int lastWordEnd = message.lastIndexOf(' ', TITLE_MAX_LENGTH); + if (lastWordEnd == -1) { + lastWordEnd = TITLE_MAX_LENGTH; + } + + return message.substring(0, lastWordEnd); + } + + private @NotNull RestAction handleEvent(@NotNull ThreadChannel threadChannel, + @NotNull Message message, @NotNull String title) { + Member author = message.getMember(); + userIdToLastHelpThread.put(author.getIdLong(), + new HelpThread(threadChannel.getIdLong(), author.getIdLong(), Instant.now())); + + return sendInitialMessage(threadChannel, message, title) + .flatMap(any -> notifyUser(threadChannel, message)) + .flatMap(any -> message.delete()) + .flatMap(any -> helper.sendExplanationMessage(threadChannel)); + } + + private static @NotNull RestAction inviteUsersToThread( + @NotNull ThreadChannel threadChannel, @NotNull Member author) { + return threadChannel.addThreadMember(author); + } + + private static @NotNull MessageAction sendInitialMessage(@NotNull ThreadChannel threadChannel, + @NotNull Message originalMessage, @NotNull String title) { + String content = originalMessage.getContentRaw(); + Member author = originalMessage.getMember(); + + MessageEmbed embed = new EmbedBuilder().setDescription(content) + .setAuthor(author.getEffectiveName(), author.getEffectiveAvatarUrl(), + author.getEffectiveAvatarUrl()) + .setColor(HelpSystemHelper.AMBIENT_COLOR) + .build(); + + Message threadMessage = new MessageBuilder( + """ + %s has a question about '**%s**' and will send the details now. + + Please use `/change-help-category` to greatly increase the visibility of the question.""" + .formatted(author, title)).setEmbeds(embed).build(); + + + return threadChannel.sendMessage(threadMessage); + } + + private static @NotNull MessageAction notifyUser(@NotNull IMentionable threadChannel, + @NotNull Message message) { + return message.getChannel() + .sendMessage( + """ + %s Please use `/ask` to ask questions. Don't worry though, I created %s for you. \ + Please continue there, thanks.""" + .formatted(message.getAuthor().getAsMention(), + threadChannel.getAsMention())); + } + + private static void handleFailure(@NotNull Throwable exception) { + if (exception instanceof ErrorResponseException responseException) { + ErrorResponse response = responseException.getErrorResponse(); + if (response == ErrorResponse.MAX_CHANNELS + || response == ErrorResponse.MAX_ACTIVE_THREADS) { + return; + } + } + + logger.error("Attempted to create a help thread, but failed", exception); + } + + private record HelpThread(long channelId, long authorId, @NotNull Instant creationTime) { + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/help/package-info.java b/application/src/main/java/org/togetherjava/tjbot/commands/help/package-info.java new file mode 100644 index 0000000000..d6deedbaf4 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/help/package-info.java @@ -0,0 +1,5 @@ +/** + * This package offers all functionality for the help system. For example commands that let users + * ask questions, such as {@link org.togetherjava.tjbot.commands.help.AskCommand}. + */ +package org.togetherjava.tjbot.commands.help; diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/tophelper/TopHelpersMessageListener.java b/application/src/main/java/org/togetherjava/tjbot/commands/tophelper/TopHelpersMessageListener.java index 3e8483e568..db10bf7527 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/tophelper/TopHelpersMessageListener.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/tophelper/TopHelpersMessageListener.java @@ -1,11 +1,14 @@ package org.togetherjava.tjbot.commands.tophelper; +import net.dv8tion.jda.api.entities.ChannelType; +import net.dv8tion.jda.api.entities.ThreadChannel; import net.dv8tion.jda.api.events.message.MessageReceivedEvent; import org.jetbrains.annotations.NotNull; import org.togetherjava.tjbot.commands.MessageReceiverAdapter; import org.togetherjava.tjbot.config.Config; import org.togetherjava.tjbot.db.Database; +import java.util.function.Predicate; import java.util.regex.Pattern; import static org.togetherjava.tjbot.db.generated.tables.HelpChannelMessages.HELP_CHANNEL_MESSAGES; @@ -17,6 +20,8 @@ public final class TopHelpersMessageListener extends MessageReceiverAdapter { private final Database database; + private final Predicate isStagingChannelName; + /** * Creates a new listener to receive all message sent in help channels. * @@ -24,8 +29,12 @@ public final class TopHelpersMessageListener extends MessageReceiverAdapter { * @param config the config to use for this */ public TopHelpersMessageListener(@NotNull Database database, @NotNull Config config) { - super(Pattern.compile(config.getHelpChannelPattern())); + super(Pattern.compile(".*")); + this.database = database; + + isStagingChannelName = Pattern.compile(config.getHelpSystem().getStagingChannelPattern()) + .asMatchPredicate(); } @Override @@ -34,9 +43,22 @@ public void onMessageReceived(@NotNull MessageReceivedEvent event) { return; } + if (!isHelpThread(event)) { + return; + } + addMessageRecord(event); } + private boolean isHelpThread(@NotNull MessageReceivedEvent event) { + if (event.getChannelType() != ChannelType.GUILD_PUBLIC_THREAD) { + return false; + } + + ThreadChannel thread = event.getThreadChannel(); + return isStagingChannelName.test(thread.getParentChannel().getName()); + } + private void addMessageRecord(@NotNull MessageReceivedEvent event) { database.write(context -> context.newRecord(HELP_CHANNEL_MESSAGES) .setMessageId(event.getMessage().getIdLong()) 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 f12e80a543..73534bb24e 100644 --- a/application/src/main/java/org/togetherjava/tjbot/config/Config.java +++ b/application/src/main/java/org/togetherjava/tjbot/config/Config.java @@ -8,9 +8,6 @@ import java.io.IOException; import java.nio.file.Path; -import java.util.Collection; -import java.util.Collections; -import java.util.List; /** * Configuration of the application. Create instances using {@link #load(Path)}. @@ -25,12 +22,12 @@ public final class Config { private final String heavyModerationRolePattern; private final String softModerationRolePattern; private final String tagManageRolePattern; - private final List freeCommand; private final String helpChannelPattern; private final SuggestionsConfig suggestions; private final String quarantinedRolePattern; private final ScamBlockerConfig scamBlocker; private final String wolframAlphaAppId; + private final HelpSystemConfig helpSystem; @SuppressWarnings("ConstructorWithTooManyParameters") @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) @@ -43,12 +40,12 @@ private Config(@JsonProperty("token") String token, @JsonProperty("heavyModerationRolePattern") String heavyModerationRolePattern, @JsonProperty("softModerationRolePattern") String softModerationRolePattern, @JsonProperty("tagManageRolePattern") String tagManageRolePattern, - @JsonProperty("freeCommand") List freeCommand, @JsonProperty("helpChannelPattern") String helpChannelPattern, @JsonProperty("suggestions") SuggestionsConfig suggestions, @JsonProperty("quarantinedRolePattern") String quarantinedRolePattern, @JsonProperty("scamBlocker") ScamBlockerConfig scamBlocker, - @JsonProperty("wolframAlphaAppId") String wolframAlphaAppId) { + @JsonProperty("wolframAlphaAppId") String wolframAlphaAppId, + @JsonProperty("helpSystem") HelpSystemConfig helpSystem) { this.token = token; this.databasePath = databasePath; this.projectWebsite = projectWebsite; @@ -58,12 +55,12 @@ private Config(@JsonProperty("token") String token, this.heavyModerationRolePattern = heavyModerationRolePattern; this.softModerationRolePattern = softModerationRolePattern; this.tagManageRolePattern = tagManageRolePattern; - this.freeCommand = Collections.unmodifiableList(freeCommand); this.helpChannelPattern = helpChannelPattern; this.suggestions = suggestions; this.quarantinedRolePattern = quarantinedRolePattern; this.scamBlocker = scamBlocker; this.wolframAlphaAppId = wolframAlphaAppId; + this.helpSystem = helpSystem; } /** @@ -163,17 +160,6 @@ 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 - } - /** * Gets the REGEX pattern used to identify channels that are used for helping people with their * questions. @@ -219,4 +205,13 @@ public String getQuarantinedRolePattern() { public @NotNull String getWolframAlphaAppId() { return wolframAlphaAppId; } + + /** + * Gets the config for the help system. + * + * @return the help system config + */ + public @NotNull HelpSystemConfig getHelpSystem() { + return helpSystem; + } } diff --git a/application/src/main/java/org/togetherjava/tjbot/config/FreeCommandConfig.java b/application/src/main/java/org/togetherjava/tjbot/config/FreeCommandConfig.java deleted file mode 100644 index 03fab031c4..0000000000 --- a/application/src/main/java/org/togetherjava/tjbot/config/FreeCommandConfig.java +++ /dev/null @@ -1,88 +0,0 @@ -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.Duration; -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": [
- *   {
- *       "inactiveChannelDuration": duration,
- *       "messageRetrieveLimit": int_number,
- *       "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 { - 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("inactiveChannelDuration") Duration inactiveChannelDuration, - @JsonProperty("messageRetrieveLimit") int messageRetrieveLimit) { - this.statusChannel = statusChannel; - this.monitoredChannels = Collections.unmodifiableList(monitoredChannels); - this.messageRetrieveLimit = messageRetrieveLimit; - this.inactiveChannelDuration = inactiveChannelDuration; - } - - /** - * 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 - } - - /** - * 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/main/java/org/togetherjava/tjbot/config/HelpSystemConfig.java b/application/src/main/java/org/togetherjava/tjbot/config/HelpSystemConfig.java new file mode 100644 index 0000000000..feb02505b0 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/config/HelpSystemConfig.java @@ -0,0 +1,76 @@ +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.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Configuration for the help system, see {@link org.togetherjava.tjbot.commands.help.AskCommand}. + */ +@SuppressWarnings("ClassCanBeRecord") +@JsonRootName("helpSystem") +public final class HelpSystemConfig { + private final String stagingChannelPattern; + private final String overviewChannelPattern; + private final List categories; + private final String categoryRoleSuffix; + + @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) + private HelpSystemConfig(@JsonProperty("stagingChannelPattern") String stagingChannelPattern, + @JsonProperty("overviewChannelPattern") String overviewChannelPattern, + @JsonProperty("categories") List categories, + @JsonProperty("categoryRoleSuffix") String categoryRoleSuffix) { + this.stagingChannelPattern = stagingChannelPattern; + this.overviewChannelPattern = overviewChannelPattern; + this.categories = new ArrayList<>(categories); + this.categoryRoleSuffix = categoryRoleSuffix; + } + + /** + * Gets the REGEX pattern used to identify the channel that acts as the staging channel for + * getting help. Users ask help here and help threads are also created in this channel. + * + * @return the channel name pattern + */ + public @NotNull String getStagingChannelPattern() { + return stagingChannelPattern; + } + + /** + * Gets the REGEX pattern used to identify the channel that provides an overview of all active + * help threads. + * + * @return the channel name pattern + */ + public @NotNull String getOverviewChannelPattern() { + return overviewChannelPattern; + } + + /** + * Gets a list of all categories, available to categorize help questions. + * + * @return a list of all categories + */ + public @NotNull List getCategories() { + return Collections.unmodifiableList(categories); + } + + /** + * Gets the suffix that, together with a category from {@link #getCategories()}, forms the name + * of the role of people interested in helping with questions that are categorized with the + * corresponding category. + * + * E.g. if the category is {@code "Java"} and the suffix {@code "- Helper"}, the name of the + * role is {@code "Java - Helper"}. + * + * @return the suffix + */ + public @NotNull String getCategoryRoleSuffix() { + return categoryRoleSuffix; + } +} diff --git a/application/src/main/resources/codeSyntaxExample.png b/application/src/main/resources/codeSyntaxExample.png new file mode 100644 index 0000000000..11eb793b1e Binary files /dev/null and b/application/src/main/resources/codeSyntaxExample.png differ