diff --git a/src/main/java/com/javadiscord/javabot/Bot.java b/src/main/java/com/javadiscord/javabot/Bot.java index b09cf599c..2b5693aa9 100644 --- a/src/main/java/com/javadiscord/javabot/Bot.java +++ b/src/main/java/com/javadiscord/javabot/Bot.java @@ -2,6 +2,7 @@ import com.javadiscord.javabot.data.H2DataSource; import com.javadiscord.javabot.events.*; +import com.javadiscord.javabot.help.HelpChannelListener; import com.javadiscord.javabot.properties.config.BotConfig; import net.dv8tion.jda.api.JDA; import net.dv8tion.jda.api.JDABuilder; @@ -92,7 +93,8 @@ private static void addEventListeners(JDA jda) { new AutoMod(), new SubmissionListener(), new StarboardListener(), - new InteractionListener() + new InteractionListener(), + new HelpChannelListener() ); } } diff --git a/src/main/java/com/javadiscord/javabot/events/InteractionListener.java b/src/main/java/com/javadiscord/javabot/events/InteractionListener.java index 7ef04465f..619c24e4f 100644 --- a/src/main/java/com/javadiscord/javabot/events/InteractionListener.java +++ b/src/main/java/com/javadiscord/javabot/events/InteractionListener.java @@ -2,11 +2,12 @@ import com.google.gson.JsonObject; import com.google.gson.JsonParser; +import com.javadiscord.javabot.Bot; +import com.javadiscord.javabot.help.HelpChannelManager; import com.mongodb.client.MongoCollection; import com.mongodb.client.MongoDatabase; -import net.dv8tion.jda.api.entities.Guild; -import net.dv8tion.jda.api.entities.Member; -import net.dv8tion.jda.api.entities.Role; +import lombok.extern.slf4j.Slf4j; +import net.dv8tion.jda.api.entities.*; import net.dv8tion.jda.api.events.interaction.ButtonClickEvent; import net.dv8tion.jda.api.hooks.ListenerAdapter; import org.bson.Document; @@ -15,6 +16,7 @@ import static com.javadiscord.javabot.events.Startup.preferredGuild; import static com.mongodb.client.model.Filters.eq; +@Slf4j public class InteractionListener extends ListenerAdapter { // TODO: add Context-Menu Commands (once they're available in JDA) @@ -31,6 +33,7 @@ public void onButtonClick(ButtonClickEvent event) { case "dm-submission" -> this.handleDmSubmission(database, guild, event); case "submission" -> this.handleSubmission(database, guild, event); case "reaction-role" -> this.handleReactionRoles(event); + case "help-channel" -> this.handleHelpChannel(event, id[1]); } } @@ -81,4 +84,30 @@ private void handleReactionRoles(ButtonClickEvent event) { event.getHook().sendMessage("Added Role: " + role.getAsMention()).setEphemeral(true).queue(); } } + + private void handleHelpChannel(ButtonClickEvent event, String action) { + var config = Bot.config.get(event.getGuild()).getHelp(); + var channelManager = new HelpChannelManager(config); + TextChannel channel = event.getTextChannel(); + User owner = channelManager.getReservedChannelOwner(channel); + if (owner == null) { + return; // This channel will be pruned automatically. + } + + if ( + event.getUser().equals(owner) || + (event.getMember() != null && event.getMember().getRoles().contains(Bot.config.get(event.getGuild()).getModeration().getStaffRole())) + ) { + if (action.equals("done")) { + log.info("Removing reserved channel {} because it was marked as done.", channel.getAsMention()); + channel.delete().queue(); + } else if (action.equals("not-done")) { + if (event.getMessage() != null) { + log.info("Removing timeout check message in {} because it was marked as not-done.", channel.getAsMention()); + event.getMessage().delete().queue(); + channel.sendMessage("Okay, we'll keep this channel reserved for you, and check again in **" + config.getInactivityTimeoutMinutes() + "** minutes.").queue(); + } + } + } + } } diff --git a/src/main/java/com/javadiscord/javabot/events/Startup.java b/src/main/java/com/javadiscord/javabot/events/Startup.java index aa944e4df..3eb2569fc 100644 --- a/src/main/java/com/javadiscord/javabot/events/Startup.java +++ b/src/main/java/com/javadiscord/javabot/events/Startup.java @@ -5,6 +5,7 @@ import ch.qos.logback.classic.LoggerContext; import com.javadiscord.javabot.Bot; import com.javadiscord.javabot.commands.other.Version; +import com.javadiscord.javabot.help.HelpChannelUpdater; import com.javadiscord.javabot.other.Database; import com.javadiscord.javabot.other.Misc; import com.mongodb.MongoClient; @@ -92,6 +93,10 @@ public void onReady(ReadyEvent event) { new Database().deleteOpenSubmissions(guild); new StarboardListener().updateAllSBM(guild); Bot.slashCommands.registerSlashCommands(guild); + + // Schedule the help channel updater to run periodically for each guild. + var helpConfig = Bot.config.get(guild).getHelp(); + Bot.asyncPool.scheduleAtFixedRate(new HelpChannelUpdater(event.getJDA(), helpConfig), 5, helpConfig.getUpdateIntervalSeconds(), TimeUnit.SECONDS); } diff --git a/src/main/java/com/javadiscord/javabot/help/AlphabetNamingStrategy.java b/src/main/java/com/javadiscord/javabot/help/AlphabetNamingStrategy.java new file mode 100644 index 000000000..f153b8cde --- /dev/null +++ b/src/main/java/com/javadiscord/javabot/help/AlphabetNamingStrategy.java @@ -0,0 +1,20 @@ +package com.javadiscord.javabot.help; + +import com.javadiscord.javabot.properties.config.guild.HelpConfig; +import net.dv8tion.jda.api.entities.TextChannel; + +import java.util.List; +import java.util.concurrent.ThreadLocalRandom; + +/** + * Naming strategy that names help channels with a random letter. Note that the + * chance for duplicates is quite high! + */ +public class AlphabetNamingStrategy implements ChannelNamingStrategy { + private static final String ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; + + @Override + public String getName(List channels, HelpConfig config) { + return config.getOpenChannelPrefix() + "help-" + ALPHABET.charAt(ThreadLocalRandom.current().nextInt(ALPHABET.length())); + } +} diff --git a/src/main/java/com/javadiscord/javabot/help/AnimalNamingStrategy.java b/src/main/java/com/javadiscord/javabot/help/AnimalNamingStrategy.java new file mode 100644 index 000000000..2751eda23 --- /dev/null +++ b/src/main/java/com/javadiscord/javabot/help/AnimalNamingStrategy.java @@ -0,0 +1,25 @@ +package com.javadiscord.javabot.help; + +import com.javadiscord.javabot.properties.config.guild.HelpConfig; +import net.dv8tion.jda.api.entities.TextChannel; + +import java.util.List; +import java.util.concurrent.ThreadLocalRandom; + +/** + * A naming strategy that names channels with a random animal name. + */ +public class AnimalNamingStrategy implements ChannelNamingStrategy { + private static final String[] ANIMALS = { + "bear", "tiger", "lion", "snake", "cheetah", "panther", "bat", "mosquito", "opossum", "raccoon", "beaver", + "walrus", "seal", "dolphin", "shark", "narwhal", "orca", "whale", "squid", "tuna", "nautilus", "jellyfish", + "seagull", "eagle", "hawk", "flamingo", "spoonbill", "puffin", "condor", "albatross", "parrot", "parakeet", + "rabbit", "sloth", "deer", "boar", "ferret", "dog", "cat", "marmoset", "mole", "lizard", "kangaroo" + }; + + @Override + public String getName(List channels, HelpConfig config) { + String name = ANIMALS[ThreadLocalRandom.current().nextInt(ANIMALS.length)]; + return config.getOpenChannelPrefix() + "help-" + name; + } +} diff --git a/src/main/java/com/javadiscord/javabot/help/ChannelNamingStrategy.java b/src/main/java/com/javadiscord/javabot/help/ChannelNamingStrategy.java new file mode 100644 index 000000000..4dc7f879a --- /dev/null +++ b/src/main/java/com/javadiscord/javabot/help/ChannelNamingStrategy.java @@ -0,0 +1,13 @@ +package com.javadiscord.javabot.help; + +import com.javadiscord.javabot.properties.config.guild.HelpConfig; +import net.dv8tion.jda.api.entities.TextChannel; + +import java.util.List; + +/** + * A strategy to use to generate names for new help channels as they're needed. + */ +public interface ChannelNamingStrategy { + String getName(List channels, HelpConfig config); +} diff --git a/src/main/java/com/javadiscord/javabot/help/HelpChannelListener.java b/src/main/java/com/javadiscord/javabot/help/HelpChannelListener.java new file mode 100644 index 000000000..406e05944 --- /dev/null +++ b/src/main/java/com/javadiscord/javabot/help/HelpChannelListener.java @@ -0,0 +1,31 @@ +package com.javadiscord.javabot.help; + +import com.javadiscord.javabot.Bot; +import net.dv8tion.jda.api.entities.Category; +import net.dv8tion.jda.api.entities.TextChannel; +import net.dv8tion.jda.api.events.message.guild.GuildMessageReceivedEvent; +import net.dv8tion.jda.api.hooks.ListenerAdapter; +import org.jetbrains.annotations.NotNull; + +/** + * This listener is responsible for handling messages that are sent in one or + * more designated help channels. + */ +public class HelpChannelListener extends ListenerAdapter { + + @Override + public void onGuildMessageReceived(@NotNull GuildMessageReceivedEvent event) { + if (event.getAuthor().isBot() || event.getAuthor().isSystem()) return; + + var config = Bot.config.get(event.getGuild()).getHelp(); + TextChannel channel = event.getChannel(); + Category category = channel.getParent(); + if (category == null || !category.equals(config.getHelpChannelCategory())) return; + var channelManager = new HelpChannelManager(config); + + // If a message was sent in an open text channel, reserve it. + if (channel.getName().startsWith(config.getOpenChannelPrefix())) { + channelManager.reserve(channel, event.getAuthor()); + } + } +} diff --git a/src/main/java/com/javadiscord/javabot/help/HelpChannelManager.java b/src/main/java/com/javadiscord/javabot/help/HelpChannelManager.java new file mode 100644 index 000000000..bd48e0d82 --- /dev/null +++ b/src/main/java/com/javadiscord/javabot/help/HelpChannelManager.java @@ -0,0 +1,79 @@ +package com.javadiscord.javabot.help; + +import com.javadiscord.javabot.properties.config.guild.HelpConfig; +import lombok.extern.slf4j.Slf4j; +import net.dv8tion.jda.api.entities.TextChannel; +import net.dv8tion.jda.api.entities.User; + +import java.util.Objects; +import java.util.regex.Pattern; + +/** + * This manager is responsible for all the main interactions that affect the + * help system's channels. + */ +@Slf4j +public class HelpChannelManager { + private final HelpConfig config; + + public HelpChannelManager(HelpConfig config) { + this.config = config; + } + + public boolean isOpen(TextChannel channel) { + return channel.getName().startsWith(config.getOpenChannelPrefix()); + } + + public boolean isReserved(TextChannel channel) { + return channel.getName().startsWith(config.getReservedChannelPrefix()); + } + + /** + * Opens a text channel so that it is ready for a new question. + */ + public void openNew() { + var category = config.getHelpChannelCategory(); + if (category == null) throw new IllegalStateException("Missing help channel category. Cannot open a new help channel."); + String name = this.config.getChannelNamingStrategy().getName(category.getTextChannels(), config); + category.createTextChannel(name).queue(channel -> { + channel.getManager().setPosition(0).setTopic("Ask a question here!").queue(); + log.info("Created new help channel {}.", channel.getAsMention()); + }); + } + + /** + * Reserves a text channel for a user. + * @param channel The channel to reserve. + * @param reservingUser The user who is reserving the channel. + */ + public void reserve(TextChannel channel, User reservingUser) { + String rawChannelName = channel.getName().substring(config.getOpenChannelPrefix().length()); + channel.getManager() + .setName(config.getReservedChannelPrefix() + rawChannelName) + .setPosition(Objects.requireNonNull(channel.getParent()).getTextChannels().size()) + .setTopic(String.format( + "Reserved for %s\n(_id=%s_)", + reservingUser.getAsTag(), + reservingUser.getId() + )).queue(); + log.info("Reserved channel {} for {}.", channel.getAsMention(), reservingUser.getAsTag()); + openNew(); // Open a new channel immediately, to keep things balanced. + } + + /** + * Gets the owner of a reserved channel. + * @param channel The channel to get the owner of. + * @return The user who reserved the channel, or null. + */ + public User getReservedChannelOwner(TextChannel channel) { + var pattern = Pattern.compile("\\(_id=(\\d+)_\\)"); + if (channel.getTopic() != null) { + var matcher = pattern.matcher(channel.getTopic()); + if (matcher.find()) { + String id = matcher.group(1); + return channel.getJDA().retrieveUserById(id).complete(); + } + } + return null; + } +} diff --git a/src/main/java/com/javadiscord/javabot/help/HelpChannelUpdater.java b/src/main/java/com/javadiscord/javabot/help/HelpChannelUpdater.java new file mode 100644 index 000000000..0c379fbd2 --- /dev/null +++ b/src/main/java/com/javadiscord/javabot/help/HelpChannelUpdater.java @@ -0,0 +1,126 @@ +package com.javadiscord.javabot.help; + +import com.javadiscord.javabot.properties.config.guild.HelpConfig; +import lombok.extern.slf4j.Slf4j; +import net.dv8tion.jda.api.JDA; +import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.entities.TextChannel; +import net.dv8tion.jda.api.entities.User; +import net.dv8tion.jda.api.interactions.components.ButtonStyle; +import net.dv8tion.jda.internal.interactions.ButtonImpl; + +import java.time.OffsetDateTime; +import java.util.concurrent.TimeUnit; + +/** + * Task that updates all help channels in a particular guild. + */ +@Slf4j +public class HelpChannelUpdater implements Runnable { + private final JDA jda; + private final HelpConfig config; + private final HelpChannelManager channelManager; + + public HelpChannelUpdater(JDA jda, HelpConfig config) { + this.jda = jda; + this.config = config; + this.channelManager = new HelpChannelManager(config); + } + + + @Override + public void run() { + var category = config.getHelpChannelCategory(); + if (category == null) throw new IllegalStateException("Missing required help channel category. Cannot update help channels."); + var channels = category.getTextChannels(); + int openChannelCount = 0; + for (var channel : channels) { + if (channelManager.isReserved(channel)) { + this.checkReservedChannel(channel); + } else if (channelManager.isOpen(channel) && this.checkOpenChannel(channel)) { + openChannelCount++; + } + } + while (openChannelCount < config.getPreferredOpenChannelCount()) { + channelManager.openNew(); + openChannelCount++; + } + } + + /** + * Performs checks on a reserved channel. This will do several things: + *
    + *
  • + * If the most recent message in the channel is old enough, it will + * send an activity check message in the channel, asking the user to + * confirm whether they're still using it. + *
  • + *
  • + * If the most recent message is an activity check message, and it + * has stuck around long enough without any response, then the + * channel will be removed. + *
  • + *
  • + * If for some reason we can't retrieve the owner of the channel, + * like if they left the server, the channel will be removed. + *
  • + *
+ * @param channel The channel to check. + */ + private void checkReservedChannel(TextChannel channel) { + User owner = this.channelManager.getReservedChannelOwner(channel); + if (owner == null) { + log.info("Removing reserved channel {} because no owner could be found.", channel.getAsMention()); + channel.delete().queue(); + return; + } + channel.getHistory().retrievePast(1).queue(messages -> { + Message mostRecentMessage = messages.isEmpty() ? null : messages.get(0); + if (mostRecentMessage == null) { + log.info("Removing reserved channel {} because no recent messages could be found.", channel.getAsMention()); + channel.delete().queue(); + return; + } + + // Check if the most recent message is a channel inactivity check, and check that it's old enough to surpass the remove timeout. + if ( + mostRecentMessage.getAuthor().equals(this.jda.getSelfUser()) && + mostRecentMessage.getContentRaw().contains("Are you finished with this channel?") && + mostRecentMessage.getTimeCreated().plusMinutes(config.getRemoveTimeoutMinutes()).isBefore(OffsetDateTime.now()) + ) { + log.info("Removing reserved channel {} because of inactivity for {} minutes following inactive check.", channel.getAsMention(), config.getRemoveTimeoutMinutes()); + channel.sendMessage(String.format( + "%s, this channel will be closed in 30 seconds due to prolonged inactivity. If your question still isn't answered, please ask again in an open channel.", + owner.getAsMention() + )).queue(); + channel.delete().queueAfter(30, TimeUnit.SECONDS); + return; + } + + // The most recent message is not an activity check, so check if it's old enough to warrant sending an activity check. + if (mostRecentMessage.getTimeCreated().plusMinutes(config.getInactivityTimeoutMinutes()).isBefore(OffsetDateTime.now())) { + log.info("Sending inactivity check to {} because of no activity after {} minutes.", channel.getAsMention(), config.getInactivityTimeoutMinutes()); + channel.sendMessage(String.format( + "Hey %s, it looks like this channel is inactive. Are you finished with this channel?\n\n> _If no response is received after %d minutes, this channel will be removed._", + owner.getAsMention(), + config.getRemoveTimeoutMinutes() + )) + .setActionRow( + new ButtonImpl("help-channel:done", "Yes, I'm done here!", ButtonStyle.SUCCESS, false, null), + new ButtonImpl("help-channel:not-done", "No, I'm still using it.", ButtonStyle.DANGER, false, null) + ) + .queue(); + } + }); + } + + private boolean checkOpenChannel(TextChannel channel) { + boolean isEmpty = channel.getHistoryFromBeginning(1).complete().isEmpty(); + if (!isEmpty) { + log.info("Removing non-empty open channel {}.", channel.getAsMention()); + channel.delete().complete(); + return false; + } + return true; + } +} diff --git a/src/main/java/com/javadiscord/javabot/properties/config/GuildConfig.java b/src/main/java/com/javadiscord/javabot/properties/config/GuildConfig.java index 057f776a5..c994f3b67 100644 --- a/src/main/java/com/javadiscord/javabot/properties/config/GuildConfig.java +++ b/src/main/java/com/javadiscord/javabot/properties/config/GuildConfig.java @@ -27,6 +27,7 @@ public class GuildConfig { private transient Path file; private SlashCommandConfig slashCommand; + private HelpConfig help; private ModerationConfig moderation; private QOTWConfig qotw; private WelcomeConfig welcome; @@ -38,6 +39,7 @@ public GuildConfig(Guild guild, Path file) { this.file = file; // Initialize all config items. this.slashCommand = new SlashCommandConfig(); + this.help = new HelpConfig(); this.moderation = new ModerationConfig(); this.qotw = new QOTWConfig(); this.welcome = new WelcomeConfig(); @@ -49,12 +51,21 @@ public GuildConfig(Guild guild, Path file) { private void setGuild(Guild guild) { this.guild = guild; + if (this.slashCommand == null) this.slashCommand = new SlashCommandConfig(); this.slashCommand.setGuildConfig(this); + if (this.help == null) this.help = new HelpConfig(); + this.help.setGuildConfig(this); + if (this.moderation == null) this.moderation = new ModerationConfig(); this.moderation.setGuildConfig(this); + if (this.qotw == null) this.qotw = new QOTWConfig(); this.qotw.setGuildConfig(this); + if (this.welcome == null) this.welcome = new WelcomeConfig(); this.welcome.setGuildConfig(this); + if (this.stats == null) this.stats = new StatsConfig(); this.stats.setGuildConfig(this); + if (this.starBoard == null) this.starBoard = new StarBoardConfig(); this.starBoard.setGuildConfig(this); + if (this.jam == null) this.jam = new JamConfig(); this.jam.setGuildConfig(this); } diff --git a/src/main/java/com/javadiscord/javabot/properties/config/guild/HelpConfig.java b/src/main/java/com/javadiscord/javabot/properties/config/guild/HelpConfig.java new file mode 100644 index 000000000..a08f3de8d --- /dev/null +++ b/src/main/java/com/javadiscord/javabot/properties/config/guild/HelpConfig.java @@ -0,0 +1,79 @@ +package com.javadiscord.javabot.properties.config.guild; + +import com.javadiscord.javabot.help.AlphabetNamingStrategy; +import com.javadiscord.javabot.help.AnimalNamingStrategy; +import com.javadiscord.javabot.help.ChannelNamingStrategy; +import com.javadiscord.javabot.properties.config.GuildConfigItem; +import lombok.Data; +import lombok.EqualsAndHashCode; +import net.dv8tion.jda.api.entities.Category; + +/** + * Configuration for the guild's help system. + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class HelpConfig extends GuildConfigItem { + /** + * The id of the channel category that all help channels are in. + */ + private long categoryId; + + /** + * The strategy to use when naming help channels. + */ + private String channelNamingStrategy = "animal"; + + /** + * The number of open help channels to maintain. + */ + private int preferredOpenChannelCount = 3; + + /** + * The string which is prefixed to any open help channel, where users are + * free to ask a question. + */ + private String openChannelPrefix = "\uD83D\uDFE2"; + + /** + * The string which is prefixed to any reserved help channel, where a user + * has already asked a question and is in the process of getting an answer. + */ + private String reservedChannelPrefix = "\u26D4"; + + /** + * The string which is prefixed to any inactive reserved help channel, where + * a user has asked a question but the channel has been inactive for a set + * amount of time. + */ + private String inactiveChannelPrefix = "\uD83D\uDFE0"; + + /** + * The number of minutes of inactivity before a channel is considered inactive. + */ + private int inactivityTimeoutMinutes = 30; + + /** + * The number of minutes of inactivity before a previously inactive channel + * is removed. This is measured from the time at which the bot determined + * the channel to be inactive. + */ + private int removeTimeoutMinutes = 60; + + /** + * The number of seconds to wait between each help channel update check. + */ + private long updateIntervalSeconds = 60; + + public Category getHelpChannelCategory() { + return getGuild().getCategoryById(this.categoryId); + } + + public ChannelNamingStrategy getChannelNamingStrategy() { + return switch (this.channelNamingStrategy) { + case "alphabet" -> new AlphabetNamingStrategy(); + case "animal" -> new AnimalNamingStrategy(); + default -> throw new IllegalArgumentException("Invalid channel naming strategy."); + }; + } +}