From 3cfd990b26ff1271a90c5fc7353de8d9a89520d0 Mon Sep 17 00:00:00 2001 From: Zabuzard Date: Tue, 15 Feb 2022 15:41:57 +0100 Subject: [PATCH 1/5] Replaced Config singleton with regular instance This is a quite simple change but it touches a lot of files. All of them now require the config as parameter instead of using global singleton access. This makes it possible to mock the Config (needed for the unit tests). --- .../org/togetherjava/tjbot/Application.java | 17 +++++----- .../togetherjava/tjbot/commands/Features.java | 34 ++++++++++--------- .../tjbot/commands/free/FreeCommand.java | 13 ++++--- .../commands/moderation/AuditCommand.java | 7 ++-- .../tjbot/commands/moderation/BanCommand.java | 7 ++-- .../commands/moderation/KickCommand.java | 6 ++-- .../commands/moderation/ModerationUtils.java | 23 ++++++++----- .../commands/moderation/MuteCommand.java | 17 ++++++---- .../moderation/RejoinMuteListener.java | 14 +++++--- .../commands/moderation/UnbanCommand.java | 7 ++-- .../commands/moderation/UnmuteCommand.java | 17 ++++++---- .../commands/moderation/WarnCommand.java | 7 ++-- .../temp/TemporaryModerationRoutine.java | 6 ++-- .../moderation/temp/TemporaryMuteAction.java | 13 ++++++- .../tjbot/commands/system/BotCore.java | 10 +++--- .../tjbot/commands/tags/TagManageCommand.java | 6 ++-- .../commands/tophelper/TopHelpersCommand.java | 6 ++-- .../tophelper/TopHelpersMessageListener.java | 5 +-- .../org/togetherjava/tjbot/config/Config.java | 31 ++++------------- .../tjbot/routines/ModAuditLogRoutine.java | 23 +++++++------ 20 files changed, 148 insertions(+), 121 deletions(-) diff --git a/application/src/main/java/org/togetherjava/tjbot/Application.java b/application/src/main/java/org/togetherjava/tjbot/Application.java index 7383bebb8d..e26f948db2 100644 --- a/application/src/main/java/org/togetherjava/tjbot/Application.java +++ b/application/src/main/java/org/togetherjava/tjbot/Application.java @@ -44,8 +44,9 @@ public static void main(final String[] args) { } Path configPath = Path.of(args.length == 1 ? args[0] : DEFAULT_CONFIG_PATH); + Config config; try { - Config.load(configPath); + config = Config.load(configPath); } catch (IOException e) { logger.error("Unable to load the configuration file from path '{}'", configPath.toAbsolutePath(), e); @@ -53,8 +54,7 @@ public static void main(final String[] args) { } try { - Config config = Config.getInstance(); - runBot(config.getToken(), Path.of(config.getDatabasePath())); + runBot(config); } catch (Exception t) { logger.error("Unknown error", t); } @@ -63,12 +63,13 @@ public static void main(final String[] args) { /** * Runs an instance of the bot, connecting to the given token and using the given database. * - * @param token the Discord Bot token to connect with - * @param databasePath the path to the database to use + * @param config the configuration to run the bot with */ @SuppressWarnings("WeakerAccess") - public static void runBot(String token, Path databasePath) { + public static void runBot(Config config) { logger.info("Starting bot..."); + + Path databasePath = Path.of(config.getDatabasePath()); try { Path parentDatabasePath = databasePath.toAbsolutePath().getParent(); if (parentDatabasePath != null) { @@ -76,10 +77,10 @@ public static void runBot(String token, Path databasePath) { } Database database = new Database("jdbc:sqlite:" + databasePath.toAbsolutePath()); - JDA jda = JDABuilder.createDefault(token) + JDA jda = JDABuilder.createDefault(config.getToken()) .enableIntents(GatewayIntent.GUILD_MEMBERS) .build(); - jda.addEventListener(new BotCore(jda, database)); + jda.addEventListener(new BotCore(jda, database, config)); jda.awaitReady(); logger.info("Bot is ready"); 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 94c8e74a90..bc7638a2ac 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/Features.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/Features.java @@ -17,6 +17,7 @@ import org.togetherjava.tjbot.commands.tophelper.TopHelpersCommand; import org.togetherjava.tjbot.commands.tophelper.TopHelpersMessageListener; import org.togetherjava.tjbot.commands.tophelper.TopHelpersPurgeMessagesRoutine; +import org.togetherjava.tjbot.config.Config; import org.togetherjava.tjbot.db.Database; import org.togetherjava.tjbot.routines.ModAuditLogRoutine; @@ -29,7 +30,7 @@ * it with the system. *

* To add a new slash command, extend the commands returned by - * {@link #createFeatures(JDA, Database)}. + * {@link #createFeatures(JDA, Database, Config)}. */ public enum Features { ; @@ -42,10 +43,11 @@ public enum Features { * * @param jda the JDA instance commands will be registered at * @param database the database of the application, which features can use to persist data + * @param config the configuration features should use * @return a collection of all features */ public static @NotNull Collection createFeatures(@NotNull JDA jda, - @NotNull Database database) { + @NotNull Database database, @NotNull Config config) { TagSystem tagSystem = new TagSystem(database); ModerationActionsStore actionsStore = new ModerationActionsStore(database); @@ -55,35 +57,35 @@ public enum Features { Collection features = new ArrayList<>(); // Routines - features.add(new ModAuditLogRoutine(database)); - features.add(new TemporaryModerationRoutine(jda, actionsStore)); + features.add(new ModAuditLogRoutine(database, config)); + features.add(new TemporaryModerationRoutine(jda, actionsStore, config)); features.add(new TopHelpersPurgeMessagesRoutine(database)); // Message receivers - features.add(new TopHelpersMessageListener(database)); + features.add(new TopHelpersMessageListener(database, config)); // Event receivers - features.add(new RejoinMuteListener(actionsStore)); + features.add(new RejoinMuteListener(actionsStore, config)); // Slash commands features.add(new PingCommand()); features.add(new TeXCommand()); features.add(new TagCommand(tagSystem)); - features.add(new TagManageCommand(tagSystem)); + features.add(new TagManageCommand(tagSystem, config)); features.add(new TagsCommand(tagSystem)); features.add(new VcActivityCommand()); - features.add(new WarnCommand(actionsStore)); - features.add(new KickCommand(actionsStore)); - features.add(new BanCommand(actionsStore)); - features.add(new UnbanCommand(actionsStore)); - features.add(new AuditCommand(actionsStore)); - features.add(new MuteCommand(actionsStore)); - features.add(new UnmuteCommand(actionsStore)); + features.add(new WarnCommand(actionsStore, config)); + features.add(new KickCommand(actionsStore, config)); + features.add(new BanCommand(actionsStore, config)); + features.add(new UnbanCommand(actionsStore, config)); + features.add(new AuditCommand(actionsStore, config)); + features.add(new MuteCommand(actionsStore, config)); + features.add(new UnmuteCommand(actionsStore, config)); + features.add(new TopHelpersCommand(database, config)); features.add(new RoleSelectCommand()); - features.add(new TopHelpersCommand(database)); // Mixtures - features.add(new FreeCommand()); + features.add(new FreeCommand(config)); return features; } 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 ee289c0402..dea8890c7c 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 @@ -61,6 +61,8 @@ public final class FreeCommand extends SlashCommandAdapter implements EventRecei 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; @@ -73,11 +75,14 @@ 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 */ - public FreeCommand() { + public FreeCommand(@NotNull Config config) { 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(); @@ -339,8 +344,7 @@ public void onEvent(@NotNull GenericEvent event) { } private void initChannelsToMonitor() { - Config.getInstance() - .getFreeCommandConfig() + config.getFreeCommandConfig() .stream() .map(FreeCommandConfig::getMonitoredChannels) .flatMap(Collection::stream) @@ -348,8 +352,7 @@ private void initChannelsToMonitor() { } private void initStatusMessageChannels(@NotNull final JDA jda) { - Config.getInstance() - .getFreeCommandConfig() + config.getFreeCommandConfig() .stream() .map(FreeCommandConfig::getStatusChannel) // throws IllegalStateException if the id's don't match TextChannels diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/AuditCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/AuditCommand.java index a9391a5871..366aca3aa1 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/AuditCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/AuditCommand.java @@ -39,16 +39,17 @@ public final class AuditCommand extends SlashCommandAdapter { * Constructs an instance. * * @param actionsStore used to store actions issued by this command + * @param config the config to use for this */ - public AuditCommand(@NotNull ModerationActionsStore actionsStore) { + public AuditCommand(@NotNull ModerationActionsStore actionsStore, @NotNull Config config) { super(COMMAND_NAME, "Lists all moderation actions that have been taken against a user", SlashCommandVisibility.GUILD); getData().addOption(OptionType.USER, TARGET_OPTION, "The user who to retrieve actions for", true); - hasRequiredRole = Pattern.compile(Config.getInstance().getHeavyModerationRolePattern()) - .asMatchPredicate(); + hasRequiredRole = + Pattern.compile(config.getHeavyModerationRolePattern()).asMatchPredicate(); this.actionsStore = Objects.requireNonNull(actionsStore); } diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/BanCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/BanCommand.java index f11d15772c..d4a700d02b 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/BanCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/BanCommand.java @@ -55,8 +55,9 @@ public final class BanCommand extends SlashCommandAdapter { * Constructs an instance. * * @param actionsStore used to store actions issued by this command + * @param config the config to use for this */ - public BanCommand(@NotNull ModerationActionsStore actionsStore) { + public BanCommand(@NotNull ModerationActionsStore actionsStore, @NotNull Config config) { super(COMMAND_NAME, "Bans the given user from the server", SlashCommandVisibility.GUILD); OptionData durationData = new OptionData(OptionType.STRING, DURATION_OPTION, @@ -70,8 +71,8 @@ public BanCommand(@NotNull ModerationActionsStore actionsStore) { "the amount of days of the message history to delete, none means no messages are deleted.", true).addChoice("none", 0).addChoice("recent", 1).addChoice("all", 7)); - hasRequiredRole = Pattern.compile(Config.getInstance().getHeavyModerationRolePattern()) - .asMatchPredicate(); + hasRequiredRole = + Pattern.compile(config.getHeavyModerationRolePattern()).asMatchPredicate(); this.actionsStore = Objects.requireNonNull(actionsStore); } diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/KickCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/KickCommand.java index 8c9fcf5dfe..b943b1e27d 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/KickCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/KickCommand.java @@ -42,15 +42,15 @@ public final class KickCommand extends SlashCommandAdapter { * Constructs an instance. * * @param actionsStore used to store actions issued by this command + * @param config the config to use for this */ - public KickCommand(@NotNull ModerationActionsStore actionsStore) { + public KickCommand(@NotNull ModerationActionsStore actionsStore, @NotNull Config config) { super(COMMAND_NAME, "Kicks the given user from the server", SlashCommandVisibility.GUILD); getData().addOption(OptionType.USER, TARGET_OPTION, "The user who you want to kick", true) .addOption(OptionType.STRING, REASON_OPTION, "Why the user should be kicked", true); - hasRequiredRole = Pattern.compile(Config.getInstance().getSoftModerationRolePattern()) - .asMatchPredicate(); + hasRequiredRole = Pattern.compile(config.getSoftModerationRolePattern()).asMatchPredicate(); this.actionsStore = Objects.requireNonNull(actionsStore); } diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/ModerationUtils.java b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/ModerationUtils.java index fcb916be95..04d8ae5fd8 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/ModerationUtils.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/ModerationUtils.java @@ -40,12 +40,6 @@ public enum ModerationUtils { * embeds. */ static final Color AMBIENT_COLOR = Color.decode("#895FE8"); - /** - * Matches the name of the role that is used to mute users, as used by {@link MuteCommand} and - * similar. - */ - public static final Predicate isMuteRole = - Pattern.compile(Config.getInstance().getMutedRolePattern()).asMatchPredicate(); /** * Checks whether the given reason is valid. If not, it will handle the situation and respond to @@ -331,14 +325,27 @@ static boolean handleHasAuthorRole(@NotNull String actionVerb, .build(); } + /** + * Gets a predicate that identifies the role used to mute a member in a guild. + * + * @param config the config used to identify the muted role + * @return predicate that matches the name of the muted role + */ + public static Predicate getIsMutedRolePredicate(@NotNull Config config) { + return Pattern.compile(config.getMutedRolePattern()).asMatchPredicate(); + } + /** * Gets the role used to mute a member in a guild. * * @param guild the guild to get the muted role from + * @param config the config used to identify the muted role * @return the muted role, if found */ - public static @NotNull Optional getMutedRole(@NotNull Guild guild) { - return guild.getRoles().stream().filter(role -> isMuteRole.test(role.getName())).findAny(); + public static @NotNull Optional getMutedRole(@NotNull Guild guild, + @NotNull Config config) { + Predicate isMutedRole = getIsMutedRolePredicate(config); + return guild.getRoles().stream().filter(role -> isMutedRole.test(role.getName())).findAny(); } /** diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/MuteCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/MuteCommand.java index f10606c8a4..31ff2bfb93 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/MuteCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/MuteCommand.java @@ -43,13 +43,15 @@ public final class MuteCommand extends SlashCommandAdapter { "3 hours", "1 day", "3 days", "7 days", ModerationUtils.PERMANENT_DURATION); private final Predicate hasRequiredRole; private final ModerationActionsStore actionsStore; + private final Config config; /** * Constructs an instance. * * @param actionsStore used to store actions issued by this command + * @param config the config to use for this */ - public MuteCommand(@NotNull ModerationActionsStore actionsStore) { + public MuteCommand(@NotNull ModerationActionsStore actionsStore, @NotNull Config config) { super(COMMAND_NAME, "Mutes the given user so that they can not send messages anymore", SlashCommandVisibility.GUILD); @@ -61,8 +63,8 @@ public MuteCommand(@NotNull ModerationActionsStore actionsStore) { .addOptions(durationData) .addOption(OptionType.STRING, REASON_OPTION, "Why the user should be muted", true); - hasRequiredRole = Pattern.compile(Config.getInstance().getSoftModerationRolePattern()) - .asMatchPredicate(); + this.config = config; + hasRequiredRole = Pattern.compile(config.getSoftModerationRolePattern()).asMatchPredicate(); this.actionsStore = Objects.requireNonNull(actionsStore); } @@ -116,7 +118,8 @@ private AuditableRestAction muteUser(@NotNull Member target, @NotNull Memb actionsStore.addAction(guild.getIdLong(), author.getIdLong(), target.getIdLong(), ModerationAction.MUTE, expiresAt, reason); - return guild.addRoleToMember(target, ModerationUtils.getMutedRole(guild).orElseThrow()) + return guild + .addRoleToMember(target, ModerationUtils.getMutedRole(guild, config).orElseThrow()) .reason(reason); } @@ -137,15 +140,15 @@ private boolean handleChecks(@NotNull Member bot, @NotNull Member author, @Nullable Member target, @NotNull CharSequence reason, @NotNull Guild guild, @NotNull Interaction event) { if (!ModerationUtils.handleRoleChangeChecks( - ModerationUtils.getMutedRole(guild).orElse(null), ACTION_VERB, target, bot, author, - guild, hasRequiredRole, reason, event)) { + ModerationUtils.getMutedRole(guild, config).orElse(null), ACTION_VERB, target, bot, + author, guild, hasRequiredRole, reason, event)) { return false; } if (Objects.requireNonNull(target) .getRoles() .stream() .map(Role::getName) - .anyMatch(ModerationUtils.isMuteRole)) { + .anyMatch(ModerationUtils.getIsMutedRolePredicate(config))) { handleAlreadyMutedTarget(event); return false; } diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/RejoinMuteListener.java b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/RejoinMuteListener.java index 3fa6440a92..825c01ffa7 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/RejoinMuteListener.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/RejoinMuteListener.java @@ -9,9 +9,9 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.togetherjava.tjbot.commands.EventReceiver; +import org.togetherjava.tjbot.config.Config; import java.time.Instant; -import java.util.Objects; import java.util.Optional; /** @@ -26,23 +26,27 @@ public final class RejoinMuteListener implements EventReceiver { private static final Logger logger = LoggerFactory.getLogger(RejoinMuteListener.class); private final ModerationActionsStore actionsStore; + private final Config config; /** * Constructs an instance. * * @param actionsStore used to store actions issued by this command and to retrieve whether a * user should be muted + * @param config the config to use for this */ - public RejoinMuteListener(@NotNull ModerationActionsStore actionsStore) { - this.actionsStore = Objects.requireNonNull(actionsStore); + public RejoinMuteListener(@NotNull ModerationActionsStore actionsStore, + @NotNull Config config) { + this.actionsStore = actionsStore; + this.config = config; } - private static void muteMember(@NotNull Member member) { + private void muteMember(@NotNull Member member) { Guild guild = member.getGuild(); logger.info("Reapplied existing mute to user '{}' ({}) in guild '{}' after rejoining.", member.getUser().getAsTag(), member.getId(), guild.getName()); - guild.addRoleToMember(member, ModerationUtils.getMutedRole(guild).orElseThrow()) + guild.addRoleToMember(member, ModerationUtils.getMutedRole(guild, config).orElseThrow()) .reason("Reapplied existing mute after rejoining the server") .queue(); } diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/UnbanCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/UnbanCommand.java index 516ee88183..b7c72b6948 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/UnbanCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/UnbanCommand.java @@ -35,8 +35,9 @@ public final class UnbanCommand extends SlashCommandAdapter { * Constructs an instance. * * @param actionsStore used to store actions issued by this command + * @param config the config to use for this */ - public UnbanCommand(@NotNull ModerationActionsStore actionsStore) { + public UnbanCommand(@NotNull ModerationActionsStore actionsStore, @NotNull Config config) { super(COMMAND_NAME, "Unbans the given user from the server", SlashCommandVisibility.GUILD); getData() @@ -44,8 +45,8 @@ public UnbanCommand(@NotNull ModerationActionsStore actionsStore) { true) .addOption(OptionType.STRING, REASON_OPTION, "Why the user should be unbanned", true); - hasRequiredRole = Pattern.compile(Config.getInstance().getHeavyModerationRolePattern()) - .asMatchPredicate(); + hasRequiredRole = + Pattern.compile(config.getHeavyModerationRolePattern()).asMatchPredicate(); this.actionsStore = Objects.requireNonNull(actionsStore); } diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/UnmuteCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/UnmuteCommand.java index fcd0f66a31..751b92b213 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/UnmuteCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/UnmuteCommand.java @@ -35,13 +35,15 @@ public final class UnmuteCommand extends SlashCommandAdapter { private static final String ACTION_VERB = "unmute"; private final Predicate hasRequiredRole; private final ModerationActionsStore actionsStore; + private final Config config; /** * Constructs an instance. * * @param actionsStore used to store actions issued by this command + * @param config the config to use for this */ - public UnmuteCommand(@NotNull ModerationActionsStore actionsStore) { + public UnmuteCommand(@NotNull ModerationActionsStore actionsStore, @NotNull Config config) { super(COMMAND_NAME, "Unmutes the given already muted user so that they can send messages again", SlashCommandVisibility.GUILD); @@ -49,8 +51,8 @@ public UnmuteCommand(@NotNull ModerationActionsStore actionsStore) { getData().addOption(OptionType.USER, TARGET_OPTION, "The user who you want to unmute", true) .addOption(OptionType.STRING, REASON_OPTION, "Why the user should be unmuted", true); - hasRequiredRole = Pattern.compile(Config.getInstance().getSoftModerationRolePattern()) - .asMatchPredicate(); + this.config = config; + hasRequiredRole = Pattern.compile(config.getSoftModerationRolePattern()).asMatchPredicate(); this.actionsStore = Objects.requireNonNull(actionsStore); } @@ -91,7 +93,8 @@ private AuditableRestAction unmuteUser(@NotNull Member target, @NotNull Me actionsStore.addAction(guild.getIdLong(), author.getIdLong(), target.getIdLong(), ModerationAction.UNMUTE, null, reason); - return guild.removeRoleFromMember(target, ModerationUtils.getMutedRole(guild).orElseThrow()) + return guild + .removeRoleFromMember(target, ModerationUtils.getMutedRole(guild, config).orElseThrow()) .reason(reason); } @@ -110,15 +113,15 @@ private boolean handleChecks(@NotNull Member bot, @NotNull Member author, @Nullable Member target, @NotNull CharSequence reason, @NotNull Guild guild, @NotNull Interaction event) { if (!ModerationUtils.handleRoleChangeChecks( - ModerationUtils.getMutedRole(guild).orElse(null), ACTION_VERB, target, bot, author, - guild, hasRequiredRole, reason, event)) { + ModerationUtils.getMutedRole(guild, config).orElse(null), ACTION_VERB, target, bot, + author, guild, hasRequiredRole, reason, event)) { return false; } if (Objects.requireNonNull(target) .getRoles() .stream() .map(Role::getName) - .noneMatch(ModerationUtils.isMuteRole)) { + .noneMatch(ModerationUtils.getIsMutedRolePredicate(config))) { handleNotMutedTarget(event); return false; } diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/WarnCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/WarnCommand.java index 2b2c8265c2..f82ffb00b9 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/WarnCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/WarnCommand.java @@ -39,15 +39,16 @@ public final class WarnCommand extends SlashCommandAdapter { * Creates a new instance. * * @param actionsStore used to store actions issued by this command + * @param config the config to use for this */ - public WarnCommand(@NotNull ModerationActionsStore actionsStore) { + public WarnCommand(@NotNull ModerationActionsStore actionsStore, @NotNull Config config) { super("warn", "Warns the given user", SlashCommandVisibility.GUILD); getData().addOption(OptionType.USER, USER_OPTION, "The user who you want to warn", true) .addOption(OptionType.STRING, REASON_OPTION, "Why you want to warn the user", true); - hasRequiredRole = Pattern.compile(Config.getInstance().getHeavyModerationRolePattern()) - .asMatchPredicate(); + hasRequiredRole = + Pattern.compile(config.getHeavyModerationRolePattern()).asMatchPredicate(); this.actionsStore = Objects.requireNonNull(actionsStore); } diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/temp/TemporaryModerationRoutine.java b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/temp/TemporaryModerationRoutine.java index 45fa89203a..a193069d1e 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/temp/TemporaryModerationRoutine.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/temp/TemporaryModerationRoutine.java @@ -11,6 +11,7 @@ import org.togetherjava.tjbot.commands.moderation.ActionRecord; import org.togetherjava.tjbot.commands.moderation.ModerationAction; import org.togetherjava.tjbot.commands.moderation.ModerationActionsStore; +import org.togetherjava.tjbot.config.Config; import java.time.Instant; import java.util.Map; @@ -41,13 +42,14 @@ public final class TemporaryModerationRoutine implements Routine { * * @param jda the JDA instance to use to send messages and retrieve information * @param actionsStore the store used to retrieve temporary moderation actions + * @param config the config to use for this */ public TemporaryModerationRoutine(@NotNull JDA jda, - @NotNull ModerationActionsStore actionsStore) { + @NotNull ModerationActionsStore actionsStore, @NotNull Config config) { this.actionsStore = actionsStore; this.jda = jda; - typeToRevocableAction = Stream.of(new TemporaryBanAction(), new TemporaryMuteAction()) + typeToRevocableAction = Stream.of(new TemporaryBanAction(), new TemporaryMuteAction(config)) .collect( Collectors.toMap(RevocableModerationAction::getApplyType, Function.identity())); } diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/temp/TemporaryMuteAction.java b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/temp/TemporaryMuteAction.java index 0edd9f3308..f7c0d29a58 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/temp/TemporaryMuteAction.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/temp/TemporaryMuteAction.java @@ -10,6 +10,7 @@ import org.slf4j.LoggerFactory; import org.togetherjava.tjbot.commands.moderation.ModerationAction; import org.togetherjava.tjbot.commands.moderation.ModerationUtils; +import org.togetherjava.tjbot.config.Config; /** * Action to revoke temporary mutes, as applied by @@ -18,6 +19,16 @@ */ final class TemporaryMuteAction implements RevocableModerationAction { private static final Logger logger = LoggerFactory.getLogger(TemporaryMuteAction.class); + private final Config config; + + /** + * Creates a new instance of a temporary mute action. + * + * @param config the config to use to identify the muted role + */ + TemporaryMuteAction(@NotNull Config config) { + this.config = config; + } @Override public @NotNull ModerationAction getApplyType() { @@ -34,7 +45,7 @@ final class TemporaryMuteAction implements RevocableModerationAction { @NotNull String reason) { return guild .removeRoleFromMember(target.getIdLong(), - ModerationUtils.getMutedRole(guild).orElseThrow()) + ModerationUtils.getMutedRole(guild, config).orElseThrow()) .reason(reason); } diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/system/BotCore.java b/application/src/main/java/org/togetherjava/tjbot/commands/system/BotCore.java index ce68ecde5f..57dce916b2 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/system/BotCore.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/system/BotCore.java @@ -54,6 +54,7 @@ public final class BotCore extends ListenerAdapter implements SlashCommandProvid private static final ExecutorService COMMAND_SERVICE = Executors.newCachedThreadPool(); private static final ScheduledExecutorService ROUTINE_SERVICE = Executors.newScheduledThreadPool(5); + private final Config config; private final Map nameToSlashCommands; private final ComponentIdParser componentIdParser; private final ComponentIdStore componentIdStore; @@ -66,10 +67,12 @@ public final class BotCore extends ListenerAdapter implements SlashCommandProvid * * @param jda the JDA instance that this command system will be used with * @param database the database that commands may use to persist data + * @param config the configuration to use for this system */ @SuppressWarnings("ThisEscapedInObjectConstruction") - public BotCore(@NotNull JDA jda, @NotNull Database database) { - Collection features = Features.createFeatures(jda, database); + public BotCore(@NotNull JDA jda, @NotNull Database database, @NotNull Config config) { + this.config = config; + Collection features = Features.createFeatures(jda, database, config); // Message receivers features.stream() @@ -271,7 +274,7 @@ private void forwardComponentCommand(@NotNull T return Objects.requireNonNull(nameToSlashCommands.get(name)); } - private static void handleRegisterErrors(Throwable ex, Guild guild) { + private void handleRegisterErrors(Throwable ex, Guild guild) { new ErrorHandler().handle(ErrorResponse.MISSING_ACCESS, errorResponse -> { // Find a channel that we have permissions to write to // NOTE Unfortunately, there is no better accurate way to find a proper channel @@ -283,7 +286,6 @@ private static void handleRegisterErrors(Throwable ex, Guild guild) { .findAny(); // Report the problem to the guild - Config config = Config.getInstance(); channelToReportTo.ifPresent(textChannel -> textChannel .sendMessage("I need the commands scope, please invite me correctly." + " You can join '%s' or visit '%s' for more info, I will leave your guild now." diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/tags/TagManageCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/tags/TagManageCommand.java index e257db4c53..0cc8acb4d1 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/tags/TagManageCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/tags/TagManageCommand.java @@ -56,13 +56,13 @@ public final class TagManageCommand extends SlashCommandAdapter { * Creates a new instance, using the given tag system as base. * * @param tagSystem the system providing the actual tag data + * @param config the config to use for this */ - public TagManageCommand(TagSystem tagSystem) { + public TagManageCommand(TagSystem tagSystem, @NotNull Config config) { super("tag-manage", "Provides commands to manage all tags", SlashCommandVisibility.GUILD); this.tagSystem = tagSystem; - hasRequiredRole = - Pattern.compile(Config.getInstance().getTagManageRolePattern()).asMatchPredicate(); + hasRequiredRole = Pattern.compile(config.getTagManageRolePattern()).asMatchPredicate(); // TODO Think about adding a "Are you sure"-dialog to 'edit', 'edit-with-message' and // 'delete' diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/tophelper/TopHelpersCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/tophelper/TopHelpersCommand.java index 876ce3dcb2..9e46748efd 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/tophelper/TopHelpersCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/tophelper/TopHelpersCommand.java @@ -55,12 +55,12 @@ public final class TopHelpersCommand extends SlashCommandAdapter { * Creates a new instance. * * @param database the database containing the message counts of top helpers + * @param config the config to use for this */ - public TopHelpersCommand(@NotNull Database database) { + public TopHelpersCommand(@NotNull Database database, @NotNull Config config) { super(COMMAND_NAME, "Lists top helpers for the last month", SlashCommandVisibility.GUILD); // TODO Add options to optionally pick a time range once JDA/Discord offers a date-picker - hasRequiredRole = Pattern.compile(Config.getInstance().getSoftModerationRolePattern()) - .asMatchPredicate(); + hasRequiredRole = Pattern.compile(config.getSoftModerationRolePattern()).asMatchPredicate(); this.database = database; } diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/tophelper/TopHelpersMessageListener.java b/application/src/main/java/org/togetherjava/tjbot/commands/tophelper/TopHelpersMessageListener.java index fadb5b50ff..26c314e8f0 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/tophelper/TopHelpersMessageListener.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/tophelper/TopHelpersMessageListener.java @@ -21,9 +21,10 @@ public final class TopHelpersMessageListener extends MessageReceiverAdapter { * Creates a new listener to receive all message sent in help channels. * * @param database to store message meta-data in + * @param config the config to use for this */ - public TopHelpersMessageListener(@NotNull Database database) { - super(Pattern.compile(Config.getInstance().getHelpChannelPattern())); + public TopHelpersMessageListener(@NotNull Database database, @NotNull Config config) { + super(Pattern.compile(config.getHelpChannelPattern())); this.database = database; } 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 470758b199..010d11093a 100644 --- a/application/src/main/java/org/togetherjava/tjbot/config/Config.java +++ b/application/src/main/java/org/togetherjava/tjbot/config/Config.java @@ -10,19 +10,13 @@ import java.util.Collection; import java.util.Collections; import java.util.List; -import java.util.Objects; /** - * Configuration of the application, as singleton. - *

- * Create instances using {@link #load(Path)} and then access them with {@link #getInstance()}. + * Configuration of the application. Create instances using {@link #load(Path)}. */ -@SuppressWarnings({"Singleton", "ClassCanBeRecord"}) +@SuppressWarnings("ClassCanBeRecord") public final class Config { - @SuppressWarnings("RedundantFieldInitialization") - private static Config config = null; - private final String token; private final String databasePath; private final String projectWebsite; @@ -62,27 +56,14 @@ private Config(@JsonProperty("token") String token, } /** - * Loads the configuration from the given file. Will override any previously loaded data. - *

- * Access the instance using {@link #getInstance()}. + * Loads the configuration from the given file. * * @param path the configuration file, as JSON object + * @return the loaded configuration * @throws IOException if the file could not be loaded */ - public static void load(Path path) throws IOException { - config = new ObjectMapper().readValue(path.toFile(), Config.class); - } - - /** - * Gets the singleton instance of the configuration. - *

- * Must be loaded beforehand using {@link #load(Path)}. - * - * @return the previously loaded configuration - */ - public static Config getInstance() { - return Objects.requireNonNull(config, - "can not get the configuration before it has been loaded"); + public static Config load(Path path) throws IOException { + return new ObjectMapper().readValue(path.toFile(), Config.class); } /** diff --git a/application/src/main/java/org/togetherjava/tjbot/routines/ModAuditLogRoutine.java b/application/src/main/java/org/togetherjava/tjbot/routines/ModAuditLogRoutine.java index aa498888ed..d6f23d1bbd 100644 --- a/application/src/main/java/org/togetherjava/tjbot/routines/ModAuditLogRoutine.java +++ b/application/src/main/java/org/togetherjava/tjbot/routines/ModAuditLogRoutine.java @@ -49,20 +49,24 @@ public final class ModAuditLogRoutine implements Routine { private static final int HOURS_OF_DAY = 24; private static final Color AMBIENT_COLOR = Color.decode("#4FC3F7"); + private final String modAuditLogChannelPattern; private final Predicate isAuditLogChannel; private final Database database; + private final Config config; /** * Creates a new instance. * * @param database the database for memorizing audit log dates + * @param config the config to use for this */ - public ModAuditLogRoutine(@NotNull Database database) { + public ModAuditLogRoutine(@NotNull Database database, @NotNull Config config) { + modAuditLogChannelPattern = config.getModAuditLogChannelPattern(); Predicate isAuditLogChannelName = - Pattern.compile(Config.getInstance().getModAuditLogChannelPattern()) - .asMatchPredicate(); + Pattern.compile(modAuditLogChannelPattern).asMatchPredicate(); isAuditLogChannel = channel -> isAuditLogChannelName.test(channel.getName()); + this.config = config; this.database = database; } @@ -232,7 +236,7 @@ private void checkAuditLogsRoutine(@NotNull JDA jda) { if (auditLogChannel.isEmpty()) { logger.warn( "Unable to log moderation events, did not find a mod audit log channel matching the configured pattern '{}' for guild '{}'", - Config.getInstance().getModAuditLogChannelPattern(), guild.getName()); + modAuditLogChannelPattern, guild.getName()); return; } @@ -283,8 +287,8 @@ private void handleAuditLogs(@NotNull MessageChannel auditLogChannel, }); } - private static Optional> handleAuditLog( - @NotNull MessageChannel auditLogChannel, @NotNull AuditLogEntry entry) { + private Optional> handleAuditLog(@NotNull MessageChannel auditLogChannel, + @NotNull AuditLogEntry entry) { Optional> maybeMessage = switch (entry.getType()) { case BAN -> handleBanEntry(entry); case UNBAN -> handleUnbanEntry(entry); @@ -296,7 +300,7 @@ private static Optional> handleAuditLog( return maybeMessage.map(message -> message.flatMap(auditLogChannel::sendMessageEmbeds)); } - private static @NotNull Optional> handleRoleUpdateEntry( + private @NotNull Optional> handleRoleUpdateEntry( @NotNull AuditLogEntry entry) { if (containsMutedRole(entry, AuditLogKey.MEMBER_ROLES_ADD)) { return handleMuteEntry(entry); @@ -307,8 +311,7 @@ private static Optional> handleAuditLog( return Optional.empty(); } - private static boolean containsMutedRole(@NotNull AuditLogEntry entry, - @NotNull AuditLogKey key) { + private boolean containsMutedRole(@NotNull AuditLogEntry entry, @NotNull AuditLogKey key) { List> roleChanges = Optional.ofNullable(entry.getChangeByKey(key)) .>>map(AuditLogChange::getNewValue) .orElse(List.of()); @@ -317,7 +320,7 @@ private static boolean containsMutedRole(@NotNull AuditLogEntry entry, .flatMap(Collection::stream) .filter(changeEntry -> "name".equals(changeEntry.getKey())) .map(Map.Entry::getValue) - .anyMatch(ModerationUtils.isMuteRole); + .anyMatch(ModerationUtils.getIsMutedRolePredicate(config)); } private Optional getModAuditLogChannel(@NotNull Guild guild) { From 497916f546a41ffbb46921818b5c8febf8c87d58 Mon Sep 17 00:00:00 2001 From: Zabuzard Date: Tue, 15 Feb 2022 15:29:09 +0100 Subject: [PATCH 2/5] Mocking for final classes enabling mockito inline extension to make it possible to mock final classes --- .../resources/mockito-extensions/org.mockito.plugins.MockMaker | 1 + 1 file changed, 1 insertion(+) create mode 100644 application/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker diff --git a/application/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/application/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 0000000000..1f0955d450 --- /dev/null +++ b/application/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-inline From c8f72704d09a55764cfd3e294bb3ffe40a44ac1f Mon Sep 17 00:00:00 2001 From: Zabuzard Date: Tue, 15 Feb 2022 15:30:33 +0100 Subject: [PATCH 3/5] Added possibility to test with database --- .../org/togetherjava/tjbot/db/Database.java | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/database/src/main/java/org/togetherjava/tjbot/db/Database.java b/database/src/main/java/org/togetherjava/tjbot/db/Database.java index 221f6364b4..2986571170 100644 --- a/database/src/main/java/org/togetherjava/tjbot/db/Database.java +++ b/database/src/main/java/org/togetherjava/tjbot/db/Database.java @@ -3,6 +3,7 @@ import org.flywaydb.core.Flyway; import org.jooq.DSLContext; import org.jooq.SQLDialect; +import org.jooq.Table; import org.jooq.exception.DataAccessException; import org.jooq.impl.DSL; import org.sqlite.SQLiteConfig; @@ -53,6 +54,22 @@ public Database(String jdbcUrl) throws SQLException { dslContext = DSL.using(dataSource.getConnection(), SQLDialect.SQLITE); } + /** + * Creates a new empty database that is hold in memory. + * + * @param tables the tables the database will hold if desired, otherwise null + * @return the created database + */ + public static Database createMemoryDatabase(Table... tables) { + try { + Database database = new Database("jdbc:sqlite:"); + database.write(context -> context.ddl(tables).executeBatch()); + return database; + } catch (SQLException e) { + throw new DatabaseException(e); + } + } + /** * Acquires read-only access to the database. * From 8ecfaa9e961682bd12f6799921683ba665aa472d Mon Sep 17 00:00:00 2001 From: Zabuzard Date: Tue, 15 Feb 2022 15:31:37 +0100 Subject: [PATCH 4/5] Improved command mocking * Added ButtonClickEvent generation and mocking * Added USER option to SlashCommandEventBuilder * Added `userWhoTriggered(user)` to slash command event builder * Added mocked rest actions (success & failure), and discord exceptions --- .../tjbot/jda/ButtonClickEventBuilder.java | 245 ++++++++++++++ .../org/togetherjava/tjbot/jda/JdaTester.java | 309 ++++++++++++++++-- .../tjbot/jda/SlashCommandEventBuilder.java | 213 +++++++++--- .../PayloadMember.java} | 28 +- .../PayloadUser.java} | 32 +- .../slashcommand}/PayloadSlashCommand.java | 15 +- .../PayloadSlashCommandData.java | 23 +- .../PayloadSlashCommandMembers.java | 28 ++ .../PayloadSlashCommandOption.java | 6 +- .../PayloadSlashCommandResolved.java | 33 ++ .../PayloadSlashCommandUsers.java | 28 ++ 11 files changed, 865 insertions(+), 95 deletions(-) create mode 100644 application/src/test/java/org/togetherjava/tjbot/jda/ButtonClickEventBuilder.java rename application/src/test/java/org/togetherjava/tjbot/jda/{PayloadSlashCommandMember.java => payloads/PayloadMember.java} (76%) rename application/src/test/java/org/togetherjava/tjbot/jda/{PayloadSlashCommandUser.java => payloads/PayloadUser.java} (59%) rename application/src/test/java/org/togetherjava/tjbot/jda/{ => payloads/slashcommand}/PayloadSlashCommand.java (82%) rename application/src/test/java/org/togetherjava/tjbot/jda/{ => payloads/slashcommand}/PayloadSlashCommandData.java (62%) create mode 100644 application/src/test/java/org/togetherjava/tjbot/jda/payloads/slashcommand/PayloadSlashCommandMembers.java rename application/src/test/java/org/togetherjava/tjbot/jda/{ => payloads/slashcommand}/PayloadSlashCommandOption.java (87%) create mode 100644 application/src/test/java/org/togetherjava/tjbot/jda/payloads/slashcommand/PayloadSlashCommandResolved.java create mode 100644 application/src/test/java/org/togetherjava/tjbot/jda/payloads/slashcommand/PayloadSlashCommandUsers.java diff --git a/application/src/test/java/org/togetherjava/tjbot/jda/ButtonClickEventBuilder.java b/application/src/test/java/org/togetherjava/tjbot/jda/ButtonClickEventBuilder.java new file mode 100644 index 0000000000..dbb4178e27 --- /dev/null +++ b/application/src/test/java/org/togetherjava/tjbot/jda/ButtonClickEventBuilder.java @@ -0,0 +1,245 @@ +package org.togetherjava.tjbot.jda; + +import com.fasterxml.jackson.databind.ObjectMapper; +import net.dv8tion.jda.api.MessageBuilder; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.entities.MessageEmbed; +import net.dv8tion.jda.api.entities.User; +import net.dv8tion.jda.api.events.interaction.ButtonClickEvent; +import net.dv8tion.jda.api.interactions.components.ActionRow; +import net.dv8tion.jda.api.interactions.components.Button; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.togetherjava.tjbot.commands.SlashCommand; + +import java.util.List; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.function.UnaryOperator; +import java.util.stream.Stream; + +import static org.mockito.Mockito.when; + +/** + * Builder to create button click events that can be used for example with + * {@link SlashCommand#onButtonClick(ButtonClickEvent, List)}. + *

+ * Create instances of this class by using {@link JdaTester#createButtonClickEvent()}. + *

+ * Among other Discord related things, the builder optionally accepts a message + * ({@link #setMessage(Message)}) and the user who clicked on the button + * ({@link #setUserWhoClicked(Member)} ). As well as several ways to modify the message directly for + * convenience, such as {@link #setContent(String)} or {@link #setActionRows(ActionRow...)}. The + * builder is by default already setup with a valid dummy message and the user who clicked the + * button is set to the author of the message. + *

+ * In order to build the event, at least one button has to be added to the message and marked as + * clicked. Therefore, use {@link #setActionRows(ActionRow...)} or modify the message + * manually using {@link #setMessage(Message)}. Then mark the desired button as clicked using + * {@link #build(Button)} or, if the message only contains a single button, + * {@link #buildWithSingleButton()} will automatically select the button. + *

+ * Refer to the following examples: + * + *

+ * {@code
+ * // Default message with a delete button
+ * jdaTester.createButtonClickEvent()
+ *   .setActionRows(ActionRow.of(Button.of(ButtonStyle.DANGER, "1", "Delete"))
+ *   .buildWithSingleButton();
+ *
+ * // More complex message with a user who clicked the button that is not the message author and multiple buttons
+ * Button clickedButton = Button.of(ButtonStyle.PRIMARY, "1", "Next");
+ * jdaTester.createButtonClickEvent()
+ *   .setMessage(new MessageBuilder()
+ *     .setContent("See the following entry")
+ *     .setEmbeds(
+ *       new EmbedBuilder()
+ *         .setDescription("John")
+ *         .build())
+ *     .build())
+ *   .setUserWhoClicked(jdaTester.createMemberSpy(5))
+ *   .setActionRows(
+ *     ActionRow.of(Button.of(ButtonStyle.PRIMARY, "1", "Previous"),
+ *     clickedButton)
+ *   .build(clickedButton);
+ * }
+ * 
+ */ +public final class ButtonClickEventBuilder { + private static final ObjectMapper JSON = new ObjectMapper(); + private final @NotNull Supplier mockEventSupplier; + private final UnaryOperator mockMessageOperator; + private MessageBuilder messageBuilder; + private Member userWhoClicked; + + ButtonClickEventBuilder(@NotNull Supplier mockEventSupplier, + @NotNull UnaryOperator mockMessageOperator) { + this.mockEventSupplier = mockEventSupplier; + this.mockMessageOperator = mockMessageOperator; + + messageBuilder = new MessageBuilder(); + messageBuilder.setContent("test message"); + } + + /** + * Sets the given message that this event is associated to. Will override any data previously + * set with the more direct methods such as {@link #setContent(String)} or + * {@link #setActionRows(ActionRow...)}. + *

+ * The message must contain at least one button, or the button has to be added later with + * {@link #setActionRows(ActionRow...)}. + * + * @param message the message to set + * @return this builder instance for chaining + */ + public @NotNull ButtonClickEventBuilder setMessage(@NotNull Message message) { + messageBuilder = new MessageBuilder(message); + return this; + } + + /** + * Sets the content of the message that this event is associated to. Usage of + * {@link #setMessage(Message)} will overwrite any content set by this. + * + * @param content the content of the message + * @return this builder instance for chaining + */ + public @NotNull ButtonClickEventBuilder setContent(@NotNull String content) { + messageBuilder.setContent(content); + return this; + } + + /** + * Sets the embeds of the message that this event is associated to. Usage of + * {@link #setMessage(Message)} will overwrite any content set by this. + * + * @param embeds the embeds of the message + * @return this builder instance for chaining + */ + public @NotNull ButtonClickEventBuilder setEmbeds(@NotNull MessageEmbed... embeds) { + messageBuilder.setEmbeds(embeds); + return this; + } + + /** + * Sets the action rows of the message that this event is associated to. Usage of + * {@link #setMessage(Message)} will overwrite any content set by this. + *

+ * At least one of the rows must contain a button before {@link #build(Button)} is called. + * + * @param rows the action rows of the message + * @return this builder instance for chaining + */ + public @NotNull ButtonClickEventBuilder setActionRows(@NotNull ActionRow... rows) { + messageBuilder.setActionRows(rows); + return this; + } + + /** + * Sets the user who clicked the button, i.e. who triggered the event. + * + * @param userWhoClicked the user who clicked the button + * @return this builder instance for chaining + */ + @NotNull + public ButtonClickEventBuilder setUserWhoClicked(@NotNull Member userWhoClicked) { + this.userWhoClicked = userWhoClicked; + return this; + } + + /** + * Builds an instance of a button click event, corresponding to the current configuration of the + * builder. + *

+ * The message must contain exactly one button, which is automatically assumed to be the button + * that has been clicked. Use {@link #build(Button)} for messages with multiple buttons instead. + * + * @return the created slash command instance + */ + public @NotNull ButtonClickEvent buildWithSingleButton() { + return createEvent(null); + } + + /** + * Builds an instance of a button click event, corresponding to the current configuration of the + * builder. + *

+ * The message must the given button. {@link #buildWithSingleButton()} can be used for + * convenience for messages that only have a single button. + * + * @param clickedButton the button that was clicked, i.e. that triggered the event. Must be + * contained in the message. + * @return the created slash command instance + */ + public @NotNull ButtonClickEvent build(@NotNull Button clickedButton) { + return createEvent(clickedButton); + } + + private @NotNull ButtonClickEvent createEvent(@Nullable Button maybeClickedButton) { + Message message = mockMessageOperator.apply(messageBuilder.build()); + Button clickedButton = determineClickedButton(maybeClickedButton, message); + + return mockButtonClickEvent(message, clickedButton); + } + + private static @NotNull Button determineClickedButton(@Nullable Button maybeClickedButton, + @NotNull Message message) { + if (maybeClickedButton != null) { + return requireButtonInMessage(maybeClickedButton, message); + } + + // Otherwise, attempt to extract the button from the message. Only allow a single button in + // this case to prevent ambiguity. + return requireSingleButton(getMessageButtons(message)); + } + + private static @NotNull Button requireButtonInMessage(@NotNull Button clickedButton, + @NotNull Message message) { + boolean isClickedButtonUnknown = + getMessageButtons(message).noneMatch(clickedButton::equals); + + if (isClickedButtonUnknown) { + throw new IllegalArgumentException( + "The given clicked button is not part of the messages components," + + " make sure to add the button to one of the messages action rows first."); + } + return clickedButton; + } + + private static @NotNull Button requireSingleButton(@NotNull Stream stream) { + Function descriptionToException = + IllegalArgumentException::new; + + return stream.reduce((x, y) -> { + throw descriptionToException + .apply("The message contains more than a single button, unable to automatically determine the clicked button." + + " Either only use a single button or explicitly state the clicked button"); + }) + .orElseThrow(() -> descriptionToException.apply( + "The message contains no buttons, unable to automatically determine the clicked button." + + " Add the button to the message first.")); + } + + private static @NotNull Stream