From 41829f88ce361442ce0e18ac96c47623ff4afeee Mon Sep 17 00:00:00 2001 From: nltbee Date: Tue, 29 Mar 2022 15:03:41 +0200 Subject: [PATCH 01/11] Made remindme command's implementation global to MessageChannel --- .../togetherjava/tjbot/commands/reminder/RemindRoutine.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/reminder/RemindRoutine.java b/application/src/main/java/org/togetherjava/tjbot/commands/reminder/RemindRoutine.java index 4e0e167b42..e232ae1519 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/reminder/RemindRoutine.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/reminder/RemindRoutine.java @@ -71,7 +71,7 @@ private static void sendReminder(@NotNull JDA jda, long id, long channelId, long private static RestAction computeReminderRoute(@NotNull JDA jda, long channelId, long authorId) { // If guild channel can still be found, send there - TextChannel channel = jda.getTextChannelById(channelId); + MessageChannel channel = jda.getChannelById(MessageChannel.class, channelId); if (channel != null) { return createGuildReminderRoute(jda, authorId, channel); } @@ -81,7 +81,7 @@ private static RestAction computeReminderRoute(@NotNull JDA jda, } private static @NotNull RestAction createGuildReminderRoute(@NotNull JDA jda, - long authorId, @NotNull TextChannel channel) { + long authorId, @NotNull MessageChannel channel) { return jda.retrieveUserById(authorId) .onErrorMap(error -> null) .map(author -> ReminderRoute.toPublic(channel, author)); @@ -128,7 +128,7 @@ Failed to send a reminder (id '{}'), skipping it. This can be due to a network i private record ReminderRoute(@NotNull MessageChannel channel, @Nullable User target, @Nullable String description) { - static ReminderRoute toPublic(@NotNull TextChannel channel, @Nullable User target) { + static ReminderRoute toPublic(@NotNull MessageChannel channel, @Nullable User target) { return new ReminderRoute(channel, target, target == null ? null : target.getAsMention()); } From df53f17bde4c6895372701812d1ceb886e8adb76 Mon Sep 17 00:00:00 2001 From: Tais993 Date: Thu, 3 Mar 2022 09:33:30 +0100 Subject: [PATCH 02/11] Added WhoIs command --- .../togetherjava/tjbot/commands/Features.java | 1 + .../commands/moderation/WhoIsCommand.java | 263 ++++++++++++++++++ 2 files changed, 264 insertions(+) create mode 100644 application/src/main/java/org/togetherjava/tjbot/commands/moderation/WhoIsCommand.java diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/Features.java b/application/src/main/java/org/togetherjava/tjbot/commands/Features.java index 095ee74662..89b55ad7db 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/Features.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/Features.java @@ -100,6 +100,7 @@ public enum Features { features.add(new RemindCommand(database)); features.add(new QuarantineCommand(actionsStore, config)); features.add(new UnquarantineCommand(actionsStore, config)); + features.add(new WhoIsCommand()); // Mixtures features.add(new FreeCommand(config)); diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/WhoIsCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/WhoIsCommand.java new file mode 100644 index 0000000000..9e0f6b0618 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/WhoIsCommand.java @@ -0,0 +1,263 @@ +package org.togetherjava.tjbot.commands.moderation; + +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.entities.GuildVoiceState; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.Role; +import net.dv8tion.jda.api.entities.User; +import net.dv8tion.jda.api.events.interaction.SlashCommandEvent; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.components.Button; +import net.dv8tion.jda.api.interactions.components.ButtonStyle; +import net.dv8tion.jda.api.requests.restaction.interactions.ReplyAction; +import org.jetbrains.annotations.NotNull; +import org.togetherjava.tjbot.commands.SlashCommandAdapter; +import org.togetherjava.tjbot.commands.SlashCommandVisibility; +import org.togetherjava.tjbot.commands.utils.DiscordClientAction; + +import javax.annotation.CheckReturnValue; +import java.awt.*; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Collection; +import java.util.Objects; +import java.util.stream.Collectors; + +/** + * This command allows you to look up user (or member) info. + */ +@SuppressWarnings("ClassWithoutLogger") +public final class WhoIsCommand extends SlashCommandAdapter { + private static final String USER_OPTION = "user"; + private static final String SHOW_SERVER_INFO_OPTION = "show_server_specific_info"; + + private static final String USER_PROFILE_PICTURE_SIZE = "4096"; + + // Sun, December 11, 2016, 13:36:30 + private static final DateTimeFormatter DATE_TIME_FORMAT = + DateTimeFormatter.ofPattern("E, MMMM d, u, HH:mm:ss"); + + /** + * Creates an instance. + */ + public WhoIsCommand() { + super("whois", "Provides info about the given user", SlashCommandVisibility.GUILD); + + getData().addOption(OptionType.USER, USER_OPTION, "the user to look up", true) + .addOption(OptionType.BOOLEAN, SHOW_SERVER_INFO_OPTION, + "Whenever to show info that is specific to this server, such as their roles. This is true by default.", + false); + } + + @Override + public void onSlashCommand(@NotNull final SlashCommandEvent event) { + OptionMapping userOption = Objects.requireNonNull(event.getOption(USER_OPTION), + "The given user option cannot be null"); + OptionMapping showServerSpecificInfoOption = event.getOption(SHOW_SERVER_INFO_OPTION); + + User user = userOption.getAsUser(); + Member member = userOption.getAsMember(); + + boolean showServerSpecificInfo = null != member && (null == showServerSpecificInfoOption + || showServerSpecificInfoOption.getAsBoolean()); + + user.retrieveProfile().flatMap((User.Profile profile) -> { + if (showServerSpecificInfo) { + return handleWhoIsMember(event, member, profile); + } else { + return handleWhoIsUser(event, user, profile); + } + }).queue(); + } + + @CheckReturnValue + private static @NotNull ReplyAction handleWhoIsUser(final @NotNull SlashCommandEvent event, + final @NotNull User user, final @NotNull User.Profile profile) { + + StringBuilder descriptionBuilder = + new StringBuilder().append(userIdentificationToStringItem(user)) + .append("\n**Is bot:** ") + .append(user.isBot()) + .append(userFlagsToStringItem(user.getFlags())) + .append("\n**Registration date:** ") + .append(DATE_TIME_FORMAT.format(user.getTimeCreated())); + + EmbedBuilder embedBuilder = + generateEmbedBuilder(event, user, profile, profile.getAccentColor()).setAuthor( + user.getName(), user.getEffectiveAvatarUrl(), user.getEffectiveAvatarUrl()) + .setDescription(descriptionBuilder); + + return event.replyEmbeds(embedBuilder.build()) + .addActionRow(Button.of(ButtonStyle.LINK, "discord://-/users/" + user.getId(), + "Click to see profile")); + } + + @CheckReturnValue + private static @NotNull ReplyAction handleWhoIsMember(final @NotNull SlashCommandEvent event, + final @NotNull Member member, final @NotNull User.Profile profile) { + User user = member.getUser(); + + Color memberColor = member.getColor(); + Color effectiveColor = (null == memberColor) ? profile.getAccentColor() : memberColor; + + StringBuilder descriptionBuilder = + new StringBuilder().append(userIdentificationToStringItem(user)) + .append(voiceStateToStringItem(member)) + .append("\n**Is bot:** ") + .append(user.isBot()) + .append(possibleBoosterToStringItem(member)) + .append(userFlagsToStringItem(user.getFlags())) + .append("\n**Join date:** ") + .append(DATE_TIME_FORMAT.format(member.getTimeJoined())) + .append("\n**Registration date:** ") + .append(DATE_TIME_FORMAT.format(user.getTimeCreated())) + .append("\n**Roles:** ") + .append(formatRoles(member)); + + EmbedBuilder embedBuilder = generateEmbedBuilder(event, user, profile, effectiveColor) + .setAuthor(member.getEffectiveName(), member.getEffectiveAvatarUrl(), + member.getEffectiveAvatarUrl()) + .setDescription(descriptionBuilder); + + return event.replyEmbeds(embedBuilder.build()) + .addActionRow(DiscordClientAction.General.USER.asLinkButton("Click to see profile!", + user.getId())); + } + + private static @NotNull String voiceStateToStringItem(@NotNull final Member member) { + GuildVoiceState voiceState = Objects.requireNonNull(member.getVoiceState(), + "The given voiceState cannot be null"); + if (voiceState.inVoiceChannel()) { + return "\n**In voicechannel:** " + (voiceState.getChannel().getAsMention()); + } else { + return ""; + } + } + + + /** + * Generates whois embed based on the given parameters. + * + * @param event the {@link SlashCommandEvent} + * @param user the {@link User} getting whois'd + * @param profile the {@link net.dv8tion.jda.api.entities.User.Profile} of the whois'd user + * @param effectiveColor the {@link Color} that the embed will become + * @return the generated {@link EmbedBuilder} + */ + private static @NotNull EmbedBuilder generateEmbedBuilder( + @NotNull final SlashCommandEvent event, @NotNull final User user, + final @NotNull User.Profile profile, final Color effectiveColor) { + + EmbedBuilder embedBuilder = new EmbedBuilder().setThumbnail(user.getEffectiveAvatarUrl()) + .setColor(effectiveColor) + .setFooter("Requested by " + event.getUser().getAsTag(), + event.getMember().getEffectiveAvatarUrl()) + .setTimestamp(Instant.now()); + + if (null != profile.getBannerId()) { + embedBuilder.setImage(profile.getBannerUrl() + "?size=" + USER_PROFILE_PICTURE_SIZE); + } + + return embedBuilder; + } + + /** + * Handles boosting properties of a {@link Member} + * + * @param member the {@link Member} to take the booster properties from + * @return user readable {@link String} + */ + private static @NotNull String possibleBoosterToStringItem(final @NotNull Member member) { + OffsetDateTime timeBoosted = member.getTimeBoosted(); + if (null != timeBoosted) { + return "\n**Is booster:** true \n**Boosting since:** " + + DATE_TIME_FORMAT.format(timeBoosted); + } else { + return "\n**Is booster:** false"; + } + } + + /** + * Handles the user's identifying properties (such as ID, tag) + * + * @param user the {@link User} to take the identifiers from + * @return user readable {@link StringBuilder} + */ + private static @NotNull StringBuilder userIdentificationToStringItem(final @NotNull User user) { + return new StringBuilder("**Mention:** ").append(user.getAsMention()) + .append("\n**Tag:** ") + .append(user.getAsTag()) + .append("\n**ID:** ") + .append(user.getId()); + } + + /** + * Formats the roles into a user readable {@link String} + * + * @param member member to take the Roles from + * @return user readable {@link String} of the roles + */ + private static String formatRoles(final @NotNull Member member) { + return member.getRoles().stream().map(Role::getAsMention).collect(Collectors.joining(", ")); + } + + /** + * Formats Hypesquad and the flags + * + * @param flags the {@link Collection} of {@link net.dv8tion.jda.api.entities.User.UserFlag} + * (recommend {@link java.util.EnumSet} + * @return user readable {@link StringBuilder} + */ + private static @NotNull StringBuilder userFlagsToStringItem( + final @NotNull Collection flags) { + String formattedFlags = formatUserFlags(flags); + + if (formattedFlags.isBlank()) { + return hypeSquadToStringItem(flags); + } else { + return hypeSquadToStringItem(flags).append("\n**Flags:** ") + .append(formatUserFlags(flags)); + } + } + + /** + * Formats user readable Hypesquad item + * + * @param flags the {@link Collection} of {@link net.dv8tion.jda.api.entities.User.UserFlag} + * (recommend {@link java.util.EnumSet} + * @return user readable {@link StringBuilder} + */ + private static @NotNull StringBuilder hypeSquadToStringItem( + final @NotNull Collection flags) { + StringBuilder stringBuilder = new StringBuilder("**\nHypesquad:** "); + + if (flags.contains(User.UserFlag.HYPESQUAD_BALANCE)) { + stringBuilder.append(User.UserFlag.HYPESQUAD_BALANCE.getName()); + } else if (flags.contains(User.UserFlag.HYPESQUAD_BRAVERY)) { + stringBuilder.append(User.UserFlag.HYPESQUAD_BRAVERY.getName()); + } else if (flags.contains(User.UserFlag.HYPESQUAD_BRILLIANCE)) { + stringBuilder.append(User.UserFlag.HYPESQUAD_BRILLIANCE.getName()); + } else { + stringBuilder.append("joined none"); + } + + return stringBuilder; + } + + /** + * Formats the flags into a user readable {@link String}, filters Hypesquad relating flags + * + * @param flags the {@link Collection} of {@link net.dv8tion.jda.api.entities.User.UserFlag} + * (recommend {@link java.util.EnumSet} + * @return the user readable string + */ + @NotNull + private static String formatUserFlags(final @NotNull Collection flags) { + return flags.stream() + .map(User.UserFlag::getName) + .filter(name -> (name.contains("Hypesquad"))) + .collect(Collectors.joining(", ")); + } +} From 7d51bbb8b1f1f4d31b5d38b9e006f348ace9c65f Mon Sep 17 00:00:00 2001 From: Zabuzard Date: Tue, 3 May 2022 19:30:29 +0200 Subject: [PATCH 03/11] Fixes after rebase and some code style issues --- .../commands/moderation/WhoIsCommand.java | 105 ++++++++---------- 1 file changed, 46 insertions(+), 59 deletions(-) diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/WhoIsCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/WhoIsCommand.java index 9e0f6b0618..056f43ba8d 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/WhoIsCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/WhoIsCommand.java @@ -1,23 +1,20 @@ package org.togetherjava.tjbot.commands.moderation; import net.dv8tion.jda.api.EmbedBuilder; -import net.dv8tion.jda.api.entities.GuildVoiceState; -import net.dv8tion.jda.api.entities.Member; -import net.dv8tion.jda.api.entities.Role; -import net.dv8tion.jda.api.entities.User; -import net.dv8tion.jda.api.events.interaction.SlashCommandEvent; +import net.dv8tion.jda.api.entities.*; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.Interaction; +import net.dv8tion.jda.api.interactions.callbacks.IReplyCallback; import net.dv8tion.jda.api.interactions.commands.OptionMapping; import net.dv8tion.jda.api.interactions.commands.OptionType; -import net.dv8tion.jda.api.interactions.components.Button; -import net.dv8tion.jda.api.interactions.components.ButtonStyle; -import net.dv8tion.jda.api.requests.restaction.interactions.ReplyAction; +import net.dv8tion.jda.api.requests.restaction.interactions.ReplyCallbackAction; import org.jetbrains.annotations.NotNull; import org.togetherjava.tjbot.commands.SlashCommandAdapter; import org.togetherjava.tjbot.commands.SlashCommandVisibility; import org.togetherjava.tjbot.commands.utils.DiscordClientAction; import javax.annotation.CheckReturnValue; -import java.awt.*; +import java.awt.Color; import java.time.Instant; import java.time.OffsetDateTime; import java.time.format.DateTimeFormatter; @@ -52,7 +49,7 @@ public WhoIsCommand() { } @Override - public void onSlashCommand(@NotNull final SlashCommandEvent event) { + public void onSlashCommand(@NotNull final SlashCommandInteractionEvent event) { OptionMapping userOption = Objects.requireNonNull(event.getOption(USER_OPTION), "The given user option cannot be null"); OptionMapping showServerSpecificInfoOption = event.getOption(SHOW_SERVER_INFO_OPTION); @@ -73,63 +70,56 @@ public void onSlashCommand(@NotNull final SlashCommandEvent event) { } @CheckReturnValue - private static @NotNull ReplyAction handleWhoIsUser(final @NotNull SlashCommandEvent event, + private static @NotNull ReplyCallbackAction handleWhoIsUser(final @NotNull IReplyCallback event, final @NotNull User user, final @NotNull User.Profile profile) { - - StringBuilder descriptionBuilder = - new StringBuilder().append(userIdentificationToStringItem(user)) - .append("\n**Is bot:** ") - .append(user.isBot()) - .append(userFlagsToStringItem(user.getFlags())) - .append("\n**Registration date:** ") - .append(DATE_TIME_FORMAT.format(user.getTimeCreated())); + String description = userIdentificationToStringItem(user) + "\n**Is bot:** " + user.isBot() + + userFlagsToStringItem(user.getFlags()) + "\n**Registration date:** " + + DATE_TIME_FORMAT.format(user.getTimeCreated()); EmbedBuilder embedBuilder = generateEmbedBuilder(event, user, profile, profile.getAccentColor()).setAuthor( user.getName(), user.getEffectiveAvatarUrl(), user.getEffectiveAvatarUrl()) - .setDescription(descriptionBuilder); + .setDescription(description); - return event.replyEmbeds(embedBuilder.build()) - .addActionRow(Button.of(ButtonStyle.LINK, "discord://-/users/" + user.getId(), - "Click to see profile")); + return sendEmbedWithProfileAction(event, embedBuilder.build(), user.getId()); } @CheckReturnValue - private static @NotNull ReplyAction handleWhoIsMember(final @NotNull SlashCommandEvent event, - final @NotNull Member member, final @NotNull User.Profile profile) { + private static @NotNull ReplyCallbackAction handleWhoIsMember( + final @NotNull IReplyCallback event, final @NotNull Member member, + final @NotNull User.Profile profile) { User user = member.getUser(); Color memberColor = member.getColor(); Color effectiveColor = (null == memberColor) ? profile.getAccentColor() : memberColor; - StringBuilder descriptionBuilder = - new StringBuilder().append(userIdentificationToStringItem(user)) - .append(voiceStateToStringItem(member)) - .append("\n**Is bot:** ") - .append(user.isBot()) - .append(possibleBoosterToStringItem(member)) - .append(userFlagsToStringItem(user.getFlags())) - .append("\n**Join date:** ") - .append(DATE_TIME_FORMAT.format(member.getTimeJoined())) - .append("\n**Registration date:** ") - .append(DATE_TIME_FORMAT.format(user.getTimeCreated())) - .append("\n**Roles:** ") - .append(formatRoles(member)); + String description = userIdentificationToStringItem(user) + voiceStateToStringItem(member) + + "\n**Is bot:** " + user.isBot() + possibleBoosterToStringItem(member) + + userFlagsToStringItem(user.getFlags()) + "\n**Join date:** " + + DATE_TIME_FORMAT.format(member.getTimeJoined()) + "\n**Registration date:** " + + DATE_TIME_FORMAT.format(user.getTimeCreated()) + "\n**Roles:** " + + formatRoles(member); EmbedBuilder embedBuilder = generateEmbedBuilder(event, user, profile, effectiveColor) .setAuthor(member.getEffectiveName(), member.getEffectiveAvatarUrl(), member.getEffectiveAvatarUrl()) - .setDescription(descriptionBuilder); + .setDescription(description); + + return sendEmbedWithProfileAction(event, embedBuilder.build(), user.getId()); + } - return event.replyEmbeds(embedBuilder.build()) - .addActionRow(DiscordClientAction.General.USER.asLinkButton("Click to see profile!", - user.getId())); + private static @NotNull ReplyCallbackAction sendEmbedWithProfileAction( + final @NotNull IReplyCallback event, @NotNull MessageEmbed embed, + @NotNull String userId) { + return event.replyEmbeds(embed) + .addActionRow( + DiscordClientAction.General.USER.asLinkButton("Click to see profile!", userId)); } private static @NotNull String voiceStateToStringItem(@NotNull final Member member) { GuildVoiceState voiceState = Objects.requireNonNull(member.getVoiceState(), "The given voiceState cannot be null"); - if (voiceState.inVoiceChannel()) { + if (voiceState.inAudioChannel()) { return "\n**In voicechannel:** " + (voiceState.getChannel().getAsMention()); } else { return ""; @@ -140,15 +130,15 @@ public void onSlashCommand(@NotNull final SlashCommandEvent event) { /** * Generates whois embed based on the given parameters. * - * @param event the {@link SlashCommandEvent} + * @param event the {@link SlashCommandInteractionEvent} * @param user the {@link User} getting whois'd * @param profile the {@link net.dv8tion.jda.api.entities.User.Profile} of the whois'd user * @param effectiveColor the {@link Color} that the embed will become * @return the generated {@link EmbedBuilder} */ - private static @NotNull EmbedBuilder generateEmbedBuilder( - @NotNull final SlashCommandEvent event, @NotNull final User user, - final @NotNull User.Profile profile, final Color effectiveColor) { + private static @NotNull EmbedBuilder generateEmbedBuilder(@NotNull final Interaction event, + @NotNull final User user, final @NotNull User.Profile profile, + final Color effectiveColor) { EmbedBuilder embedBuilder = new EmbedBuilder().setThumbnail(user.getEffectiveAvatarUrl()) .setColor(effectiveColor) @@ -183,14 +173,11 @@ public void onSlashCommand(@NotNull final SlashCommandEvent event) { * Handles the user's identifying properties (such as ID, tag) * * @param user the {@link User} to take the identifiers from - * @return user readable {@link StringBuilder} + * @return user readable {@link String} */ - private static @NotNull StringBuilder userIdentificationToStringItem(final @NotNull User user) { - return new StringBuilder("**Mention:** ").append(user.getAsMention()) - .append("\n**Tag:** ") - .append(user.getAsTag()) - .append("\n**ID:** ") - .append(user.getId()); + private static @NotNull String userIdentificationToStringItem(final @NotNull User user) { + return "**Mention:** " + user.getAsMention() + "\n**Tag:** " + user.getAsTag() + + "\n**ID:** " + user.getId(); } /** @@ -213,13 +200,13 @@ private static String formatRoles(final @NotNull Member member) { private static @NotNull StringBuilder userFlagsToStringItem( final @NotNull Collection flags) { String formattedFlags = formatUserFlags(flags); + StringBuilder result = hypeSquadToStringItem(flags); - if (formattedFlags.isBlank()) { - return hypeSquadToStringItem(flags); - } else { - return hypeSquadToStringItem(flags).append("\n**Flags:** ") - .append(formatUserFlags(flags)); + if (!formattedFlags.isBlank()) { + result.append("\n**Flags:** ").append(formattedFlags); } + + return result; } /** From e2fb1d38e54c0b5a5dd429b152558d30950e4a88 Mon Sep 17 00:00:00 2001 From: Zabuzard Date: Tue, 3 May 2022 19:34:55 +0200 Subject: [PATCH 04/11] Fixes from CR (illu) --- .../tjbot/commands/moderation/WhoIsCommand.java | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/WhoIsCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/WhoIsCommand.java index 056f43ba8d..4403353880 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/WhoIsCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/WhoIsCommand.java @@ -119,11 +119,12 @@ public void onSlashCommand(@NotNull final SlashCommandInteractionEvent event) { private static @NotNull String voiceStateToStringItem(@NotNull final Member member) { GuildVoiceState voiceState = Objects.requireNonNull(member.getVoiceState(), "The given voiceState cannot be null"); - if (voiceState.inAudioChannel()) { - return "\n**In voicechannel:** " + (voiceState.getChannel().getAsMention()); - } else { + + if (!voiceState.inAudioChannel()) { return ""; } + + return "\n**In voicechannel:** " + (voiceState.getChannel().getAsMention()); } @@ -161,12 +162,13 @@ public void onSlashCommand(@NotNull final SlashCommandInteractionEvent event) { */ private static @NotNull String possibleBoosterToStringItem(final @NotNull Member member) { OffsetDateTime timeBoosted = member.getTimeBoosted(); - if (null != timeBoosted) { - return "\n**Is booster:** true \n**Boosting since:** " - + DATE_TIME_FORMAT.format(timeBoosted); - } else { + + if (null == timeBoosted) { return "\n**Is booster:** false"; } + + return "\n**Is booster:** true \n**Boosting since:** " + + DATE_TIME_FORMAT.format(timeBoosted); } /** From fbf235a246abde13a62342eb3464c73b60427e1d Mon Sep 17 00:00:00 2001 From: Zabuzard Date: Wed, 4 May 2022 15:23:33 +0200 Subject: [PATCH 05/11] Tex inline support --- .../commands/mathcommands/TeXCommand.java | 112 ++++++++++++++---- 1 file changed, 90 insertions(+), 22 deletions(-) diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/mathcommands/TeXCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/mathcommands/TeXCommand.java index 1f5be8d3ea..da4c585d8c 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/mathcommands/TeXCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/mathcommands/TeXCommand.java @@ -2,8 +2,10 @@ import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent; +import net.dv8tion.jda.api.interactions.callbacks.IDeferrableCallback; import net.dv8tion.jda.api.interactions.commands.OptionType; import net.dv8tion.jda.api.interactions.components.buttons.Button; +import net.dv8tion.jda.api.interactions.components.buttons.ButtonStyle; import org.jetbrains.annotations.NotNull; import org.scilab.forge.jlatexmath.ParseException; import org.scilab.forge.jlatexmath.TeXConstants; @@ -21,6 +23,8 @@ import java.io.IOException; import java.util.List; import java.util.Objects; +import java.util.regex.Matcher; +import java.util.regex.Pattern; /** * Implementation of a tex command which takes a string and renders an image corresponding to the @@ -31,11 +35,15 @@ * message. */ -public class TeXCommand extends SlashCommandAdapter { - +public final class TeXCommand extends SlashCommandAdapter { private static final String LATEX_OPTION = "latex"; + // Matches regions between two dollars, like '$foo$'. + private static final String MATH_REGION = "(\\$[^$]+\\$)"; + private static final String TEXT_REGION = "([^$]+)"; + private static final Pattern INLINE_LATEX_REPLACEMENT = + Pattern.compile(MATH_REGION + "|" + TEXT_REGION); private static final String RENDERING_ERROR = "There was an error generating the image"; - private static final float DEFAULT_IMAGE_SIZE = 40F; + private static final float DEFAULT_IMAGE_SIZE = 40.0F; private static final Color BACKGROUND_COLOR = Color.decode("#36393F"); private static final Color FOREGROUND_COLOR = Color.decode("#FFFFFF"); private static final Logger logger = LoggerFactory.getLogger(TeXCommand.class); @@ -44,8 +52,7 @@ public class TeXCommand extends SlashCommandAdapter { * Creates a new Instance. */ public TeXCommand() { - super("tex", - "This command accepts a latex expression and generates an image corresponding to it.", + super("tex", "Renders LaTeX, also supports inline $-regions like 'see this $\frac{x}{2}$'.", SlashCommandVisibility.GUILD); getData().addOption(OptionType.STRING, LATEX_OPTION, "The latex which is rendered as an image", true); @@ -56,42 +63,103 @@ public void onSlashCommand(@NotNull final SlashCommandInteractionEvent event) { String latex = Objects.requireNonNull(event.getOption(LATEX_OPTION)).getAsString(); String userID = (Objects.requireNonNull(event.getMember()).getId()); TeXFormula formula; + try { + if (latex.contains("$")) { + latex = convertInlineLatexToFull(latex); + } formula = new TeXFormula(latex); } catch (ParseException e) { event.reply("That is an invalid latex: " + e.getMessage()).setEphemeral(true).queue(); return; } + event.deferReply().queue(); - Image image = formula.createBufferedImage(TeXConstants.STYLE_DISPLAY, DEFAULT_IMAGE_SIZE, - FOREGROUND_COLOR, BACKGROUND_COLOR); - if (image.getWidth(null) == -1 || image.getHeight(null) == -1) { - event.getHook().setEphemeral(true).editOriginal(RENDERING_ERROR).queue(); - logger.warn( - "Unable to render latex, image does not have an accessible width or height. Formula was {}", - latex); - return; - } - BufferedImage renderedTextImage = new BufferedImage(image.getWidth(null), - image.getHeight(null), BufferedImage.TYPE_4BYTE_ABGR); - renderedTextImage.getGraphics().drawImage(image, 0, 0, null); - ByteArrayOutputStream renderedTextImageStream = new ByteArrayOutputStream(); try { - ImageIO.write(renderedTextImage, "png", renderedTextImageStream); + Image image = renderImage(formula); + sendImage(event, userID, image); } catch (IOException e) { - event.getHook().setEphemeral(true).editOriginal(RENDERING_ERROR).queue(); + event.getHook().editOriginal(RENDERING_ERROR).queue(); logger.warn( "Unable to render latex, could not convert the image into an attachable form. Formula was {}", latex, e); - return; + + } catch (IllegalStateException e) { + event.getHook().editOriginal(RENDERING_ERROR).queue(); + + logger.warn( + "Unable to render latex, image does not have an accessible width or height. Formula was {}", + latex, e); } + } + + private @NotNull Image renderImage(@NotNull TeXFormula formula) { + Image image = formula.createBufferedImage(TeXConstants.STYLE_DISPLAY, DEFAULT_IMAGE_SIZE, + FOREGROUND_COLOR, BACKGROUND_COLOR); + + if (image.getWidth(null) == -1 || image.getHeight(null) == -1) { + throw new IllegalStateException("Image has no height or width"); + } + return image; + } + + private void sendImage(@NotNull IDeferrableCallback event, @NotNull String userID, + @NotNull Image image) throws IOException { + ByteArrayOutputStream renderedTextImageStream = getRenderedTextImageStream(image); event.getHook() .editOriginal(renderedTextImageStream.toByteArray(), "tex.png") - .setActionRow(Button.danger(generateComponentId(userID), "Delete")) + .setActionRow(Button.of(ButtonStyle.DANGER, generateComponentId(userID), "Delete")) .queue(); } + @NotNull + private ByteArrayOutputStream getRenderedTextImageStream(@NotNull Image image) + throws IOException { + BufferedImage renderedTextImage = new BufferedImage(image.getWidth(null), + image.getHeight(null), BufferedImage.TYPE_4BYTE_ABGR); + + renderedTextImage.getGraphics().drawImage(image, 0, 0, null); + ByteArrayOutputStream renderedTextImageStream = new ByteArrayOutputStream(); + + ImageIO.write(renderedTextImage, "png", renderedTextImageStream); + + return renderedTextImageStream; + } + + /** + * Converts inline latex like: {@code hello $\frac{x}{2}$ world} to full latex + * {@code \text{hello}\frac{x}{2}\text{ world}}. + * + * @param latex the latex to convert + * @return the converted latex + */ + @NotNull + private String convertInlineLatexToFull(@NotNull String latex) { + if (isInvalidInlineFormat(latex)) { + throw new ParseException( + "The amount of $-symbols must be divisible by two. Did you forget to close an expression? "); + } + + Matcher matcher = INLINE_LATEX_REPLACEMENT.matcher(latex); + StringBuilder sb = new StringBuilder(latex.length()); + + while (matcher.find()) { + boolean isInsideMathRegion = matcher.group(1) != null; + if (isInsideMathRegion) { + sb.append(matcher.group(1).replace("$", "")); + } else { + sb.append("\\text{").append(matcher.group(2)).append("}"); + } + } + + return sb.toString(); + } + + private boolean isInvalidInlineFormat(@NotNull String latex) { + return latex.chars().filter(charAsInt -> charAsInt == '$').count() % 2 == 1; + } + @Override public void onButtonClick(@NotNull final ButtonInteractionEvent event, @NotNull final List args) { From f68092ef9d548f09385723c213af89409dd85b4f Mon Sep 17 00:00:00 2001 From: Zabuzard Date: Thu, 5 May 2022 10:14:29 +0200 Subject: [PATCH 06/11] unit tests for /tex and interaction hook mocking --- .../commands/mathcommands/TeXCommand.java | 10 +- .../commands/mathcommands/TeXCommandTest.java | 109 ++++++++++++++++++ .../org/togetherjava/tjbot/jda/JdaTester.java | 34 ++++++ 3 files changed, 149 insertions(+), 4 deletions(-) create mode 100644 application/src/test/java/org/togetherjava/tjbot/commands/mathcommands/TeXCommandTest.java diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/mathcommands/TeXCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/mathcommands/TeXCommand.java index da4c585d8c..00d1037c57 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/mathcommands/TeXCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/mathcommands/TeXCommand.java @@ -36,13 +36,16 @@ */ public final class TeXCommand extends SlashCommandAdapter { - private static final String LATEX_OPTION = "latex"; + static final String LATEX_OPTION = "latex"; // Matches regions between two dollars, like '$foo$'. private static final String MATH_REGION = "(\\$[^$]+\\$)"; private static final String TEXT_REGION = "([^$]+)"; private static final Pattern INLINE_LATEX_REPLACEMENT = Pattern.compile(MATH_REGION + "|" + TEXT_REGION); private static final String RENDERING_ERROR = "There was an error generating the image"; + static final String BAD_LATEX_ERROR_PREFIX = "That is an invalid latex: "; + static final String INVALID_INLINE_FORMAT_ERROR_MESSAGE = + "The amount of $-symbols must be divisible by two. Did you forget to close an expression?"; private static final float DEFAULT_IMAGE_SIZE = 40.0F; private static final Color BACKGROUND_COLOR = Color.decode("#36393F"); private static final Color FOREGROUND_COLOR = Color.decode("#FFFFFF"); @@ -70,7 +73,7 @@ public void onSlashCommand(@NotNull final SlashCommandInteractionEvent event) { } formula = new TeXFormula(latex); } catch (ParseException e) { - event.reply("That is an invalid latex: " + e.getMessage()).setEphemeral(true).queue(); + event.reply(BAD_LATEX_ERROR_PREFIX + e.getMessage()).setEphemeral(true).queue(); return; } @@ -137,8 +140,7 @@ private ByteArrayOutputStream getRenderedTextImageStream(@NotNull Image image) @NotNull private String convertInlineLatexToFull(@NotNull String latex) { if (isInvalidInlineFormat(latex)) { - throw new ParseException( - "The amount of $-symbols must be divisible by two. Did you forget to close an expression? "); + throw new ParseException(INVALID_INLINE_FORMAT_ERROR_MESSAGE); } Matcher matcher = INLINE_LATEX_REPLACEMENT.matcher(latex); diff --git a/application/src/test/java/org/togetherjava/tjbot/commands/mathcommands/TeXCommandTest.java b/application/src/test/java/org/togetherjava/tjbot/commands/mathcommands/TeXCommandTest.java new file mode 100644 index 0000000000..be2dd18c0e --- /dev/null +++ b/application/src/test/java/org/togetherjava/tjbot/commands/mathcommands/TeXCommandTest.java @@ -0,0 +1,109 @@ +package org.togetherjava.tjbot.commands.mathcommands; + +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.togetherjava.tjbot.commands.SlashCommand; +import org.togetherjava.tjbot.jda.JdaTester; + +import java.util.ArrayList; +import java.util.List; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.description; +import static org.mockito.Mockito.verify; + +final class TeXCommandTest { + private JdaTester jdaTester; + private SlashCommand command; + + @BeforeEach + void setUp() { + jdaTester = new JdaTester(); + command = jdaTester.spySlashCommand(new TeXCommand()); + } + + private @NotNull SlashCommandInteractionEvent triggerSlashCommand(@NotNull String latex) { + SlashCommandInteractionEvent event = jdaTester.createSlashCommandInteractionEvent(command) + .setOption(TeXCommand.LATEX_OPTION, latex) + .build(); + + command.onSlashCommand(event); + return event; + } + + private void verifySuccessfulResponse(@NotNull SlashCommandInteractionEvent event, + @NotNull String query) { + verify(jdaTester.getInteractionHookMock(), description("Testing query: " + query)) + .editOriginal(any(byte[].class), eq("tex.png")); + } + + private static List provideSupportedQueries() { + List fullLatex = List.of("\\frac{x}{2}", "f \\in \\mathcal{O}(n^2)", + "a^{\\varphi(n)} \\equiv 1\\ (\\textrm{mod}\\ n)", "\\textrm{I like } \\xi"); + + List inlineLatex = List.of("$\\frac{x}{2}$", "$x$ hello", "hello $x$", + "hello $x$ world $y$", "$x$$y$$z$", "$x \\cdot y$"); + + List edgeCases = List.of("", " ", " \n "); + + List allQueries = new ArrayList<>(); + allQueries.addAll(fullLatex); + allQueries.addAll(inlineLatex); + allQueries.addAll(edgeCases); + + return allQueries; + } + + @ParameterizedTest + @MethodSource("provideSupportedQueries") + @DisplayName("The command supports and renders all supported latex queries") + void canRenderSupportedQuery(@NotNull String supportedQuery) { + // GIVEN a supported latex query + + // WHEN triggering the command + SlashCommandInteractionEvent event = triggerSlashCommand(supportedQuery); + + // THEN the command send a successful response + verifySuccessfulResponse(event, supportedQuery); + } + + private static List provideBadInlineQueries() { + return List.of("hello $x world", "$", " $ ", "hello $x$ world$", "$$$$$", "$x$$y$$z"); + } + + @ParameterizedTest + @MethodSource("provideBadInlineQueries") + @DisplayName("The command does not support bad inline latex queries, for example with missing dollars") + void failsOnBadInlineQuery(@NotNull String badInlineQuery) { + // GIVEN a bad inline latex query + + // WHEN triggering the command + SlashCommandInteractionEvent event = triggerSlashCommand(badInlineQuery); + + // THEN the command send a failure response + verify(event, description("Testing query: " + badInlineQuery)) + .reply(contains(TeXCommand.INVALID_INLINE_FORMAT_ERROR_MESSAGE)); + } + + private static List provideBadQueries() { + return List.of("__", "\\foo", "\\left(x + y)"); + } + + @ParameterizedTest + @MethodSource("provideBadQueries") + @DisplayName("The command does not support bad latex queries, for example with unknown symbols or incomplete braces") + void failsOnBadQuery(@NotNull String badQuery) { + // GIVEN a bad inline latex query + + // WHEN triggering the command + SlashCommandInteractionEvent event = triggerSlashCommand(badQuery); + + // THEN the command send a failure response + verify(event, description("Testing query: " + badQuery)) + .reply(startsWith(TeXCommand.BAD_LATEX_ERROR_PREFIX)); + } +} diff --git a/application/src/test/java/org/togetherjava/tjbot/jda/JdaTester.java b/application/src/test/java/org/togetherjava/tjbot/jda/JdaTester.java index 4c96a216ae..2b07ba9c50 100644 --- a/application/src/test/java/org/togetherjava/tjbot/jda/JdaTester.java +++ b/application/src/test/java/org/togetherjava/tjbot/jda/JdaTester.java @@ -6,6 +6,7 @@ import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent; 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.components.ItemComponent; import net.dv8tion.jda.api.requests.ErrorResponse; @@ -20,6 +21,7 @@ import net.dv8tion.jda.internal.requests.Requester; import net.dv8tion.jda.internal.requests.restaction.AuditableRestActionImpl; import net.dv8tion.jda.internal.requests.restaction.MessageActionImpl; +import net.dv8tion.jda.internal.requests.restaction.WebhookMessageUpdateActionImpl; import net.dv8tion.jda.internal.requests.restaction.interactions.ReplyCallbackActionImpl; import net.dv8tion.jda.internal.utils.config.AuthorizationConfig; import org.jetbrains.annotations.NotNull; @@ -83,8 +85,11 @@ public final class JdaTester { private final ReplyCallbackActionImpl replyAction; private final AuditableRestActionImpl auditableRestAction; private final MessageActionImpl messageAction; + private final WebhookMessageUpdateActionImpl webhookMessageUpdateAction; private final TextChannelImpl textChannel; private final PrivateChannelImpl privateChannel; + private final InteractionHook interactionHook; + private final ReplyCallbackAction replyCallbackAction; /** * Creates a new instance. The instance uses a fresh and isolated mocked JDA setup. @@ -108,6 +113,8 @@ public JdaTester() { textChannel = spy(new TextChannelImpl(TEXT_CHANNEL_ID, guild)); privateChannel = spy(new PrivateChannelImpl(jda, PRIVATE_CHANNEL_ID, user)); messageAction = mock(MessageActionImpl.class); + webhookMessageUpdateAction = mock(WebhookMessageUpdateActionImpl.class); + replyCallbackAction = mock(ReplyCallbackAction.class); EntityBuilder entityBuilder = mock(EntityBuilder.class); Role everyoneRole = new RoleImpl(GUILD_ID, guild); @@ -149,12 +156,22 @@ public JdaTester() { doNothing().when(auditableRestAction).queue(); doNothing().when(messageAction).queue(); + doNothing().when(webhookMessageUpdateAction).queue(); + doReturn(webhookMessageUpdateAction).when(webhookMessageUpdateAction) + .setActionRow(any(ItemComponent.class)); doReturn(everyoneRole).when(guild).getPublicRole(); doReturn(selfMember).when(guild).getMember(selfUser); doReturn(member).when(guild).getMember(not(eq(selfUser))); doReturn(null).when(textChannel).retrieveMessageById(any()); + + interactionHook = mock(InteractionHook.class); + when(interactionHook.editOriginal(anyString())).thenReturn(webhookMessageUpdateAction); + when(interactionHook.editOriginal(any(Message.class))) + .thenReturn(webhookMessageUpdateAction); + when(interactionHook.editOriginal(any(byte[].class), any(), any())) + .thenReturn(webhookMessageUpdateAction); } /** @@ -251,6 +268,19 @@ public JdaTester() { return replyAction; } + /** + * Gets the Mockito mock used as universal interaction hook by all mocks created by this tester + * instance. + *

+ * For example the events created by {@link #createSlashCommandInteractionEvent(SlashCommand)} + * will return this mock on several of their methods. + * + * @return the interaction hook mock used by this tester + */ + public @NotNull InteractionHook getInteractionHookMock() { + return interactionHook; + } + /** * Gets the text channel spy used as universal text channel by all mocks created by this tester * instance. @@ -371,6 +401,10 @@ private void mockInteraction(@NotNull IReplyCallback interaction) { doReturn(textChannel).when(interaction).getTextChannel(); doReturn(textChannel).when(interaction).getGuildChannel(); doReturn(privateChannel).when(interaction).getPrivateChannel(); + + doReturn(interactionHook).when(interaction).getHook(); + doReturn(replyCallbackAction).when(interaction).deferReply(); + doReturn(replyCallbackAction).when(interaction).deferReply(anyBoolean()); } private void mockButtonClickEvent(@NotNull ButtonInteractionEvent event) { From 00d001350311a2fe971effa10a08a0504fb88122 Mon Sep 17 00:00:00 2001 From: Zabuzard Date: Wed, 4 May 2022 14:57:04 +0200 Subject: [PATCH 07/11] Auto-free help channels after 2h of inactivity --- application/build.gradle | 1 + application/config.json.template | 2 + .../togetherjava/tjbot/commands/Features.java | 6 +- .../tjbot/commands/free/AutoFreeRoutine.java | 55 +++++ .../tjbot/commands/free/ChannelStatus.java | 8 +- ...elMonitor.java => FreeChannelMonitor.java} | 222 ++++++++++++++---- .../tjbot/commands/free/FreeCommand.java | 130 ++-------- .../tjbot/commands/free/FreeUtil.java | 26 -- .../tjbot/commands/free/UserStrings.java | 4 + .../org/togetherjava/tjbot/config/Config.java | 4 +- .../tjbot/config/FreeCommandConfig.java | 37 ++- 11 files changed, 296 insertions(+), 199 deletions(-) create mode 100644 application/src/main/java/org/togetherjava/tjbot/commands/free/AutoFreeRoutine.java rename application/src/main/java/org/togetherjava/tjbot/commands/free/{ChannelMonitor.java => FreeChannelMonitor.java} (60%) diff --git a/application/build.gradle b/application/build.gradle index b9b1f3e3aa..a86b8a66a2 100644 --- a/application/build.gradle +++ b/application/build.gradle @@ -53,6 +53,7 @@ dependencies { implementation 'org.scilab.forge:jlatexmath-font-cyrillic:1.0.7' implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-csv:2.13.0' + implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.13.0' implementation 'com.fasterxml.jackson.core:jackson-databind:2.13.0' implementation 'com.github.freva:ascii-table:1.2.0' diff --git a/application/config.json.template b/application/config.json.template index 58bb204c19..e0f9271047 100644 --- a/application/config.json.template +++ b/application/config.json.template @@ -10,6 +10,8 @@ "tagManageRolePattern": "Moderator|Staff Assistant|Top Helpers .+", "freeCommand": [ { + "inactiveChannelDuration": "PT2H", + "messageRetrieveLimit": 10, "statusChannel": , "monitoredChannels": [ diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/Features.java b/application/src/main/java/org/togetherjava/tjbot/commands/Features.java index 89b55ad7db..e8528391c0 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/Features.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/Features.java @@ -6,6 +6,8 @@ import org.togetherjava.tjbot.commands.basic.RoleSelectCommand; import org.togetherjava.tjbot.commands.basic.SuggestionsUpDownVoter; import org.togetherjava.tjbot.commands.basic.VcActivityCommand; +import org.togetherjava.tjbot.commands.free.AutoFreeRoutine; +import org.togetherjava.tjbot.commands.free.FreeChannelMonitor; import org.togetherjava.tjbot.commands.free.FreeCommand; import org.togetherjava.tjbot.commands.mathcommands.TeXCommand; import org.togetherjava.tjbot.commands.moderation.*; @@ -59,6 +61,7 @@ public enum Features { ModerationActionsStore actionsStore = new ModerationActionsStore(database); ModAuditLogWriter modAuditLogWriter = new ModAuditLogWriter(config); ScamHistoryStore scamHistoryStore = new ScamHistoryStore(database); + FreeChannelMonitor freeChannelMonitor = new FreeChannelMonitor(config); // NOTE The system can add special system relevant commands also by itself, // hence this list may not necessarily represent the full list of all commands actually @@ -71,6 +74,7 @@ public enum Features { features.add(new TopHelpersPurgeMessagesRoutine(database)); features.add(new RemindRoutine(database)); features.add(new ScamHistoryPurgeRoutine(scamHistoryStore)); + features.add(new AutoFreeRoutine(freeChannelMonitor)); // Message receivers features.add(new TopHelpersMessageListener(database, config)); @@ -103,7 +107,7 @@ public enum Features { features.add(new WhoIsCommand()); // Mixtures - features.add(new FreeCommand(config)); + features.add(new FreeCommand(config, freeChannelMonitor)); return features; } diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/free/AutoFreeRoutine.java b/application/src/main/java/org/togetherjava/tjbot/commands/free/AutoFreeRoutine.java new file mode 100644 index 0000000000..bafe787745 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/free/AutoFreeRoutine.java @@ -0,0 +1,55 @@ +package org.togetherjava.tjbot.commands.free; + +import net.dv8tion.jda.api.JDA; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.TextChannel; +import org.jetbrains.annotations.NotNull; +import org.togetherjava.tjbot.commands.Routine; + +import java.util.Collection; +import java.util.Objects; +import java.util.concurrent.TimeUnit; + +/** + * Routine that automatically marks busy help channels free after a certain time without any + * activity. + */ +public final class AutoFreeRoutine implements Routine { + private final FreeChannelMonitor channelMonitor; + + /** + * Creates a new instance. + * + * @param channelMonitor used to monitor and control the free-status of channels + */ + public AutoFreeRoutine(@NotNull FreeChannelMonitor channelMonitor) { + this.channelMonitor = channelMonitor; + } + + @Override + public void runRoutine(@NotNull JDA jda) { + channelMonitor.guildIds() + .map(jda::getGuildById) + .filter(Objects::nonNull) + .forEach(this::processGuild); + } + + private void processGuild(@NotNull Guild guild) { + // Mark inactive channels free + Collection inactiveChannels = channelMonitor.freeInactiveChannels(guild); + + // Then update the status + channelMonitor.displayStatus(guild); + + // Finally, send the messages (the order is important to ensure sane behavior in case of + // crashes) + inactiveChannels.forEach(inactiveChannel -> inactiveChannel + .sendMessage(UserStrings.AUTO_MARK_AS_FREE.message()) + .queue()); + } + + @Override + public @NotNull Schedule createSchedule() { + return new Schedule(ScheduleMode.FIXED_RATE, 1, 5, TimeUnit.MINUTES); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/free/ChannelStatus.java b/application/src/main/java/org/togetherjava/tjbot/commands/free/ChannelStatus.java index 41eb9528fe..6a025e39e3 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/free/ChannelStatus.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/free/ChannelStatus.java @@ -101,11 +101,11 @@ private void setName(@NotNull final String name) { * * @param guild the {@link Guild} that the channel belongs to, to retrieve its name from. * @throws IllegalArgumentException if the guild has not been added, see - * {@link ChannelMonitor#addChannelForStatus(TextChannel)} + * {@link FreeChannelMonitor#addChannelForStatus(TextChannel)} * @throws IllegalStateException if a channel was added, see - * {@link ChannelMonitor#addChannelToMonitor(long)}, that is not a {@link TextChannel}. - * Since addChannelToMonitor does not access the {@link JDA} the entry can only be - * validated before use instead of on addition. + * {@link FreeChannelMonitor#addChannelToMonitor(long)}, that is not a + * {@link TextChannel}. Since addChannelToMonitor does not access the {@link JDA} the + * entry can only be validated before use instead of on addition. */ public void updateChannelName(@NotNull final Guild guild) { GuildChannel channel = guild.getGuildChannelById(channelId); diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/free/ChannelMonitor.java b/application/src/main/java/org/togetherjava/tjbot/commands/free/FreeChannelMonitor.java similarity index 60% rename from application/src/main/java/org/togetherjava/tjbot/commands/free/ChannelMonitor.java rename to application/src/main/java/org/togetherjava/tjbot/commands/free/FreeChannelMonitor.java index 7eec12bb24..faa2d9abd7 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/free/ChannelMonitor.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/free/FreeChannelMonitor.java @@ -1,38 +1,56 @@ package org.togetherjava.tjbot.commands.free; -import net.dv8tion.jda.api.entities.Category; -import net.dv8tion.jda.api.entities.Guild; -import net.dv8tion.jda.api.entities.GuildChannel; -import net.dv8tion.jda.api.entities.TextChannel; +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.entities.*; +import net.dv8tion.jda.api.requests.RestAction; +import net.dv8tion.jda.api.utils.TimeUtil; import org.jetbrains.annotations.NotNull; +import org.togetherjava.tjbot.config.Config; +import org.togetherjava.tjbot.config.FreeCommandConfig; +import java.awt.Color; +import java.time.Instant; import java.util.*; +import java.util.stream.LongStream; import java.util.stream.Stream; /** * A class responsible for monitoring the status of channels and reporting on their busy/free status * for use by {@link FreeCommand}. - * + *

* Channels for monitoring are added via {@link #addChannelToMonitor(long)} however the monitoring * will not be accessible/visible until a channel in the same {@link Guild} is registered for the * output via {@link #addChannelForStatus(TextChannel)}. This will all happen automatically for any * channels listed in {@link org.togetherjava.tjbot.config.FreeCommandConfig}. - * + *

* When a status channel is added for a guild, all monitored channels for that guild are tested and * an {@link IllegalStateException} is thrown if any of them are not {@link TextChannel}s. - * + *

* After successful configuration, any changes in busy/free status will automatically be displayed * in the configured {@code Status Channel} for that guild. */ -final class ChannelMonitor { +public final class FreeChannelMonitor { // Map to store channel ID's, use Guild.getChannels() to guarantee order for display private final Map channelsToMonitorById; private final Map guildIdToStatusChannel; + private final Map channelIdToMessageIdForStatus; - ChannelMonitor() { + private static final String STATUS_TITLE = "**__CHANNEL STATUS__**\n\n"; + private static final Color MESSAGE_HIGHLIGHT_COLOR = Color.decode("#CCCC00"); + + private final Config config; + + /** + * Creates a new instance. + * + * @param config the config to use + */ + public FreeChannelMonitor(@NotNull Config config) { guildIdToStatusChannel = new HashMap<>(); // JDA required to populate map channelsToMonitorById = new HashMap<>(); + channelIdToMessageIdForStatus = new HashMap<>(); + this.config = config; } /** @@ -48,7 +66,7 @@ public void addChannelToMonitor(final long channelId) { * Method for adding the channel that the status will be printed in. Even though the method only * stores the long id it requires, the method requires the actual {@link TextChannel} to be * passed because it needs to verify it as well as store the guild id. - * + *

* This method also calls a method which updates the status of the channels in the * {@link Guild}. So always add the status channel after you have added all * monitored channels for the guild, see {@link #addChannelToMonitor(long)}. @@ -57,14 +75,14 @@ public void addChannelToMonitor(final long channelId) { */ public void addChannelForStatus(@NotNull final TextChannel channel) { guildIdToStatusChannel.put(channel.getGuild().getIdLong(), channel.getIdLong()); - updateStatusFor(channel.getGuild()); + freeInactiveChannels(channel.getGuild()); } /** * This method tests whether a guild id is configured for monitoring in the free command system. * To add a guild for monitoring see {@link org.togetherjava.tjbot.config.FreeCommandConfig} or * {@link #addChannelForStatus(TextChannel)}. - * + * * @param guildId the id of the guild to test. * @return whether the guild is configured in the free command system or not. */ @@ -77,7 +95,7 @@ public boolean isMonitoringGuild(final long guildId) { * system. To add a channel for monitoring see * {@link org.togetherjava.tjbot.config.FreeCommandConfig} or * {@link #addChannelToMonitor(long)}. - * + * * @param channelId the id of the channel to test. * @return {@code true} if the channel is configured in the system, {@code false} otherwise. */ @@ -107,36 +125,38 @@ public boolean isChannelBusy(final long channelId) { /** * This method tests if a channel is currently active by fetching the latest message and testing - * if it was posted more recently than the configured time limit, see - * {@link FreeUtil#inactiveTimeLimit()} and - * {@link org.togetherjava.tjbot.config.FreeCommandConfig#INACTIVE_DURATION}, - * {@link org.togetherjava.tjbot.config.FreeCommandConfig#INACTIVE_UNIT}. + * if it was posted more recently than the configured time limit. * * @param channel the channel to test. + * @param when the reference moment, usually "now" * @return {@code true} if the channel is inactive, false if it has received messages more * recently than the configured duration. * @throws IllegalArgumentException if the channel passed is not monitored. See * {@link #addChannelToMonitor(long)} */ - public boolean isChannelInactive(@NotNull final TextChannel channel) { + public boolean isChannelInactive(@NotNull final TextChannel channel, @NotNull Instant when) { requiresIsMonitored(channel.getIdLong()); - // TODO change the entire inactive test to work via rest-actions - return FreeUtil.getLastMessageId(channel) - // black magic to convert OptionalLong into Optional because OptionalLong does not - // have .map + OptionalLong maybeLastMessageId = FreeUtil.getLastMessageId(channel); + if (maybeLastMessageId.isEmpty()) { + return true; + } + + FreeCommandConfig configForChannel = config.getFreeCommandConfig() .stream() - .boxed() - .findFirst() - .map(FreeUtil::timeFromId) - .map(createdTime -> createdTime.isBefore(FreeUtil.inactiveTimeLimit())) - .orElse(true); // if no channel history could be fetched assume channel is free + .filter(freeConfig -> freeConfig.getMonitoredChannels().contains(channel.getIdLong())) + .findAny() + .orElseThrow(); + + return TimeUtil.getTimeCreated(maybeLastMessageId.orElseThrow()) + .toInstant() + .isBefore(when.minus(configForChannel.getInactiveChannelDuration())); } /** * This method sets the channel's status to 'busy' see {@link ChannelStatus#setBusy(long)} for * details. - * + * * @param channelId the id for the channel status to modify. * @param userId the id of the user changing the status to busy. * @throws IllegalArgumentException if the channel passed is not monitored. See @@ -149,7 +169,7 @@ public void setChannelBusy(final long channelId, final long userId) { /** * This method sets the channel's status to 'free', see {@link ChannelStatus#setFree()} for * details. - * + * * @param channelId the id for the channel status to modify. * @throws IllegalArgumentException if the channel passed is not monitored. See * {@link #addChannelToMonitor(long)} @@ -171,7 +191,7 @@ public void setChannelFree(final long channelId) { /** * This method provides a stream of the id's for channels where statuses are displayed. This is * streamed purely as a simple method of encapsulation. - * + * * @return a stream of channel id's */ public @NotNull Stream statusIds() { @@ -187,6 +207,18 @@ public void setChannelFree(final long channelId) { .toList(); } + /** + * Gets a stream with IDs of all monitored channels that are currently marked busy. + * + * @return stream with IDs of all busy channels + */ + public LongStream getBusyChannelIds() { + return channelsToMonitorById.values() + .stream() + .filter(ChannelStatus::isBusy) + .mapToLong(ChannelStatus::getChannelId); + } + /** * Creates the status message (specific to the guild specified) that shows which channels are * busy/free. @@ -194,7 +226,7 @@ public void setChannelFree(final long channelId) { * It first updates the channel names, order and grouping(categories) according to * {@link net.dv8tion.jda.api.JDA} for the monitored channels. So that the output is always * consistent with remote changes. - * + * * @param guild the guild the message is intended for. * @return a string representing the busy/free status of channels in this guild. The String * includes emojis and other discord specific markup. Attempting to display this @@ -232,26 +264,30 @@ public String statusMessage(@NotNull final Guild guild) { /** * This method checks all channels in a guild that are currently being monitored and are busy - * and determines if the last time it was updated is more recent than the configured time see - * {@link org.togetherjava.tjbot.config.FreeCommandConfig#INACTIVE_UNIT}. If so it changes the - * channel's status to free, see {@link ChannelMonitor#isChannelInactive(TextChannel)}. + * and determines if the last time it was updated is more recent than the configured time. If so + * it changes the channel's status to free, see + * {@link FreeChannelMonitor#isChannelInactive(TextChannel, Instant)}. *

- * This method is run automatically during startup and should be run on a set schedule, as - * defined in {@link org.togetherjava.tjbot.config.FreeCommandConfig}. The scheduled execution - * is not currently implemented - * + * This method is run automatically during startup and on a set schedule, as defined in + * {@link org.togetherjava.tjbot.config.FreeCommandConfig}. + * * @param guild the guild for which to test the channel statuses of. + * @return all inactive channels that have been updated */ - public void updateStatusFor(@NotNull Guild guild) { - // TODO add automation after Routine support (#235) is pushed - guildMonitoredChannelsList(guild).parallelStream() + public @NotNull Collection freeInactiveChannels(@NotNull Guild guild) { + Instant now = Instant.now(); + + List inactiveChannels = guildMonitoredChannelsList(guild).parallelStream() .filter(ChannelStatus::isBusy) .map(ChannelStatus::getChannelId) .map(guild::getTextChannelById) .filter(Objects::nonNull) // pointless, added for warnings - .filter(this::isChannelInactive) - .map(TextChannel::getIdLong) - .forEach(this::setChannelFree); + .filter(busyChannel -> isChannelInactive(busyChannel, now)) + .toList(); + + inactiveChannels.stream().map(TextChannel::getIdLong).forEach(this::setChannelFree); + + return inactiveChannels; } /** @@ -281,10 +317,106 @@ public void updateStatusFor(@NotNull Guild guild) { return channel; } + /** + * Displays the message that will be displayed for users. + *

+ * This method detects if any messages have been posted in the channel below the status message. + * If that is the case this will delete the existing status message and post another one so that + * it's the last message in the channel. + *

+ * If it cannot find an existing status message it will create a new one. + *

+ * Otherwise it will edit the existing message. + * + * @param guild the guild to display the status in. + */ + public void displayStatus(@NotNull Guild guild) { + TextChannel channel = getStatusChannelFor(guild); + + String messageTxt = buildStatusMessage(guild); + MessageEmbed embed = new EmbedBuilder().setTitle(STATUS_TITLE) + .setDescription(messageTxt) + .setFooter(channel.getJDA().getSelfUser().getName()) + .setTimestamp(Instant.now()) + .setColor(MESSAGE_HIGHLIGHT_COLOR) + .build(); + + getStatusMessageIn(channel).flatMap(this::deleteIfNotLatest) + .ifPresentOrElse(message -> message.editMessageEmbeds(embed).queue(), + () -> channel.sendMessageEmbeds(embed) + .queue(message -> channelIdToMessageIdForStatus.put(channel.getIdLong(), + message.getIdLong()))); + } + + private @NotNull Optional deleteIfNotLatest(@NotNull Message message) { + OptionalLong lastId = FreeUtil.getLastMessageId(message.getTextChannel()); + if (lastId.isPresent() && lastId.getAsLong() != message.getIdLong()) { + message.delete().queue(); + return Optional.empty(); + } + + return Optional.of(message); + } + + private @NotNull Optional getStatusMessageIn(@NotNull TextChannel channel) { + if (!channelIdToMessageIdForStatus.containsKey(channel.getIdLong())) { + return findExistingStatusMessage(channel); + } + return Optional.ofNullable(channelIdToMessageIdForStatus.get(channel.getIdLong())) + .map(channel::retrieveMessageById) + .map(RestAction::complete); + } + + private @NotNull Optional findExistingStatusMessage(@NotNull TextChannel channel) { + // will only run when bot starts, afterwards its stored in a map + + FreeCommandConfig configForChannel = config.getFreeCommandConfig() + .stream() + .filter(freeConfig -> freeConfig.getStatusChannel() == channel.getIdLong()) + .findAny() + .orElseThrow(); + + Optional statusMessage = FreeUtil + .getChannelHistory(channel, configForChannel.getMessageRetrieveLimit()) + .flatMap(history -> history.stream() + .filter(message -> !message.getEmbeds().isEmpty()) + .filter(message -> message.getAuthor().equals(channel.getJDA().getSelfUser())) + // TODO the equals is not working, i believe its because there is no getTitleRaw() + // .filter(message -> STATUS_TITLE.equals(message.getEmbeds().get(0).getTitle())) + .findFirst()); + + channelIdToMessageIdForStatus.put(channel.getIdLong(), + statusMessage.map(Message::getIdLong).orElse(null)); + return statusMessage; + } + + /** + * Method for creating the message that shows the channel statuses for the specified guild. + *

+ * This method dynamically builds the status message as per the current values on the guild, + * including the channel categories. This method will detect any changes made on the guild and + * represent those changes in the status message. + * + * @param guild the guild that the message is required for. + * @return the message to display showing the channel statuses. Includes Discord specific + * formatting, trying to display elsewhere may have unpredictable results. + * @throws IllegalArgumentException if the guild passed in is not configured in the free command + * system, see {@link FreeChannelMonitor#addChannelForStatus(TextChannel)}. + */ + public @NotNull String buildStatusMessage(@NotNull Guild guild) { + if (!isMonitoringGuild(guild.getIdLong())) { + throw new IllegalArgumentException( + "The guild '%s(%s)' is not configured in the free command system" + .formatted(guild.getName(), guild.getIdLong())); + } + + return statusMessage(guild); + } + /** * The toString method for this class, it generates a human-readable text string of the * currently monitored channels and the channels the status are printed in. - * + * * @return the human-readable text string that describes this class. */ @Override diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/free/FreeCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/free/FreeCommand.java index 3814855c94..7f2dcd86d1 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/free/FreeCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/free/FreeCommand.java @@ -1,16 +1,12 @@ package org.togetherjava.tjbot.commands.free; -import net.dv8tion.jda.api.EmbedBuilder; import net.dv8tion.jda.api.JDA; import net.dv8tion.jda.api.entities.Guild; -import net.dv8tion.jda.api.entities.Message; -import net.dv8tion.jda.api.entities.MessageEmbed; import net.dv8tion.jda.api.entities.TextChannel; import net.dv8tion.jda.api.events.GenericEvent; import net.dv8tion.jda.api.events.ReadyEvent; import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; import net.dv8tion.jda.api.events.message.MessageReceivedEvent; -import net.dv8tion.jda.api.requests.RestAction; import org.jetbrains.annotations.NotNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -20,9 +16,8 @@ import org.togetherjava.tjbot.config.Config; import org.togetherjava.tjbot.config.FreeCommandConfig; -import java.awt.*; -import java.time.Instant; -import java.util.*; +import java.util.Collection; +import java.util.stream.Collectors; // TODO (can SlashCommandVisibility be narrower than GUILD?) // TODO monitor all channels when list is empty? monitor none? @@ -34,6 +29,7 @@ // TODO add scheduled tasks to check last message every predefined duration and mark as free if // applicable + /** * Implementation of the free command. It is used to monitor a predefined list of channels and show * users which ones are available for use and which are not. @@ -57,15 +53,12 @@ public final class FreeCommand extends SlashCommandAdapter implements EventReceiver { private static final Logger logger = LoggerFactory.getLogger(FreeCommand.class); - private static final String STATUS_TITLE = "**__CHANNEL STATUS__**\n\n"; private static final String COMMAND_NAME = "free"; - private static final Color MESSAGE_HIGHLIGHT_COLOR = Color.decode("#CCCC00"); private final Config config; // Map to store channel ID's, use Guild.getChannels() to guarantee order for display - private final ChannelMonitor channelMonitor; - private final Map channelIdToMessageIdForStatus; + private final FreeChannelMonitor channelMonitor; private volatile boolean isReady; @@ -75,16 +68,16 @@ public final class FreeCommand extends SlashCommandAdapter implements EventRecei *

* This fetches configuration information from a json configuration file (see * {@link FreeCommandConfig}) for further details. - * + * * @param config the config to use for this + * @param channelMonitor used to monitor and control the free-status of channels */ - public FreeCommand(@NotNull Config config) { + public FreeCommand(@NotNull Config config, @NotNull FreeChannelMonitor channelMonitor) { super(COMMAND_NAME, "Marks this channel as free for another user to ask a question", SlashCommandVisibility.GUILD); this.config = config; - channelIdToMessageIdForStatus = new HashMap<>(); - channelMonitor = new ChannelMonitor(); + this.channelMonitor = channelMonitor; isReady = false; } @@ -113,7 +106,9 @@ public void onReady(@NotNull final ReadyEvent event) { channelMonitor.statusIds() .map(id -> requiresTextChannel(jda, id)) - .forEach(this::displayStatus); + .map(TextChannel::getGuild) + .collect(Collectors.toSet()) + .forEach(channelMonitor::displayStatus); isReady = true; } @@ -124,7 +119,7 @@ public void onReady(@NotNull final ReadyEvent event) { *

* If this is called on from a channel that was not configured for monitoring (see * {@link FreeCommandConfig}) the user will receive an ephemeral message stating such. - * + * * @param event the event that triggered this * @throws IllegalStateException if this method is called for a Global Slash Command */ @@ -144,7 +139,7 @@ public void onSlashCommand(@NotNull final SlashCommandInteractionEvent event) { } // TODO check if /free called by original author, if not put message asking if he approves channelMonitor.setChannelFree(id); - displayStatus(channelMonitor.getStatusChannelFor(requiresGuild(event))); + channelMonitor.displayStatus(requiresGuild(event)); event.reply(UserStrings.MARK_AS_FREE.message()).queue(); } @@ -187,52 +182,10 @@ private boolean handleShouldBeProcessed(@NotNull final SlashCommandInteractionEv return true; } - /** - * Displays the message that will be displayed for users. - *

- * This method detects if any messages have been posted in the channel below the status message. - * If that is the case this will delete the existing status message and post another one so that - * it's the last message in the channel. - *

- * If it cannot find an existing status message it will create a new one. - *

- * Otherwise it will edit the existing message. - * - * @param channel the text channel the status message will be posted in. - */ - public void displayStatus(@NotNull TextChannel channel) { - final Guild guild = channel.getGuild(); - - String messageTxt = buildStatusMessage(guild); - MessageEmbed embed = new EmbedBuilder().setTitle(STATUS_TITLE) - .setDescription(messageTxt) - .setFooter(channel.getJDA().getSelfUser().getName()) - .setTimestamp(Instant.now()) - .setColor(MESSAGE_HIGHLIGHT_COLOR) - .build(); - - getStatusMessageIn(channel).flatMap(this::deleteIfNotLatest) - .ifPresentOrElse(message -> message.editMessageEmbeds(embed).queue(), - () -> channel.sendMessageEmbeds(embed) - .queue(message -> channelIdToMessageIdForStatus.put(channel.getIdLong(), - message.getIdLong()))); - } - - private @NotNull Optional deleteIfNotLatest(@NotNull Message message) { - - OptionalLong lastId = FreeUtil.getLastMessageId(message.getTextChannel()); - if (lastId.isPresent() && lastId.getAsLong() != message.getIdLong()) { - message.delete().queue(); - return Optional.empty(); - } - - return Optional.of(message); - } - private void checkBusyStatusAllChannels(@NotNull JDA jda) { channelMonitor.guildIds() .map(id -> requiresGuild(jda, id)) - .forEach(channelMonitor::updateStatusFor); + .forEach(channelMonitor::freeInactiveChannels); } private @NotNull Guild requiresGuild(@NotNull JDA jda, long id) { @@ -255,29 +208,6 @@ private void checkBusyStatusAllChannels(@NotNull JDA jda) { return guild; } - /** - * Method for creating the message that shows the channel statuses for the specified guild. - *

- * This method dynamically builds the status message as per the current values on the guild, - * including the channel categories. This method will detect any changes made on the guild and - * represent those changes in the status message. - * - * @param guild the guild that the message is required for. - * @return the message to display showing the channel statuses. Includes Discord specific - * formatting, trying to display elsewhere may have unpredictable results. - * @throws IllegalArgumentException if the guild passed in is not configured in the free command - * system, see {@link ChannelMonitor#addChannelForStatus(TextChannel)}. - */ - public @NotNull String buildStatusMessage(@NotNull Guild guild) { - if (!channelMonitor.isMonitoringGuild(guild.getIdLong())) { - throw new IllegalArgumentException( - "The guild '%s(%s)' is not configured in the free command system" - .formatted(guild.getName(), guild.getIdLong())); - } - - return channelMonitor.statusMessage(guild); - } - /** * Method for responding to 'onGuildMessageReceived' this will need to be replaced by a more * appropriate method when the bot has more functionality. @@ -288,8 +218,8 @@ private void checkBusyStatusAllChannels(@NotNull JDA jda) { * @param event the generic event that includes the 'onGuildMessageReceived'. */ @SuppressWarnings("squid:S2583") // False-positive about the if-else-instanceof, sonar thinks - // the second case is unreachable; but it passes without - // pattern-matching. Probably a bug in SonarLint with Java 17. + // the second case is unreachable; but it passes without + // pattern-matching. Probably a bug in SonarLint with Java 17. @Override public void onEvent(@NotNull GenericEvent event) { if (event instanceof ReadyEvent readyEvent) { @@ -316,37 +246,11 @@ public void onEvent(@NotNull GenericEvent event) { } channelMonitor.setChannelBusy(messageEvent.getChannel().getIdLong(), messageEvent.getAuthor().getIdLong()); - displayStatus(channelMonitor.getStatusChannelFor(messageEvent.getGuild())); + channelMonitor.displayStatus(messageEvent.getGuild()); messageEvent.getMessage().reply(UserStrings.NEW_QUESTION.message()).queue(); } } - private @NotNull Optional getStatusMessageIn(@NotNull TextChannel channel) { - if (!channelIdToMessageIdForStatus.containsKey(channel.getIdLong())) { - return findExistingStatusMessage(channel); - } - return Optional.ofNullable(channelIdToMessageIdForStatus.get(channel.getIdLong())) - .map(channel::retrieveMessageById) - .map(RestAction::complete); - } - - private @NotNull Optional findExistingStatusMessage(@NotNull TextChannel channel) { - // will only run when bot starts, afterwards its stored in a map - - Optional statusMessage = FreeUtil - .getChannelHistory(channel, FreeCommandConfig.MESSAGE_RETRIEVE_LIMIT) - .flatMap(history -> history.stream() - .filter(message -> !message.getEmbeds().isEmpty()) - .filter(message -> message.getAuthor().equals(channel.getJDA().getSelfUser())) - // TODO the equals is not working, i believe its because there is no getTitleRaw() - // .filter(message -> STATUS_TITLE.equals(message.getEmbeds().get(0).getTitle())) - .findFirst()); - - channelIdToMessageIdForStatus.put(channel.getIdLong(), - statusMessage.map(Message::getIdLong).orElse(null)); - return statusMessage; - } - private void initChannelsToMonitor() { config.getFreeCommandConfig() .stream() diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/free/FreeUtil.java b/application/src/main/java/org/togetherjava/tjbot/commands/free/FreeUtil.java index 0ab0711a4c..bf61ad7bc8 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/free/FreeUtil.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/free/FreeUtil.java @@ -3,13 +3,10 @@ import net.dv8tion.jda.api.entities.Message; import net.dv8tion.jda.api.entities.TextChannel; import net.dv8tion.jda.api.interactions.callbacks.IReplyCallback; -import net.dv8tion.jda.api.utils.TimeUtil; import org.jetbrains.annotations.NotNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.togetherjava.tjbot.config.FreeCommandConfig; -import java.time.OffsetDateTime; import java.util.List; import java.util.Optional; import java.util.OptionalLong; @@ -65,7 +62,6 @@ public static void sendErrorMessage(@NotNull IReplyCallback interaction, * @return the id of the latest message or empty if it could not be retrieved. */ public static @NotNull OptionalLong getLastMessageId(@NotNull TextChannel channel) { - // black magic to convert Optional into OptionalLong because Optional does not have // .mapToLong return getChannelHistory(channel, 1).stream() @@ -73,26 +69,4 @@ public static void sendErrorMessage(@NotNull IReplyCallback interaction, .mapToLong(Message::getIdLong) .findFirst(); } - - /** - * Method that returns the time data from a discord snowflake. - * - * @param id the snowflake containing the time desired - * @return the creation time of the entity the id represents - */ - public static @NotNull OffsetDateTime timeFromId(long id) { - return TimeUtil.getTimeCreated(id); - } - - /** - * Method that calculates a time value a specific duration before now. The duration is - * configured in {@link FreeCommandConfig#INACTIVE_UNIT} and - * {@link FreeCommandConfig#INACTIVE_DURATION}. - * - * @return the time value a set duration before now. - */ - public static @NotNull OffsetDateTime inactiveTimeLimit() { - return OffsetDateTime.now() - .minus(FreeCommandConfig.INACTIVE_DURATION, FreeCommandConfig.INACTIVE_UNIT); - } } diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/free/UserStrings.java b/application/src/main/java/org/togetherjava/tjbot/commands/free/UserStrings.java index 8f8b619151..b10db9bf10 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/free/UserStrings.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/free/UserStrings.java @@ -18,6 +18,10 @@ enum UserStrings { MARK_AS_FREE(""" This channel is now available for a question to be asked. """), + AUTO_MARK_AS_FREE( + """ + This channel seems to be inactive and was now marked available for a question to be asked. + """), ALREADY_FREE_ERROR(""" This channel is already free, no changes made. """), diff --git a/application/src/main/java/org/togetherjava/tjbot/config/Config.java b/application/src/main/java/org/togetherjava/tjbot/config/Config.java index 0581e329cf..a6deae76f7 100644 --- a/application/src/main/java/org/togetherjava/tjbot/config/Config.java +++ b/application/src/main/java/org/togetherjava/tjbot/config/Config.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import org.jetbrains.annotations.NotNull; import java.io.IOException; @@ -70,7 +71,8 @@ private Config(@JsonProperty("token") String token, * @throws IOException if the file could not be loaded */ public static Config load(Path path) throws IOException { - return new ObjectMapper().readValue(path.toFile(), Config.class); + return new ObjectMapper().registerModule(new JavaTimeModule()) + .readValue(path.toFile(), Config.class); } /** diff --git a/application/src/main/java/org/togetherjava/tjbot/config/FreeCommandConfig.java b/application/src/main/java/org/togetherjava/tjbot/config/FreeCommandConfig.java index e1cc6bd01c..03fab031c4 100644 --- a/application/src/main/java/org/togetherjava/tjbot/config/FreeCommandConfig.java +++ b/application/src/main/java/org/togetherjava/tjbot/config/FreeCommandConfig.java @@ -5,7 +5,7 @@ import com.fasterxml.jackson.annotation.JsonRootName; import org.jetbrains.annotations.NotNull; -import java.time.temporal.ChronoUnit; +import java.time.Duration; import java.util.Collection; import java.util.Collections; import java.util.List; @@ -19,6 +19,8 @@ *

  * "freeCommand": [
  *   {
+ *       "inactiveChannelDuration": duration,
+ *       "messageRetrieveLimit": int_number,
  *       "statusChannel": long_number,
  *       "monitoredChannels": [long_number, long_number]
  *   }]
@@ -31,21 +33,20 @@
 @SuppressWarnings("ClassCanBeRecord")
 @JsonRootName("freeCommand")
 public final class FreeCommandConfig {
-    // TODO make constants configurable via config file once config templating (#234) is pushed
-    public static final long INACTIVE_DURATION = 1;
-    public static final ChronoUnit INACTIVE_UNIT = ChronoUnit.HOURS;
-    public static final long INACTIVE_TEST_INTERVAL = 15;
-    public static final ChronoUnit INACTIVE_TEST_UNIT = ChronoUnit.MINUTES;
-    public static final int MESSAGE_RETRIEVE_LIMIT = 10;
-
     private final long statusChannel;
     private final List monitoredChannels;
+    private final Duration inactiveChannelDuration;
+    private final int messageRetrieveLimit;
 
     @JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
     private FreeCommandConfig(@JsonProperty("statusChannel") long statusChannel,
-            @JsonProperty("monitoredChannels") List monitoredChannels) {
+            @JsonProperty("monitoredChannels") List monitoredChannels,
+            @JsonProperty("inactiveChannelDuration") Duration inactiveChannelDuration,
+            @JsonProperty("messageRetrieveLimit") int messageRetrieveLimit) {
         this.statusChannel = statusChannel;
         this.monitoredChannels = Collections.unmodifiableList(monitoredChannels);
+        this.messageRetrieveLimit = messageRetrieveLimit;
+        this.inactiveChannelDuration = inactiveChannelDuration;
     }
 
     /**
@@ -66,4 +67,22 @@ public long getStatusChannel() {
     public @NotNull Collection getMonitoredChannels() {
         return monitoredChannels; // already unmodifiable
     }
+
+    /**
+     * Gets the duration of inactivity after which a channel is considered inactive.
+     * 
+     * @return inactivity duration
+     */
+    public @NotNull Duration getInactiveChannelDuration() {
+        return inactiveChannelDuration;
+    }
+
+    /**
+     * Gets the limit of messages to retrieve when searching for previous status messages.
+     * 
+     * @return the message retrieve limit
+     */
+    public int getMessageRetrieveLimit() {
+        return messageRetrieveLimit;
+    }
 }

From 755cc4a9fc9085978408cc766ac393463bc297fc Mon Sep 17 00:00:00 2001
From: Zabuzard 
Date: Thu, 5 May 2022 10:17:09 +0200
Subject: [PATCH 08/11] javadoc linkfix (CR tais)

---
 .../togetherjava/tjbot/commands/free/FreeChannelMonitor.java    | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/free/FreeChannelMonitor.java b/application/src/main/java/org/togetherjava/tjbot/commands/free/FreeChannelMonitor.java
index faa2d9abd7..0f74b8ac48 100644
--- a/application/src/main/java/org/togetherjava/tjbot/commands/free/FreeChannelMonitor.java
+++ b/application/src/main/java/org/togetherjava/tjbot/commands/free/FreeChannelMonitor.java
@@ -269,7 +269,7 @@ public String statusMessage(@NotNull final Guild guild) {
      * {@link FreeChannelMonitor#isChannelInactive(TextChannel, Instant)}.
      * 

* This method is run automatically during startup and on a set schedule, as defined in - * {@link org.togetherjava.tjbot.config.FreeCommandConfig}. + * {@link FreeCommandConfig}. * * @param guild the guild for which to test the channel statuses of. * @return all inactive channels that have been updated From b8a270d8f0f7762fd9fb5ce6978d6dbe10ab71a8 Mon Sep 17 00:00:00 2001 From: Zabuzard Date: Tue, 3 May 2022 15:33:21 +0200 Subject: [PATCH 09/11] Bugfix with remind-command mixing reminders across guilds --- .../commands/reminder/RemindCommand.java | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/reminder/RemindCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/reminder/RemindCommand.java index 664bd69306..ef1128f035 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/reminder/RemindCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/reminder/RemindCommand.java @@ -1,5 +1,6 @@ package org.togetherjava.tjbot.commands.reminder; +import net.dv8tion.jda.api.entities.Guild; import net.dv8tion.jda.api.entities.ISnowflake; import net.dv8tion.jda.api.entities.User; import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; @@ -25,7 +26,7 @@ * *

  * {@code
- * /remind amount: 5 unit: weeks content: Hello World!
+ * /remind time-amount: 5 time-unit: weeks content: Hello World!
  * }
  * 
*

@@ -33,16 +34,16 @@ */ public final class RemindCommand extends SlashCommandAdapter { private static final String COMMAND_NAME = "remind"; - private static final String TIME_AMOUNT_OPTION = "time-amount"; - private static final String TIME_UNIT_OPTION = "time-unit"; - private static final String CONTENT_OPTION = "content"; + static final String TIME_AMOUNT_OPTION = "time-amount"; + static final String TIME_UNIT_OPTION = "time-unit"; + static final String CONTENT_OPTION = "content"; private static final int MIN_TIME_AMOUNT = 1; private static final int MAX_TIME_AMOUNT = 1_000; private static final List TIME_UNITS = List.of("minutes", "hours", "days", "weeks", "months", "years"); private static final Period MAX_TIME_PERIOD = Period.ofYears(3); - private static final int MAX_PENDING_REMINDERS_PER_USER = 100; + static final int MAX_PENDING_REMINDERS_PER_USER = 100; private final Database database; @@ -78,11 +79,12 @@ public void onSlashCommand(@NotNull SlashCommandInteractionEvent event) { Instant remindAt = parseWhen(timeAmount, timeUnit); User author = event.getUser(); + Guild guild = event.getGuild(); if (!handleIsRemindAtWithinLimits(remindAt, event)) { return; } - if (!handleIsUserBelowMaxPendingReminders(author, event)) { + if (!handleIsUserBelowMaxPendingReminders(author, guild, event)) { return; } @@ -92,7 +94,7 @@ public void onSlashCommand(@NotNull SlashCommandInteractionEvent event) { database.write(context -> context.newRecord(PENDING_REMINDERS) .setCreatedAt(Instant.now()) - .setGuildId(event.getGuild().getIdLong()) + .setGuildId(guild.getIdLong()) .setChannelId(event.getChannel().getIdLong()) .setAuthorId(author.getIdLong()) .setRemindAt(remindAt) @@ -133,9 +135,10 @@ private static boolean handleIsRemindAtWithinLimits(@NotNull Instant remindAt, } private boolean handleIsUserBelowMaxPendingReminders(@NotNull ISnowflake author, - @NotNull IReplyCallback event) { + @NotNull ISnowflake guild, @NotNull IReplyCallback event) { int pendingReminders = database.read(context -> context.fetchCount(PENDING_REMINDERS, - PENDING_REMINDERS.AUTHOR_ID.equal(author.getIdLong()))); + PENDING_REMINDERS.AUTHOR_ID.equal(author.getIdLong()) + .and(PENDING_REMINDERS.GUILD_ID.equal(guild.getIdLong())))); if (pendingReminders < MAX_PENDING_REMINDERS_PER_USER) { return true; From b98f37f4f5d41bf8b6d6720d56f7956af25c7550 Mon Sep 17 00:00:00 2001 From: Zabuzard Date: Tue, 3 May 2022 15:33:53 +0200 Subject: [PATCH 10/11] Enhanced JDA mocking test suite * support for `OptionType.INTEGER` in slash command event builder * `getMemberSpy()` * `getJdaMock()` * `createTextChannelSpy` * `getPrivateChannelSpy()` and mocking of private channel in general * `map` and `flatMap` support for `succeeded` and `failed` actions --- .../org/togetherjava/tjbot/jda/JdaTester.java | 103 +++++++++++++++++- .../SlashCommandInteractionEventBuilder.java | 30 ++++- 2 files changed, 129 insertions(+), 4 deletions(-) diff --git a/application/src/test/java/org/togetherjava/tjbot/jda/JdaTester.java b/application/src/test/java/org/togetherjava/tjbot/jda/JdaTester.java index 2b07ba9c50..3566102b23 100644 --- a/application/src/test/java/org/togetherjava/tjbot/jda/JdaTester.java +++ b/application/src/test/java/org/togetherjava/tjbot/jda/JdaTester.java @@ -1,6 +1,7 @@ package org.togetherjava.tjbot.jda; import net.dv8tion.jda.api.AccountType; +import net.dv8tion.jda.api.JDA; import net.dv8tion.jda.api.Permission; import net.dv8tion.jda.api.entities.*; import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; @@ -38,6 +39,7 @@ import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledThreadPoolExecutor; import java.util.function.Consumer; +import java.util.function.Function; import java.util.function.Supplier; import java.util.function.UnaryOperator; @@ -130,6 +132,7 @@ public JdaTester() { doReturn(APPLICATION_ID).when(selfUser).getApplicationIdLong(); doReturn(selfUser).when(jda).getSelfUser(); when(jda.getGuildChannelById(anyLong())).thenReturn(textChannel); + when(jda.getTextChannelById(anyLong())).thenReturn(textChannel); when(jda.getPrivateChannelById(anyLong())).thenReturn(privateChannel); when(jda.getGuildById(anyLong())).thenReturn(guild); when(jda.getEntityBuilder()).thenReturn(entityBuilder); @@ -140,8 +143,6 @@ public JdaTester() { doReturn(new Requester(jda, new AuthorizationConfig(TEST_TOKEN))).when(jda).getRequester(); when(jda.getAccountType()).thenReturn(AccountType.BOT); - doReturn(messageAction).when(privateChannel).sendMessage(anyString()); - replyAction = mock(ReplyCallbackActionImpl.class); when(replyAction.setEphemeral(anyBoolean())).thenReturn(replyAction); when(replyAction.addActionRow(anyCollection())).thenReturn(replyAction); @@ -155,7 +156,6 @@ public JdaTester() { auditableRestAction = (AuditableRestActionImpl) mock(AuditableRestActionImpl.class); doNothing().when(auditableRestAction).queue(); - doNothing().when(messageAction).queue(); doNothing().when(webhookMessageUpdateAction).queue(); doReturn(webhookMessageUpdateAction).when(webhookMessageUpdateAction) .setActionRow(any(ItemComponent.class)); @@ -164,6 +164,9 @@ public JdaTester() { doReturn(selfMember).when(guild).getMember(selfUser); doReturn(member).when(guild).getMember(not(eq(selfUser))); + RestAction userAction = createSucceededActionMock(member.getUser()); + when(jda.retrieveUserById(anyLong())).thenReturn(userAction); + doReturn(null).when(textChannel).retrieveMessageById(any()); interactionHook = mock(InteractionHook.class); @@ -172,6 +175,20 @@ public JdaTester() { .thenReturn(webhookMessageUpdateAction); when(interactionHook.editOriginal(any(byte[].class), any(), any())) .thenReturn(webhookMessageUpdateAction); + + doReturn(messageAction).when(textChannel).sendMessageEmbeds(any(), any()); + doReturn(messageAction).when(textChannel).sendMessageEmbeds(any()); + + doNothing().when(messageAction).queue(); + when(messageAction.content(any())).thenReturn(messageAction); + + RestAction privateChannelAction = createSucceededActionMock(privateChannel); + when(jda.openPrivateChannelById(anyLong())).thenReturn(privateChannelAction); + when(jda.openPrivateChannelById(anyString())).thenReturn(privateChannelAction); + doReturn(null).when(privateChannel).retrieveMessageById(any()); + doReturn(messageAction).when(privateChannel).sendMessage(anyString()); + doReturn(messageAction).when(privateChannel).sendMessageEmbeds(any(), any()); + doReturn(messageAction).when(privateChannel).sendMessageEmbeds(any()); } /** @@ -246,6 +263,8 @@ public JdaTester() { /** * Creates a Mockito spy for a member with the given user id. + *

+ * See {@link #getMemberSpy()} to get the default member used by this tester. * * @param userId the id of the member to create * @return the created spy @@ -255,6 +274,18 @@ public JdaTester() { return spy(new MemberImpl(guild, user)); } + /** + * Creates a Mockito spy for a text channel with the given channel id. + *

+ * See {@link #getTextChannelSpy()} to get the default text channel used by this tester. + * + * @param channelId the id of the text channel to create + * @return the created spy + */ + public @NotNull TextChannel createTextChannelSpy(long channelId) { + return spy(new TextChannelImpl(channelId, guild)); + } + /** * Gets the Mockito mock used as universal reply action by all mocks created by this tester * instance. @@ -294,6 +325,42 @@ public JdaTester() { return textChannel; } + /** + * Gets the private channel spy used as universal private channel by all mocks created by this + * tester instance. + *

+ * For example {@link JDA#openPrivateChannelById(long)} will return this spy if used on the + * instance returned by {@link #getJdaMock()}. + * + * @return the private channel spy used by this tester + */ + public @NotNull PrivateChannel getPrivateChannelSpy() { + return privateChannel; + } + + /** + * Gets the member spy used as universal member by all mocks created by this tester instance. + *

+ * For example the events created by {@link #createSlashCommandInteractionEvent(SlashCommand)} + * will return this spy on several of their methods. + *

+ * See {@link #createMemberSpy(long)} to create other members. + * + * @return the member spy used by this tester + */ + public @NotNull Member getMemberSpy() { + return member; + } + + /** + * Gets the JDA mock used as universal instance by all mocks created by this tester instance. + * + * @return the JDA mock used by this tester + */ + public @NotNull JDA getJdaMock() { + return jda; + } + /** * Creates a mocked action that always succeeds and consumes the given object. *

@@ -325,11 +392,25 @@ public JdaTester() { successConsumer.accept(t); return null; }; + Answer> mapExecution = invocation -> { + Function mapFunction = invocation.getArgument(0); + Object result = mapFunction.apply(t); + return createSucceededActionMock(result); + }; + Answer> flatMapExecution = invocation -> { + Function> flatMapFunction = invocation.getArgument(0); + return flatMapFunction.apply(t); + }; doNothing().when(action).queue(); doAnswer(successExecution).when(action).queue(any()); doAnswer(successExecution).when(action).queue(any(), any()); + when(action.onErrorMap(any())).thenReturn(action); + when(action.onErrorMap(any(), any())).thenReturn(action); + + doAnswer(mapExecution).when(action).map(any()); + doAnswer(flatMapExecution).when(action).flatMap(any()); return action; } @@ -366,11 +447,27 @@ public JdaTester() { return null; }; + Answer> errorMapExecution = invocation -> { + Function mapFunction = invocation.getArgument(0); + Object result = mapFunction.apply(failureReason); + return createSucceededActionMock(result); + }; + + Answer> mapExecution = invocation -> createFailedActionMock(failureReason); + Answer> flatMapExecution = + invocation -> createFailedActionMock(failureReason); + doNothing().when(action).queue(); doNothing().when(action).queue(any()); + doAnswer(errorMapExecution).when(action).onErrorMap(any()); + doAnswer(errorMapExecution).when(action).onErrorMap(any(), any()); + doAnswer(failureExecution).when(action).queue(any(), any()); + doAnswer(mapExecution).when(action).map(any()); + doAnswer(flatMapExecution).when(action).flatMap(any()); + return action; } diff --git a/application/src/test/java/org/togetherjava/tjbot/jda/SlashCommandInteractionEventBuilder.java b/application/src/test/java/org/togetherjava/tjbot/jda/SlashCommandInteractionEventBuilder.java index ec414445dd..4f4323944d 100644 --- a/application/src/test/java/org/togetherjava/tjbot/jda/SlashCommandInteractionEventBuilder.java +++ b/application/src/test/java/org/togetherjava/tjbot/jda/SlashCommandInteractionEventBuilder.java @@ -2,9 +2,9 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; -import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; import net.dv8tion.jda.api.entities.Member; import net.dv8tion.jda.api.entities.User; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; import net.dv8tion.jda.api.interactions.commands.OptionType; import net.dv8tion.jda.api.interactions.commands.build.OptionData; import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; @@ -103,6 +103,26 @@ public final class SlashCommandInteractionEventBuilder { return this; } + /** + * Sets the given option, overriding an existing value under the same name. + *

+ * If {@link #setSubcommand(String)} is set, this option will be interpreted as option to the + * subcommand. + *

+ * Use {@link #clearOptions()} to clear any set options. + * + * @param name the name of the option + * @param value the value of the option + * @return this builder instance for chaining + * @throws IllegalArgumentException if the option does not exist in the corresponding command, + * as specified by its {@link SlashCommand#getData()} + */ + public @NotNull SlashCommandInteractionEventBuilder setOption(@NotNull String name, + long value) { + putOptionRaw(name, value, OptionType.INTEGER); + return this; + } + /** * Sets the given option, overriding an existing value under the same name. *

@@ -292,6 +312,14 @@ private SlashCommandInteractionEvent spySlashCommandEvent(String jsonData) { @NotNull OptionType type) { if (type == OptionType.STRING) { return (String) value; + } else if (type == OptionType.INTEGER) { + if (value instanceof Long asLong) { + return value.toString(); + } + + throw new IllegalArgumentException( + "Expected a long, since the type was set to INTEGER. But got '%s'" + .formatted(value.getClass())); } else if (type == OptionType.USER) { if (value instanceof User user) { return user.getId(); From 0e1cfb5b6e2b74de956aab9c2286fa3d48dcddc8 Mon Sep 17 00:00:00 2001 From: Zabuzard Date: Mon, 9 May 2022 13:07:44 +0200 Subject: [PATCH 11/11] Unit tests for /remind --- .../commands/reminder/RemindRoutine.java | 2 +- .../reminder/RawReminderTestHelper.java | 67 +++++++ .../commands/reminder/RemindCommandTest.java | 137 +++++++++++++ .../commands/reminder/RemindRoutineTest.java | 181 ++++++++++++++++++ .../org/togetherjava/tjbot/jda/JdaTester.java | 2 + 5 files changed, 388 insertions(+), 1 deletion(-) create mode 100644 application/src/test/java/org/togetherjava/tjbot/commands/reminder/RawReminderTestHelper.java create mode 100644 application/src/test/java/org/togetherjava/tjbot/commands/reminder/RemindCommandTest.java create mode 100644 application/src/test/java/org/togetherjava/tjbot/commands/reminder/RemindRoutineTest.java diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/reminder/RemindRoutine.java b/application/src/main/java/org/togetherjava/tjbot/commands/reminder/RemindRoutine.java index e232ae1519..563231afb3 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/reminder/RemindRoutine.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/reminder/RemindRoutine.java @@ -27,7 +27,7 @@ * Reminders can be set by using {@link RemindCommand}. */ public final class RemindRoutine implements Routine { - private static final Logger logger = LoggerFactory.getLogger(RemindRoutine.class); + static final Logger logger = LoggerFactory.getLogger(RemindRoutine.class); private static final Color AMBIENT_COLOR = Color.decode("#F7F492"); private static final int SCHEDULE_INTERVAL_SECONDS = 30; private final Database database; diff --git a/application/src/test/java/org/togetherjava/tjbot/commands/reminder/RawReminderTestHelper.java b/application/src/test/java/org/togetherjava/tjbot/commands/reminder/RawReminderTestHelper.java new file mode 100644 index 0000000000..14125f5b76 --- /dev/null +++ b/application/src/test/java/org/togetherjava/tjbot/commands/reminder/RawReminderTestHelper.java @@ -0,0 +1,67 @@ +package org.togetherjava.tjbot.commands.reminder; + +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.TextChannel; +import org.jetbrains.annotations.NotNull; +import org.togetherjava.tjbot.db.Database; +import org.togetherjava.tjbot.db.generated.Tables; +import org.togetherjava.tjbot.db.generated.tables.records.PendingRemindersRecord; +import org.togetherjava.tjbot.jda.JdaTester; + +import java.time.Instant; +import java.util.List; + +import static org.togetherjava.tjbot.db.generated.tables.PendingReminders.PENDING_REMINDERS; + +final class RawReminderTestHelper { + private Database database; + private JdaTester jdaTester; + + RawReminderTestHelper(@NotNull Database database, @NotNull JdaTester jdaTester) { + this.database = database; + this.jdaTester = jdaTester; + } + + void insertReminder(@NotNull String content, @NotNull Instant remindAt) { + insertReminder(content, remindAt, jdaTester.getMemberSpy(), jdaTester.getTextChannelSpy()); + } + + void insertReminder(@NotNull String content, @NotNull Instant remindAt, + @NotNull Member author) { + insertReminder(content, remindAt, author, jdaTester.getTextChannelSpy()); + } + + void insertReminder(@NotNull String content, @NotNull Instant remindAt, @NotNull Member author, + @NotNull TextChannel channel) { + long channelId = channel.getIdLong(); + long guildId = channel.getGuild().getIdLong(); + long authorId = author.getIdLong(); + + database.write(context -> context.newRecord(Tables.PENDING_REMINDERS) + .setCreatedAt(Instant.now()) + .setGuildId(guildId) + .setChannelId(channelId) + .setAuthorId(authorId) + .setRemindAt(remindAt) + .setContent(content) + .insert()); + } + + @NotNull + List readReminders() { + return readReminders(jdaTester.getMemberSpy()); + } + + @NotNull + List readReminders(@NotNull Member author) { + long guildId = jdaTester.getTextChannelSpy().getGuild().getIdLong(); + long authorId = author.getIdLong(); + + return database.read(context -> context.selectFrom(PENDING_REMINDERS) + .where(PENDING_REMINDERS.AUTHOR_ID.eq(authorId) + .and(PENDING_REMINDERS.GUILD_ID.eq(guildId))) + .stream() + .map(PendingRemindersRecord::getContent) + .toList()); + } +} diff --git a/application/src/test/java/org/togetherjava/tjbot/commands/reminder/RemindCommandTest.java b/application/src/test/java/org/togetherjava/tjbot/commands/reminder/RemindCommandTest.java new file mode 100644 index 0000000000..697e96853c --- /dev/null +++ b/application/src/test/java/org/togetherjava/tjbot/commands/reminder/RemindCommandTest.java @@ -0,0 +1,137 @@ +package org.togetherjava.tjbot.commands.reminder; + +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.function.Executable; +import org.togetherjava.tjbot.commands.SlashCommand; +import org.togetherjava.tjbot.db.Database; +import org.togetherjava.tjbot.jda.JdaTester; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.startsWith; +import static org.mockito.Mockito.verify; +import static org.togetherjava.tjbot.db.generated.tables.PendingReminders.PENDING_REMINDERS; + +final class RemindCommandTest { + private SlashCommand command; + private JdaTester jdaTester; + private RawReminderTestHelper rawReminders; + + @BeforeEach + void setUp() { + Database database = Database.createMemoryDatabase(PENDING_REMINDERS); + command = new RemindCommand(database); + jdaTester = new JdaTester(); + rawReminders = new RawReminderTestHelper(database, jdaTester); + } + + private @NotNull SlashCommandInteractionEvent triggerSlashCommand(int timeAmount, + @NotNull String timeUnit, @NotNull String content) { + return triggerSlashCommand(timeAmount, timeUnit, content, jdaTester.getMemberSpy()); + } + + private @NotNull SlashCommandInteractionEvent triggerSlashCommand(int timeAmount, + @NotNull String timeUnit, @NotNull String content, @NotNull Member author) { + SlashCommandInteractionEvent event = jdaTester.createSlashCommandInteractionEvent(command) + .setOption(RemindCommand.TIME_AMOUNT_OPTION, timeAmount) + .setOption(RemindCommand.TIME_UNIT_OPTION, timeUnit) + .setOption(RemindCommand.CONTENT_OPTION, content) + .setUserWhoTriggered(author) + .build(); + + command.onSlashCommand(event); + return event; + } + + @Test + @DisplayName("Throws an exception if the time unit is not supported, i.e. not part of the actual choice dialog") + void throwsWhenGivenUnsupportedUnit() { + // GIVEN + // WHEN triggering /remind with the unsupported time unit 'nanoseconds' + Executable triggerRemind = () -> triggerSlashCommand(10, "nanoseconds", "foo"); + + // THEN command throws, no reminder was created + Assertions.assertThrows(IllegalArgumentException.class, triggerRemind); + assertTrue(rawReminders.readReminders().isEmpty()); + } + + @Test + @DisplayName("Rejects a reminder time that is set too far in the future and responds accordingly") + void doesNotSupportDatesTooFarInFuture() { + // GIVEN + // WHEN triggering /remind too far in the future + SlashCommandInteractionEvent event = triggerSlashCommand(10, "years", "foo"); + + // THEN rejects and responds accordingly, no reminder was created + verify(event).reply(startsWith("The reminder is set too far in the future")); + assertTrue(rawReminders.readReminders().isEmpty()); + } + + @Test + @DisplayName("Rejects a reminder if a user has too many reminders still pending") + void userIsLimitedIfTooManyPendingReminders() { + // GIVEN a user with too many reminders still pending + Instant remindAt = Instant.now().plus(100, ChronoUnit.DAYS); + for (int i = 0; i < RemindCommand.MAX_PENDING_REMINDERS_PER_USER; i++) { + rawReminders.insertReminder("foo " + i, remindAt); + } + + // WHEN triggering another reminder + SlashCommandInteractionEvent event = triggerSlashCommand(5, "minutes", "foo"); + + // THEN rejects and responds accordingly, no new reminder was created + verify(event) + .reply(startsWith("You have reached the maximum amount of pending reminders per user")); + assertEquals(RemindCommand.MAX_PENDING_REMINDERS_PER_USER, + rawReminders.readReminders().size()); + } + + @Test + @DisplayName("Does not limit a user if another user has too many reminders still pending, i.e. the limit is per user") + void userIsNotLimitedIfOtherUserHasTooManyPendingReminders() { + // GIVEN a user with too many reminders still pending, + // and a second user with no reminders yet + Member firstUser = jdaTester.createMemberSpy(1); + Instant remindAt = Instant.now().plus(100, ChronoUnit.DAYS); + for (int i = 0; i < RemindCommand.MAX_PENDING_REMINDERS_PER_USER; i++) { + rawReminders.insertReminder("foo " + i, remindAt, firstUser); + } + + Member secondUser = jdaTester.createMemberSpy(2); + + // WHEN the second user triggers another reminder + SlashCommandInteractionEvent event = triggerSlashCommand(5, "minutes", "foo", secondUser); + + // THEN accepts the reminder and responds accordingly + verify(event).reply("Will remind you about 'foo' in 5 minutes."); + + List remindersOfSecondUser = rawReminders.readReminders(secondUser); + assertEquals(1, remindersOfSecondUser.size()); + assertEquals("foo", remindersOfSecondUser.get(0)); + } + + @Test + @DisplayName("The command can create a reminder, the regular base case") + void canCreateReminders() { + // GIVEN + // WHEN triggering the /remind command + SlashCommandInteractionEvent event = triggerSlashCommand(5, "minutes", "foo"); + + // THEN accepts the reminder and responds accordingly + verify(event).reply("Will remind you about 'foo' in 5 minutes."); + + List pendingReminders = rawReminders.readReminders(); + assertEquals(1, pendingReminders.size()); + assertEquals("foo", pendingReminders.get(0)); + } +} diff --git a/application/src/test/java/org/togetherjava/tjbot/commands/reminder/RemindRoutineTest.java b/application/src/test/java/org/togetherjava/tjbot/commands/reminder/RemindRoutineTest.java new file mode 100644 index 0000000000..01271f94ab --- /dev/null +++ b/application/src/test/java/org/togetherjava/tjbot/commands/reminder/RemindRoutineTest.java @@ -0,0 +1,181 @@ +package org.togetherjava.tjbot.commands.reminder; + +import net.dv8tion.jda.api.entities.*; +import net.dv8tion.jda.api.requests.ErrorResponse; +import net.dv8tion.jda.api.requests.RestAction; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.ArgumentMatchers; +import org.togetherjava.tjbot.commands.Routine; +import org.togetherjava.tjbot.db.Database; +import org.togetherjava.tjbot.jda.JdaTester; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.*; +import static org.togetherjava.tjbot.db.generated.tables.PendingReminders.PENDING_REMINDERS; + +final class RemindRoutineTest { + private Routine routine; + private JdaTester jdaTester; + private RawReminderTestHelper rawReminders; + + @BeforeEach + void setUp() { + Database database = Database.createMemoryDatabase(PENDING_REMINDERS); + routine = new RemindRoutine(database); + jdaTester = new JdaTester(); + rawReminders = new RawReminderTestHelper(database, jdaTester); + } + + private void triggerRoutine() { + routine.runRoutine(jdaTester.getJdaMock()); + } + + private static @NotNull MessageEmbed getLastMessageFrom(@NotNull MessageChannel channel) { + ArgumentCaptor responseCaptor = ArgumentCaptor.forClass(MessageEmbed.class); + verify(channel).sendMessageEmbeds(responseCaptor.capture()); + return responseCaptor.getValue(); + } + + private @NotNull Member createAndSetupUnknownMember() { + int unknownMemberId = 2; + + Member member = jdaTester.createMemberSpy(unknownMemberId); + + RestAction unknownMemberAction = jdaTester.createFailedActionMock( + jdaTester.createErrorResponseException(ErrorResponse.UNKNOWN_USER)); + when(jdaTester.getJdaMock().retrieveUserById(unknownMemberId)) + .thenReturn(unknownMemberAction); + + RestAction unknownPrivateChannelAction = jdaTester.createFailedActionMock( + jdaTester.createErrorResponseException(ErrorResponse.UNKNOWN_USER)); + when(jdaTester.getJdaMock().openPrivateChannelById(anyLong())) + .thenReturn(unknownPrivateChannelAction); + when(jdaTester.getJdaMock().openPrivateChannelById(anyString())) + .thenReturn(unknownPrivateChannelAction); + + return member; + } + + private @NotNull TextChannel createAndSetupUnknownChannel() { + long unknownChannelId = 2; + + TextChannel channel = jdaTester.createTextChannelSpy(unknownChannelId); + when(jdaTester.getJdaMock() + .getChannelById(ArgumentMatchers.>any(), eq(unknownChannelId))) + .thenReturn(null); + + return channel; + } + + @Test + @DisplayName("Sends out a pending reminder to a guild channel, the base case") + void sendsPendingReminderChannelFoundAuthorFound() { + // GIVEN a pending reminder + Instant remindAt = Instant.now(); + String reminderContent = "foo"; + Member author = jdaTester.getMemberSpy(); + rawReminders.insertReminder("foo", remindAt, author); + + // WHEN running the routine + triggerRoutine(); + + // THEN the reminder is sent out and deleted from the database + assertTrue(rawReminders.readReminders().isEmpty()); + + MessageEmbed lastMessage = getLastMessageFrom(jdaTester.getTextChannelSpy()); + assertEquals(reminderContent, lastMessage.getDescription()); + assertSimilar(remindAt, lastMessage.getTimestamp().toInstant()); + assertEquals(author.getUser().getAsTag(), lastMessage.getAuthor().getName()); + } + + @Test + @DisplayName("Sends out a pending reminder to a guild channel, even if the author could not be retrieved anymore") + void sendsPendingReminderChannelFoundAuthorNotFound() { + // GIVEN a pending reminder from an unknown user + Instant remindAt = Instant.now(); + String reminderContent = "foo"; + Member unknownAuthor = createAndSetupUnknownMember(); + rawReminders.insertReminder("foo", remindAt, unknownAuthor); + + // WHEN running the routine + triggerRoutine(); + + // THEN the reminder is sent out and deleted from the database + assertTrue(rawReminders.readReminders().isEmpty()); + + MessageEmbed lastMessage = getLastMessageFrom(jdaTester.getTextChannelSpy()); + assertEquals(reminderContent, lastMessage.getDescription()); + assertSimilar(remindAt, lastMessage.getTimestamp().toInstant()); + assertEquals("Unknown user", lastMessage.getAuthor().getName()); + } + + @Test + @DisplayName("Sends out a pending reminder via DM, even if the channel could not be retrieved anymore") + void sendsPendingReminderChannelNotFoundAuthorFound() { + // GIVEN a pending reminder from an unknown channel + Instant remindAt = Instant.now(); + String reminderContent = "foo"; + Member author = jdaTester.getMemberSpy(); + TextChannel unknownChannel = createAndSetupUnknownChannel(); + rawReminders.insertReminder("foo", remindAt, author, unknownChannel); + + // WHEN running the routine + triggerRoutine(); + + // THEN the reminder is sent out and deleted from the database + assertTrue(rawReminders.readReminders().isEmpty()); + + MessageEmbed lastMessage = getLastMessageFrom(jdaTester.getPrivateChannelSpy()); + assertEquals(reminderContent, lastMessage.getDescription()); + assertSimilar(remindAt, lastMessage.getTimestamp().toInstant()); + assertEquals(author.getUser().getAsTag(), lastMessage.getAuthor().getName()); + } + + @Test + @DisplayName("Skips a pending reminder if sending it out resulted in an error") + void skipPendingReminderOnErrorChannelNotFoundAuthorNotFound() { + // GIVEN a pending reminder and from an unknown channel and author + Instant remindAt = Instant.now(); + String reminderContent = "foo"; + Member unknownAuthor = createAndSetupUnknownMember(); + TextChannel unknownChannel = createAndSetupUnknownChannel(); + rawReminders.insertReminder("foo", remindAt, unknownAuthor, unknownChannel); + + // WHEN running the routine + triggerRoutine(); + + // THEN the reminder is skipped and deleted from the database + assertTrue(rawReminders.readReminders().isEmpty()); + } + + @Test + @DisplayName("A reminder that is not pending yet, is not send out") + void reminderIsNotSendIfNotPending() { + // GIVEN a reminder that is not pending yet + Instant remindAt = Instant.now().plus(1, ChronoUnit.HOURS); + String reminderContent = "foo"; + rawReminders.insertReminder("foo", remindAt); + + // WHEN running the routine + triggerRoutine(); + + // THEN the reminder is not send yet and still in the database + assertEquals(1, rawReminders.readReminders().size()); + verify(jdaTester.getTextChannelSpy(), never()).sendMessageEmbeds(any(MessageEmbed.class)); + } + + private static void assertSimilar(@NotNull Instant expected, @NotNull Instant actual) { + // NOTE For some reason, the instant ends up in the database slightly wrong already (about + // half a second), seems to be an issue with jOOQ + assertEquals(expected.toEpochMilli(), actual.toEpochMilli(), TimeUnit.SECONDS.toMillis(1)); + } +} diff --git a/application/src/test/java/org/togetherjava/tjbot/jda/JdaTester.java b/application/src/test/java/org/togetherjava/tjbot/jda/JdaTester.java index 3566102b23..ab2428770e 100644 --- a/application/src/test/java/org/togetherjava/tjbot/jda/JdaTester.java +++ b/application/src/test/java/org/togetherjava/tjbot/jda/JdaTester.java @@ -133,6 +133,8 @@ public JdaTester() { doReturn(selfUser).when(jda).getSelfUser(); when(jda.getGuildChannelById(anyLong())).thenReturn(textChannel); when(jda.getTextChannelById(anyLong())).thenReturn(textChannel); + when(jda.getChannelById(ArgumentMatchers.>any(), anyLong())) + .thenReturn(textChannel); when(jda.getPrivateChannelById(anyLong())).thenReturn(privateChannel); when(jda.getGuildById(anyLong())).thenReturn(guild); when(jda.getEntityBuilder()).thenReturn(entityBuilder);