diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 8d17ee2cb9..afe5444f91 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -19,13 +19,15 @@ "extensions": [ "vscjava.vscode-java-pack", "vscjava.vscode-gradle", - "alexcvzz.vscode-sqlite", - "richardwillis.vscode-spotless-gradle" + "alexcvzz.vscode-sqlite" ], "settings": { "[java]": { - "spotlessGradle.format.enable": true, - "editor.defaultFormatter": "richardwillis.vscode-spotless-gradle" + "java.compile.nullAnalysis.mode": "disabled", + "java.format.settings.url": "meta/formatting/google-style-eclipse.xml", + "editor.formatOnSave": true, + "editor.formatOnPaste": true, + "java.format.enabled": true } } } @@ -33,4 +35,4 @@ "postCreateCommand": { "config": "cp application/config.json.template application/config.json" } -} +} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 42983f369c..72e35328b6 100644 --- a/.gitignore +++ b/.gitignore @@ -160,3 +160,15 @@ logviewer/pnpm-lock.yaml logviewer/webpack.config.js logviewer/webpack.generated.js .DS_Store + +# VisualStudioCode +.vscode/* +!.vscode/settings.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix \ No newline at end of file diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000000..44d6e10323 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,8 @@ +{ + "recommendations": [ + "vscjava.vscode-gradle", + "github.vscode-pull-request-github", + "vscjava.vscode-java-pack", + "alexcvzz.vscode-sqlite" + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000000..5f19eba23c --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,8 @@ +{ + //Configure the JDK to Java 18 in settings + "java.compile.nullAnalysis.mode": "disabled", + "java.format.settings.url": "meta/formatting/google-style-eclipse.xml", + "editor.formatOnSave": true, + "editor.formatOnPaste": true, + "java.format.enabled": true, +} \ No newline at end of file diff --git a/application/build.gradle b/application/build.gradle index 12806c29ca..c583342439 100644 --- a/application/build.gradle +++ b/application/build.gradle @@ -40,7 +40,7 @@ shadowJar { dependencies { implementation 'com.google.code.findbugs:jsr305:3.0.2' - implementation 'org.jetbrains:annotations:23.0.0' + implementation 'org.jetbrains:annotations:23.1.0' implementation project(':database') implementation project(':utils') diff --git a/application/config.json.template b/application/config.json.template index 780e844ed1..26980743ee 100644 --- a/application/config.json.template +++ b/application/config.json.template @@ -27,8 +27,7 @@ }, "wolframAlphaAppId": "79J52T-6239TVXHR7", "helpSystem": { - "stagingChannelPattern": "ask_here", - "overviewChannelPattern": "active_questions", + "helpForumPattern": "questions", "categories": [ "Java", "Frameworks", 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 d839a05a14..e89a7eab6c 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/Features.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/Features.java @@ -86,7 +86,6 @@ public static Collection createFeatures(JDA jda, Database database, Con features.add(new TopHelpersPurgeMessagesRoutine(database)); features.add(new RemindRoutine(database)); features.add(new ScamHistoryPurgeRoutine(scamHistoryStore)); - features.add(new BotMessageCleanup(config)); features.add(new HelpThreadMetadataPurger(database)); features.add(new HelpThreadActivityUpdater(helpSystemHelper)); features @@ -98,7 +97,6 @@ public static Collection createFeatures(JDA jda, Database database, Con features.add(new TopHelpersMessageListener(database, config)); features.add(new SuggestionsUpDownVoter(config)); features.add(new ScamBlocker(actionsStore, scamHistoryStore, config)); - features.add(new ImplicitAskListener(config, helpSystemHelper)); features.add(new MediaOnlyChannelListener(config)); features.add(new FileSharingMessageListener(config)); features.add(new BlacklistedAttachmentListener(config, modAuditLogWriter)); @@ -111,6 +109,7 @@ public static Collection createFeatures(JDA jda, Database database, Con features.add(new OnGuildLeaveCloseThreadListener(database)); features.add(new UserBannedDeleteRecentThreadsListener(database)); features.add(new LeftoverBookmarksListener(bookmarksSystem)); + features.add(new HelpThreadCreatedListener(helpSystemHelper)); // Message context commands @@ -139,14 +138,12 @@ public static Collection createFeatures(JDA jda, Database database, Con features.add(new UnquarantineCommand(actionsStore, config)); features.add(new WhoIsCommand()); features.add(new WolframAlphaCommand(config)); - features.add(new AskCommand(config, helpSystemHelper, database)); features.add(new ModMailCommand(jda, config)); features.add(new HelpThreadCommand(config, helpSystemHelper)); features.add(new ReportCommand(config)); features.add(new BookmarksCommand(bookmarksSystem)); // Mixtures - features.add(new HelpThreadOverviewUpdater(config, helpSystemHelper)); return features; } diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/bookmarks/BookmarksSystem.java b/application/src/main/java/org/togetherjava/tjbot/commands/bookmarks/BookmarksSystem.java index 4c2348f928..bed490dbe0 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/bookmarks/BookmarksSystem.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/bookmarks/BookmarksSystem.java @@ -39,7 +39,7 @@ public final class BookmarksSystem { static final Color COLOR_FAILURE = new Color(238, 153, 160); private final Database database; - private final Predicate isOverviewChannelName; + private final Predicate isHelpForumName; /** * Creates a new instance of the bookmarks system. @@ -50,8 +50,8 @@ public final class BookmarksSystem { public BookmarksSystem(Config config, Database database) { this.database = database; - isOverviewChannelName = Pattern.compile(config.getHelpSystem().getOverviewChannelPattern()) - .asMatchPredicate(); + isHelpForumName = + Pattern.compile(config.getHelpSystem().getHelpForumPattern()).asMatchPredicate(); } boolean isHelpThread(MessageChannelUnion channel) { @@ -60,9 +60,9 @@ boolean isHelpThread(MessageChannelUnion channel) { } ThreadChannel threadChannel = channel.asThreadChannel(); - String parentChannelName = threadChannel.getParentMessageChannel().getName(); + String parentChannelName = threadChannel.getParentChannel().getName(); - return isOverviewChannelName.test(parentChannelName); + return isHelpForumName.test(parentChannelName); } boolean didUserBookmarkChannel(long userID, long channelID) { diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/code/CodeMessageAutoDetection.java b/application/src/main/java/org/togetherjava/tjbot/commands/code/CodeMessageAutoDetection.java index 64572420b2..30d7aea1b5 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/code/CodeMessageAutoDetection.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/code/CodeMessageAutoDetection.java @@ -23,8 +23,7 @@ public final class CodeMessageAutoDetection extends MessageReceiverAdapter { private final CodeMessageHandler codeMessageHandler; - private final Predicate isStagingChannelName; - private final Predicate isOverviewChannelName; + private final Predicate isHelpForumName; /** * Creates a new instance. @@ -37,10 +36,8 @@ public CodeMessageAutoDetection(Config config, CodeMessageHandler codeMessageHan this.codeMessageHandler = codeMessageHandler; - isStagingChannelName = Pattern.compile(config.getHelpSystem().getStagingChannelPattern()) - .asMatchPredicate(); - isOverviewChannelName = Pattern.compile(config.getHelpSystem().getOverviewChannelPattern()) - .asMatchPredicate(); + isHelpForumName = + Pattern.compile(config.getHelpSystem().getHelpForumPattern()).asMatchPredicate(); } @Override @@ -73,7 +70,6 @@ private boolean isHelpThread(MessageReceivedEvent event) { ThreadChannel thread = event.getChannel().asThreadChannel(); String rootChannelName = thread.getParentChannel().getName(); - return isStagingChannelName.test(rootChannelName) - || isOverviewChannelName.test(rootChannelName); + return isHelpForumName.test(rootChannelName); } } diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/filesharing/FileSharingMessageListener.java b/application/src/main/java/org/togetherjava/tjbot/commands/filesharing/FileSharingMessageListener.java index 67e29900f7..7ee09f4b7e 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/filesharing/FileSharingMessageListener.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/filesharing/FileSharingMessageListener.java @@ -63,8 +63,7 @@ public class FileSharingMessageListener extends MessageReceiverAdapter implement private final Set extensionFilter = Set.of("txt", "java", "gradle", "xml", "kt", "json", "fxml", "css", "c", "h", "cpp", "py", "yml"); - private final Predicate isStagingChannelName; - private final Predicate isOverviewChannelName; + private final Predicate isHelpForumName; private final Predicate isSoftModRole; /** @@ -77,10 +76,8 @@ public FileSharingMessageListener(Config config) { super(Pattern.compile(".*")); gistApiKey = config.getGistApiKey(); - isStagingChannelName = Pattern.compile(config.getHelpSystem().getStagingChannelPattern()) - .asMatchPredicate(); - isOverviewChannelName = Pattern.compile(config.getHelpSystem().getOverviewChannelPattern()) - .asMatchPredicate(); + isHelpForumName = + Pattern.compile(config.getHelpSystem().getHelpForumPattern()).asMatchPredicate(); isSoftModRole = Pattern.compile(config.getSoftModerationRolePattern()).asMatchPredicate(); } @@ -245,8 +242,7 @@ private boolean isHelpThread(MessageReceivedEvent event) { ThreadChannel thread = event.getChannel().asThreadChannel(); String rootChannelName = thread.getParentChannel().getName(); - return isStagingChannelName.test(rootChannelName) - || isOverviewChannelName.test(rootChannelName); + return isHelpForumName.test(rootChannelName); } private void deleteGist(String gistId) { 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 deleted file mode 100644 index ed4e0c518e..0000000000 --- a/application/src/main/java/org/togetherjava/tjbot/commands/help/AskCommand.java +++ /dev/null @@ -1,266 +0,0 @@ -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.entities.*; -import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; -import net.dv8tion.jda.api.entities.channel.concrete.ThreadChannel; -import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; -import net.dv8tion.jda.api.exceptions.ErrorResponseException; -import net.dv8tion.jda.api.interactions.InteractionHook; -import net.dv8tion.jda.api.interactions.callbacks.IReplyCallback; -import net.dv8tion.jda.api.interactions.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 org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import org.togetherjava.tjbot.commands.CommandVisibility; -import org.togetherjava.tjbot.commands.SlashCommandAdapter; -import org.togetherjava.tjbot.commands.utils.MessageUtils; -import org.togetherjava.tjbot.config.Config; -import org.togetherjava.tjbot.db.Database; -import org.togetherjava.tjbot.db.generated.tables.records.HelpThreadsRecord; - -import javax.annotation.Nullable; - -import java.time.Instant; -import java.time.temporal.ChronoUnit; -import java.util.List; -import java.util.Optional; -import java.util.concurrent.TimeUnit; -import java.util.function.Function; - -import static org.togetherjava.tjbot.commands.help.HelpSystemHelper.TITLE_COMPACT_LENGTH_MAX; -import static org.togetherjava.tjbot.commands.help.HelpSystemHelper.TITLE_COMPACT_LENGTH_MIN; -import static org.togetherjava.tjbot.commands.help.HelpThreadCommand.CHANGE_CATEGORY_SUBCOMMAND; -import static org.togetherjava.tjbot.commands.help.HelpThreadCommand.CHANGE_TITLE_SUBCOMMAND; -import static org.togetherjava.tjbot.db.generated.Tables.HELP_THREADS; - -/** - * 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 int COOLDOWN_DURATION_VALUE = 5; - private static final ChronoUnit COOLDOWN_DURATION_UNIT = ChronoUnit.MINUTES; - private static final Logger logger = LoggerFactory.getLogger(AskCommand.class); - public static final String COMMAND_NAME = "ask"; - private static final String TITLE_OPTION = "title"; - private static final String CATEGORY_OPTION = "category"; - private final Cache userToLastAsk = Caffeine.newBuilder() - .maximumSize(1_000) - .expireAfterAccess(COOLDOWN_DURATION_VALUE, TimeUnit.of(COOLDOWN_DURATION_UNIT)) - .build(); - private final HelpSystemHelper helper; - private final Database database; - - /** - * Creates a new instance. - * - * @param config the config to use - * @param helper the helper to use - * @param database the database to get help threads from - */ - public AskCommand(Config config, HelpSystemHelper helper, Database database) { - super("ask", "Ask a question - use this in the staging channel", CommandVisibility.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; - this.database = database; - } - - @Override - public void onSlashCommand(SlashCommandInteractionEvent event) { - if (isUserOnCooldown(event.getUser())) { - sendCooldownResponse(event); - return; - } - - String title = event.getOption(TITLE_OPTION).getAsString(); - String category = event.getOption(CATEGORY_OPTION).getAsString(); - - if (!handleIsValidTitle(title, event)) { - return; - } - - Optional maybeOverviewChannel = - helper.handleRequireOverviewChannelForAsk(event.getGuild(), event.getChannel()); - if (maybeOverviewChannel.isEmpty()) { - return; - } - TextChannel overviewChannel = maybeOverviewChannel.orElseThrow(); - - InteractionHook eventHook = event.getHook(); - Member author = event.getMember(); - Guild guild = event.getGuild(); - event.deferReply(true).queue(); - - HelpSystemHelper.HelpThreadName name = new HelpSystemHelper.HelpThreadName( - HelpSystemHelper.ThreadActivity.NEEDS_HELP, category, title); - - overviewChannel.createThreadChannel(name.toChannelName()) - .flatMap(threadChannel -> handleEvent(eventHook, threadChannel, author, title, category, - guild)) - .queue(any -> userToLastAsk.put(event.getUser().getIdLong(), Instant.now()), - e -> handleFailure(e, eventHook)); - } - - private boolean isUserOnCooldown(User user) { - return Optional.ofNullable(userToLastAsk.getIfPresent(user.getIdLong())) - .map(lastAction -> lastAction.plus(COOLDOWN_DURATION_VALUE, COOLDOWN_DURATION_UNIT)) - .filter(Instant.now()::isBefore) - .isPresent(); - } - - private void sendCooldownResponse(SlashCommandInteractionEvent event) { - User user = event.getUser(); - Guild guild = event.getGuild(); - - HelpThreadsRecord lastThreadByAuthor = getLatestHelpThread(user); - - String cooldownDuration = - COOLDOWN_DURATION_VALUE + " " + COOLDOWN_DURATION_UNIT.name().toLowerCase(); - - if (lastThreadByAuthor == null) { - logger.warn("Can't find the last help thread created by the user with id ({})", - user.getId()); - event - .reply("Sorry, something went wrong. Please try again after %s." - .formatted(cooldownDuration)) - .setEphemeral(true) - .queue(); - return; - } - - Function, String> formatMessage = commandMentions -> { - String message = - """ - Sorry, you can only create a single help thread every %s. Please use your existing thread %s instead. - If you made a typo or similar, you can adjust the title using the command %s and the category with %s šŸ‘Œ"""; - - String lastThreadMention = - MessageUtils.mentionChannelById(lastThreadByAuthor.getChannelId()); - - return message.formatted(cooldownDuration, lastThreadMention, commandMentions.get(0), - commandMentions.get(1)); - }; - - RestAction changeTitle = mentionHelpChangeCommand(guild, CHANGE_TITLE_SUBCOMMAND); - RestAction changeCategory = - mentionHelpChangeCommand(guild, CHANGE_CATEGORY_SUBCOMMAND); - - RestAction.allOf(changeCategory, changeTitle) - .map(formatMessage) - .flatMap(text -> event.reply(text).setEphemeral(true)) - .queue(); - } - - @Nullable - private HelpThreadsRecord getLatestHelpThread(User user) { - return database.read(context -> context.selectFrom(HELP_THREADS) - .where(HELP_THREADS.AUTHOR_ID.eq(user.getIdLong())) - .orderBy(HELP_THREADS.CREATED_AT.desc()) - .limit(1) - .fetchOne()); - } - - private boolean handleIsValidTitle(CharSequence title, IReplyCallback event) { - if (HelpSystemHelper.isTitleValid(title)) { - return true; - } - - event.reply(""" - Sorry, but your title is invalid. Please pick a title where: - • length is between %d and %d - • must not contain the word 'help' - Thanks, and sorry for the inconvenience šŸ‘ - """.formatted(TITLE_COMPACT_LENGTH_MIN, TITLE_COMPACT_LENGTH_MAX)) - .setEphemeral(true) - .queue(); - - return false; - } - - private RestAction handleEvent(InteractionHook eventHook, ThreadChannel threadChannel, - Member author, String title, String category, Guild guild) { - helper.writeHelpThreadToDatabase(author, threadChannel); - return sendInitialMessage(guild, threadChannel, author, title, category) - .flatMap(Message::pin) - .flatMap(any -> notifyUser(eventHook, threadChannel)) - .flatMap(any -> helper.sendExplanationMessage(threadChannel)); - } - - private RestAction sendInitialMessage(Guild guild, ThreadChannel threadChannel, - Member author, String title, String category) { - String roleMentionDescription = helper.handleFindRoleForCategory(category, guild) - .map(role -> " (%s)".formatted(role.getAsMention())) - .orElse(""); - - String contentPrefix = - "%s has a question about '**%s**'".formatted(author.getAsMention(), title); - String contentSuffix = " and will send the details now."; - String contentWithoutRole = contentPrefix + contentSuffix; - String contentWithRole = contentPrefix + roleMentionDescription + contentSuffix; - - // 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 RestAction notifyUser(InteractionHook eventHook, - IMentionable threadChannel) { - return eventHook.editOriginal(""" - Created a thread for you: %s - Please ask your question there, thanks.""".formatted(threadChannel.getAsMention())); - } - - private static void handleFailure(Throwable exception, InteractionHook eventHook) { - if (exception instanceof ErrorResponseException responseException) { - ErrorResponse response = responseException.getErrorResponse(); - if (response == ErrorResponse.MAX_CHANNELS - || response == ErrorResponse.MAX_ACTIVE_THREADS) { - eventHook.editOriginal( - "It seems that there are currently too many active questions, please try again in a few minutes.") - .queue(); - return; - } - } - - logger.error("Attempted to create a help thread, but failed", exception); - } - - private static RestAction mentionHelpChangeCommand(Guild guild, String subcommand) { - return MessageUtils.mentionGuildSlashCommand(guild, HelpThreadCommand.COMMAND_NAME, - HelpThreadCommand.CHANGE_SUBCOMMAND_GROUP, subcommand); - } -} diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/help/AutoPruneHelperRoutine.java b/application/src/main/java/org/togetherjava/tjbot/commands/help/AutoPruneHelperRoutine.java index d3f790745e..22d95e45f7 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/help/AutoPruneHelperRoutine.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/help/AutoPruneHelperRoutine.java @@ -4,7 +4,7 @@ import net.dv8tion.jda.api.entities.Guild; import net.dv8tion.jda.api.entities.Member; import net.dv8tion.jda.api.entities.Role; -import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; +import net.dv8tion.jda.api.entities.channel.concrete.ForumChannel; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -69,9 +69,9 @@ public void runRoutine(JDA jda) { } private void pruneForGuild(Guild guild) { - TextChannel overviewChannel = guild.getTextChannels() + ForumChannel helpForum = guild.getForumChannels() .stream() - .filter(channel -> helper.isOverviewChannelName(channel.getName())) + .filter(channel -> helper.isHelpForumName(channel.getName())) .findAny() .orElseThrow(); Instant now = Instant.now(); @@ -80,14 +80,14 @@ private void pruneForGuild(Guild guild) { .map(category -> helper.handleFindRoleForCategory(category, guild)) .filter(Optional::isPresent) .map(Optional::orElseThrow) - .forEach(role -> pruneRoleIfFull(role, overviewChannel, now)); + .forEach(role -> pruneRoleIfFull(role, helpForum, now)); } - private void pruneRoleIfFull(Role role, TextChannel overviewChannel, Instant when) { + private void pruneRoleIfFull(Role role, ForumChannel helpForum, Instant when) { role.getGuild().findMembersWithRoles(role).onSuccess(members -> { if (isRoleFull(members)) { logger.debug("Helper role {} is full, starting to prune.", role.getName()); - pruneRole(role, members, overviewChannel, when); + pruneRole(role, members, helpForum, when); } }); } @@ -96,7 +96,7 @@ private boolean isRoleFull(Collection members) { return members.size() >= ROLE_FULL_THRESHOLD; } - private void pruneRole(Role role, List members, TextChannel overviewChannel, + private void pruneRole(Role role, List members, ForumChannel helpForum, Instant when) { List membersShuffled = new ArrayList<>(members); Collections.shuffle(membersShuffled); @@ -120,7 +120,7 @@ private void pruneRole(Role role, List members, TextChannel ov logger.info("Pruning {} users {} from role {}", membersToPrune.size(), membersToPrune, role.getName()); - membersToPrune.forEach(member -> pruneMemberFromRole(member, role, overviewChannel)); + membersToPrune.forEach(member -> pruneMemberFromRole(member, role, helpForum)); } private boolean isMemberInactive(Member member, Instant when) { @@ -142,7 +142,7 @@ private boolean isMemberInactive(Member member, Instant when) { .and(HELP_CHANNEL_MESSAGES.SENT_AT.greaterThan(latestActiveMoment)))) == 0; } - private void pruneMemberFromRole(Member member, Role role, TextChannel overviewChannel) { + private void pruneMemberFromRole(Member member, Role role, ForumChannel helpForum) { Guild guild = member.getGuild(); String dmMessage = @@ -150,7 +150,7 @@ private void pruneMemberFromRole(Member member, Role role, TextChannel overviewC You seem to have been inactive for some time in server **%s**, hence we removed you from the **%s** role. If that was a mistake, just head back to %s and select the role again. Sorry for any inconvenience caused by this šŸ™‡""" - .formatted(guild.getName(), role.getName(), overviewChannel.getAsMention()); + .formatted(guild.getName(), role.getName(), helpForum.getAsMention()); guild.removeRoleFromMember(member, role) .flatMap(any -> member.getUser().openPrivateChannel()) 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 deleted file mode 100644 index 514f2ebbbf..0000000000 --- a/application/src/main/java/org/togetherjava/tjbot/commands/help/BotMessageCleanup.java +++ /dev/null @@ -1,129 +0,0 @@ -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.Message; -import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; -import net.dv8tion.jda.api.entities.channel.middleman.GuildMessageChannel; -import net.dv8tion.jda.api.requests.RestAction; -import net.dv8tion.jda.internal.requests.CompletedRestAction; -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(Config config) { - this.config = config.getHelpSystem(); - - isStagingChannelName = Pattern.compile(config.getHelpSystem().getStagingChannelPattern()) - .asMatchPredicate(); - } - - @Override - public Schedule createSchedule() { - return new Schedule(ScheduleMode.FIXED_RATE, 1, 1, TimeUnit.MINUTES); - } - - @Override - public void runRoutine(JDA jda) { - jda.getGuildCache().forEach(this::cleanupBotMessagesForGuild); - } - - private void cleanupBotMessagesForGuild(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 Optional handleRequireStagingChannel(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(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 RestAction cleanupBotMessages(GuildMessageChannel channel, - Collection messages) { - logger.debug("Cleaning up old bot messages in the staging channel"); - List messageIdsToDelete = messages.stream() - .filter(BotMessageCleanup::shouldMessageBeCleanedUp) - .map(Message::getId) - .toList(); - - logger.debug("Found {} messages to delete", messageIdsToDelete.size()); - - if (messageIdsToDelete.isEmpty()) { - return new CompletedRestAction<>(channel.getJDA(), 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/HelpSystemHelper.java b/application/src/main/java/org/togetherjava/tjbot/commands/help/HelpSystemHelper.java index 9f622837b1..81308a1df1 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/help/HelpSystemHelper.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/help/HelpSystemHelper.java @@ -3,17 +3,16 @@ import net.dv8tion.jda.api.EmbedBuilder; import net.dv8tion.jda.api.JDA; import net.dv8tion.jda.api.entities.*; -import net.dv8tion.jda.api.entities.channel.Channel; -import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; +import net.dv8tion.jda.api.entities.channel.attribute.IThreadContainer; +import net.dv8tion.jda.api.entities.channel.concrete.ForumChannel; import net.dv8tion.jda.api.entities.channel.concrete.ThreadChannel; +import net.dv8tion.jda.api.entities.channel.forums.ForumTag; +import net.dv8tion.jda.api.entities.channel.forums.ForumTagSnowflake; import net.dv8tion.jda.api.entities.channel.middleman.GuildChannel; import net.dv8tion.jda.api.entities.channel.middleman.GuildMessageChannel; -import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel; import net.dv8tion.jda.api.requests.RestAction; import net.dv8tion.jda.api.requests.restaction.MessageCreateAction; import net.dv8tion.jda.api.utils.FileUpload; -import net.dv8tion.jda.api.utils.messages.MessageCreateBuilder; -import net.dv8tion.jda.api.utils.messages.MessageCreateData; import net.dv8tion.jda.internal.requests.CompletedRestAction; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -29,17 +28,11 @@ import java.awt.Color; import java.io.InputStream; -import java.util.List; -import java.util.Locale; -import java.util.Optional; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; +import java.util.*; import java.util.function.Consumer; import java.util.function.Predicate; -import java.util.regex.Matcher; import java.util.regex.Pattern; -import java.util.stream.Stream; +import java.util.stream.Collectors; /** * Helper class offering certain methods used by the help system. @@ -51,24 +44,10 @@ public final class HelpSystemHelper { private static final String CODE_SYNTAX_EXAMPLE_PATH = "codeSyntaxExample.png"; - private static final String ACTIVITY_GROUP = "activity"; - private static final String CATEGORY_GROUP = "category"; - private static final String TITLE_GROUP = "title"; - private static final Pattern EXTRACT_HELP_NAME_PATTERN = - Pattern.compile("(?:(?<%s>\\W) )?(?:\\[(?<%s>[^\\[]+)] )?(?<%s>.+)" - .formatted(ACTIVITY_GROUP, CATEGORY_GROUP, TITLE_GROUP)); - - private static final Pattern TITLE_COMPACT_REMOVAL_PATTERN = Pattern.compile("\\W"); - static final int TITLE_COMPACT_LENGTH_MIN = 2; - static final int TITLE_COMPACT_LENGTH_MAX = 70; - - private static final ScheduledExecutorService SERVICE = Executors.newScheduledThreadPool(3); - private static final int SEND_UNCATEGORIZED_ADVICE_AFTER_MINUTES = 5; - - private final Predicate isOverviewChannelName; - private final String overviewChannelPattern; - private final Predicate isStagingChannelName; - private final String stagingChannelPattern; + private final Predicate isHelpForumName; + private final String helpForumPattern; + private final Set categories; + private final Set threadActivityTagNames; private final String categoryRoleSuffix; private final Database database; private final JDA jda; @@ -85,13 +64,15 @@ public HelpSystemHelper(JDA jda, Config config, Database database) { HelpSystemConfig helpConfig = config.getHelpSystem(); this.database = database; - overviewChannelPattern = helpConfig.getOverviewChannelPattern(); - isOverviewChannelName = Pattern.compile(overviewChannelPattern).asMatchPredicate(); - - stagingChannelPattern = helpConfig.getStagingChannelPattern(); - isStagingChannelName = Pattern.compile(stagingChannelPattern).asMatchPredicate(); + helpForumPattern = helpConfig.getHelpForumPattern(); + isHelpForumName = Pattern.compile(helpForumPattern).asMatchPredicate(); + categories = new HashSet<>(helpConfig.getCategories()); categoryRoleSuffix = helpConfig.getCategoryRoleSuffix(); + + threadActivityTagNames = Arrays.stream(ThreadActivity.values()) + .map(ThreadActivity::getTagName) + .collect(Collectors.toSet()); } RestAction sendExplanationMessage(GuildMessageChannel threadChannel) { @@ -106,7 +87,7 @@ private RestAction sendExplanationMessage(GuildMessageChannel threadCha String closeCommandMention) { boolean useCodeSyntaxExampleImage = true; InputStream codeSyntaxExampleData = - AskCommand.class.getResourceAsStream("/" + CODE_SYNTAX_EXAMPLE_PATH); + HelpSystemHelper.class.getResourceAsStream("/" + CODE_SYNTAX_EXAMPLE_PATH); if (codeSyntaxExampleData == null) { useCodeSyntaxExampleImage = false; } @@ -139,10 +120,10 @@ private RestAction sendExplanationMessage(GuildMessageChannel threadCha return action.setEmbeds(embeds); } - void writeHelpThreadToDatabase(Member author, ThreadChannel threadChannel) { + void writeHelpThreadToDatabase(long authorId, ThreadChannel threadChannel) { database.write(content -> { HelpThreadsRecord helpThreadsRecord = content.newRecord(HelpThreads.HELP_THREADS) - .setAuthorId(author.getIdLong()) + .setAuthorId(authorId) .setChannelId(threadChannel.getIdLong()) .setCreatedAt(threadChannel.getTimeCreated().toInstant()); if (helpThreadsRecord.update() == 0) { @@ -173,74 +154,100 @@ Optional handleFindRoleForCategory(String category, Guild guild) { return maybeHelperRole; } - Optional getCategoryOfChannel(Channel channel) { - return Optional.ofNullable(HelpThreadName.ofChannelName(channel.getName()).category); - } + RestAction renameChannel(GuildChannel channel, String title) { + String currentTitle = channel.getName(); + if (title.equals(currentTitle)) { + // Do not stress rate limits if no actual change is done + return new CompletedRestAction<>(channel.getJDA(), null); + } - RestAction renameChannelToCategory(GuildChannel channel, String category) { - HelpThreadName currentName = HelpThreadName.ofChannelName(channel.getName()); - HelpThreadName nextName = - new HelpThreadName(currentName.activity, category, currentName.title); + return channel.getManager().setName(title); + } - return renameChannel(channel, currentName, nextName); + Optional getCategoryTagOfChannel(ThreadChannel channel) { + return getFirstMatchingTagOfChannel(categories, channel); } - RestAction renameChannelToTitle(GuildChannel channel, String title) { - HelpThreadName currentName = HelpThreadName.ofChannelName(channel.getName()); - HelpThreadName nextName = - new HelpThreadName(currentName.activity, currentName.category, title); + Optional getActivityTagOfChannel(ThreadChannel channel) { + return getFirstMatchingTagOfChannel(threadActivityTagNames, channel); + } - return renameChannel(channel, currentName, nextName); + private static Optional getFirstMatchingTagOfChannel(Set tagNamesToMatch, + ThreadChannel channel) { + return channel.getAppliedTags() + .stream() + .filter(tag -> tagNamesToMatch.contains(tag.getName())) + .findFirst(); } - RestAction renameChannelToActivity(GuildChannel channel, ThreadActivity activity) { - HelpThreadName currentName = HelpThreadName.ofChannelName(channel.getName()); - HelpThreadName nextName = - new HelpThreadName(activity, currentName.category, currentName.title); + RestAction changeChannelCategory(ThreadChannel channel, String category) { + return changeMatchingTagOfChannel(category, categories, channel); + } - return renameChannel(channel, currentName, nextName); + RestAction changeChannelActivity(ThreadChannel channel, ThreadActivity activity) { + return changeMatchingTagOfChannel(activity.getTagName(), threadActivityTagNames, channel); } - private RestAction renameChannel(GuildChannel channel, HelpThreadName currentName, - HelpThreadName nextName) { - if (currentName.equals(nextName)) { - // Do not stress rate limits if no actual change is done - return new CompletedRestAction<>(channel.getJDA(), null); + private static RestAction changeMatchingTagOfChannel(String tagName, + Set tagNamesToMatch, ThreadChannel channel) { + List tags = new ArrayList<>(channel.getAppliedTags()); + + Optional currentTag = getFirstMatchingTagOfChannel(tagNamesToMatch, channel); + if (currentTag.isPresent()) { + if (currentTag.orElseThrow().getName().equals(tagName)) { + // Do not stress rate limits if no actual change is done + return new CompletedRestAction<>(channel.getJDA(), null); + } + + tags.remove(currentTag.orElseThrow()); } - return channel.getManager().setName(nextName.toChannelName()); - } + ForumTag nextTag = requireTag(tagName, channel.getParentChannel().asForumChannel()); + // In case the tag was already there, but not in front, we first remove it + tags.remove(nextTag); - boolean isOverviewChannelName(String channelName) { - return isOverviewChannelName.test(channelName); - } + if (tags.size() >= ForumChannel.MAX_POST_TAGS) { + // If still at max size, remove last to make place for the new tag. + // The last tag is the least important. + // NOTE In practice, this can happen if the user selected 5 categories and + // the bot then tries to add the activity tag + tags.remove(tags.size() - 1); + } - String getOverviewChannelPattern() { - return overviewChannelPattern; - } + Collection nextTags = new ArrayList<>(tags.size()); + // Tag should be in front, to take priority over others + nextTags.add(nextTag); + nextTags.addAll(tags); - boolean isStagingChannelName(String channelName) { - return isStagingChannelName.test(channelName); + List tagSnowflakes = + nextTags.stream().map(ForumTag::getIdLong).map(ForumTagSnowflake::fromId).toList(); + return channel.getManager().setAppliedTags(tagSnowflakes); } - String getStagingChannelPattern() { - return stagingChannelPattern; + private static ForumTag requireTag(String tagName, ForumChannel forumChannel) { + List matchingTags = forumChannel.getAvailableTagsByName(tagName, false); + if (matchingTags.isEmpty()) { + throw new IllegalStateException("The forum %s in guild %s is missing the tag %s." + .formatted(forumChannel.getName(), forumChannel.getGuild().getName(), tagName)); + } + + return matchingTags.get(0); } - static boolean isTitleValid(CharSequence title) { - String titleCompact = TITLE_COMPACT_REMOVAL_PATTERN.matcher(title).replaceAll(""); + boolean isHelpForumName(String channelName) { + return isHelpForumName.test(channelName); + } - return titleCompact.length() >= TITLE_COMPACT_LENGTH_MIN - && titleCompact.length() <= TITLE_COMPACT_LENGTH_MAX - && !titleCompact.toLowerCase(Locale.US).contains("help"); + String getHelpForumPattern() { + return helpForumPattern; } - Optional handleRequireOverviewChannel(Guild guild, + Optional handleRequireHelpForum(Guild guild, Consumer consumeChannelPatternIfNotFound) { - Predicate isChannelName = this::isOverviewChannelName; - String channelPattern = getOverviewChannelPattern(); + Predicate isChannelName = this::isHelpForumName; + String channelPattern = getHelpForumPattern(); - Optional maybeChannel = guild.getTextChannelCache() + Optional maybeChannel = guild.getForumChannelCache() .stream() .filter(channel -> isChannelName.test(channel.getName())) .findAny(); @@ -252,135 +259,26 @@ Optional handleRequireOverviewChannel(Guild guild, return maybeChannel; } - Optional handleRequireOverviewChannelForAsk(Guild guild, - MessageChannel respondTo) { - return handleRequireOverviewChannel(guild, channelPattern -> { - logger.warn( - "Attempted to create a help thread, did not find the overview channel matching the configured pattern '{}' for guild '{}'", - channelPattern, guild.getName()); - - respondTo.sendMessage( - "Sorry, I was unable to locate the overview channel. The server seems wrongly configured, please contact a moderator.") - .queue(); - }); - } - - List getActiveThreadsIn(TextChannel channel) { + List getActiveThreadsIn(IThreadContainer channel) { return channel.getThreadChannels() .stream() .filter(Predicate.not(ThreadChannel::isArchived)) .toList(); } - void scheduleUncategorizedAdviceCheck(long threadChannelId, long authorId) { - SERVICE.schedule(() -> { - try { - executeUncategorizedAdviceCheck(threadChannelId, authorId); - } catch (Exception e) { - logger.warn( - "Unknown error during an uncategorized advice check on thread {} by author {}.", - threadChannelId, authorId, e); - } - }, SEND_UNCATEGORIZED_ADVICE_AFTER_MINUTES, TimeUnit.MINUTES); - } - - private void executeUncategorizedAdviceCheck(long threadChannelId, long authorId) { - logger.debug("Executing uncategorized advice check for thread {} by author {}.", - threadChannelId, authorId); - jda.retrieveUserById(authorId).flatMap(author -> { - ThreadChannel threadChannel = jda.getThreadChannelById(threadChannelId); - if (threadChannel == null) { - logger.debug( - "Channel for uncategorized advice check seems to be deleted (thread {} by author {}).", - threadChannelId, authorId); - return new CompletedRestAction<>(jda, null); - } - - if (threadChannel.isArchived()) { - logger.debug( - "Channel for uncategorized advice check is archived already (thread {} by author {}).", - threadChannelId, authorId); - return new CompletedRestAction<>(jda, null); - } - - Optional category = getCategoryOfChannel(threadChannel); - if (category.isPresent()) { - logger.debug( - "Channel for uncategorized advice check seems to have a category now (thread {} by author {}).", - threadChannelId, authorId); - return new CompletedRestAction<>(jda, null); - } - - // Still no category, send advice - return MessageUtils - .mentionGuildSlashCommand(threadChannel.getGuild(), HelpThreadCommand.COMMAND_NAME, - HelpThreadCommand.CHANGE_SUBCOMMAND_GROUP, - HelpThreadCommand.Subcommand.CHANGE_CATEGORY.getCommandName()) - .flatMap(command -> { - MessageEmbed embed = HelpSystemHelper.embedWith( - """ - Hey there šŸ‘‹ You have to select a category for your help thread, otherwise nobody can see your question. - Please use the %s slash-command and pick what fits best, thanks šŸ™‚ - """ - .formatted(command)); - MessageCreateData message = - new MessageCreateBuilder().setContent(author.getAsMention()) - .setEmbeds(embed) - .build(); - - return threadChannel.sendMessage(message); - }); - }).queue(); - } - - record HelpThreadName(@Nullable ThreadActivity activity, @Nullable String category, - String title) { - static HelpThreadName ofChannelName(CharSequence channelName) { - Matcher matcher = EXTRACT_HELP_NAME_PATTERN.matcher(channelName); - - if (!matcher.matches()) { - throw new AssertionError("Pattern must match any thread name"); - } - - String activityText = matcher.group(ACTIVITY_GROUP); - - ThreadActivity activity = - activityText == null ? null : ThreadActivity.ofSymbol(activityText); - String category = matcher.group(CATEGORY_GROUP); - String title = matcher.group(TITLE_GROUP); - - return new HelpThreadName(activity, category, title); - } - - String toChannelName() { - String activityText = activity == null ? "" : activity.getSymbol() + " "; - String categoryText = category == null ? "" : "[%s] ".formatted(category); - - return activityText + categoryText + title; - } - } - enum ThreadActivity { - NEEDS_HELP("šŸ”»"), - LIKELY_NEEDS_HELP("šŸ”ø"), - SEEMS_GOOD("šŸ”¹"); - - private final String symbol; + LOW("Nobody helped yet"), + MEDIUM("Needs attention"), + HIGH("Active"); - ThreadActivity(String symbol) { - this.symbol = symbol; - } + private final String tagName; - public String getSymbol() { - return symbol; + ThreadActivity(String tagName) { + this.tagName = tagName; } - static ThreadActivity ofSymbol(String symbol) { - return Stream.of(values()) - .filter(activity -> activity.getSymbol().equals(symbol)) - .findAny() - .orElseThrow(() -> new IllegalArgumentException( - "Unknown thread activity symbol: " + symbol)); + public String getTagName() { + return tagName; } } } diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/help/HelpThreadActivityUpdater.java b/application/src/main/java/org/togetherjava/tjbot/commands/help/HelpThreadActivityUpdater.java index 4f46f68e5d..4b21fb37e8 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/help/HelpThreadActivityUpdater.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/help/HelpThreadActivityUpdater.java @@ -4,7 +4,7 @@ import net.dv8tion.jda.api.entities.Guild; import net.dv8tion.jda.api.entities.Message; import net.dv8tion.jda.api.entities.User; -import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; +import net.dv8tion.jda.api.entities.channel.concrete.ForumChannel; import net.dv8tion.jda.api.entities.channel.concrete.ThreadChannel; import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel; import net.dv8tion.jda.api.requests.RestAction; @@ -53,19 +53,18 @@ public void runRoutine(JDA jda) { } private void updateActivityForGuild(Guild guild) { - Optional maybeOverviewChannel = helper - .handleRequireOverviewChannel(guild, channelPattern -> logger.warn( - "Unable to update help thread overview, did not find an overview channel matching the configured pattern '{}' for guild '{}'", + Optional maybeHelpForum = helper + .handleRequireHelpForum(guild, channelPattern -> logger.warn( + "Unable to update help thread activities, did not find a help forum matching the configured pattern '{}' for guild '{}'", channelPattern, guild.getName())); - if (maybeOverviewChannel.isEmpty()) { + if (maybeHelpForum.isEmpty()) { return; } logger.debug("Updating activities of active questions"); - List activeThreads = - helper.getActiveThreadsIn(maybeOverviewChannel.orElseThrow()); + List activeThreads = helper.getActiveThreadsIn(maybeHelpForum.orElseThrow()); logger.debug("Found {} active questions", activeThreads.size()); activeThreads.forEach(this::updateActivityForThread); @@ -73,8 +72,7 @@ private void updateActivityForGuild(Guild guild) { private void updateActivityForThread(ThreadChannel threadChannel) { determineActivity(threadChannel) - .flatMap( - threadActivity -> helper.renameChannelToActivity(threadChannel, threadActivity)) + .flatMap(threadActivity -> helper.changeChannelActivity(threadChannel, threadActivity)) .queue(); } @@ -83,7 +81,7 @@ private static RestAction determineActivity( return channel.getHistory().retrievePast(ACTIVITY_DETERMINE_MESSAGE_LIMIT).map(messages -> { if (messages.size() >= ACTIVITY_DETERMINE_MESSAGE_LIMIT) { // There are likely even more messages, but we hit the limit - return HelpSystemHelper.ThreadActivity.SEEMS_GOOD; + return HelpSystemHelper.ThreadActivity.HIGH; } Map> authorToMessages = messages.stream() @@ -94,8 +92,8 @@ private static RestAction determineActivity( .stream() .anyMatch(messagesByAuthor -> messagesByAuthor.size() >= 2); - return isThereActivity ? HelpSystemHelper.ThreadActivity.LIKELY_NEEDS_HELP - : HelpSystemHelper.ThreadActivity.NEEDS_HELP; + return isThereActivity ? HelpSystemHelper.ThreadActivity.MEDIUM + : HelpSystemHelper.ThreadActivity.LOW; }); } diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/help/HelpThreadAutoArchiver.java b/application/src/main/java/org/togetherjava/tjbot/commands/help/HelpThreadAutoArchiver.java index e60341cbbc..8cb853e199 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/help/HelpThreadAutoArchiver.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/help/HelpThreadAutoArchiver.java @@ -4,7 +4,7 @@ import net.dv8tion.jda.api.JDA; import net.dv8tion.jda.api.entities.Guild; import net.dv8tion.jda.api.entities.MessageEmbed; -import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; +import net.dv8tion.jda.api.entities.channel.concrete.ForumChannel; import net.dv8tion.jda.api.entities.channel.concrete.ThreadChannel; import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel; import net.dv8tion.jda.api.utils.TimeUtil; @@ -50,19 +50,18 @@ public void runRoutine(JDA jda) { } private void autoArchiveForGuild(Guild guild) { - Optional maybeOverviewChannel = helper - .handleRequireOverviewChannel(guild, channelPattern -> logger.warn( - "Unable to auto archive help threads, did not find an overview channel matching the configured pattern '{}' for guild '{}'", + Optional maybeHelpForum = helper + .handleRequireHelpForum(guild, channelPattern -> logger.warn( + "Unable to auto archive help threads, did not find a help forum matching the configured pattern '{}' for guild '{}'", channelPattern, guild.getName())); - if (maybeOverviewChannel.isEmpty()) { + if (maybeHelpForum.isEmpty()) { return; } logger.debug("Auto archiving of help threads"); - List activeThreads = - helper.getActiveThreadsIn(maybeOverviewChannel.orElseThrow()); + List activeThreads = helper.getActiveThreadsIn(maybeHelpForum.orElseThrow()); logger.debug("Found {} active questions", activeThreads.size()); Instant archiveAfterMoment = computeArchiveAfterMoment(); diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/help/HelpThreadCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/help/HelpThreadCommand.java index 9d708a90cc..5ca8c3eab6 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/help/HelpThreadCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/help/HelpThreadCommand.java @@ -31,15 +31,12 @@ import java.util.stream.Collectors; import java.util.stream.Stream; -import static org.togetherjava.tjbot.commands.help.HelpSystemHelper.TITLE_COMPACT_LENGTH_MAX; -import static org.togetherjava.tjbot.commands.help.HelpSystemHelper.TITLE_COMPACT_LENGTH_MIN; - /** - * Implements the {@code /help-thread} command, which are contains special command for help threads - * only + * Implements the {@code /help-thread} command, used to maintain certain aspects of help threads, + * such as renaming or closing them. */ public final class HelpThreadCommand extends SlashCommandAdapter { - private static final int COOLDOWN_DURATION_VALUE = 30; + private static final int COOLDOWN_DURATION_VALUE = 2; private static final ChronoUnit COOLDOWN_DURATION_UNIT = ChronoUnit.MINUTES; public static final String CHANGE_CATEGORY_SUBCOMMAND = "category"; private static final String CHANGE_CATEGORY_OPTION = "category"; @@ -71,8 +68,11 @@ public HelpThreadCommand(Config config, HelpSystemHelper helper) { SubcommandData changeCategory = Subcommand.CHANGE_CATEGORY.toSubcommandData().addOptions(categoryChoices); - SubcommandData changeTitle = Subcommand.CHANGE_TITLE.toSubcommandData() - .addOption(OptionType.STRING, CHANGE_TITLE_OPTION, "new title", true); + OptionData changeTitleOption = + new OptionData(OptionType.STRING, CHANGE_TITLE_OPTION, "new title", true) + .setMinLength(2); + SubcommandData changeTitle = + Subcommand.CHANGE_TITLE.toSubcommandData().addOptions(changeTitleOption); SubcommandGroupData changeCommands = new SubcommandGroupData(CHANGE_SUBCOMMAND_GROUP, "Change the details of this help thread").addSubcommands(changeCategory, @@ -146,7 +146,7 @@ private void changeCategory(SlashCommandInteractionEvent event, ThreadChannel he event.deferReply().queue(); refreshCooldownFor(Subcommand.CHANGE_CATEGORY, helpThread); - helper.renameChannelToCategory(helpThread, category) + helper.changeChannelCategory(helpThread, category) .flatMap(any -> sendCategoryChangedMessage(helpThread.getGuild(), event.getHook(), helpThread, category)) .queue(); @@ -181,18 +181,9 @@ private RestAction sendCategoryChangedMessage(Guild guild, InteractionH private void changeTitle(SlashCommandInteractionEvent event, ThreadChannel helpThread) { String title = event.getOption(CHANGE_TITLE_OPTION).getAsString(); - if (!HelpSystemHelper.isTitleValid(title)) { - event.reply( - "Sorry, but the title length (after removal of special characters) has to be between %d and %d." - .formatted(TITLE_COMPACT_LENGTH_MIN, TITLE_COMPACT_LENGTH_MAX)) - .setEphemeral(true) - .queue(); - return; - } - refreshCooldownFor(Subcommand.CHANGE_TITLE, helpThread); - helper.renameChannelToTitle(helpThread, title) + helper.renameChannel(helpThread, title) .flatMap(any -> event.reply("Changed the title to **%s**.".formatted(title))) .queue(); } diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/help/HelpThreadCreatedListener.java b/application/src/main/java/org/togetherjava/tjbot/commands/help/HelpThreadCreatedListener.java new file mode 100644 index 0000000000..e018d80edc --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/help/HelpThreadCreatedListener.java @@ -0,0 +1,100 @@ +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.entities.Message; +import net.dv8tion.jda.api.entities.Role; +import net.dv8tion.jda.api.entities.channel.concrete.ThreadChannel; +import net.dv8tion.jda.api.entities.channel.forums.ForumTag; +import net.dv8tion.jda.api.events.channel.ChannelCreateEvent; +import net.dv8tion.jda.api.hooks.ListenerAdapter; +import net.dv8tion.jda.api.requests.RestAction; + +import org.togetherjava.tjbot.commands.EventReceiver; + +import javax.annotation.Nonnull; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.concurrent.TimeUnit; + +/** + * Listens for new help threads being created. That is, a user posted a question in the help forum. + *

+ * Will for example record thread metadata in the database and send an explanation message to the + * user. + */ +public final class HelpThreadCreatedListener extends ListenerAdapter implements EventReceiver { + private final HelpSystemHelper helper; + private final Cache threadIdToCreatedAtCache = Caffeine.newBuilder() + .maximumSize(1_000) + .expireAfterAccess(2, TimeUnit.of(ChronoUnit.MINUTES)) + .build(); + + /** + * Creates a new instance. + * + * @param helper to work with the help threads + */ + public HelpThreadCreatedListener(HelpSystemHelper helper) { + this.helper = helper; + } + + @Override + public void onChannelCreate(@Nonnull ChannelCreateEvent createEvent) { + if (!createEvent.getChannelType().isThread()) { + return; + } + ThreadChannel threadChannel = createEvent.getChannel().asThreadChannel(); + + if (wasThreadAlreadyHandled(threadChannel.getIdLong())) { + return; + } + + if (!helper.isHelpForumName(threadChannel.getParentChannel().getName())) { + return; + } + handleHelpThreadCreated(threadChannel); + } + + private boolean wasThreadAlreadyHandled(long threadChannelId) { + // NOTE Discord/JDA fires this event twice per thread (bug?), we work around by remembering + // the threads we already handled + Instant now = Instant.now(); + // NOTE It is necessary to do the "check if exists, otherwise insert" atomic + Instant createdAt = threadIdToCreatedAtCache.get(threadChannelId, any -> now); + return createdAt != now; + } + + private void handleHelpThreadCreated(ThreadChannel threadChannel) { + helper.writeHelpThreadToDatabase(threadChannel.getOwnerIdLong(), threadChannel); + + createMessages(threadChannel).queue(); + } + + private RestAction createMessages(ThreadChannel threadChannel) { + return sendHelperHeadsUp(threadChannel).flatMap(Message::pin) + .flatMap(any -> helper.sendExplanationMessage(threadChannel)); + } + + private RestAction sendHelperHeadsUp(ThreadChannel threadChannel) { + String alternativeMention = "Helper"; + String helperMention = helper.getCategoryTagOfChannel(threadChannel) + .map(ForumTag::getName) + .flatMap(category -> helper.handleFindRoleForCategory(category, + threadChannel.getGuild())) + .map(Role::getAsMention) + .orElse(alternativeMention); + + // 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 = "%s please have a look, thanks."; + String headsUpWithoutRole = headsUpPattern.formatted(alternativeMention); + String headsUpWithRole = headsUpPattern.formatted(helperMention); + + return threadChannel.sendMessage(headsUpWithoutRole) + .flatMap(message -> message.editMessage(headsUpWithRole)); + } +} 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 deleted file mode 100644 index 85e0a59fc2..0000000000 --- a/application/src/main/java/org/togetherjava/tjbot/commands/help/HelpThreadOverviewUpdater.java +++ /dev/null @@ -1,221 +0,0 @@ -package org.togetherjava.tjbot.commands.help; - -import net.dv8tion.jda.api.EmbedBuilder; -import net.dv8tion.jda.api.JDA; -import net.dv8tion.jda.api.entities.Guild; -import net.dv8tion.jda.api.entities.Message; -import net.dv8tion.jda.api.entities.MessageType; -import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; -import net.dv8tion.jda.api.entities.channel.concrete.ThreadChannel; -import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel; -import net.dv8tion.jda.api.events.message.MessageReceivedEvent; -import net.dv8tion.jda.api.requests.RestAction; -import net.dv8tion.jda.api.utils.messages.MessageCreateData; -import net.dv8tion.jda.api.utils.messages.MessageEditBuilder; -import net.dv8tion.jda.api.utils.messages.MessageEditData; -import net.dv8tion.jda.internal.requests.CompletedRestAction; -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 javax.annotation.Nullable; - -import java.util.*; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; -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 static final int OVERVIEW_QUESTION_LIMIT = 150; - private static final AtomicInteger FIND_STATUS_MESSAGE_CONSECUTIVE_FAILURES = - new AtomicInteger(0); - private static final int FIND_STATUS_MESSAGE_FAILURE_THRESHOLD = 3; - - 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(Config config, HelpSystemHelper helper) { - super(Pattern.compile(config.getHelpSystem().getOverviewChannelPattern())); - - allCategories = config.getHelpSystem().getCategories(); - this.helper = helper; - } - - @Override - public Schedule createSchedule() { - return new Schedule(ScheduleMode.FIXED_RATE, 1, 1, TimeUnit.MINUTES); - } - - @Override - public void runRoutine(JDA jda) { - jda.getGuildCache().forEach(this::updateOverviewForGuild); - } - - @Override - public void onMessageReceived(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. - Runnable updateOverviewCommand = () -> { - try { - updateOverviewForGuild(event.getGuild()); - } catch (Exception e) { - logger.error( - "Unknown error while attempting to update the help overview for guild {}.", - event.getGuild().getId(), e); - } - }; - UPDATE_SERVICE.schedule(updateOverviewCommand, 2, TimeUnit.SECONDS); - } - - private void updateOverviewForGuild(Guild guild) { - Optional maybeOverviewChannel = helper - .handleRequireOverviewChannel(guild, channelPattern -> logger.warn( - "Unable to update help thread overview, did not find an overview channel matching the configured pattern '{}' for guild '{}'", - channelPattern, guild.getName())); - - if (maybeOverviewChannel.isEmpty()) { - return; - } - - updateOverview(maybeOverviewChannel.orElseThrow()); - } - - private void updateOverview(TextChannel overviewChannel) { - logger.debug("Updating overview of active questions"); - - List activeThreads = helper.getActiveThreadsIn(overviewChannel); - logger.debug("Found {} active questions", activeThreads.size()); - - MessageEditData message = new MessageEditBuilder() - .setEmbeds(new EmbedBuilder().setTitle(STATUS_TITLE) - .setDescription(createDescription(activeThreads)) - .build()) - .build(); - - getStatusMessage(overviewChannel) - .flatMap(maybeStatusMessage -> sendUpdatedOverview(maybeStatusMessage.orElse(null), - message, overviewChannel)) - .queue(); - } - - private String createDescription(Collection activeThreads) { - if (activeThreads.isEmpty()) { - return "Currently none."; - } - - return activeThreads.stream() - .sorted(Comparator.comparing(ThreadChannel::getTimeCreated).reversed()) - .limit(OVERVIEW_QUESTION_LIMIT) - .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 RestAction> getStatusMessage(MessageChannel channel) { - return channel.getHistory() - .retrievePast(1) - .map(messages -> messages.stream() - .findFirst() - .filter(HelpThreadOverviewUpdater::isStatusMessage)); - } - - private static boolean isStatusMessage(Message message) { - if (message.getEmbeds().isEmpty() - || !message.getAuthor().equals(message.getJDA().getSelfUser())) { - return false; - } - - String messageEmbedTitle = message.getEmbeds().get(0).getTitle(); - return STATUS_TITLE.equals(messageEmbedTitle); - } - - private RestAction sendUpdatedOverview(@Nullable Message statusMessage, - MessageEditData updatedStatusMessage, MessageChannel overviewChannel) { - logger.debug("Sending the updated question overview"); - if (statusMessage == null) { - int currentFailures = FIND_STATUS_MESSAGE_CONSECUTIVE_FAILURES.incrementAndGet(); - if (currentFailures >= FIND_STATUS_MESSAGE_FAILURE_THRESHOLD) { - logger.warn( - "Failed to locate the question overview too often ({} times), sending a fresh message instead.", - currentFailures); - FIND_STATUS_MESSAGE_CONSECUTIVE_FAILURES.set(0); - return overviewChannel - .sendMessage(MessageCreateData.fromEditData(updatedStatusMessage)); - } - - logger.info( - "Failed to locate the question overview ({} times), trying again next time.", - currentFailures); - return new CompletedRestAction<>(overviewChannel.getJDA(), null); - } - - FIND_STATUS_MESSAGE_CONSECUTIVE_FAILURES.set(0); - String statusMessageId = statusMessage.getId(); - return overviewChannel.editMessageById(statusMessageId, updatedStatusMessage); - } - - private record CategoryWithThreads(String category, List threads) { - - String toDiscordString() { - String threadListText = threads.stream() - .map(ThreadChannel::getAsMention) - .collect(Collectors.joining("\n• ", "• ", "")); - - return "**%s**:%n%s".formatted(category, threadListText); - } - - static CategoryWithThreads ofEntry( - Map.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 deleted file mode 100644 index 22ad2eca3f..0000000000 --- a/application/src/main/java/org/togetherjava/tjbot/commands/help/ImplicitAskListener.java +++ /dev/null @@ -1,231 +0,0 @@ -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.entities.*; -import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; -import net.dv8tion.jda.api.entities.channel.concrete.ThreadChannel; -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.utils.messages.MessageCreateBuilder; -import net.dv8tion.jda.api.utils.messages.MessageCreateData; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import org.togetherjava.tjbot.commands.MessageReceiverAdapter; -import org.togetherjava.tjbot.commands.utils.MessageUtils; -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 = 50; - - 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(Config config, 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(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()); - - Optional maybeOverviewChannel = - helper.handleRequireOverviewChannelForAsk(event.getGuild(), event.getChannel()); - if (maybeOverviewChannel.isEmpty()) { - return; - } - TextChannel overviewChannel = maybeOverviewChannel.orElseThrow(); - - HelpSystemHelper.HelpThreadName name = new HelpSystemHelper.HelpThreadName( - HelpSystemHelper.ThreadActivity.NEEDS_HELP, null, title); - - overviewChannel.createThreadChannel(name.toChannelName()) - .flatMap(threadChannel -> handleEvent(threadChannel, message, title)) - .queue(any -> { - }, ImplicitAskListener::handleFailure); - } - - private boolean handleIsNotOnCooldown(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(); - - MessageUtils.mentionGuildSlashCommand(message.getGuild(), AskCommand.COMMAND_NAME) - .flatMap(command -> message.getChannel() - .sendMessage(""" - %s Please use %s to follow up on your question, \ - or use %s to ask a new questions, thanks.""" - .formatted(author.getAsMention(), threadDescription, command)) - .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 String createTitle(String message) { - String titleCandidate; - if (message.length() < TITLE_MAX_LENGTH) { - titleCandidate = message; - } else { - // 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; - } - - titleCandidate = message.substring(0, lastWordEnd); - } - - return HelpSystemHelper.isTitleValid(titleCandidate) ? titleCandidate : "Untitled"; - } - - private RestAction handleEvent(ThreadChannel threadChannel, Message message, String title) { - Member author = message.getMember(); - helper.writeHelpThreadToDatabase(author, threadChannel); - 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)) - .onSuccess(any -> helper.scheduleUncategorizedAdviceCheck(threadChannel.getIdLong(), - author.getIdLong())); - } - - private static RestAction sendInitialMessage(ThreadChannel threadChannel, - Message originalMessage, 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(); - - return MessageUtils - .mentionGuildSlashCommand(originalMessage.getGuild(), HelpThreadCommand.COMMAND_NAME, - HelpThreadCommand.CHANGE_SUBCOMMAND_GROUP, - HelpThreadCommand.Subcommand.CHANGE_CATEGORY.getCommandName()) - .flatMap(command -> { - MessageCreateData threadMessage = new MessageCreateBuilder() - .setContent(""" - %s has a question about '**%s**' and will send the details now. - - Please use %s to greatly increase the visibility of the question.""" - .formatted(author, title, command)) - .setEmbeds(embed) - .build(); - - return threadChannel.sendMessage(threadMessage); - }); - - } - - private static RestAction notifyUser(IMentionable threadChannel, Message message) { - return MessageUtils.mentionGuildSlashCommand(message.getGuild(), AskCommand.COMMAND_NAME) - .flatMap(command -> message.getChannel() - .sendMessage( - """ - %s Please use %s to ask questions. Don't worry though, I created %s for you. \ - Please continue there, thanks.""" - .formatted(message.getAuthor().getAsMention(), command, - threadChannel.getAsMention()))); - } - - private static void handleFailure(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, Instant creationTime) { - } -} diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/ReportCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/ReportCommand.java index 6127e84f58..e3fe2bd3ad 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/ReportCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/ReportCommand.java @@ -3,16 +3,14 @@ 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.entities.Guild; -import net.dv8tion.jda.api.entities.Message; -import net.dv8tion.jda.api.entities.MessageEmbed; -import net.dv8tion.jda.api.entities.Role; +import net.dv8tion.jda.api.entities.*; import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; import net.dv8tion.jda.api.events.interaction.ModalInteractionEvent; import net.dv8tion.jda.api.events.interaction.command.MessageContextInteractionEvent; import net.dv8tion.jda.api.interactions.InteractionHook; import net.dv8tion.jda.api.interactions.commands.build.Commands; import net.dv8tion.jda.api.interactions.components.Modal; +import net.dv8tion.jda.api.interactions.components.buttons.Button; import net.dv8tion.jda.api.interactions.components.text.TextInput; import net.dv8tion.jda.api.interactions.components.text.TextInputStyle; import net.dv8tion.jda.api.requests.restaction.MessageCreateAction; @@ -23,7 +21,6 @@ import org.togetherjava.tjbot.commands.BotCommandAdapter; import org.togetherjava.tjbot.commands.CommandVisibility; import org.togetherjava.tjbot.commands.MessageContextCommand; -import org.togetherjava.tjbot.commands.utils.DiscordClientAction; import org.togetherjava.tjbot.commands.utils.MessageUtils; import org.togetherjava.tjbot.config.Config; @@ -37,14 +34,12 @@ import java.util.function.Predicate; import java.util.regex.Pattern; - /** * Implements the /report command, which allows users to report a selected offensive message from * another user. The message is then forwarded to moderators in a dedicated channel given by * {@link Config#getModMailChannelPattern()}. */ public final class ReportCommand extends BotCommandAdapter implements MessageContextCommand { - private static final Logger logger = LoggerFactory.getLogger(ReportCommand.class); private static final String COMMAND_NAME = "report"; private static final String REPORT_REASON_INPUT_ID = "reportReason"; @@ -92,6 +87,7 @@ public void onMessageContext(MessageContextInteractionEvent event) { String reportedMessage = event.getTarget().getContentRaw(); String reportedMessageID = event.getTarget().getId(); + String reportedMessageJumpUrl = event.getTarget().getJumpUrl(); String reportedMessageChannel = event.getTarget().getChannel().getId(); String reportedAuthorName = event.getTarget().getAuthor().getName(); String reportedAuthorAvatarURL = event.getTarget().getAuthor().getAvatarUrl(); @@ -105,8 +101,8 @@ public void onMessageContext(MessageContextInteractionEvent event) { .build(); String reportModalComponentID = generateComponentId(reportedMessage, reportedMessageID, - reportedMessageChannel, reportedMessageTimestamp, reportedAuthorName, - reportedAuthorAvatarURL, reportedAuthorID); + reportedMessageJumpUrl, reportedMessageChannel, reportedMessageTimestamp, + reportedAuthorName, reportedAuthorAvatarURL, reportedAuthorID); Modal reportModal = Modal.create(reportModalComponentID, "Report this to a moderator") .addActionRow(modalTextInput) .build(); @@ -169,7 +165,6 @@ private Optional handleRequireModMailChannel(ModalInteractionEvent private MessageCreateAction createModMessage(String reportReason, ReportedMessage reportedMessage, Guild guild, TextChannel modMailAuditLog) { - MessageEmbed reportedMessageEmbed = new EmbedBuilder().setTitle("Report") .setDescription(MessageUtils.abbreviate(reportedMessage.content, MessageEmbed.DESCRIPTION_MAX_LENGTH)) @@ -185,9 +180,7 @@ private MessageCreateAction createModMessage(String reportReason, MessageCreateAction message = modMailAuditLog.sendMessageEmbeds(reportedMessageEmbed, reportReasonEmbed) - .addActionRow(DiscordClientAction.Channels.GUILD_CHANNEL_MESSAGE.asLinkButton( - "Go to Message", guild.getId(), reportedMessage.channelID, - reportedMessage.id)); + .addActionRow(Button.link(reportedMessage.jumpUrl, "Go to message")); Optional moderatorRole = guild.getRoles() .stream() @@ -210,12 +203,12 @@ private void sendModMessage(ModalInteractionEvent event, List args, ReportedMessage reportedMessage = ReportedMessage.ofArgs(args); createModMessage(reportReason, reportedMessage, guild, modMailAuditLog).mapToResult() - .map(this::createUserReply) + .map(ReportCommand::createUserReply) .flatMap(hook::editOriginal) .queue(); } - private String createUserReply(Result result) { + private static String createUserReply(Result result) { if (result.isFailure()) { logger.warn("Unable to forward a message report to modmail channel.", result.getFailure()); @@ -224,19 +217,18 @@ private String createUserReply(Result result) { return "Thank you for reporting this message. A moderator will take care of the matter as soon as possible."; } - private record ReportedMessage(String content, String id, String channelID, Instant timestamp, - String authorName, String authorAvatarUrl) { + private record ReportedMessage(String content, String id, String jumpUrl, String channelID, + Instant timestamp, String authorName, String authorAvatarUrl) { static ReportedMessage ofArgs(List args) { String content = args.get(0); String id = args.get(1); - String channelID = args.get(2); - Instant timestamp = Instant.parse(args.get(3)); - String authorName = args.get(4); - String authorAvatarUrl = args.get(5); - return new ReportedMessage(content, id, channelID, timestamp, authorName, + String jumpUrl = args.get(2); + String channelID = args.get(3); + Instant timestamp = Instant.parse(args.get(4)); + String authorName = args.get(5); + String authorAvatarUrl = args.get(6); + return new ReportedMessage(content, id, jumpUrl, channelID, timestamp, authorName, authorAvatarUrl); } - } - } 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 e1019ae39d..b781760d92 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 @@ -29,8 +29,7 @@ public final class TopHelpersMessageListener extends MessageReceiverAdapter { private final Database database; - private final Predicate isStagingChannelName; - private final Predicate isOverviewChannelName; + private final Predicate isHelpForumName; /** * Creates a new listener to receive all message sent in help channels. @@ -43,10 +42,8 @@ public TopHelpersMessageListener(Database database, Config config) { this.database = database; - isStagingChannelName = Pattern.compile(config.getHelpSystem().getStagingChannelPattern()) - .asMatchPredicate(); - isOverviewChannelName = Pattern.compile(config.getHelpSystem().getOverviewChannelPattern()) - .asMatchPredicate(); + isHelpForumName = + Pattern.compile(config.getHelpSystem().getHelpForumPattern()).asMatchPredicate(); } @Override @@ -83,8 +80,7 @@ boolean isHelpThread(MessageChannelUnion channel) { ThreadChannel thread = channel.asThreadChannel(); String rootChannelName = thread.getParentChannel().getName(); - return isStagingChannelName.test(rootChannelName) - || isOverviewChannelName.test(rootChannelName); + return isHelpForumName.test(rootChannelName); } static long countValidCharacters(String messageContent) { diff --git a/application/src/main/java/org/togetherjava/tjbot/config/HelpSystemConfig.java b/application/src/main/java/org/togetherjava/tjbot/config/HelpSystemConfig.java index e95c4ec25f..c1eff0e8c8 100644 --- a/application/src/main/java/org/togetherjava/tjbot/config/HelpSystemConfig.java +++ b/application/src/main/java/org/togetherjava/tjbot/config/HelpSystemConfig.java @@ -14,44 +14,29 @@ */ @JsonRootName("helpSystem") public final class HelpSystemConfig { - private final String stagingChannelPattern; - private final String overviewChannelPattern; + private final String helpForumPattern; private final List categories; private final String categoryRoleSuffix; @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) private HelpSystemConfig( - @JsonProperty(value = "stagingChannelPattern", - required = true) String stagingChannelPattern, - @JsonProperty(value = "overviewChannelPattern", - required = true) String overviewChannelPattern, + @JsonProperty(value = "helpForumPattern", required = true) String helpForumPattern, @JsonProperty(value = "categories", required = true) List categories, @JsonProperty(value = "categoryRoleSuffix", required = true) String categoryRoleSuffix) { - this.stagingChannelPattern = Objects.requireNonNull(stagingChannelPattern); - this.overviewChannelPattern = Objects.requireNonNull(overviewChannelPattern); + this.helpForumPattern = Objects.requireNonNull(helpForumPattern); this.categories = new ArrayList<>(Objects.requireNonNull(categories)); this.categoryRoleSuffix = Objects.requireNonNull(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. + * Gets the REGEX pattern used to identify the forum channel that used for getting help. Users + * ask questions here and help threads are also created in this channel. * - * @return the channel name pattern + * @return the forum name pattern */ - public 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 String getOverviewChannelPattern() { - return overviewChannelPattern; + public String getHelpForumPattern() { + return helpForumPattern; } /** diff --git a/application/src/test/java/org/togetherjava/tjbot/commands/tophelper/TopHelperMessageListenerTest.java b/application/src/test/java/org/togetherjava/tjbot/commands/tophelper/TopHelperMessageListenerTest.java index f58fbb20a4..d3df4aa76c 100644 --- a/application/src/test/java/org/togetherjava/tjbot/commands/tophelper/TopHelperMessageListenerTest.java +++ b/application/src/test/java/org/togetherjava/tjbot/commands/tophelper/TopHelperMessageListenerTest.java @@ -25,8 +25,7 @@ final class TopHelperMessageListenerTest { - private static final String STAGING_CHANNEL_PATTERN = "ask_here"; - private static final String OVERVIEW_CHANNEL_PATTERN = "active_questions"; + private static final String HELP_FORUM_PATTERN = "questions"; private static JdaTester jdaTester; private static TopHelpersMessageListener topHelpersListener; @@ -37,8 +36,7 @@ static void setUp() { Config config = mock(Config.class); HelpSystemConfig helpSystemConfig = mock(HelpSystemConfig.class); - when(helpSystemConfig.getStagingChannelPattern()).thenReturn(STAGING_CHANNEL_PATTERN); - when(helpSystemConfig.getOverviewChannelPattern()).thenReturn(OVERVIEW_CHANNEL_PATTERN); + when(helpSystemConfig.getHelpForumPattern()).thenReturn(HELP_FORUM_PATTERN); when(config.getHelpSystem()).thenReturn(helpSystemConfig); @@ -50,7 +48,7 @@ static void setUp() { void recognizesValidMessages() { // GIVEN a message by a human in a help channel MessageReceivedEvent event = - createMessageReceivedEvent(false, false, true, OVERVIEW_CHANNEL_PATTERN); + createMessageReceivedEvent(false, false, true, HELP_FORUM_PATTERN); // WHEN checking if the message should be ignored boolean shouldBeIgnored = topHelpersListener.shouldIgnoreMessage(event); @@ -63,7 +61,7 @@ void recognizesValidMessages() { void ignoresBots() { // GIVEN a message from a bot MessageReceivedEvent event = - createMessageReceivedEvent(true, false, true, OVERVIEW_CHANNEL_PATTERN); + createMessageReceivedEvent(true, false, true, HELP_FORUM_PATTERN); // WHEN checking if the message should be ignored boolean shouldBeIgnored = topHelpersListener.shouldIgnoreMessage(event); @@ -76,7 +74,7 @@ void ignoresBots() { void ignoresWebhooks() { // GIVEN a message from a webhook MessageReceivedEvent event = - createMessageReceivedEvent(false, true, true, OVERVIEW_CHANNEL_PATTERN); + createMessageReceivedEvent(false, true, true, HELP_FORUM_PATTERN); // WHEN checking if the message should be ignored boolean shouldBeIgnored = topHelpersListener.shouldIgnoreMessage(event); @@ -89,7 +87,7 @@ void ignoresWebhooks() { void ignoresWrongChannels() { // GIVEN a message outside a help thread MessageReceivedEvent eventNotAThread = - createMessageReceivedEvent(false, false, false, OVERVIEW_CHANNEL_PATTERN); + createMessageReceivedEvent(false, false, false, HELP_FORUM_PATTERN); MessageReceivedEvent eventWrongParentName = createMessageReceivedEvent(false, false, true, "memes"); diff --git a/buildSrc/build.gradle b/buildSrc/build.gradle index db4883292f..add521571a 100644 --- a/buildSrc/build.gradle +++ b/buildSrc/build.gradle @@ -7,6 +7,6 @@ repositories { } dependencies { - implementation "gradle.plugin.org.flywaydb:gradle-plugin-publishing:9.8.1" + implementation "gradle.plugin.org.flywaydb:gradle-plugin-publishing:9.10.0" implementation 'nu.studer:gradle-jooq-plugin:8.0' } diff --git a/database/build.gradle b/database/build.gradle index e128ff2679..75b1ef2393 100644 --- a/database/build.gradle +++ b/database/build.gradle @@ -7,7 +7,7 @@ var sqliteVersion = "3.40.0.0" dependencies { implementation 'com.google.code.findbugs:jsr305:3.0.2' implementation "org.xerial:sqlite-jdbc:${sqliteVersion}" - implementation 'org.flywaydb:flyway-core:9.8.1' + implementation 'org.flywaydb:flyway-core:9.10.0' implementation "org.jooq:jooq:$jooqVersion" implementation project(':utils')