diff --git a/.gitignore b/.gitignore index 883b4421d..7252d6ddc 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ build/ /config.json /config /purgeArchives +/logs # Eclipse settings .classpath diff --git a/README.md b/README.md index d90222275..18e7868f4 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,13 @@ -# JavaBot +# JavaBot — General Utility Bot for the [JavaDiscord Community](https://join.javadiscord.net/) -General utility bot for the [JavaDiscord Community](https://join.javadiscord.net/) +![Banner](https://user-images.githubusercontent.com/48297101/174893242-c8fc553a-e36b-4c5f-91d3-9c3bc659a7c9.png) # Usage To start up, run the bot once, and it will generate a `config` directory. Stop the bot, and set the up **all of the following values**: - in `systems.json` - `jdaBotToken` to your bot's token + - (some `adminUsers` which, e.g., can manipulate the database) - in `{guildId}.json` - `moderation.logChannelId` to a channelId - `moderation.staffRoleId` to a roleId @@ -15,65 +16,49 @@ To start up, run the bot once, and it will generate a `config` directory. Stop t Note that this is just what is required for the bot to start. Certain features may require other values to be set. +# Configuration -# Commands -Commands are defined in this bot using a `.yaml` configuration file located in `src/main/resources/commands`. The data in this file is transformed at startup time into an array of `net.javadiscord.javabot.command.data.slash_commands.SlashCommandConfig` objects using JSON deserialization. - -These commands are then used by `net.javadiscord.javabot.command.InteractionHandler#registerSlashCommands(Guild)` to register the defined commands as Discord slash commands which become available to users in guilds and private messages with the bot. - -**Each command MUST define a `handler` property, whose name is the fully-qualified class name of a `SlashCommand`.** When registering commands, the bot will look for such a class, and attempt to create a new instance of it using a no-args constructor. Therefore, make sure that your handler class has a no-args constructor. - -### Privileges -To specify that a command should only be allowed to be executed by certain people, you can specify a list of privileges. For example: -```yaml -- name: jam-admin - description: Administrator actions for configuring the Java Jam. - handler: net.javadiscord.javabot.systems.jam.JamAdminCommandHandler - enabledByDefault: false - privileges: - - type: ROLE - id: jam.adminRoleId - - type: USER - id: 235439851263098880 -``` -In this example, we define that the `jam-admin` command is first of all, *not enabled by default*, and also we say that anyone from the `jam.adminRoleId` role (as found using `Bot.config.getJam().getAdminRoleId()`). Additionally, we also say that the user whose id is `235439851263098880` is allowed to use this command. See `BotConfig#resolve(String)` for more information about how role names are resolved at runtime. - -*Context-Commands work in almost the same way, follow the steps above but replace `SlashCommand` with `MessageContextCommand` or `UserContextCommand`.* +The bot's configuration consists of a collection of simple JSON files: +- `systems.json` contains global settings for the bot's core systems. +- For every guild, a `{guildId}.json` file exists, which contains any guild-specific configuration settings. -### Autocomplete -To enable Autocomplete for a certain Slash Command Option, head to the command's entry in the corresponding -YAML-File and set the `autocomplete` value. +At startup, the bot will initially start by loading just the global settings, and then when the Discord ready event is received, the bot will add configuration for each guild it's in, loading it from the matching JSON file, or creating a new file if needed. -```yaml -- name: remove - description: Removes a question from the queue. - options: - - name: id - description: The id of the question to remove. - required: true - autocomplete: true - type: INTEGER -``` +# Commands -Now, you just need to implement `Autocompletable` in your handler class. +We're using [DIH4JDA](https://github.com/DynxstyGIT/DIH4JDA) as our Command/Interaction framework, which makes it quite easy to add new commands. +[PingCommand.java](https://github.com/Java-Discord/JavaBot/blob/main/src/main/java/net/javadiscord/javabot/systems/commands/PingCommand.java) ```java -@Override -public AutoCompleteCallbackAction handleAutocomplete(CommandAutoCompleteInteractionEvent event) { - return switch (event.getSubcommandName()) { - case "remove" -> ListQuestionsSubcommand.replyQuestions(event); - default -> event.replyChoices(); - }; +/** + *

This class represents the /ping command.

+ */ +public class PingCommand extends SlashCommand { + /** + * The constructor of this class, which sets the corresponding {@link net.dv8tion.jda.api.interactions.commands.build.SlashCommandData}. + */ + public PingCommand() { + setSlashCommandData(Commands.slash("ping", "Shows the bot's gateway ping.") + .setGuildOnly(true) + ); + } + + @Override + public void execute(@NotNull SlashCommandInteractionEvent event) { + event.replyEmbeds(new EmbedBuilder() + .setAuthor(event.getJDA().getGatewayPing() + "ms", null, event.getJDA().getSelfUser().getAvatarUrl()) + .setColor(Responses.Type.DEFAULT.getColor()) + .build() + ).queue(); + } } ``` -The `handleAutocomplete` method of your handler class now gets fired once someone is focusing any Slash Command Option that has the `autocomplete` -property set to true. +For more information on how this works, visit the [DIH4JDA Wiki!](https://github.com/DynxstyGIT/DIH4JDA/wiki) +# Credits -# Configuration -The bot's configuration consists of a collection of simple JSON files: -- `systems.json` contains global settings for the bot's core systems. -- For every guild, a `{guildId}.json` file exists, which contains any guild-specific configuration settings. +Inspiration we took from other communities: -At startup, the bot will initially start by loading just the global settings, and then when the Discord ready event is received, the bot will add configuration for each guild it's in, loading it from the matching JSON file, or creating a new file if needed. +- We designed our [Help Channel System](https://github.com/Java-Discord/JavaBot/tree/main/src/main/java/net/javadiscord/javabot/systems/help) similar to the one on the [Python Discord](https://discord.gg/python). +- [`/move-conversation`](https://github.com/Java-Discord/JavaBot/blob/main/src/main/java/net/javadiscord/javabot/systems/user_commands/MoveConversationCommand.java) is heavily inspired by the [Rust Programming Language Community Server](https://discord.gg/rust-lang-community) diff --git a/build.gradle.kts b/build.gradle.kts index 4627fb5ec..8c0b6f85e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -22,7 +22,9 @@ dependencies { testImplementation("org.junit.jupiter:junit-jupiter-api:5.8.2") testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.8.2") - implementation("net.dv8tion:JDA:5.0.0-alpha.12") + // DIH4JDA (Interaction Framework) (includes JDA (jda5.0.0-alpha.17)) + implementation("com.github.DynxstyGIT:DIH4JDA:f87f54eb42") + implementation("com.google.code.gson:gson:2.9.0") implementation("org.yaml:snakeyaml:1.30") implementation("com.google.re2j:re2j:1.6") @@ -32,20 +34,23 @@ dependencies { implementation("com.mashape.unirest:unirest-java:1.4.9") // H2 Database - implementation("com.h2database:h2:1.4.200") + implementation("com.h2database:h2:2.1.212") implementation("com.zaxxer:HikariCP:5.0.1") - // Quartz scheduler + // Quartz Scheduler implementation("org.quartz-scheduler:quartz:2.3.2") // Webhooks - implementation("club.minnced:discord-webhooks:0.8.0") + implementation("com.github.DynxstyGIT:discord-webhooks:74301a46a0") // Lombok Annotations compileOnly("org.projectlombok:lombok:1.18.24") annotationProcessor("org.projectlombok:lombok:1.18.24") testCompileOnly("org.projectlombok:lombok:1.18.24") testAnnotationProcessor("org.projectlombok:lombok:1.18.24") + + // Sentry + implementation("io.sentry:sentry:6.3.0") } tasks.withType { diff --git a/src/main/java/net/javadiscord/javabot/Bot.java b/src/main/java/net/javadiscord/javabot/Bot.java index bc7ab629d..bd08ee11a 100644 --- a/src/main/java/net/javadiscord/javabot/Bot.java +++ b/src/main/java/net/javadiscord/javabot/Bot.java @@ -1,6 +1,10 @@ package net.javadiscord.javabot; +import com.dynxsty.dih4jda.DIH4JDA; +import com.dynxsty.dih4jda.DIH4JDABuilder; +import com.dynxsty.dih4jda.interactions.commands.RegistrationType; import com.zaxxer.hikari.HikariDataSource; +import io.sentry.Sentry; import lombok.extern.slf4j.Slf4j; import net.dv8tion.jda.api.JDA; import net.dv8tion.jda.api.JDABuilder; @@ -11,25 +15,41 @@ import net.dv8tion.jda.api.utils.ChunkingFilter; import net.dv8tion.jda.api.utils.MemberCachePolicy; import net.dv8tion.jda.api.utils.cache.CacheFlag; -import net.javadiscord.javabot.command.InteractionHandler; import net.javadiscord.javabot.data.config.BotConfig; import net.javadiscord.javabot.data.h2db.DbHelper; +import net.javadiscord.javabot.data.h2db.commands.QuickMigrateSubcommand; import net.javadiscord.javabot.data.h2db.message_cache.MessageCache; import net.javadiscord.javabot.data.h2db.message_cache.MessageCacheListener; import net.javadiscord.javabot.listener.*; +import net.javadiscord.javabot.systems.help.HelpChannelInteractionManager; import net.javadiscord.javabot.systems.help.HelpChannelListener; import net.javadiscord.javabot.systems.moderation.AutoMod; -import net.javadiscord.javabot.systems.moderation.ServerLock; +import net.javadiscord.javabot.systems.moderation.report.ReportManager; +import net.javadiscord.javabot.systems.moderation.server_lock.ServerLockManager; +import net.javadiscord.javabot.systems.qotw.commands.questions_queue.AddQuestionSubcommand; +import net.javadiscord.javabot.systems.qotw.submissions.SubmissionInteractionManager; +import net.javadiscord.javabot.systems.staff_commands.self_roles.SelfRoleInteractionManager; +import net.javadiscord.javabot.systems.staff_commands.embeds.AddEmbedFieldSubcommand; +import net.javadiscord.javabot.systems.staff_commands.embeds.CreateEmbedSubcommand; +import net.javadiscord.javabot.systems.staff_commands.embeds.EditEmbedSubcommand; import net.javadiscord.javabot.systems.starboard.StarboardManager; +import net.javadiscord.javabot.systems.staff_commands.tags.CustomTagManager; +import net.javadiscord.javabot.systems.staff_commands.tags.commands.CreateCustomTagSubcommand; +import net.javadiscord.javabot.systems.staff_commands.tags.commands.EditCustomTagSubcommand; +import net.javadiscord.javabot.systems.user_commands.leaderboard.ExperienceLeaderboardSubcommand; +import net.javadiscord.javabot.tasks.MetricsUpdater; import net.javadiscord.javabot.tasks.PresenceUpdater; import net.javadiscord.javabot.tasks.ScheduledTasks; -import net.javadiscord.javabot.tasks.StatsUpdater; -import net.javadiscord.javabot.util.ImageCacheUtils; +import net.javadiscord.javabot.util.ExceptionLogger; +import net.javadiscord.javabot.util.InteractionUtils; +import org.jetbrains.annotations.NotNull; import org.quartz.SchedulerException; import java.nio.file.Path; import java.time.ZoneOffset; import java.util.EnumSet; +import java.util.List; +import java.util.Map; import java.util.TimeZone; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; @@ -44,36 +64,44 @@ public class Bot { * The set of configuration properties that this bot uses. */ public static BotConfig config; - /** - * A reference to the slash command listener that's the main point of - * interaction for users with this bot. It's marked as a publicly accessible - * reference so that {@link InteractionHandler#registerCommands} can - * be called wherever it's needed. - */ - public static InteractionHandler interactionHandler; + /** * An instance of {@link AutoMod}. - * */ + */ public static AutoMod autoMod; + + /** + * A reference to the Bot's {@link DIH4JDA}. + */ + public static DIH4JDA dih4jda; + /** * The Bots {@link MessageCache}, which handles logging of deleted and edited messages. */ public static MessageCache messageCache; + + /** + * A reference to the Bot's {@link ServerLockManager}. + */ + public static ServerLockManager serverLockManager; + + /** + * A static reference to the {@link CustomTagManager} which handles and loads all registered Custom Commands. + */ + public static CustomTagManager customTagManager; + /** * A reference to the data source that provides access to the relational * database that this bot users for certain parts of the application. Use * this to obtain a connection and perform transactions. */ public static HikariDataSource dataSource; + /** * A general-purpose thread pool that can be used by the bot to execute * tasks outside the main event processing thread. */ public static ScheduledExecutorService asyncPool; - /** - * A reference to the Bot's {@link ImageCacheUtils}. - */ - public static ImageCacheUtils imageCache; private Bot() { } @@ -83,8 +111,8 @@ private Bot() { *
    *
  1. Setting the time zone to UTC, to keep our sanity when working with times.
  2. *
  3. Loading the configuration JSON file.
  4. - *
  5. Initializing the {@link InteractionHandler} listener (which reads command data from a YAML file).
  6. *
  7. Creating and configuring the {@link JDA} instance that enables the bot's Discord connectivity.
  8. + *
  9. Initializing the {@link DIH4JDA} instance.
  10. *
  11. Adding event listeners to the bot.
  12. *
* @@ -95,56 +123,93 @@ public static void main(String[] args) throws Exception { TimeZone.setDefault(TimeZone.getTimeZone(ZoneOffset.UTC)); config = new BotConfig(Path.of("config")); dataSource = DbHelper.initDataSource(config); - interactionHandler = new InteractionHandler(); - messageCache = new MessageCache(); - autoMod = new AutoMod(); - imageCache = new ImageCacheUtils(); asyncPool = Executors.newScheduledThreadPool(config.getSystems().getAsyncPoolSize()); - var jda = JDABuilder.createDefault(config.getSystems().getJdaBotToken()) + autoMod = new AutoMod(); + JDA jda = JDABuilder.createDefault(config.getSystems().getJdaBotToken()) .setStatus(OnlineStatus.DO_NOT_DISTURB) .setChunkingFilter(ChunkingFilter.ALL) .setMemberCachePolicy(MemberCachePolicy.ALL) .enableCache(CacheFlag.ACTIVITY) - .enableIntents(GatewayIntent.GUILD_MEMBERS, GatewayIntent.GUILD_PRESENCES) - .addEventListeners(interactionHandler, autoMod) + .enableIntents(GatewayIntent.GUILD_MEMBERS, GatewayIntent.GUILD_PRESENCES, GatewayIntent.MESSAGE_CONTENT) + .addEventListeners(autoMod, new StateListener()) .build(); - AllowedMentions.setDefaultMentions(EnumSet.of(Message.MentionType.ROLE, Message.MentionType.CHANNEL, Message.MentionType.USER, Message.MentionType.EMOTE)); - addEventListeners(jda); + AllowedMentions.setDefaultMentions(EnumSet.of(Message.MentionType.ROLE, Message.MentionType.CHANNEL, Message.MentionType.USER, Message.MentionType.EMOJI)); + dih4jda = DIH4JDABuilder.setJDA(jda) + .setCommandsPackage("net.javadiscord.javabot") + .setDefaultCommandType(RegistrationType.GUILD) + .build(); + customTagManager = new CustomTagManager(jda, dataSource); + messageCache = new MessageCache(); + serverLockManager = new ServerLockManager(jda); + addEventListeners(jda, dih4jda); + addComponentHandler(dih4jda); + // initialize Sentry + Sentry.init(options -> { + options.setDsn(config.getSystems().getSentryDsn()); + options.setTracesSampleRate(1.0); + options.setDebug(false); + }); try { ScheduledTasks.init(jda); log.info("Initialized scheduled tasks."); } catch (SchedulerException e) { + ExceptionLogger.capture(e, Bot.class.getSimpleName()); log.error("Could not initialize all scheduled tasks.", e); jda.shutdown(); } } /** - * Adds all the bot's event listeners to the JDA instance, except for the - * main {@link InteractionHandler} listener and {@link AutoMod}. + * Adds all the bot's event listeners to the JDA instance, except for + * the {@link AutoMod} instance. * - * @param jda The JDA bot instance to add listeners to. + * @param jda The JDA bot instance to add listeners to. + * @param dih4jda The {@link DIH4JDA} instance. */ - private static void addEventListeners(JDA jda) { + private static void addEventListeners(@NotNull JDA jda, @NotNull DIH4JDA dih4jda) { jda.addEventListener( + serverLockManager, + PresenceUpdater.standardActivities(), new MessageCacheListener(), new GitHubLinkListener(), new MessageLinkListener(), new GuildJoinListener(), - new ServerLock(jda), new UserLeaveListener(), - new StartupListener(), - PresenceUpdater.standardActivities(), - new StatsUpdater(), + new MetricsUpdater(), new SuggestionListener(), new StarboardManager(), - new InteractionListener(), new HelpChannelListener(), new ShareKnowledgeVoteListener(), new JobChannelVoteListener(), new PingableNameListener(), new HugListener() ); + dih4jda.addListener(new DIH4JDAListener()); + } + + private static void addComponentHandler(@NotNull DIH4JDA dih4jda) { + dih4jda.addButtonHandlers(Map.of( + List.of("experience-leaderboard"), new ExperienceLeaderboardSubcommand(), + List.of("utils"), new InteractionUtils(), + List.of("resolve-report"), new ReportManager(), + List.of("self-role"), new SelfRoleInteractionManager(), + List.of("qotw-submission"), new SubmissionInteractionManager(), + List.of("help-channel", "help-thank"), new HelpChannelInteractionManager() + )); + dih4jda.addModalHandlers(Map.of( + List.of("qotw-add-question"), new AddQuestionSubcommand(), + List.of("embed-create"), new CreateEmbedSubcommand(), + List.of(EditEmbedSubcommand.EDIT_EMBED_ID), new EditEmbedSubcommand(), + List.of("embed-addfield"), new AddEmbedFieldSubcommand(), + List.of("quick-migrate"), new QuickMigrateSubcommand(), + List.of("report"), new ReportManager(), + List.of("self-role"), new SelfRoleInteractionManager(), + List.of("tag-create"), new CreateCustomTagSubcommand(), + List.of("tag-edit"), new EditCustomTagSubcommand() + )); + dih4jda.addSelectMenuHandlers(Map.of( + List.of("qotw-submission-select"), new SubmissionInteractionManager() + )); } } diff --git a/src/main/java/net/javadiscord/javabot/command/DelegatingCommandHandler.java b/src/main/java/net/javadiscord/javabot/command/DelegatingCommandHandler.java deleted file mode 100644 index ca11949d8..000000000 --- a/src/main/java/net/javadiscord/javabot/command/DelegatingCommandHandler.java +++ /dev/null @@ -1,125 +0,0 @@ -package net.javadiscord.javabot.command; - -import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; -import net.dv8tion.jda.api.requests.restaction.interactions.InteractionCallbackAction; -import net.dv8tion.jda.api.requests.restaction.interactions.ReplyCallbackAction; -import net.javadiscord.javabot.command.interfaces.SlashCommand; - -import java.util.Collections; -import java.util.HashMap; -import java.util.Map; - -/** - * Abstract command handler which is useful for commands which consist of lots - * of subcommands. A child class will supply a map of subcommand handlers, so - * that this parent handler can do the logic of finding the right subcommand to - * invoke depending on the event received. - */ -public class DelegatingCommandHandler implements SlashCommand { - private final Map subcommandHandlers; - private final Map subcommandGroupHandlers; - - /** - * Constructs the handler with an already-initialized map of subcommands. - * - * @param subcommandHandlers The map of subcommands to use. - */ - public DelegatingCommandHandler(Map subcommandHandlers) { - this.subcommandHandlers = subcommandHandlers; - this.subcommandGroupHandlers = new HashMap<>(); - } - - /** - * Constructs the handler with an empty map, which subcommands can be added - * to via {@link DelegatingCommandHandler#addSubcommand(String, SlashCommand)}. - */ - public DelegatingCommandHandler() { - this.subcommandHandlers = new HashMap<>(); - this.subcommandGroupHandlers = new HashMap<>(); - } - - /** - * Gets an unmodifiable map of the subcommand handlers this delegating - * handler has registered. - * - * @return An unmodifiable map containing all registered subcommands. - */ - public Map getSubcommandHandlers() { - return Collections.unmodifiableMap(this.subcommandHandlers); - } - - /** - * Gets an unmodifiable map of the subcommand group handlers that this - * handler has registered. - * - * @return An unmodifiable map containing all registered group handlers. - */ - public Map getSubcommandGroupHandlers() { - return Collections.unmodifiableMap(this.subcommandGroupHandlers); - } - - /** - * Adds a subcommand to this handler. - * - * @param name The name of the subcommand. This is case-sensitive. - * @param handler The handler that will be called to handle subcommands with - * the given name. - * @throws UnsupportedOperationException If this handler was initialized - * with an unmodifiable map of subcommand handlers. - */ - protected void addSubcommand(String name, SlashCommand handler) { - this.subcommandHandlers.put(name, handler); - } - - /** - * Adds a subcommand group handler to this handler. - * - * @param name The name of the subcommand group. This is case-sensitive. - * @param handler The handler that will be called to handle commands within - * the given subcommand's name. - * @throws UnsupportedOperationException If this handler was initialized - * with an unmodifiable map of subcommand group handlers. - */ - protected void addSubcommandGroup(String name, SlashCommand handler) { - this.subcommandGroupHandlers.put(name, handler); - } - - /** - * Handles slash command events by checking if a subcommand name was given, - * and if so, delegating the handling of the event to that subcommand. - * - * @param event The event. - * @return The reply action that is sent to the user. - */ - @Override - public InteractionCallbackAction handleSlashCommandInteraction(SlashCommandInteractionEvent event) throws ResponseException { - // First we check if the event has specified a subcommand group, and if we have a group handler for it. - if (event.getSubcommandGroup() != null) { - SlashCommand groupHandler = this.getSubcommandGroupHandlers().get(event.getSubcommandGroup()); - if (groupHandler != null) { - return groupHandler.handleSlashCommandInteraction(event); - } - } - // If the event doesn't have a subcommand group, or no handler was found for the group, we just move on to the subcommand. - if (event.getSubcommandName() == null) { - return this.handleNonSubcommand(event); - } else { - SlashCommand handler = this.getSubcommandHandlers().get(event.getSubcommandName()); - if (handler != null) { - return handler.handleSlashCommandInteraction(event); - } else { - return Responses.warning(event, "Unknown Subcommand", "The subcommand you entered could not be found."); - } - } - } - - /** - * Handles the case where the main command is called without any subcommand. - * - * @param event The event. - * @return The reply action that is sent to the user. - */ - protected ReplyCallbackAction handleNonSubcommand(SlashCommandInteractionEvent event) { - return Responses.warning(event, "Missing Subcommand", "Please specify a subcommand."); - } -} diff --git a/src/main/java/net/javadiscord/javabot/command/InteractionHandler.java b/src/main/java/net/javadiscord/javabot/command/InteractionHandler.java deleted file mode 100644 index 0ede7372b..000000000 --- a/src/main/java/net/javadiscord/javabot/command/InteractionHandler.java +++ /dev/null @@ -1,371 +0,0 @@ -package net.javadiscord.javabot.command; - -import net.dv8tion.jda.api.EmbedBuilder; -import net.dv8tion.jda.api.entities.Guild; -import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent; -import net.dv8tion.jda.api.events.interaction.command.MessageContextInteractionEvent; -import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; -import net.dv8tion.jda.api.events.interaction.command.UserContextInteractionEvent; -import net.dv8tion.jda.api.hooks.ListenerAdapter; -import net.dv8tion.jda.api.interactions.commands.Command; -import net.dv8tion.jda.api.interactions.commands.CommandInteraction; -import net.dv8tion.jda.api.interactions.commands.OptionType; -import net.dv8tion.jda.api.interactions.commands.build.Commands; -import net.dv8tion.jda.api.interactions.commands.privileges.CommandPrivilege; -import net.dv8tion.jda.api.requests.RestAction; -import net.dv8tion.jda.api.requests.restaction.CommandListUpdateAction; -import net.javadiscord.javabot.Bot; -import net.javadiscord.javabot.Constants; -import net.javadiscord.javabot.command.data.CommandDataLoader; -import net.javadiscord.javabot.command.data.context_commands.ContextCommandConfig; -import net.javadiscord.javabot.command.data.slash_commands.SlashCommandConfig; -import net.javadiscord.javabot.command.data.slash_commands.SlashOptionConfig; -import net.javadiscord.javabot.command.data.slash_commands.SlashSubCommandConfig; -import net.javadiscord.javabot.command.data.slash_commands.SlashSubCommandGroupConfig; -import net.javadiscord.javabot.command.interfaces.Autocompletable; -import net.javadiscord.javabot.command.interfaces.MessageContextCommand; -import net.javadiscord.javabot.command.interfaces.SlashCommand; -import net.javadiscord.javabot.command.interfaces.UserContextCommand; -import net.javadiscord.javabot.systems.staff.custom_commands.dao.CustomCommandRepository; -import net.javadiscord.javabot.systems.staff.custom_commands.model.CustomCommand; -import net.javadiscord.javabot.systems.notification.GuildNotificationService; -import net.javadiscord.javabot.util.GuildUtils; -import org.jetbrains.annotations.NotNull; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.PrintWriter; -import java.io.StringWriter; -import java.sql.SQLException; -import java.util.*; - -/** - * This listener is responsible for handling slash commands sent by users in - * guilds where the bot is active, and responding to them by calling the - * appropriate {@link SlashCommand}. - *

- * The list of valid commands, and their associated handlers, are defined in - * their corresponding YAML-file under the resources/commands directory. - *

- */ -public class InteractionHandler extends ListenerAdapter { - private static final Logger log = LoggerFactory.getLogger(InteractionHandler.class); - - /** - * Maps every command name and alias to an instance of the command, for - * constant-time lookup. - */ - private final Map slashCommandIndex; - - private final Map userContextCommandIndex; - private final Map messageContextCommandIndex; - private final Map autocompleteIndex; - - private SlashCommandConfig[] slashCommandConfigs; - private ContextCommandConfig[] contextCommandConfigs; - - /** - * Constructor of this class. - */ - public InteractionHandler() { - this.slashCommandIndex = new HashMap<>(); - this.userContextCommandIndex = new HashMap<>(); - this.messageContextCommandIndex = new HashMap<>(); - this.autocompleteIndex = new HashMap<>(); - } - - @Override - public void onSlashCommandInteraction(@NotNull SlashCommandInteractionEvent event) { - if (event.getGuild() == null) return; - SlashCommand command = this.slashCommandIndex.get(event.getName()); - try { - if (command != null) { - command.handleSlashCommandInteraction(event).queue(); - } else { - this.handleCustomCommand(event).queue(); - } - } catch (ResponseException e) { - this.handleResponseException(e, event); - } - } - - @Override - public void onCommandAutoCompleteInteraction(@NotNull CommandAutoCompleteInteractionEvent event) { - if (event.getGuild() == null) return; - SlashCommand command = this.slashCommandIndex.get(event.getName()); - if (command == null) return; - Autocompletable autocomplete = this.autocompleteIndex.get(command); - autocomplete.handleAutocomplete(event).queue(); - } - - /** - * Checks whether a slash command exists. - * @param name the name of the command - * @param guild the {@link Guild} the command may exist in - * @return true if the command exists, else false - */ - public boolean doesSlashCommandExist(String name, Guild guild){ - try { - return this.slashCommandIndex.containsKey(name) || this.getCustomCommand(name, guild).isPresent(); - } catch(SQLException e) { - return false; - } - } - - @Override - public void onUserContextInteraction(@NotNull UserContextInteractionEvent event) { - if (event.getGuild() == null) return; - var command = this.userContextCommandIndex.get(event.getName()); - if (command != null) { - try { - command.handleUserContextCommandInteraction(event).queue(); - } catch (ResponseException e) { - this.handleResponseException(e, event.getInteraction()); - } - } - } - - @Override - public void onMessageContextInteraction(@NotNull MessageContextInteractionEvent event) { - if (event.getGuild() == null) return; - var command = this.messageContextCommandIndex.get(event.getName()); - if (command != null) { - try { - command.handleMessageContextCommandInteraction(event).queue(); - } catch (ResponseException e) { - this.handleResponseException(e, event.getInteraction()); - } - } - } - - private void handleResponseException(ResponseException e, CommandInteraction interaction) { - switch (e.getType()) { - case WARNING -> Responses.warning(interaction, e.getMessage()).queue(); - case ERROR -> Responses.error(interaction, e.getMessage()).queue(); - } - if (e.getCause() != null) { - StringWriter sw = new StringWriter(); - PrintWriter pw = new PrintWriter(sw); - e.getCause().printStackTrace(pw); - new GuildNotificationService(interaction.getGuild()).sendLogChannelNotification( - "An exception occurred when %s issued the **%s** command in %s:\n```%s```\n", - interaction.getUser().getAsMention(), - interaction.getName(), - interaction.getTextChannel().getAsMention(), - sw.toString() - ); - } - } - - /** - * Registers all slash commands defined in the set YAML-files for the given guild - * so that users can see the commands when they type a "/". - *

- * It does this by attempting to add an entry to {@link InteractionHandler#slashCommandIndex} - * whose key is the command name, and whose value is a new instance of - * the handler class which the command has specified. - *

- * - * @param guild The guild to update commands for. - */ - public void registerCommands(Guild guild) { - this.slashCommandConfigs = CommandDataLoader.loadSlashCommandConfig( - "commands/slash/help.yaml", - "commands/slash/jam.yaml", - "commands/slash/qotw.yaml", - "commands/slash/staff.yaml", - "commands/slash/user.yaml" - ); - this.contextCommandConfigs = CommandDataLoader.loadContextCommandConfig( - "commands/context/message.yaml", - "commands/context/user.yaml" - ); - CommandListUpdateAction commandUpdateAction = this.updateCommands(guild); - Set customCommandNames = this.updateCustomCommands(commandUpdateAction, guild); - commandUpdateAction.queue(commands -> { - // Add privileges to the non-custom commands, after the commands have been registered. - commands.removeIf(cmd -> customCommandNames.contains(cmd.getName())); - commands.removeIf(cmd -> cmd.getType() != Command.Type.SLASH); - this.addCommandPrivileges(commands, guild); - }); - } - - private CommandListUpdateAction updateCommands(Guild guild) { - log.info("{}[{}]{} Registering commands", Constants.TEXT_WHITE, guild.getName(), Constants.TEXT_RESET); - if (this.slashCommandConfigs.length > Commands.MAX_SLASH_COMMANDS) { - throw new IllegalArgumentException(String.format("Cannot add more than %s commands.", Commands.MAX_SLASH_COMMANDS)); - } - if (Arrays.stream(this.contextCommandConfigs).filter(p -> p.getEnumType() == Command.Type.USER).count() > Commands.MAX_USER_COMMANDS) { - throw new IllegalArgumentException(String.format("Cannot add more than %s User Context Commands", Commands.MAX_USER_COMMANDS)); - } - if (Arrays.stream(this.contextCommandConfigs).filter(p -> p.getEnumType() == Command.Type.MESSAGE).count() > Commands.MAX_MESSAGE_COMMANDS) { - throw new IllegalArgumentException(String.format("Cannot add more than %s Message Context Commands", Commands.MAX_MESSAGE_COMMANDS)); - } - CommandListUpdateAction commandUpdateAction = guild.updateCommands(); - for (var config : slashCommandConfigs) { - if (config.getHandler() != null && !config.getHandler().isEmpty()) { - try { - Class handlerClass = Class.forName(config.getHandler()); - Object instance = handlerClass.getConstructor().newInstance(); - this.slashCommandIndex.put(config.getName(), (SlashCommand) instance); - if (this.hasAutocomplete(config)) { - this.autocompleteIndex.put((SlashCommand) instance, (Autocompletable) instance); - } - } catch (ReflectiveOperationException e) { - e.printStackTrace(); - } - } else { - log.warn("Slash Command \"{}\" does not have an associated handler class. It will be ignored.", config.getName()); - } - commandUpdateAction.addCommands(config.toData()); - } - for (var config : this.contextCommandConfigs) { - if (config.getHandler() != null && !config.getHandler().isEmpty()) { - try { - Class handlerClass = Class.forName(config.getHandler()); - if (config.getEnumType() == Command.Type.USER) { - this.userContextCommandIndex.put(config.getName(), (UserContextCommand) handlerClass.getConstructor().newInstance()); - } else if (config.getEnumType() == Command.Type.MESSAGE) { - this.messageContextCommandIndex.put(config.getName(), (MessageContextCommand) handlerClass.getConstructor().newInstance()); - } else { - log.warn("Unknown Context Command Type."); - } - } catch (ReflectiveOperationException e) { - e.printStackTrace(); - } - } else { - log.warn("Context Command ({}) \"{}\" does not have an associated handler class. It will be ignored.", config.getEnumType(), config.getName()); - } - commandUpdateAction.addCommands(config.toData()); - } - return commandUpdateAction; - } - - /** - * Attempts to update and register all Custom Commands. - * - * @param commandUpdateAction The {@link CommandListUpdateAction}. - * @param guild The current guild. - * @return A {@link Set} with all Custom Command names. - */ - private Set updateCustomCommands(CommandListUpdateAction commandUpdateAction, Guild guild) { - log.info("{}[{}]{} Registering custom commands", Constants.TEXT_WHITE, guild.getName(), Constants.TEXT_RESET); - try (var con = Bot.dataSource.getConnection()) { - var repo = new CustomCommandRepository(con); - var commands = repo.getCustomCommandsByGuildId(guild.getIdLong()); - Set commandNames = new HashSet<>(); - for (var c : commands) { - var response = c.getResponse(); - if (response.length() > 100) response = response.substring(0, 97).concat("..."); - commandUpdateAction.addCommands( - Commands.slash(c.getName(), response) - .addOption(OptionType.BOOLEAN, "reply", "Should the custom commands reply?") - .addOption(OptionType.BOOLEAN, "embed", "Should the response be embedded?")); - commandNames.add(c.getName()); - } - return commandNames; - } catch (SQLException e) { - e.printStackTrace(); - return Set.of(); - } - } - - private void addCommandPrivileges(List commands, Guild guild) { - log.info("{}[{}]{} Adding command privileges", - Constants.TEXT_WHITE, guild.getName(), Constants.TEXT_RESET); - - Map> map = new HashMap<>(); - for (Command command : commands) { - List privileges = getCommandPrivileges(guild, findCommandConfig(command.getName(), slashCommandConfigs)); - if (!privileges.isEmpty()) { - map.put(command.getId(), privileges); - } - } - - guild.updateCommandPrivileges(map) - .queue(success -> log.info("Commands updated successfully"), error -> log.info("Commands update failed")); - } - - @NotNull - private List getCommandPrivileges(Guild guild, SlashCommandConfig config) { - if (config == null || config.getPrivileges() == null) return Collections.emptyList(); - List privileges = new ArrayList<>(); - for (var privilegeConfig : config.getPrivileges()) { - privileges.add(privilegeConfig.toData(guild, Bot.config)); - log.info("\t{}[{}]{} Registering privilege: {}", - Constants.TEXT_WHITE, config.getName(), Constants.TEXT_RESET, privilegeConfig); - } - return privileges; - } - - private SlashCommandConfig findCommandConfig(String name, SlashCommandConfig[] configs) { - for (SlashCommandConfig config : configs) { - if (name.equals(config.getName())) { - return config; - } - } - log.warn("Could not find CommandConfig for command: {}", name); - return null; - } - - /** - * Handles a Custom Slash Command. - * - * @param event The {@link SlashCommandInteractionEvent} that is fired. - * @return The {@link RestAction}. - */ - private RestAction handleCustomCommand(SlashCommandInteractionEvent event) { - var name = event.getName(); - try { - var optional = getCustomCommand(name, event.getGuild()); - if (optional.isEmpty()) return Responses.error(event,"Unknown Command."); - var command = optional.get(); - var responseText = GuildUtils.replaceTextVariables(event.getGuild(), command.getResponse()); - var replyOption = event.getOption("reply"); - boolean reply = replyOption == null ? command.isReply() : replyOption.getAsBoolean(); - var embedOption = event.getOption("embed"); - boolean embed = embedOption == null ? command.isEmbed() : embedOption.getAsBoolean(); - if (embed) { - var e = new EmbedBuilder() - .setColor(Bot.config.get(event.getGuild()).getSlashCommand().getDefaultColor()) - .setDescription(responseText) - .build(); - if (reply) { - return event.replyEmbeds(e); - } else { - return RestAction.allOf(event.getChannel().sendMessageEmbeds(e), event.reply("Done!").setEphemeral(true)); - } - } else { - if (reply) { - return event.reply(responseText); - } else { - return RestAction.allOf(event.getChannel().sendMessage(responseText), event.reply("Done!").setEphemeral(true)); - } - } - } catch (SQLException e) { - e.printStackTrace(); - return Responses.error(event, "Unknown Command."); - } - } - - private Optional getCustomCommand(String name, Guild guild) throws SQLException { - try (var con = Bot.dataSource.getConnection()) { - var repo = new CustomCommandRepository(con); - return repo.findByName(guild.getIdLong(), name); - } - } - - private boolean hasAutocomplete(SlashCommandConfig config) { - return config.getOptions() != null && Arrays.stream(config.getOptions()).anyMatch(SlashOptionConfig::isAutocomplete) || - config.getSubCommandGroups() != null && Arrays.stream(config.getSubCommandGroups()).anyMatch(this::hasAutocomplete) || - config.getSubCommands() != null && Arrays.stream(config.getSubCommands()).anyMatch(this::hasAutocomplete); - } - - private boolean hasAutocomplete(SlashSubCommandGroupConfig config) { - return config.getSubCommands() != null && Arrays.stream(config.getSubCommands()).anyMatch(this::hasAutocomplete); - } - - private boolean hasAutocomplete(SlashSubCommandConfig config) { - return config.getOptions() != null && Arrays.stream(config.getOptions()).anyMatch(SlashOptionConfig::isAutocomplete); - } -} diff --git a/src/main/java/net/javadiscord/javabot/command/ResponseException.java b/src/main/java/net/javadiscord/javabot/command/ResponseException.java deleted file mode 100644 index dedd3860e..000000000 --- a/src/main/java/net/javadiscord/javabot/command/ResponseException.java +++ /dev/null @@ -1,29 +0,0 @@ -package net.javadiscord.javabot.command; - -import lombok.Getter; - -/** - * An exception that can be thrown while responding to a user. - */ -public class ResponseException extends Exception { - @Getter - private final Type type; - - public ResponseException(Type type, String message, Throwable cause) { - super(message, cause); - this.type = type; - } - - public static ResponseException warning(String message) { - return new ResponseException(Type.WARNING, message, null); - } - - public static ResponseException error(String message, Throwable cause) { - return new ResponseException(Type.ERROR, message, cause); - } - - /** - * Enum class representing all Response Exception types. - */ - public enum Type {WARNING, ERROR} -} diff --git a/src/main/java/net/javadiscord/javabot/command/Responses.java b/src/main/java/net/javadiscord/javabot/command/Responses.java deleted file mode 100644 index 831a167e1..000000000 --- a/src/main/java/net/javadiscord/javabot/command/Responses.java +++ /dev/null @@ -1,103 +0,0 @@ -package net.javadiscord.javabot.command; - -import net.dv8tion.jda.api.EmbedBuilder; -import net.dv8tion.jda.api.entities.Message; -import net.dv8tion.jda.api.entities.MessageEmbed; -import net.dv8tion.jda.api.interactions.InteractionHook; -import net.dv8tion.jda.api.interactions.commands.CommandInteraction; -import net.dv8tion.jda.api.requests.restaction.WebhookMessageAction; -import net.dv8tion.jda.api.requests.restaction.interactions.ReplyCallbackAction; -import net.javadiscord.javabot.Bot; - -import javax.annotation.Nullable; -import java.awt.*; -import java.time.Instant; - -/** - * Utility class that provides standardized formatting for responses the bot - * sends as replies to slash command events. - */ -public final class Responses { - - private Responses() { - } - - public static ReplyCallbackAction success(CommandInteraction event, String title, String message) { - return reply(event, title, message, Bot.config.get(event.getGuild()).getSlashCommand().getInfoColor(), true); - } - - public static WebhookMessageAction success(InteractionHook hook, String title, String message) { - return reply(hook, title, message, Bot.config.get(hook.getInteraction().getGuild()).getSlashCommand().getSuccessColor(), true); - } - - public static ReplyCallbackAction info(CommandInteraction event, String title, String message) { - return reply(event, title, message, Bot.config.get(event.getGuild()).getSlashCommand().getInfoColor(), true); - } - - public static WebhookMessageAction info(InteractionHook hook, String title, String message) { - return reply(hook, title, message, Bot.config.get(hook.getInteraction().getGuild()).getSlashCommand().getInfoColor(), true); - } - - public static ReplyCallbackAction error(CommandInteraction event, String message) { - return reply(event, "An Error Occurred", message, Bot.config.get(event.getGuild()).getSlashCommand().getErrorColor(), true); - } - - public static WebhookMessageAction error(InteractionHook hook, String message) { - return reply(hook, "An Error Occurred", message, Bot.config.get(hook.getInteraction().getGuild()).getSlashCommand().getErrorColor(), true); - } - - public static ReplyCallbackAction warning(CommandInteraction event, String message) { - return warning(event, null, message); - } - - public static WebhookMessageAction warning(InteractionHook hook, String message) { - return warning(hook, null, message); - } - - public static ReplyCallbackAction warning(CommandInteraction event, String title, String message) { - return reply(event, title, message, Bot.config.get(event.getGuild()).getSlashCommand().getWarningColor(), true); - } - - public static WebhookMessageAction warning(InteractionHook hook, String title, String message) { - return reply(hook, title, message, Bot.config.get(hook.getInteraction().getGuild()).getSlashCommand().getWarningColor(), true); - } - - /** - * Sends a reply to a slash command event. - * - * @param event The event to reply to. - * @param title The title of the reply message. - * @param message The message to send. - * @param color The color of the embed. - * @param ephemeral Whether the message should be ephemeral. - * @return The reply action. - */ - private static ReplyCallbackAction reply(CommandInteraction event, @Nullable String title, String message, Color color, boolean ephemeral) { - return event.replyEmbeds(buildEmbed(title, message, color)).setEphemeral(ephemeral); - } - - /** - * Sends a reply to an interaction hook. - * - * @param hook The interaction hook to send a message to. - * @param title The title of the message. - * @param message The message to send. - * @param color The color of the embed. - * @param ephemeral Whether the message should be ephemeral. - * @return The webhook message action. - */ - private static WebhookMessageAction reply(InteractionHook hook, @Nullable String title, String message, Color color, boolean ephemeral) { - return hook.sendMessageEmbeds(buildEmbed(title, message, color)).setEphemeral(ephemeral); - } - - private static MessageEmbed buildEmbed(@Nullable String title, String message, Color color) { - EmbedBuilder embedBuilder = new EmbedBuilder() - .setTimestamp(Instant.now()) - .setColor(color); - if (title != null && !title.isBlank()) { - embedBuilder.setTitle(title); - } - embedBuilder.setDescription(message); - return embedBuilder.build(); - } -} diff --git a/src/main/java/net/javadiscord/javabot/command/data/CommandDataLoader.java b/src/main/java/net/javadiscord/javabot/command/data/CommandDataLoader.java deleted file mode 100644 index 08d805aef..000000000 --- a/src/main/java/net/javadiscord/javabot/command/data/CommandDataLoader.java +++ /dev/null @@ -1,78 +0,0 @@ -package net.javadiscord.javabot.command.data; - -import lombok.extern.slf4j.Slf4j; -import net.javadiscord.javabot.command.data.context_commands.ContextCommandConfig; -import net.javadiscord.javabot.command.data.slash_commands.SlashCommandConfig; -import org.yaml.snakeyaml.Yaml; - -import java.io.InputStream; -import java.util.HashSet; -import java.util.Set; - -/** - * Simple helper class that loads an array of * instances - * from the YAML files. - */ -@Slf4j -public final class CommandDataLoader { - private CommandDataLoader() { - } - - /** - * Loads an array of {@link SlashCommandConfig} from the given set of classpath - * resources. - * - * @param resources The list of resources to read from. - * @return An array of slash command data objects. - */ - public static SlashCommandConfig[] loadSlashCommandConfig(String... resources) { - Yaml yaml = new Yaml(); - Set commands = new HashSet<>(); - for (var resource : resources) { - InputStream is = CommandDataLoader.class.getClassLoader().getResourceAsStream(resource); - if (is == null) { - System.err.println("Could not load commands from resource: " + resource); - continue; - } - SlashCommandConfig[] cs = yaml.loadAs(is, SlashCommandConfig[].class); - if (cs != null) { - for (var newCommand : cs) { - if (commands.contains(newCommand)) { - log.warn("Found duplicate command {} in file {}. Already loaded a command with this name; this one will be ignored.", newCommand.getName(), resource); - } - commands.add(newCommand); - } - } - } - return commands.toArray(new SlashCommandConfig[0]); - } - - /** - * Loads an array of {@link ContextCommandConfig} from the given set of classpath - * resources. - * - * @param resources The list of resources to read from. - * @return An array of slash command data objects. - */ - public static ContextCommandConfig[] loadContextCommandConfig(String... resources) { - Yaml yaml = new Yaml(); - Set commands = new HashSet<>(); - for (var resource : resources) { - InputStream is = CommandDataLoader.class.getClassLoader().getResourceAsStream(resource); - if (is == null) { - System.err.println("Could not load commands from resource: " + resource); - continue; - } - ContextCommandConfig[] cs = yaml.loadAs(is, ContextCommandConfig[].class); - if (cs != null) { - for (var newCommand : cs) { - if (commands.contains(newCommand)) { - log.warn("Found duplicate command {} in file {}. Already loaded a command with this name; this one will be ignored.", newCommand.getName(), resource); - } - commands.add(newCommand); - } - } - } - return commands.toArray(new ContextCommandConfig[0]); - } -} diff --git a/src/main/java/net/javadiscord/javabot/command/data/context_commands/ContextCommandConfig.java b/src/main/java/net/javadiscord/javabot/command/data/context_commands/ContextCommandConfig.java deleted file mode 100644 index 548b1149e..000000000 --- a/src/main/java/net/javadiscord/javabot/command/data/context_commands/ContextCommandConfig.java +++ /dev/null @@ -1,24 +0,0 @@ -package net.javadiscord.javabot.command.data.context_commands; - -import lombok.Data; -import net.dv8tion.jda.api.interactions.commands.Command; -import net.dv8tion.jda.api.interactions.commands.build.CommandData; -import net.dv8tion.jda.api.interactions.commands.build.Commands; - -/** - * Simple DTO representing a top-level Discord context command. - */ -@Data -public class ContextCommandConfig { - private String name; - private String type; - private String handler; - - public Command.Type getEnumType() { - return Command.Type.valueOf(this.type); - } - - public CommandData toData() { - return Commands.context(getEnumType(), this.name); - } -} diff --git a/src/main/java/net/javadiscord/javabot/command/data/slash_commands/SlashCommandConfig.java b/src/main/java/net/javadiscord/javabot/command/data/slash_commands/SlashCommandConfig.java deleted file mode 100644 index e3abfab89..000000000 --- a/src/main/java/net/javadiscord/javabot/command/data/slash_commands/SlashCommandConfig.java +++ /dev/null @@ -1,92 +0,0 @@ -package net.javadiscord.javabot.command.data.slash_commands; - -import lombok.Data; -import net.dv8tion.jda.api.interactions.commands.build.Commands; -import net.dv8tion.jda.api.interactions.commands.build.SlashCommandData; - -import java.util.Arrays; -import java.util.Objects; - -/** - * Simple DTO representing a top-level Discord slash command. - */ -@Data -public class SlashCommandConfig { - private String name; - private String description; - private boolean enabledByDefault = true; - private SlashCommandPrivilegeConfig[] privileges; - private SlashOptionConfig[] options; - private SlashSubCommandConfig[] subCommands; - private SlashSubCommandGroupConfig[] subCommandGroups; - private String handler; - - /** - * Creates a {@link SlashCommandConfig} object from the given {@link SlashCommandData}. - * - * @param data The original {@link SlashCommandData}. - * @return A new {@link SlashCommandConfig} object. - */ - public static SlashCommandConfig fromData(SlashCommandData data) { - SlashCommandConfig c = new SlashCommandConfig(); - c.setName(data.getName()); - c.setDescription(data.getDescription()); - c.setEnabledByDefault(data.isDefaultEnabled()); - c.setOptions(data.getOptions().stream().map(SlashOptionConfig::fromData).toArray(SlashOptionConfig[]::new)); - c.setSubCommands(data.getSubcommands().stream().map(SlashSubCommandConfig::fromData).toArray(SlashSubCommandConfig[]::new)); - c.setSubCommandGroups(data.getSubcommandGroups().stream().map(SlashSubCommandGroupConfig::fromData).toArray(SlashSubCommandGroupConfig[]::new)); - c.setHandler(null); - return c; - } - - /** - * Converts the current {@link SlashCommandConfig} into a {@link SlashCommandData} object. - * - * @return The {@link SlashCommandData} object. - */ - public SlashCommandData toData() { - SlashCommandData data = Commands.slash(this.name, this.description); - data.setDefaultEnabled(this.enabledByDefault); - if (this.options != null) { - for (SlashOptionConfig option : this.options) { - data.addOptions(option.toData()); - } - } - if (this.subCommands != null) { - for (SlashSubCommandConfig subCommand : this.subCommands) { - data.addSubcommands(subCommand.toData()); - } - } - if (this.subCommandGroups != null) { - for (SlashSubCommandGroupConfig group : this.subCommandGroups) { - data.addSubcommandGroups(group.toData()); - } - } - return data; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (!(o instanceof SlashCommandConfig that)) return false; - return getName().equals(that.getName()); - } - - @Override - public int hashCode() { - return Objects.hash(getName()); - } - - @Override - public String toString() { - return "CommandConfig{" + - "name='" + name + '\'' + - ", description='" + description + '\'' + - ", enabledByDefault=" + enabledByDefault + - ", options=" + Arrays.toString(options) + - ", subCommands=" + Arrays.toString(subCommands) + - ", subCommandGroups=" + Arrays.toString(subCommandGroups) + - ", handler=" + handler + - '}'; - } -} diff --git a/src/main/java/net/javadiscord/javabot/command/data/slash_commands/SlashCommandPrivilegeConfig.java b/src/main/java/net/javadiscord/javabot/command/data/slash_commands/SlashCommandPrivilegeConfig.java deleted file mode 100644 index 7a6448a9e..000000000 --- a/src/main/java/net/javadiscord/javabot/command/data/slash_commands/SlashCommandPrivilegeConfig.java +++ /dev/null @@ -1,48 +0,0 @@ -package net.javadiscord.javabot.command.data.slash_commands; - -import lombok.Data; -import lombok.extern.slf4j.Slf4j; -import net.dv8tion.jda.api.entities.Guild; -import net.dv8tion.jda.api.entities.Member; -import net.dv8tion.jda.api.entities.Role; -import net.dv8tion.jda.api.interactions.commands.privileges.CommandPrivilege; -import net.javadiscord.javabot.data.config.BotConfig; -import net.javadiscord.javabot.data.config.UnknownPropertyException; - -/** - * Simple DTO representing slash command privileges. - */ -@Data -@Slf4j -public class SlashCommandPrivilegeConfig { - private String type; - private boolean enabled = true; - private String id; - - /** - * Converts the current {@link SlashCommandPrivilegeConfig} into a {@link CommandPrivilege} object. - * - * @param guild The current guild. - * @param botConfig The bot's config. - * @return The {@link CommandPrivilege} object. - */ - public CommandPrivilege toData(Guild guild, BotConfig botConfig) { - if (this.type.equalsIgnoreCase(CommandPrivilege.Type.USER.name())) { - Member member = guild.getMemberById(id); - if (member == null) throw new IllegalArgumentException("Member could not be found for id " + id); - return new CommandPrivilege(CommandPrivilege.Type.USER, this.enabled, member.getIdLong()); - } else if (this.type.equalsIgnoreCase(CommandPrivilege.Type.ROLE.name())) { - Long roleId = null; - try { - roleId = (Long) botConfig.get(guild).resolve(this.id); - } catch (UnknownPropertyException e) { - log.error("Unknown property while resolving role id.", e); - } - if (roleId == null) throw new IllegalArgumentException("Missing role id."); - Role role = guild.getRoleById(roleId); - if (role == null) throw new IllegalArgumentException("Role could not be found for id " + roleId); - return new CommandPrivilege(CommandPrivilege.Type.ROLE, this.enabled, role.getIdLong()); - } - throw new IllegalArgumentException("Invalid type."); - } -} diff --git a/src/main/java/net/javadiscord/javabot/command/data/slash_commands/SlashOptionChoiceConfig.java b/src/main/java/net/javadiscord/javabot/command/data/slash_commands/SlashOptionChoiceConfig.java deleted file mode 100644 index 772cb33ba..000000000 --- a/src/main/java/net/javadiscord/javabot/command/data/slash_commands/SlashOptionChoiceConfig.java +++ /dev/null @@ -1,43 +0,0 @@ -package net.javadiscord.javabot.command.data.slash_commands; - -import lombok.Data; -import net.dv8tion.jda.api.interactions.commands.Command.Choice; - -/** - * DTO for a choice that a slash command option can have. - */ -@Data -public class SlashOptionChoiceConfig { - private String name; - private String value; - - /** - * Converts the given {@link Choice} into a {@link SlashOptionChoiceConfig} object. - * - * @param choice The {@link Choice}. - * @return The {@link SlashOptionChoiceConfig} object. - */ - public static SlashOptionChoiceConfig fromData(Choice choice) { - var c = new SlashOptionChoiceConfig(); - c.setName(choice.getName()); - c.setValue(choice.getAsString()); - return c; - } - - /** - * Converts this choice data into a JDA object for use with the API. - * - * @return The JDA option choice object. - */ - public Choice toData() { - return new Choice(name, value); - } - - @Override - public String toString() { - return "OptionChoiceConfig{" + - "name='" + name + '\'' + - ", value='" + value + '\'' + - '}'; - } -} diff --git a/src/main/java/net/javadiscord/javabot/command/data/slash_commands/SlashOptionConfig.java b/src/main/java/net/javadiscord/javabot/command/data/slash_commands/SlashOptionConfig.java deleted file mode 100644 index bec0fe694..000000000 --- a/src/main/java/net/javadiscord/javabot/command/data/slash_commands/SlashOptionConfig.java +++ /dev/null @@ -1,63 +0,0 @@ -package net.javadiscord.javabot.command.data.slash_commands; - -import lombok.Data; -import net.dv8tion.jda.api.interactions.commands.OptionType; -import net.dv8tion.jda.api.interactions.commands.build.OptionData; - -import java.util.Arrays; - -/** - * Simple DTO representing an option that can be given to a Discord slash - * command or subcommand. - */ -@Data -public class SlashOptionConfig { - private String name; - private String description; - private String type; - private boolean required; - private boolean autocomplete = false; - private SlashOptionChoiceConfig[] choices; - - /** - * Converts the given {@link OptionData} into a {@link SlashOptionConfig} object. - * - * @param data The {@link OptionData}. - * @return The {@link SlashOptionConfig} object. - */ - public static SlashOptionConfig fromData(OptionData data) { - SlashOptionConfig c = new SlashOptionConfig(); - c.setName(data.getName()); - c.setDescription(data.getDescription()); - c.setType(data.getType().name()); - c.setRequired(data.isRequired()); - c.setAutocomplete(data.isAutoComplete()); - c.setChoices(data.getChoices().stream().map(SlashOptionChoiceConfig::fromData).toArray(SlashOptionChoiceConfig[]::new)); - return c; - } - - /** - * Converts the current {@link SlashOptionConfig} to a {@link OptionData} object. - * - * @return The {@link OptionData} object. - */ - public OptionData toData() { - var d = new OptionData(OptionType.valueOf(this.type.toUpperCase()), this.name, this.description, this.required, this.autocomplete); - if (this.choices != null && this.choices.length > 0) { - d.addChoices(Arrays.stream(this.choices).map(SlashOptionChoiceConfig::toData).toList()); - } - return d; - } - - @Override - public String toString() { - return "OptionConfig{" + - "name='" + name + '\'' + - ", description='" + description + '\'' + - ", type='" + type + '\'' + - ", required=" + required + - ", autocomplete=" + autocomplete + - ", choices=" + Arrays.toString(choices) + - '}'; - } -} diff --git a/src/main/java/net/javadiscord/javabot/command/data/slash_commands/SlashSubCommandConfig.java b/src/main/java/net/javadiscord/javabot/command/data/slash_commands/SlashSubCommandConfig.java deleted file mode 100644 index 1031818bf..000000000 --- a/src/main/java/net/javadiscord/javabot/command/data/slash_commands/SlashSubCommandConfig.java +++ /dev/null @@ -1,54 +0,0 @@ -package net.javadiscord.javabot.command.data.slash_commands; - -import lombok.Data; -import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; - -import java.util.Arrays; - -/** - * Simple DTO for a Discord subcommand. - */ -@Data -public class SlashSubCommandConfig { - private String name; - private String description; - private SlashOptionConfig[] options; - - /** - * Converts the given {@link SubcommandData} into a {@link SlashSubCommandConfig} object. - * - * @param data The {@link SubcommandData}. - * @return The {@link SlashSubCommandConfig} object. - */ - public static SlashSubCommandConfig fromData(SubcommandData data) { - SlashSubCommandConfig c = new SlashSubCommandConfig(); - c.setName(data.getName()); - c.setDescription(data.getDescription()); - c.setOptions(data.getOptions().stream().map(SlashOptionConfig::fromData).toArray(SlashOptionConfig[]::new)); - return c; - } - - /** - * Converts the current {@link SlashSubCommandConfig} into a {@link SubcommandData} object. - * - * @return The {@link SubcommandData} object. - */ - public SubcommandData toData() { - SubcommandData data = new SubcommandData(this.name, this.description); - if (this.options != null) { - for (SlashOptionConfig oc : this.options) { - data.addOptions(oc.toData()); - } - } - return data; - } - - @Override - public String toString() { - return "SubCommandConfig{" + - "name='" + name + '\'' + - ", description='" + description + '\'' + - ", options=" + Arrays.toString(options) + - '}'; - } -} diff --git a/src/main/java/net/javadiscord/javabot/command/data/slash_commands/SlashSubCommandGroupConfig.java b/src/main/java/net/javadiscord/javabot/command/data/slash_commands/SlashSubCommandGroupConfig.java deleted file mode 100644 index d636e4c04..000000000 --- a/src/main/java/net/javadiscord/javabot/command/data/slash_commands/SlashSubCommandGroupConfig.java +++ /dev/null @@ -1,54 +0,0 @@ -package net.javadiscord.javabot.command.data.slash_commands; - -import lombok.Data; -import net.dv8tion.jda.api.interactions.commands.build.SubcommandGroupData; - -import java.util.Arrays; - -/** - * Simple DTO for a group of Discord subcommands. - */ -@Data -public class SlashSubCommandGroupConfig { - private String name; - private String description; - private SlashSubCommandConfig[] subCommands; - - /** - * Converts the given {@link SubcommandGroupData} into a {@link SlashSubCommandGroupConfig} object. - * - * @param data The {@link SubcommandGroupData}. - * @return The {@link SlashSubCommandGroupConfig} object. - */ - public static SlashSubCommandGroupConfig fromData(SubcommandGroupData data) { - SlashSubCommandGroupConfig c = new SlashSubCommandGroupConfig(); - c.setName(data.getName()); - c.setDescription(data.getDescription()); - c.setSubCommands(data.getSubcommands().stream().map(SlashSubCommandConfig::fromData).toArray(SlashSubCommandConfig[]::new)); - return c; - } - - /** - * Converts the current {@link SlashSubCommandGroupConfig} into a {@link SubcommandGroupData} object. - * - * @return The {@link SubcommandGroupData} object. - */ - public SubcommandGroupData toData() { - SubcommandGroupData data = new SubcommandGroupData(this.name, this.description); - if (this.subCommands != null) { - for (SlashSubCommandConfig scc : this.subCommands) { - data.addSubcommands(scc.toData()); - } - } - return data; - } - - @Override - public String toString() { - return "SubCommandGroupConfig{" + - "name='" + name + '\'' + - ", description='" + description + '\'' + - ", subCommands=" + Arrays.toString(subCommands) + - '}'; - } -} diff --git a/src/main/java/net/javadiscord/javabot/command/interfaces/Autocompletable.java b/src/main/java/net/javadiscord/javabot/command/interfaces/Autocompletable.java deleted file mode 100644 index ebe22c438..000000000 --- a/src/main/java/net/javadiscord/javabot/command/interfaces/Autocompletable.java +++ /dev/null @@ -1,18 +0,0 @@ -package net.javadiscord.javabot.command.interfaces; - -import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent; -import net.dv8tion.jda.api.requests.restaction.interactions.AutoCompleteCallbackAction; - -/** - * Interface that handles Discord's Autocomplete functionality. - *

- * To enable this handler for a particular slash command option, navigate to that - * option's entry in the corresponding YAML-file, and add the following property: - *


- * autocomplete: true
- *     
- *

- */ -public interface Autocompletable { - AutoCompleteCallbackAction handleAutocomplete(CommandAutoCompleteInteractionEvent event); -} \ No newline at end of file diff --git a/src/main/java/net/javadiscord/javabot/command/interfaces/MessageContextCommand.java b/src/main/java/net/javadiscord/javabot/command/interfaces/MessageContextCommand.java deleted file mode 100644 index e02b2f712..000000000 --- a/src/main/java/net/javadiscord/javabot/command/interfaces/MessageContextCommand.java +++ /dev/null @@ -1,12 +0,0 @@ -package net.javadiscord.javabot.command.interfaces; - -import net.dv8tion.jda.api.events.interaction.command.MessageContextInteractionEvent; -import net.dv8tion.jda.api.requests.restaction.interactions.InteractionCallbackAction; -import net.javadiscord.javabot.command.ResponseException; - -/** - * Interface that handles Discord's Message Context Commands. - */ -public interface MessageContextCommand { - InteractionCallbackAction handleMessageContextCommandInteraction(MessageContextInteractionEvent event) throws ResponseException; -} diff --git a/src/main/java/net/javadiscord/javabot/command/interfaces/SlashCommand.java b/src/main/java/net/javadiscord/javabot/command/interfaces/SlashCommand.java deleted file mode 100644 index 1fbc80ae2..000000000 --- a/src/main/java/net/javadiscord/javabot/command/interfaces/SlashCommand.java +++ /dev/null @@ -1,24 +0,0 @@ -package net.javadiscord.javabot.command.interfaces; - -import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; -import net.dv8tion.jda.api.requests.restaction.interactions.InteractionCallbackAction; -import net.javadiscord.javabot.command.ResponseException; - -/** - * Implement this interface to declare that your class handles certain slash - * commands. - *

- * All implementing classes should have a public, no-args - * constructor. - *

- *

- * To enable this handler for a particular slash command, navigate to that - * command's entry in the corresponding YAML-file, and add the following property: - *


- * handler: com.javadiscord.javabot.commands.MyFullHandlerClassName
- *     
- *

- */ -public interface SlashCommand { - InteractionCallbackAction handleSlashCommandInteraction(SlashCommandInteractionEvent event) throws ResponseException; -} diff --git a/src/main/java/net/javadiscord/javabot/command/interfaces/UserContextCommand.java b/src/main/java/net/javadiscord/javabot/command/interfaces/UserContextCommand.java deleted file mode 100644 index abb1ceb2b..000000000 --- a/src/main/java/net/javadiscord/javabot/command/interfaces/UserContextCommand.java +++ /dev/null @@ -1,12 +0,0 @@ -package net.javadiscord.javabot.command.interfaces; - -import net.dv8tion.jda.api.events.interaction.command.UserContextInteractionEvent; -import net.dv8tion.jda.api.requests.restaction.interactions.InteractionCallbackAction; -import net.javadiscord.javabot.command.ResponseException; - -/** - * Interface that handles Discord's User Context Commands. - */ -public interface UserContextCommand { - InteractionCallbackAction handleUserContextCommandInteraction(UserContextInteractionEvent event) throws ResponseException; -} diff --git a/src/main/java/net/javadiscord/javabot/command/moderation/ModerateCommand.java b/src/main/java/net/javadiscord/javabot/command/moderation/ModerateCommand.java deleted file mode 100644 index 0bf6c0b87..000000000 --- a/src/main/java/net/javadiscord/javabot/command/moderation/ModerateCommand.java +++ /dev/null @@ -1,44 +0,0 @@ -package net.javadiscord.javabot.command.moderation; - -import net.dv8tion.jda.api.entities.ChannelType; -import net.dv8tion.jda.api.entities.Member; -import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; -import net.dv8tion.jda.api.requests.restaction.interactions.ReplyCallbackAction; -import net.javadiscord.javabot.command.ResponseException; -import net.javadiscord.javabot.command.Responses; -import net.javadiscord.javabot.command.interfaces.SlashCommand; - -/** - * Abstract class, that represents a single moderation command. - */ -public abstract class ModerateCommand implements SlashCommand { - private boolean allowThreads = true; - - @Override - public ReplyCallbackAction handleSlashCommandInteraction(SlashCommandInteractionEvent event) throws ResponseException { - if (event.getGuild() == null) { - return Responses.error(event, "This command can only be used inside servers."); - } - - if (allowThreads) { - if (event.getChannelType() != ChannelType.TEXT && event.getChannelType() != ChannelType.GUILD_PRIVATE_THREAD && event.getChannelType() != ChannelType.GUILD_PUBLIC_THREAD) { - return Responses.error(event, "This command can only be performed in a server text channel or thread."); - } - } else { - if (event.getChannelType() != ChannelType.TEXT) { - return Responses.error(event, "This command can only be performed in a server text channel."); - } - } - Member member = event.getMember(); - if (member == null) { - return Responses.error(event, "Unexpected error has occurred, Slash Command member was null."); - } - return handleModerationCommand(event, member); - } - - protected void setAllowThreads(boolean allowThreads) { - this.allowThreads = allowThreads; - } - - protected abstract ReplyCallbackAction handleModerationCommand(SlashCommandInteractionEvent event, Member commandUser) throws ResponseException; -} diff --git a/src/main/java/net/javadiscord/javabot/command/moderation/ModerateUserCommand.java b/src/main/java/net/javadiscord/javabot/command/moderation/ModerateUserCommand.java deleted file mode 100644 index 3d9c85569..000000000 --- a/src/main/java/net/javadiscord/javabot/command/moderation/ModerateUserCommand.java +++ /dev/null @@ -1,62 +0,0 @@ -package net.javadiscord.javabot.command.moderation; - -import net.dv8tion.jda.api.entities.Member; -import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; -import net.dv8tion.jda.api.interactions.commands.OptionMapping; -import net.dv8tion.jda.api.requests.restaction.interactions.ReplyCallbackAction; -import net.javadiscord.javabot.command.ResponseException; -import net.javadiscord.javabot.command.Responses; - -/** - * A moderation command action like ban, kick, mute, report, etc. | In short, it targets a user. - */ -public abstract class ModerateUserCommand extends ModerateCommand { - private boolean actOnSelf; - - public ModerateUserCommand() { - setActOnSelf(false); - } - - public ModerateUserCommand(boolean actOnSelf) { - setActOnSelf(actOnSelf); - } - - protected void setActOnSelf(boolean actOnSelf) { - this.actOnSelf = actOnSelf; - } - - @Override - protected final ReplyCallbackAction handleModerationCommand(SlashCommandInteractionEvent event, Member commandUser) throws ResponseException { - OptionMapping targetOption = event.getOption("user"); - if (targetOption == null) { - return Responses.error(event, "Missing required arguments."); - } - Member target = targetOption.getAsMember(); - if (target == null) { - return Responses.error(event, "Cannot perform action on an user that isn't in the server."); - } - - if (!actOnSelf || !commandUser.isOwner()) { - if (commandUser.getId().equals(target.getId())) { - return Responses.error(event, "You cannot perform this action on yourself."); - } - } - - if (target.isOwner()) { - return Responses.error(event, "You cannot preform actions on a higher member staff member."); - } else { - //If both users have at least one role. - if (target.getRoles().size() > 0 && commandUser.getRoles().size() > 0) { - if (commandUser.getRoles().get(0).getPosition() <= target.getRoles().get(0).getPosition()) { - return Responses.error(event, "You cannot preform actions on a higher/equal member staff member."); - } - } - } - - //CommandUser role less than or equal to target role - - return handleModerationActionCommand(event, commandUser, target); - } - - protected abstract ReplyCallbackAction handleModerationActionCommand(SlashCommandInteractionEvent event, Member commandUser, Member target) throws ResponseException; -} diff --git a/src/main/java/net/javadiscord/javabot/data/config/BotConfig.java b/src/main/java/net/javadiscord/javabot/data/config/BotConfig.java index 647ebe0e7..3829ed786 100644 --- a/src/main/java/net/javadiscord/javabot/data/config/BotConfig.java +++ b/src/main/java/net/javadiscord/javabot/data/config/BotConfig.java @@ -5,8 +5,12 @@ import com.google.gson.JsonSyntaxException; import lombok.extern.slf4j.Slf4j; import net.dv8tion.jda.api.entities.Guild; +import net.javadiscord.javabot.util.ExceptionLogger; +import org.jetbrains.annotations.NotNull; import javax.annotation.Nullable; +import java.io.BufferedReader; +import java.io.BufferedWriter; import java.io.IOException; import java.io.UncheckedIOException; import java.nio.file.Files; @@ -51,6 +55,7 @@ public BotConfig(Path dir) { try { Files.createDirectories(dir); } catch (IOException e) { + ExceptionLogger.capture(e, getClass().getSimpleName()); log.error("Could not create config directory " + dir, e); } } else { @@ -61,10 +66,11 @@ public BotConfig(Path dir) { Gson gson = new GsonBuilder().enableComplexMapKeySerialization().create(); Path systemsFile = dir.resolve(SYSTEMS_FILE); if (Files.exists(systemsFile)) { - try (var reader = Files.newBufferedReader(systemsFile)) { + try (BufferedReader reader = Files.newBufferedReader(systemsFile)) { this.systemsConfig = gson.fromJson(reader, SystemsConfig.class); log.info("Loaded systems config from {}", systemsFile); } catch (JsonSyntaxException e) { + ExceptionLogger.capture(e, getClass().getSimpleName()); log.error("Invalid JSON found! Please fix or remove config file " + systemsFile + " and restart.", e); throw e; } catch (IOException e) { @@ -82,10 +88,10 @@ public BotConfig(Path dir) { * * @param guilds The list of guilds to load config for. */ - public void loadGuilds(List guilds) { + public void loadGuilds(@NotNull List guilds) { for (Guild guild : guilds) { - var file = dir.resolve(guild.getId() + ".json"); - var config = GuildConfig.loadOrCreate(guild, file); + Path file = dir.resolve(guild.getId() + ".json"); + GuildConfig config = GuildConfig.loadOrCreate(guild, file); this.guilds.put(guild.getIdLong(), config); log.info("Loaded guild config for guild {} ({}).", guild.getName(), guild.getId()); } @@ -98,8 +104,8 @@ public void loadGuilds(List guilds) { * * @param guild The guild to add configuration for. */ - public void addGuild(Guild guild) { - var file = dir.resolve(guild.getId() + ".json"); + public void addGuild(@NotNull Guild guild) { + Path file = dir.resolve(guild.getId() + ".json"); this.guilds.put(guild.getIdLong(), GuildConfig.loadOrCreate(guild, file)); log.info("Added guild config for guild {} ({}).", guild.getName(), guild.getId()); } @@ -128,13 +134,14 @@ public SystemsConfig getSystems() { public void flush() { Gson gson = new GsonBuilder().serializeNulls().setPrettyPrinting().create(); Path systemsFile = this.dir.resolve(SYSTEMS_FILE); - try (var writer = Files.newBufferedWriter(systemsFile)) { + try (BufferedWriter writer = Files.newBufferedWriter(systemsFile)) { gson.toJson(this.systemsConfig, writer); writer.flush(); } catch (IOException e) { + ExceptionLogger.capture(e, getClass().getSimpleName()); log.error("Could not save systems config.", e); } - for (var config : this.guilds.values()) { + for (GuildConfig config : this.guilds.values()) { config.flush(); } } diff --git a/src/main/java/net/javadiscord/javabot/data/config/GuildConfig.java b/src/main/java/net/javadiscord/javabot/data/config/GuildConfig.java index 69372cc59..55ac73478 100644 --- a/src/main/java/net/javadiscord/javabot/data/config/GuildConfig.java +++ b/src/main/java/net/javadiscord/javabot/data/config/GuildConfig.java @@ -7,12 +7,18 @@ import lombok.extern.slf4j.Slf4j; import net.dv8tion.jda.api.entities.Guild; import net.javadiscord.javabot.data.config.guild.*; +import net.javadiscord.javabot.util.ExceptionLogger; +import net.javadiscord.javabot.util.Pair; import javax.annotation.Nullable; +import java.io.BufferedReader; +import java.io.BufferedWriter; import java.io.IOException; import java.io.UncheckedIOException; +import java.lang.reflect.Field; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Optional; /** * A collection of guild-specific configuration items, each of which represents @@ -24,16 +30,13 @@ public class GuildConfig { private transient Guild guild; private transient Path file; - private SlashCommandConfig slashCommand; - private HelpConfig help; - private ModerationConfig moderation; - private QOTWConfig qotw; - private StatsConfig stats; - private StarboardConfig starBoard; - private JamConfig jam; - private MessageCacheConfig messageCache; - private EmoteConfig emote; - private ServerLockConfig serverLock; + private HelpConfig helpConfig; + private ModerationConfig moderationConfig; + private QOTWConfig qotwConfig; + private MetricsConfig metricsConfig; + private StarboardConfig starboardConfig; + private MessageCacheConfig messageCacheConfig; + private ServerLockConfig serverLockConfig; /** * Constructor that initializes all Config classes. @@ -44,16 +47,13 @@ public class GuildConfig { public GuildConfig(Guild guild, Path file) { this.file = file; // Initialize all config items. - this.slashCommand = new SlashCommandConfig(); - this.help = new HelpConfig(); - this.moderation = new ModerationConfig(); - this.qotw = new QOTWConfig(); - this.stats = new StatsConfig(); - this.starBoard = new StarboardConfig(); - this.jam = new JamConfig(); - this.messageCache = new MessageCacheConfig(); - this.emote = new EmoteConfig(); - this.serverLock = new ServerLockConfig(); + this.helpConfig = new HelpConfig(); + this.moderationConfig = new ModerationConfig(); + this.qotwConfig = new QOTWConfig(); + this.metricsConfig = new MetricsConfig(); + this.starboardConfig = new StarboardConfig(); + this.messageCacheConfig = new MessageCacheConfig(); + this.serverLockConfig = new ServerLockConfig(); this.setGuild(guild); } @@ -71,7 +71,7 @@ public static GuildConfig loadOrCreate(Guild guild, Path file) { Gson gson = new GsonBuilder().create(); GuildConfig config; if (Files.exists(file)) { - try (var reader = Files.newBufferedReader(file)) { + try (BufferedReader reader = Files.newBufferedReader(file)) { config = gson.fromJson(reader, GuildConfig.class); config.setFile(file); config.setGuild(guild); @@ -93,26 +93,20 @@ public static GuildConfig loadOrCreate(Guild guild, Path file) { private void setGuild(Guild guild) { this.guild = guild; - if (this.slashCommand == null) this.slashCommand = new SlashCommandConfig(); - this.slashCommand.setGuildConfig(this); - if (this.help == null) this.help = new HelpConfig(); - this.help.setGuildConfig(this); - if (this.moderation == null) this.moderation = new ModerationConfig(); - this.moderation.setGuildConfig(this); - if (this.qotw == null) this.qotw = new QOTWConfig(); - this.qotw.setGuildConfig(this); - if (this.stats == null) this.stats = new StatsConfig(); - this.stats.setGuildConfig(this); - if (this.starBoard == null) this.starBoard = new StarboardConfig(); - this.starBoard.setGuildConfig(this); - if (this.jam == null) this.jam = new JamConfig(); - this.jam.setGuildConfig(this); - if (this.messageCache == null) this.messageCache = new MessageCacheConfig(); - this.messageCache.setGuildConfig(this); - if (this.emote == null) this.emote = new EmoteConfig(); - this.emote.setGuildConfig(this); - if (this.serverLock == null) this.serverLock = new ServerLockConfig(); - this.serverLock.setGuildConfig(this); + if (this.helpConfig == null) this.helpConfig = new HelpConfig(); + this.helpConfig.setGuildConfig(this); + if (this.moderationConfig == null) this.moderationConfig = new ModerationConfig(); + this.moderationConfig.setGuildConfig(this); + if (this.qotwConfig == null) this.qotwConfig = new QOTWConfig(); + this.qotwConfig.setGuildConfig(this); + if (this.metricsConfig == null) this.metricsConfig = new MetricsConfig(); + this.metricsConfig.setGuildConfig(this); + if (this.starboardConfig == null) this.starboardConfig = new StarboardConfig(); + this.starboardConfig.setGuildConfig(this); + if (this.messageCacheConfig == null) this.messageCacheConfig = new MessageCacheConfig(); + this.messageCacheConfig.setGuildConfig(this); + if (this.serverLockConfig == null) this.serverLockConfig = new ServerLockConfig(); + this.serverLockConfig.setGuildConfig(this); } /** @@ -120,33 +114,34 @@ private void setGuild(Guild guild) { */ public synchronized void flush() { Gson gson = new GsonBuilder().serializeNulls().setPrettyPrinting().create(); - try (var writer = Files.newBufferedWriter(this.file)) { + try (BufferedWriter writer = Files.newBufferedWriter(this.file)) { gson.toJson(this, writer); writer.flush(); } catch (IOException e) { + ExceptionLogger.capture(e, getClass().getSimpleName()); log.error("Could not flush config.", e); } } /** * Attempts to resolve a configuration property value by its name, using a - * '.' to concatenate property names. For example, the {@link JamConfig} has - * a property called pingRoleId. We can resolve it via the - * full name jam.pingRoleId, using the jam field + * '.' to concatenate property names. For example, the {@link ModerationConfig} has + * a property called adminRoleId. We can resolve it via the + * full name moderation.adminRoleId, using the jam field * of {@link GuildConfig} followed by the pingRoleId field from - * {@link JamConfig}. + * {@link ModerationConfig}. * * @param propertyName The name of the property. * @return The value of the property, if found, or null otherwise. */ @Nullable public Object resolve(String propertyName) throws UnknownPropertyException { - var result = ReflectionUtils.resolveField(propertyName, this); + Optional> result = ReflectionUtils.resolveField(propertyName, this); return result.map(pair -> { try { return pair.first().get(pair.second()); } catch (IllegalAccessException e) { - e.printStackTrace(); + ExceptionLogger.capture(e, getClass().getSimpleName()); return null; } }).orElse(null); @@ -160,13 +155,13 @@ public Object resolve(String propertyName) throws UnknownPropertyException { * @param value The value to set. */ public void set(String propertyName, String value) throws UnknownPropertyException { - var result = ReflectionUtils.resolveField(propertyName, this); + Optional> result = ReflectionUtils.resolveField(propertyName, this); result.ifPresent(pair -> { try { ReflectionUtils.set(pair.first(), pair.second(), value); this.flush(); } catch (IllegalAccessException e) { - e.printStackTrace(); + ExceptionLogger.capture(e, getClass().getSimpleName()); } }); } diff --git a/src/main/java/net/javadiscord/javabot/data/config/ReflectionUtils.java b/src/main/java/net/javadiscord/javabot/data/config/ReflectionUtils.java index 7cd1a3856..a9ec3c241 100644 --- a/src/main/java/net/javadiscord/javabot/data/config/ReflectionUtils.java +++ b/src/main/java/net/javadiscord/javabot/data/config/ReflectionUtils.java @@ -1,7 +1,10 @@ package net.javadiscord.javabot.data.config; import lombok.extern.slf4j.Slf4j; +import net.javadiscord.javabot.util.ExceptionLogger; import net.javadiscord.javabot.util.Pair; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import java.lang.reflect.Field; import java.lang.reflect.Modifier; @@ -34,7 +37,7 @@ public class ReflectionUtils { private ReflectionUtils() { } - public static Optional> resolveField(String propertyName, Object parent) throws UnknownPropertyException { + public static Optional> resolveField(@NotNull String propertyName, Object parent) throws UnknownPropertyException { return Optional.ofNullable(resolveField(propertyName.split("\\."), parent)); } @@ -48,7 +51,7 @@ public static Optional> resolveField(String propertyName, Ob * @return The field and the object upon which it can be applied. * @throws UnknownPropertyException If no field could be resolved. */ - public static Pair resolveField(String[] fieldNames, Object parent) throws UnknownPropertyException { + public static @Nullable Pair resolveField(@NotNull String[] fieldNames, @NotNull Object parent) throws UnknownPropertyException { if (fieldNames.length == 0) return null; try { Field field = parent.getClass().getDeclaredField(fieldNames[0]); @@ -68,6 +71,7 @@ public static Pair resolveField(String[] fieldNames, Object paren } catch (NoSuchFieldException e) { throw new UnknownPropertyException(fieldNames[0], parent.getClass()); } catch (IllegalAccessException e) { + ExceptionLogger.capture(e, ReflectionUtils.class.getSimpleName()); log.warn("Reflection error occurred while resolving property " + Arrays.toString(fieldNames) + " of object of type " + parent.getClass().getSimpleName(), e); return null; } @@ -83,9 +87,9 @@ public static Pair resolveField(String[] fieldNames, Object paren * @return The map of properties and their types. * @throws IllegalAccessException If a field cannot have its value obtained. */ - public static Map> getFields(String parentPropertyName, Class parentClass) throws IllegalAccessException { + public static @NotNull Map> getFields(@NotNull String parentPropertyName, @NotNull Class parentClass) throws IllegalAccessException { Map> fieldsMap = new HashMap<>(); - for (var field : parentClass.getDeclaredFields()) { + for (Field field : parentClass.getDeclaredFields()) { // Skip transient fields. if (Modifier.isTransient(field.getModifiers()) || Modifier.isStatic(field.getModifiers())) continue; field.setAccessible(true); @@ -94,7 +98,7 @@ public static Map> getFields(String parentPropertyName, Class> childFieldsMap = getFields(fieldPropertyName, field.getType()); fieldsMap.putAll(childFieldsMap); } } @@ -110,8 +114,8 @@ public static Map> getFields(String parentPropertyName, Class parser = propertyTypeParsers.get(field.getType()); if (parser == null) { throw new IllegalArgumentException("No supported property type parser for the type " + field.getType().getSimpleName()); } diff --git a/src/main/java/net/javadiscord/javabot/data/config/SystemsConfig.java b/src/main/java/net/javadiscord/javabot/data/config/SystemsConfig.java index 0ae88b452..c39d97a66 100644 --- a/src/main/java/net/javadiscord/javabot/data/config/SystemsConfig.java +++ b/src/main/java/net/javadiscord/javabot/data/config/SystemsConfig.java @@ -1,26 +1,39 @@ package net.javadiscord.javabot.data.config; import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import net.dv8tion.jda.api.JDA; +import net.dv8tion.jda.api.entities.emoji.Emoji; +import net.dv8tion.jda.api.entities.emoji.RichCustomEmoji; +import org.jetbrains.annotations.NotNull; /** * Contains configuration settings for various systems which the bot uses, such * as databases or dependencies that have runtime properties. */ @Data +@Slf4j public class SystemsConfig { + + /** + * The token used to create the JDA Discord bot instance. + */ + private String jdaBotToken = ""; + /** * The Key used for the bing-search-api. */ - public String azureSubscriptionKey = ""; + private String azureSubscriptionKey = ""; + /** - * The token used to create the JDA Discord bot instance. + * The DSN for the Sentry API. */ - private String jdaBotToken = ""; + private String sentryDsn = ""; + /** * The number of threads to allocate to the bot's general purpose async * thread pool. */ - private int asyncPoolSize = 4; /** @@ -29,12 +42,82 @@ public class SystemsConfig { */ private HikariConfig hikariConfig = new HikariConfig(); + /** + * Configuration settings for certain commands which need an extra layer of + * security. + */ + private AdminConfig adminConfig = new AdminConfig(); + + /** + * Configuration settings for all the different emojis the bot uses. + */ + private EmojiConfig emojiConfig = new EmojiConfig(); + /** * Configuration settings for the Hikari connection pool. */ @Data public static class HikariConfig { - private String jdbcUrl = "jdbc:h2:tcp://localhost:9123/./java_bot"; + private String jdbcUrl = "jdbc:h2:tcp://localhost:9122/./java_bot"; private int maximumPoolSize = 5; + private long leakDetectionThreshold = 10000; + } + + /** + * Configuration settings for certain commands which need an extra layer of + * security. + */ + @Data + public static class AdminConfig { + /** + * An array of user-Ids only which can manage some of the bot's systems. + */ + private Long[] adminUsers = new Long[]{}; + } + + /** + * Configuration settings for all the different emojis the bot uses. + */ + @Data + public static class EmojiConfig { + private long failureId = 0; + private long successId = 0; + private long upvoteId = 0; + private long downvoteId = 0; + private String clockUnicode = "\uD83D\uDD57"; + private String jobChannelVoteUnicode = "\uD83D\uDDD1️"; + + public Emoji getFailureEmote(JDA jda) { + return getEmoji(jda, failureId, "\u274C"); + } + + public Emoji getSuccessEmote(JDA jda) { + return getEmoji(jda, successId, "\u2714\uFE0F"); + } + + public Emoji getUpvoteEmote(JDA jda) { + return getEmoji(jda, upvoteId, "\uD83D\uDC4D"); + } + + public Emoji getDownvoteEmote(JDA jda) { + return getEmoji(jda, downvoteId, "\uD83D\uDC4E"); + } + + public Emoji getClockEmoji() { + return Emoji.fromUnicode(clockUnicode); + } + + public Emoji getJobChannelVoteEmoji() { + return Emoji.fromUnicode(jobChannelVoteUnicode); + } + + private @NotNull Emoji getEmoji(@NotNull JDA jda, long emoteId, String backup) { + RichCustomEmoji emote = jda.getEmojiById(emoteId); + if (emote != null) { + return emote; + } + log.error("Could not find emote with id {}: using backup instead: {}", emoteId, backup); + return Emoji.fromUnicode(backup); + } } } diff --git a/src/main/java/net/javadiscord/javabot/data/config/guild/EmoteConfig.java b/src/main/java/net/javadiscord/javabot/data/config/guild/EmoteConfig.java deleted file mode 100644 index 824d7b0ac..000000000 --- a/src/main/java/net/javadiscord/javabot/data/config/guild/EmoteConfig.java +++ /dev/null @@ -1,37 +0,0 @@ -package net.javadiscord.javabot.data.config.guild; - -import lombok.Data; -import lombok.EqualsAndHashCode; -import net.dv8tion.jda.api.entities.Emote; -import net.javadiscord.javabot.data.config.GuildConfigItem; - -/** - * Configuration for the guild's emotes. - */ -@Data -@EqualsAndHashCode(callSuper = true) -public class EmoteConfig extends GuildConfigItem { - private String failureId; - private String successId; - private String upvoteId; - private String downvoteId; - private String clockEmoji = "\uD83D\uDD57"; - private String jobChannelVoteEmoji; - - public Emote getFailureEmote() { - return getGuild().getJDA().getEmoteById(this.failureId); - } - - public Emote getSuccessEmote() { - return getGuild().getJDA().getEmoteById(this.successId); - } - - public Emote getUpvoteEmote() { - return getGuild().getJDA().getEmoteById(this.upvoteId); - } - - public Emote getDownvoteEmote() { - return getGuild().getJDA().getEmoteById(this.downvoteId); - } -} - diff --git a/src/main/java/net/javadiscord/javabot/data/config/guild/HelpConfig.java b/src/main/java/net/javadiscord/javabot/data/config/guild/HelpConfig.java index 54b1e7a05..32c36b09b 100644 --- a/src/main/java/net/javadiscord/javabot/data/config/guild/HelpConfig.java +++ b/src/main/java/net/javadiscord/javabot/data/config/guild/HelpConfig.java @@ -12,7 +12,7 @@ import java.util.Map; /** - * Configuration for the guild's help system. + * Configuration for the guilds' help system. */ @Data @EqualsAndHashCode(callSuper = true) @@ -153,7 +153,7 @@ public class HelpConfig extends GuildConfigItem { private double perCharacterExperience = 1; /** - * The message's minimum length. + * The messages' minimum length. */ private int minimumMessageLength = 10; diff --git a/src/main/java/net/javadiscord/javabot/data/config/guild/JamConfig.java b/src/main/java/net/javadiscord/javabot/data/config/guild/JamConfig.java deleted file mode 100644 index 722b1c4f5..000000000 --- a/src/main/java/net/javadiscord/javabot/data/config/guild/JamConfig.java +++ /dev/null @@ -1,45 +0,0 @@ -package net.javadiscord.javabot.data.config.guild; - -import lombok.Data; -import lombok.EqualsAndHashCode; -import net.dv8tion.jda.api.entities.NewsChannel; -import net.dv8tion.jda.api.entities.Role; -import net.dv8tion.jda.api.entities.TextChannel; -import net.javadiscord.javabot.data.config.GuildConfigItem; - -import java.awt.*; - -/** - * Configuration for the guild's java jam. - */ -@Data -@EqualsAndHashCode(callSuper = true) -public class JamConfig extends GuildConfigItem { - private long announcementChannelId; - private long votingChannelId; - - private long pingRoleId; - private long adminRoleId; - - private String jamEmbedColorHex = "#FC5A03"; - - public Color getJamEmbedColor() { - return Color.decode(this.jamEmbedColorHex); - } - - public NewsChannel getAnnouncementChannel() { - return this.getGuild().getNewsChannelById(this.announcementChannelId); - } - - public TextChannel getVotingChannel() { - return this.getGuild().getTextChannelById(this.votingChannelId); - } - - public Role getPingRole() { - return this.getGuild().getRoleById(this.pingRoleId); - } - - public Role getAdminRole() { - return this.getGuild().getRoleById(this.adminRoleId); - } -} diff --git a/src/main/java/net/javadiscord/javabot/data/config/guild/MessageCacheConfig.java b/src/main/java/net/javadiscord/javabot/data/config/guild/MessageCacheConfig.java index c83897103..22d5907c8 100644 --- a/src/main/java/net/javadiscord/javabot/data/config/guild/MessageCacheConfig.java +++ b/src/main/java/net/javadiscord/javabot/data/config/guild/MessageCacheConfig.java @@ -21,7 +21,7 @@ public class MessageCacheConfig extends GuildConfigItem { /** * ID of the Message Cache log channel. */ - private long messageCacheLogChannelId; + private long messageCacheLogChannelId = 0; /** * The amount of messages after which the DB is synchronized with the local cache. diff --git a/src/main/java/net/javadiscord/javabot/data/config/guild/StatsConfig.java b/src/main/java/net/javadiscord/javabot/data/config/guild/MetricsConfig.java similarity index 57% rename from src/main/java/net/javadiscord/javabot/data/config/guild/StatsConfig.java rename to src/main/java/net/javadiscord/javabot/data/config/guild/MetricsConfig.java index ed6278343..96b5234e3 100644 --- a/src/main/java/net/javadiscord/javabot/data/config/guild/StatsConfig.java +++ b/src/main/java/net/javadiscord/javabot/data/config/guild/MetricsConfig.java @@ -10,11 +10,11 @@ */ @Data @EqualsAndHashCode(callSuper = true) -public class StatsConfig extends GuildConfigItem { - private long categoryId; - private String memberCountMessageTemplate; +public class MetricsConfig extends GuildConfigItem { + private long metricsCategoryId = 0; + private String metricsMessageTemplate = ""; - public Category getCategory() { - return getGuild().getCategoryById(this.categoryId); + public Category getMetricsCategory() { + return getGuild().getCategoryById(this.metricsCategoryId); } } diff --git a/src/main/java/net/javadiscord/javabot/data/config/guild/ModerationConfig.java b/src/main/java/net/javadiscord/javabot/data/config/guild/ModerationConfig.java index b838287f0..6d1cf75b7 100644 --- a/src/main/java/net/javadiscord/javabot/data/config/guild/ModerationConfig.java +++ b/src/main/java/net/javadiscord/javabot/data/config/guild/ModerationConfig.java @@ -6,25 +6,27 @@ import net.dv8tion.jda.api.entities.TextChannel; import net.javadiscord.javabot.data.config.GuildConfigItem; +import java.util.List; + /** * Configuration for the guild's moderation system. */ @Data @EqualsAndHashCode(callSuper = true) public class ModerationConfig extends GuildConfigItem { - private long reportChannelId; - private long applicationChannelId; - private long logChannelId; - private long suggestionChannelId; - private long jobChannelId; - private long staffRoleId; - private long adminRoleId; - private long expertRoleId; + private long reportChannelId = 0; + private long applicationChannelId = 0; + private long logChannelId = 0; + private long suggestionChannelId = 0; + private long jobChannelId = 0; + private long staffRoleId = 0; + private long adminRoleId = 0; + private long expertRoleId = 0; /** * ID of the share-knowledge channel. */ - private long shareKnowledgeChannelId; + private long shareKnowledgeChannelId = 0; /** * The threshold for deleting a message in #share-knowledge. Note that this should be strictly < 0. @@ -52,9 +54,9 @@ public class ModerationConfig extends GuildConfigItem { private int maxWarnSeverity = 100; /** - * Invite liks AutoMod should exclude. + * Invite links AutoMod should exclude. */ - private String[] automodInviteExcludes; + private List automodInviteExcludes = List.of(); /** * Text that is sent to users when they're banned. diff --git a/src/main/java/net/javadiscord/javabot/data/config/guild/SlashCommandConfig.java b/src/main/java/net/javadiscord/javabot/data/config/guild/SlashCommandConfig.java deleted file mode 100644 index 221977c5e..000000000 --- a/src/main/java/net/javadiscord/javabot/data/config/guild/SlashCommandConfig.java +++ /dev/null @@ -1,40 +0,0 @@ -package net.javadiscord.javabot.data.config.guild; - -import lombok.Data; -import lombok.EqualsAndHashCode; -import net.javadiscord.javabot.data.config.GuildConfigItem; - -import java.awt.*; - -/** - * Configuration for the bot's slash commands. - */ -@Data -@EqualsAndHashCode(callSuper = true) -public class SlashCommandConfig extends GuildConfigItem { - private String defaultColorHex = "#2F3136"; - private String warningColorHex = "#EBA434"; - private String errorColorHex = "#EB3434"; - private String infoColorHex = "#34A2EB"; - private String successColorHex = "#49DE62"; - - public Color getDefaultColor() { - return Color.decode(this.defaultColorHex); - } - - public Color getWarningColor() { - return Color.decode(this.warningColorHex); - } - - public Color getErrorColor() { - return Color.decode(this.errorColorHex); - } - - public Color getInfoColor() { - return Color.decode(this.infoColorHex); - } - - public Color getSuccessColor() { - return Color.decode(this.successColorHex); - } -} diff --git a/src/main/java/net/javadiscord/javabot/data/config/guild/StarboardConfig.java b/src/main/java/net/javadiscord/javabot/data/config/guild/StarboardConfig.java index f95029526..b39ef371b 100644 --- a/src/main/java/net/javadiscord/javabot/data/config/guild/StarboardConfig.java +++ b/src/main/java/net/javadiscord/javabot/data/config/guild/StarboardConfig.java @@ -3,6 +3,8 @@ import lombok.Data; import lombok.EqualsAndHashCode; import net.dv8tion.jda.api.entities.TextChannel; +import net.dv8tion.jda.api.entities.emoji.Emoji; +import net.dv8tion.jda.api.entities.emoji.UnicodeEmoji; import net.javadiscord.javabot.data.config.GuildConfigItem; import java.util.ArrayList; @@ -14,11 +16,15 @@ @Data @EqualsAndHashCode(callSuper = true) public class StarboardConfig extends GuildConfigItem { - private long channelId; + private long starboardChannelId; private int reactionThreshold; - private List emotes = new ArrayList<>(); + private List emojiUnicodes = new ArrayList<>(); public TextChannel getStarboardChannel() { - return this.getGuild().getTextChannelById(this.channelId); + return this.getGuild().getTextChannelById(this.starboardChannelId); + } + + public List getEmojis() { + return emojiUnicodes.stream().map(Emoji::fromUnicode).toList(); } } diff --git a/src/main/java/net/javadiscord/javabot/data/h2db/DatabaseRepository.java b/src/main/java/net/javadiscord/javabot/data/h2db/DatabaseRepository.java new file mode 100644 index 000000000..2b8890644 --- /dev/null +++ b/src/main/java/net/javadiscord/javabot/data/h2db/DatabaseRepository.java @@ -0,0 +1,174 @@ +package net.javadiscord.javabot.data.h2db; + +import com.dynxsty.dih4jda.util.Checks; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.h2.api.H2Type; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.lang.reflect.InvocationTargetException; +import java.sql.*; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * This class represents a simple "Repository" for all database tables. + * It provides some basic functions in order to properly interact with the corresponding table. + * + * @param The model class for this table. + */ +@Slf4j +@RequiredArgsConstructor +public abstract class DatabaseRepository { + private final Connection con; + private final Class modelClass; + private final String tableName; + private final List> properties; + + /** + * Inserts a single instance of the specified model class into the database. + * + * @param instance The instance to insert. + * @param returnGeneratedKeys Whether this should try to retrieve and apply all generated keys to {@link T}. + * @return The model class, {@link T}. + * @throws SQLException If an error occurs. + */ + public final T insert(T instance, boolean returnGeneratedKeys) throws SQLException { + List> filteredProperties = this.properties.stream().filter(p -> !p.isKey()).toList(); + try (PreparedStatement stmt = con.prepareStatement(String.format("INSERT INTO %s (%s) VALUES (%s)", + tableName, filteredProperties.stream().map(TableProperty::getPropertyName).collect(Collectors.joining(",")), + ",?".repeat(filteredProperties.size()).substring(1) + ), Statement.RETURN_GENERATED_KEYS)) { + int index = 1; + for (TableProperty property : filteredProperties) { + stmt.setObject(index, property.getFunction().apply(instance), property.getH2Type()); + index++; + } + if (stmt.executeUpdate() > 0) { + // get generated keys + if (returnGeneratedKeys) { + List> keyProperties = this.properties.stream().filter(TableProperty::isKey).toList(); + ResultSet rs = stmt.getGeneratedKeys(); + for (TableProperty prop : keyProperties) { + if (rs.next()) { + prop.getConsumer().accept(instance, rs.getObject(prop.getPropertyName())); + } + } + } + log.info("Inserted {}: {}", modelClass.getSimpleName(), instance); + } else { + log.error("Could not insert {}: {}", modelClass.getSimpleName(), instance); + } + return instance; + } + } + + public final int update(String query, Object... args) throws SQLException { + return DbActions.update(query, args); + } + + /** + * Queries a single (the first) row which matches the given filter and converts it to the specified + * model class. Additionally, this value is then wrapped in an {@link Optional} as it is possible for the value + * to be empty. + * + * @param filter The filter for this query. Might look something like "WHEN age > 18". + * @param args Additional arguments used for formatting. "?" symbols are used as placeholders. + * @return An {@link Optional} which eventually holds the desired value. + * @throws SQLException If an error occurs. + */ + public final Optional querySingle(String filter, @NotNull Object... args) throws SQLException { + try (PreparedStatement stmt = con.prepareStatement(String.format("SELECT * FROM %s%s", tableName, filter.isEmpty() ? "" : " " + filter))) { + int i = 1; + for (Object arg : args) { + stmt.setObject(i++, arg); + } + ResultSet rs = stmt.executeQuery(); + T t = null; + if (rs.next()) { + t = read(rs); + } + return Optional.ofNullable(t); + } + } + + /** + * Queries a multiple rows which match the given filter and converts them to the specified + * model class. + * + * @param filter The filter for this query. Might look something like "WHEN age > 18". + * @param args Additional arguments used for formatting. "?" symbols are used as placeholders. + * @return An unmodifiable {@link List} which holds the desired value(s). + * @throws SQLException If an error occurs. + */ + public final @NotNull List queryMultiple(String filter, Object @NotNull ... args) throws SQLException { + try (PreparedStatement stmt = con.prepareStatement(String.format("SELECT * FROM %s%s", tableName, filter.isEmpty() ? "" : " " + filter))) { + int i = 1; + for (Object arg : args) { + stmt.setObject(i++, arg); + } + ResultSet rs = stmt.executeQuery(); + List list = new ArrayList<>(); + while (rs.next()) { + list.add(read(rs)); + } + return list; + } + } + + public final long count() { + return DbActions.count("SELECT COUNT (*) FROM " + tableName); + } + + public final long count(String query, Object... args) { + return DbActions.count(String.format(query, args)); + } + + public final int getLogicalSize() { + return DbActions.getLogicalSize(tableName); + } + + /** + * Reads the given {@link ResultSet} and tries to convert it to the specified model class {@link T}. + * + * @param rs The {@link ResultSet} to read. + * @return The specified model class {@link T}. + * @throws SQLException If an error occurs. + */ + public final T read(ResultSet rs) throws SQLException { + T instance = instantiate(); + for (TableProperty property : properties) { + property.getConsumer().accept(instance, readResultSetValue(property, rs)); + } + return instance; + } + + public final Connection getConnection() { + return con; + } + + private Object readResultSetValue(@NotNull TableProperty property, @NotNull ResultSet rs) throws SQLException { + Object object = rs.getObject(property.getPropertyName()); + // some exceptions for convenience + if (property.getH2Type() == H2Type.TIMESTAMP) { + object = ((Timestamp) object).toLocalDateTime(); + } + return object; + } + + private @Nullable T instantiate() { + try { + if (Checks.checkEmptyConstructor(modelClass)) { + return modelClass.getConstructor().newInstance(); + } else { + log.error("Expected an empty constructor for class {}", modelClass.getSimpleName()); + } + } catch (InstantiationException | IllegalAccessException | NoSuchMethodException | InvocationTargetException e) { + log.error("Could not instantiate Model Class " + modelClass.getSimpleName() + ": ", e); + } + return null; + } +} \ No newline at end of file diff --git a/src/main/java/net/javadiscord/javabot/data/h2db/DbActions.java b/src/main/java/net/javadiscord/javabot/data/h2db/DbActions.java index cd4b4d086..ed8443dd6 100644 --- a/src/main/java/net/javadiscord/javabot/data/h2db/DbActions.java +++ b/src/main/java/net/javadiscord/javabot/data/h2db/DbActions.java @@ -1,10 +1,10 @@ package net.javadiscord.javabot.data.h2db; import net.javadiscord.javabot.Bot; +import net.javadiscord.javabot.util.ExceptionLogger; +import org.jetbrains.annotations.NotNull; -import java.sql.Connection; -import java.sql.ResultSet; -import java.sql.SQLException; +import java.sql.*; import java.util.Optional; import java.util.concurrent.CompletableFuture; import java.util.function.Function; @@ -24,8 +24,8 @@ private DbActions() { * @param consumer The {@link ConnectionConsumer}. * @throws SQLException If an error occurs. */ - public static void doAction(ConnectionConsumer consumer) throws SQLException { - try (var c = Bot.dataSource.getConnection()) { + public static void doAction(@NotNull ConnectionConsumer consumer) throws SQLException { + try (Connection c = Bot.dataSource.getConnection()) { consumer.consume(c); } } @@ -38,8 +38,8 @@ public static void doAction(ConnectionConsumer consumer) throws SQLException { * @return A generic type. * @throws SQLException If an error occurs. */ - public static T map(ConnectionFunction function) throws SQLException { - try (var c = Bot.dataSource.getConnection()) { + public static T map(@NotNull ConnectionFunction function) throws SQLException { + try (Connection c = Bot.dataSource.getConnection()) { return function.apply(c); } } @@ -54,10 +54,10 @@ public static T map(ConnectionFunction function) throws SQLException { * @return A generic type. * @throws SQLException If an error occurs. */ - public static T mapQuery(String query, StatementModifier modifier, ResultSetMapper mapper) throws SQLException { - try (var c = Bot.dataSource.getConnection(); var stmt = c.prepareStatement(query)) { + public static T mapQuery(@NotNull String query, @NotNull StatementModifier modifier, @NotNull ResultSetMapper mapper) throws SQLException { + try (Connection c = Bot.dataSource.getConnection(); PreparedStatement stmt = c.prepareStatement(query)) { modifier.modify(stmt); - var rs = stmt.executeQuery(); + ResultSet rs = stmt.executeQuery(); return mapper.map(rs); } } @@ -71,12 +71,13 @@ public static T mapQuery(String query, StatementModifier modifier, ResultSet * @param The generic type. * @return A generic type. */ - public static CompletableFuture mapQueryAsync(String query, StatementModifier modifier, ResultSetMapper mapper) { + public static @NotNull CompletableFuture mapQueryAsync(@NotNull String query, @NotNull StatementModifier modifier, @NotNull ResultSetMapper mapper) { CompletableFuture cf = new CompletableFuture<>(); Bot.asyncPool.submit(() -> { try { cf.complete(mapQuery(query, modifier, mapper)); } catch (SQLException e) { + ExceptionLogger.capture(e, DbActions.class.getSimpleName()); cf.completeExceptionally(e); } }); @@ -91,14 +92,14 @@ public static CompletableFuture mapQueryAsync(String query, StatementModi * @param modifier A modifier to use to set parameters for the query. * @return The column value. */ - public static long count(String query, StatementModifier modifier) { - try (var c = Bot.dataSource.getConnection(); var stmt = c.prepareStatement(query)) { + public static long count(@NotNull String query, @NotNull StatementModifier modifier) { + try (Connection c = Bot.dataSource.getConnection(); PreparedStatement stmt = c.prepareStatement(query)) { modifier.modify(stmt); - var rs = stmt.executeQuery(); + ResultSet rs = stmt.executeQuery(); if (!rs.next()) return 0; return rs.getLong(1); } catch (SQLException e) { - e.printStackTrace(); + ExceptionLogger.capture(e, DbActions.class.getSimpleName()); return 0; } } @@ -107,19 +108,19 @@ public static long count(String query, StatementModifier modifier) { * Gets a count, using a query which must return a long * integer value as the first column of the result set. * - * @param query The query. + * @param query The query. * @return The column value. */ - public static long count(String query) { + public static long count(@NotNull String query) { try ( - var conn = Bot.dataSource.getConnection(); - var stmt = conn.createStatement() + Connection conn = Bot.dataSource.getConnection(); + Statement stmt = conn.createStatement() ) { - var rs = stmt.executeQuery(query); + ResultSet rs = stmt.executeQuery(query); if (!rs.next()) return 0; return rs.getLong(1); } catch (SQLException e) { - e.printStackTrace(); + ExceptionLogger.capture(e, DbActions.class.getSimpleName()); return 0; } } @@ -129,15 +130,15 @@ public static long count(String query) { * which allows for getting the count from a query using simple string * formatting instead of having to define a statement modifier. *

- * WARNING: This method should NEVER be called with - * user-provided data. + * WARNING: This method should NEVER be called with + * user-provided data. *

* * @param queryFormat The format string. - * @param args The set of arguments to pass to the formatter. + * @param args The set of arguments to pass to the formatter. * @return The count. */ - public static long countf(String queryFormat, Object... args) { + public static long countf(@NotNull String queryFormat, @NotNull Object... args) { return count(String.format(queryFormat, args)); } @@ -149,10 +150,10 @@ public static long countf(String queryFormat, Object... args) { * @return The rows that got updates during this process. * @throws SQLException If an error occurs. */ - public static int update(String query, Object... params) throws SQLException { - try (var c = Bot.dataSource.getConnection(); var stmt = c.prepareStatement(query)) { + public static int update(@NotNull String query, Object @NotNull ... params) throws SQLException { + try (Connection c = Bot.dataSource.getConnection(); PreparedStatement stmt = c.prepareStatement(query)) { int i = 1; - for (var param : params) { + for (Object param : params) { stmt.setObject(i++, param); } return stmt.executeUpdate(); @@ -165,13 +166,14 @@ public static int update(String query, Object... params) throws SQLException { * @param consumer The consumer that will use a connection. * @return A future that completes when the action is complete. */ - public static CompletableFuture doAsyncAction(ConnectionConsumer consumer) { + public static @NotNull CompletableFuture doAsyncAction(ConnectionConsumer consumer) { CompletableFuture future = new CompletableFuture<>(); Bot.asyncPool.submit(() -> { - try (var c = Bot.dataSource.getConnection()) { + try (Connection c = Bot.dataSource.getConnection()) { consumer.consume(c); future.complete(null); } catch (SQLException e) { + ExceptionLogger.capture(e, DbActions.class.getSimpleName()); future.completeExceptionally(e); } }); @@ -188,14 +190,15 @@ public static CompletableFuture doAsyncAction(ConnectionConsumer consumer) * @param The type of data access object. Usually some kind of repository. * @return A future that completes when the action is complete. */ - public static CompletableFuture doAsyncDaoAction(Function daoConstructor, DaoConsumer consumer) { + public static @NotNull CompletableFuture doAsyncDaoAction(Function daoConstructor, DaoConsumer consumer) { CompletableFuture future = new CompletableFuture<>(); Bot.asyncPool.submit(() -> { - try (var c = Bot.dataSource.getConnection()) { - var dao = daoConstructor.apply(c); + try (Connection c = Bot.dataSource.getConnection()) { + T dao = daoConstructor.apply(c); consumer.consume(dao); future.complete(null); } catch (SQLException e) { + ExceptionLogger.capture(e, DbActions.class.getSimpleName()); future.completeExceptionally(e); } }); @@ -209,12 +212,13 @@ public static CompletableFuture doAsyncDaoAction(Function The generic type. * @return A generic type. */ - public static CompletableFuture mapAsync(ConnectionFunction function) { + public static @NotNull CompletableFuture mapAsync(ConnectionFunction function) { CompletableFuture future = new CompletableFuture<>(); Bot.asyncPool.submit(() -> { - try (var c = Bot.dataSource.getConnection()) { + try (Connection c = Bot.dataSource.getConnection()) { future.complete(function.apply(c)); } catch (SQLException e) { + ExceptionLogger.capture(e, DbActions.class.getSimpleName()); future.completeExceptionally(e); } }); @@ -238,7 +242,7 @@ public static Optional fetchSingleEntity(String query, StatementModifier return Optional.of(mapper.map(rs)); }); } catch (SQLException e) { - e.printStackTrace(); + ExceptionLogger.capture(e, DbActions.class.getSimpleName()); return Optional.empty(); } } @@ -250,14 +254,14 @@ public static Optional fetchSingleEntity(String query, StatementModifier * @return The logical size, in bytes. */ public static int getLogicalSize(String table) { - try (var c = Bot.dataSource.getConnection(); var stmt = c.prepareStatement("CALL DISK_SPACE_USED(?)")) { + try (Connection c = Bot.dataSource.getConnection(); PreparedStatement stmt = c.prepareStatement("CALL DISK_SPACE_USED(?)")) { stmt.setString(1, table); ResultSet rs = stmt.executeQuery(); if (rs.next()) { return rs.getInt(1); } } catch (SQLException e) { - e.printStackTrace(); + ExceptionLogger.capture(e, DbActions.class.getSimpleName()); } return 0; } diff --git a/src/main/java/net/javadiscord/javabot/data/h2db/DbHelper.java b/src/main/java/net/javadiscord/javabot/data/h2db/DbHelper.java index 38c4bcccf..92be522fd 100644 --- a/src/main/java/net/javadiscord/javabot/data/h2db/DbHelper.java +++ b/src/main/java/net/javadiscord/javabot/data/h2db/DbHelper.java @@ -5,7 +5,10 @@ import lombok.extern.slf4j.Slf4j; import net.javadiscord.javabot.Bot; import net.javadiscord.javabot.data.config.BotConfig; +import net.javadiscord.javabot.data.config.SystemsConfig; +import net.javadiscord.javabot.util.ExceptionLogger; import org.h2.tools.Server; +import org.jetbrains.annotations.NotNull; import java.io.IOException; import java.io.InputStream; @@ -13,9 +16,13 @@ import java.nio.file.Path; import java.sql.Connection; import java.sql.SQLException; +import java.sql.Statement; import java.util.Arrays; +import java.util.List; import java.util.function.Function; +import java.util.regex.Matcher; import java.util.regex.Pattern; +import java.util.stream.Collectors; /** * Class that provides helper methods for dealing with the database. @@ -34,22 +41,24 @@ private DbHelper() { * @throws IllegalStateException If an error occurs and we're unable to * start the database. */ - public static HikariDataSource initDataSource(BotConfig config) { + public static @NotNull HikariDataSource initDataSource(@NotNull BotConfig config) { // Determine if we need to initialize the schema, before starting up the server. boolean shouldInitSchema = shouldInitSchema(config.getSystems().getHikariConfig().getJdbcUrl()); // Now that we have remembered whether we need to initialize the schema, start up the server. Server server; try { - server = Server.createTcpServer("-tcpPort", "9123", "-ifNotExists").start(); + server = Server.createTcpServer("-tcpPort", "9122", "-ifNotExists").start(); } catch (SQLException e) { + ExceptionLogger.capture(e, DbHelper.class.getSimpleName()); throw new IllegalStateException("Cannot start database server.", e); } - var hikariConfig = new HikariConfig(); - var hikariConfigSource = config.getSystems().getHikariConfig(); + HikariConfig hikariConfig = new HikariConfig(); + SystemsConfig.HikariConfig hikariConfigSource = config.getSystems().getHikariConfig(); hikariConfig.setJdbcUrl(hikariConfigSource.getJdbcUrl()); hikariConfig.setMaximumPoolSize(hikariConfigSource.getMaximumPoolSize()); - var ds = new HikariDataSource(hikariConfig); + hikariConfig.setLeakDetectionThreshold(hikariConfigSource.getLeakDetectionThreshold()); + HikariDataSource ds = new HikariDataSource(hikariConfig); // Add a shutdown hook to close down the datasource and server when the JVM terminates. Runtime.getRuntime().addShutdownHook(new Thread(() -> { ds.close(); @@ -59,7 +68,7 @@ public static HikariDataSource initDataSource(BotConfig config) { try { initializeSchema(ds); } catch (IOException | SQLException e) { - e.printStackTrace(); + ExceptionLogger.capture(e, DbHelper.class.getSimpleName()); throw new IllegalStateException("Cannot initialize database schema.", e); } } @@ -73,10 +82,10 @@ public static HikariDataSource initDataSource(BotConfig config) { */ public static void doDbAction(ConnectionConsumer consumer) { Bot.asyncPool.submit(() -> { - try (var c = Bot.dataSource.getConnection()) { + try (Connection c = Bot.dataSource.getConnection()) { consumer.consume(c); } catch (SQLException e) { - e.printStackTrace(); + ExceptionLogger.capture(e, DbHelper.class.getSimpleName()); } }); } @@ -92,18 +101,18 @@ public static void doDbAction(ConnectionConsumer consumer) { */ public static void doDaoAction(Function daoConstructor, DaoConsumer consumer) { Bot.asyncPool.submit(() -> { - try (var c = Bot.dataSource.getConnection()) { - var dao = daoConstructor.apply(c); + try (Connection c = Bot.dataSource.getConnection()) { + T dao = daoConstructor.apply(c); consumer.consume(dao); } catch (SQLException e) { - e.printStackTrace(); + ExceptionLogger.capture(e, DbHelper.class.getSimpleName()); } }); } private static boolean shouldInitSchema(String jdbcUrl) { - var p = Pattern.compile("jdbc:h2:tcp://localhost:\\d+/(.*)"); - var m = p.matcher(jdbcUrl); + Pattern p = Pattern.compile("jdbc:h2:tcp://localhost:\\d+/(.*)"); + Matcher m = p.matcher(jdbcUrl); boolean shouldInitSchema = false; if (m.find()) { String dbFilePath = m.group(1) + ".mv.db"; @@ -118,17 +127,21 @@ private static boolean shouldInitSchema(String jdbcUrl) { } private static void initializeSchema(HikariDataSource dataSource) throws IOException, SQLException { - InputStream is = DbHelper.class.getClassLoader().getResourceAsStream("schema.sql"); - if (is == null) throw new IOException("Could not load schema.sql."); - var queries = Arrays.stream(new String(is.readAllBytes()).split(";")) - .filter(s -> !s.isBlank()).toList(); - try (var c = dataSource.getConnection()) { - for (var query : queries) { - var stmt = c.createStatement(); - stmt.executeUpdate(query); - stmt.close(); + try (InputStream is = DbHelper.class.getClassLoader().getResourceAsStream("database/schema.sql")) { + if (is == null) throw new IOException("Could not load schema.sql."); + List queries = Arrays.stream(new String(is.readAllBytes()).split(";")) + .filter(s -> !s.isBlank()).toList(); + try (Connection c = dataSource.getConnection()) { + for (String rawQuery : queries) { + String query = rawQuery.lines() + .map(s -> s.strip().stripIndent()) + .collect(Collectors.joining("")); + try (Statement stmt = c.createStatement()) { + stmt.executeUpdate(query); + } + } } + log.info("Successfully initialized H2 database."); } - log.info("Successfully initialized H2 database."); } } diff --git a/src/main/java/net/javadiscord/javabot/data/h2db/MigrationUtils.java b/src/main/java/net/javadiscord/javabot/data/h2db/MigrationUtils.java index 1de2218a8..0eae65370 100644 --- a/src/main/java/net/javadiscord/javabot/data/h2db/MigrationUtils.java +++ b/src/main/java/net/javadiscord/javabot/data/h2db/MigrationUtils.java @@ -3,12 +3,12 @@ import net.javadiscord.javabot.data.h2db.commands.MigrationsListSubcommand; import java.io.IOException; +import java.net.URI; import java.net.URISyntaxException; -import java.nio.file.FileSystemNotFoundException; -import java.nio.file.FileSystems; -import java.nio.file.Path; -import java.nio.file.Paths; +import java.net.URL; +import java.nio.file.*; import java.util.HashMap; +import java.util.Map; /** * Utility class that handles SQL Migrations. @@ -26,16 +26,16 @@ private MigrationUtils() { * @throws IOException If an error occurs. */ public static Path getMigrationsDirectory() throws URISyntaxException, IOException { - var resource = MigrationsListSubcommand.class.getResource("/migrations/"); + URL resource = MigrationsListSubcommand.class.getResource("/database/migrations/"); if (resource == null) throw new IOException("Missing resource /migrations/"); - var uri = resource.toURI(); - Path dirPath; + URI uri = resource.toURI(); try { - dirPath = Paths.get(uri); + return Path.of(uri); } catch (FileSystemNotFoundException e) { - var env = new HashMap(); - dirPath = FileSystems.newFileSystem(uri, env).getPath("/migrations/"); + Map env = new HashMap<>(); + try (FileSystem dir = FileSystems.newFileSystem(uri, env)) { + return dir.getPath("/database/migrations/"); + } } - return dirPath; } } diff --git a/src/main/java/net/javadiscord/javabot/data/h2db/TableProperty.java b/src/main/java/net/javadiscord/javabot/data/h2db/TableProperty.java new file mode 100644 index 000000000..be4ed5f4d --- /dev/null +++ b/src/main/java/net/javadiscord/javabot/data/h2db/TableProperty.java @@ -0,0 +1,34 @@ +package net.javadiscord.javabot.data.h2db; + +import lombok.Data; +import org.h2.api.H2Type; +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.NotNull; + +import java.util.function.BiConsumer; +import java.util.function.Function; + +/** + * This class is used inside {@link DatabaseRepository}s. It describes a single + * column and combines it which their respective Model Class, {@link T}. + * + * @param The Model class. + */ +@Data +public final class TableProperty { + private final String propertyName; + private final H2Type h2Type; + private final BiConsumer consumer; + private final Function function; + private final boolean key; + + @Contract("_, _, _, _ -> new") + public static @NotNull TableProperty of(String propertyName, H2Type h2Type, BiConsumer consumer, Function function) { + return new TableProperty<>(propertyName, h2Type, consumer, function, false); + } + + @Contract("_, _, _, _, _ -> new") + public static @NotNull TableProperty of(String propertyName, H2Type h2Type, BiConsumer consumer, Function function, boolean isKey) { + return new TableProperty<>(propertyName, h2Type, consumer, function, isKey); + } +} \ No newline at end of file diff --git a/src/main/java/net/javadiscord/javabot/data/h2db/commands/DbAdminCommand.java b/src/main/java/net/javadiscord/javabot/data/h2db/commands/DbAdminCommand.java new file mode 100644 index 000000000..51340e4e2 --- /dev/null +++ b/src/main/java/net/javadiscord/javabot/data/h2db/commands/DbAdminCommand.java @@ -0,0 +1,34 @@ +package net.javadiscord.javabot.data.h2db.commands; + +import com.dynxsty.dih4jda.interactions.commands.RegistrationType; +import com.dynxsty.dih4jda.interactions.commands.SlashCommand; +import net.dv8tion.jda.api.Permission; +import net.dv8tion.jda.api.interactions.commands.DefaultMemberPermissions; +import net.dv8tion.jda.api.interactions.commands.build.Commands; +import net.dv8tion.jda.api.interactions.commands.build.SubcommandGroupData; +import net.javadiscord.javabot.Bot; + +import java.util.Map; +import java.util.Set; + +/** + * Represents the `/db-admin` command. This holds administrative commands for managing the bot's database. + */ +public class DbAdminCommand extends SlashCommand { + /** + * This classes constructor which sets the {@link net.dv8tion.jda.api.interactions.commands.build.SlashCommandData} and + * adds the corresponding {@link net.dv8tion.jda.api.interactions.commands.Command.SubcommandGroup}s & {@link net.dv8tion.jda.api.interactions.commands.Command.Subcommand}s. + */ + public DbAdminCommand() { + setRegistrationType(RegistrationType.GUILD); + setSlashCommandData(Commands.slash("db-admin", "(ADMIN ONLY) Administrative Commands for managing the bot's database.") + .setDefaultPermissions(DefaultMemberPermissions.enabledFor(Permission.MANAGE_SERVER)) + .setGuildOnly(true) + ); + addSubcommands(new ExportSchemaSubcommand(), new ExportTableSubcommand(), new MigrationsListSubcommand(), new MigrateSubcommand(), new QuickMigrateSubcommand()); + addSubcommandGroups(Map.of( + new SubcommandGroupData("message-cache", "Administrative tools for managing the Message Cache."), Set.of(new MessageCacheInfoSubcommand()) + )); + requireUsers(Bot.config.getSystems().getAdminConfig().getAdminUsers()); + } +} diff --git a/src/main/java/net/javadiscord/javabot/data/h2db/commands/DbAdminCommandHandler.java b/src/main/java/net/javadiscord/javabot/data/h2db/commands/DbAdminCommandHandler.java deleted file mode 100644 index d244f8273..000000000 --- a/src/main/java/net/javadiscord/javabot/data/h2db/commands/DbAdminCommandHandler.java +++ /dev/null @@ -1,39 +0,0 @@ -package net.javadiscord.javabot.data.h2db.commands; - -import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent; -import net.dv8tion.jda.api.interactions.commands.Command; -import net.dv8tion.jda.api.requests.restaction.interactions.AutoCompleteCallbackAction; -import net.javadiscord.javabot.command.DelegatingCommandHandler; -import net.javadiscord.javabot.command.interfaces.Autocompletable; -import net.javadiscord.javabot.util.AutocompleteUtils; - -import java.util.List; -import java.util.Map; - -/** - * Handler class for all Database related commands. - */ -public class DbAdminCommandHandler extends DelegatingCommandHandler implements Autocompletable { - /** - * Adds all subcommands {@link DelegatingCommandHandler#addSubcommand}. - */ - public DbAdminCommandHandler() { - this.addSubcommand("export-schema", new ExportSchemaSubcommand()); - this.addSubcommand("export-table", new ExportTableSubcommand()); - this.addSubcommand("migrations-list", new MigrationsListSubcommand()); - this.addSubcommand("migrate", new MigrateSubcommand()); - - this.addSubcommandGroup("message-cache", new DelegatingCommandHandler(Map.of( - "info", new MessageCacheInfoSubcommand() - ))); - } - - @Override - public AutoCompleteCallbackAction handleAutocomplete(CommandAutoCompleteInteractionEvent event) { - List choices = switch (event.getSubcommandName()) { - case "migrate" -> MigrateSubcommand.replyMigrations(event); - default -> List.of(); - }; - return event.replyChoices(AutocompleteUtils.filterChoices(event, choices)); - } -} diff --git a/src/main/java/net/javadiscord/javabot/data/h2db/commands/ExportSchemaSubcommand.java b/src/main/java/net/javadiscord/javabot/data/h2db/commands/ExportSchemaSubcommand.java index 34cf80ec6..ccd205beb 100644 --- a/src/main/java/net/javadiscord/javabot/data/h2db/commands/ExportSchemaSubcommand.java +++ b/src/main/java/net/javadiscord/javabot/data/h2db/commands/ExportSchemaSubcommand.java @@ -1,48 +1,63 @@ package net.javadiscord.javabot.data.h2db.commands; +import com.dynxsty.dih4jda.interactions.commands.SlashCommand; +import net.dv8tion.jda.api.Permission; import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; import net.dv8tion.jda.api.interactions.commands.OptionMapping; -import net.dv8tion.jda.api.requests.restaction.interactions.ReplyCallbackAction; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; import net.javadiscord.javabot.Bot; -import net.javadiscord.javabot.command.interfaces.SlashCommand; +import net.javadiscord.javabot.util.ExceptionLogger; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.sql.Connection; +import java.sql.PreparedStatement; import java.sql.SQLException; /** - * This subcommand exports the database schema to a file, and uploads that file + *

This class represents the /db-admin export-schema command.

+ * This Subcommand exports the database schema to a file, and uploads that file * to the channel in which the command was received. */ -public class ExportSchemaSubcommand implements SlashCommand { +public class ExportSchemaSubcommand extends SlashCommand.Subcommand { private static final Path SCHEMA_FILE = Path.of("___schema.sql"); + /** + * The constructor of this class, which sets the corresponding {@link SubcommandData}. + */ + public ExportSchemaSubcommand() { + setSubcommandData(new SubcommandData("export-schema", "(ADMIN ONLY) Exports the bot's schema.") + .addOption(OptionType.BOOLEAN, "include-data", "Should data be included in the export?")); + requireUsers(Bot.config.getSystems().getAdminConfig().getAdminUsers()); + requirePermissions(Permission.MANAGE_SERVER); + } + @Override - public ReplyCallbackAction handleSlashCommandInteraction(SlashCommandInteractionEvent event) { + public void execute(SlashCommandInteractionEvent event) { boolean includeData = event.getOption("include-data", false, OptionMapping::getAsBoolean); + event.deferReply(false).queue(); Bot.asyncPool.submit(() -> { - try (var con = Bot.dataSource.getConnection()) { - var stmt = con.createStatement(); - boolean success = stmt.execute(String.format("SCRIPT %s TO '%s';", includeData ? "" : "NODATA", SCHEMA_FILE)); + try (Connection con = Bot.dataSource.getConnection()) { + PreparedStatement stmt = con.prepareStatement(String.format("SCRIPT %s TO '%s';", includeData ? "" : "NODATA", SCHEMA_FILE)); + boolean success = stmt.execute(); if (!success) { event.getHook().sendMessage("Exporting the schema was not successful.").queue(); } else { - event.getHook().sendMessage("The export was successful.").queue(); - event.getChannel().sendFile(SCHEMA_FILE.toFile(), "schema.sql").queue(msg -> { + event.getHook().sendMessage("The export was successful.").addFile(SCHEMA_FILE.toFile(), "database/schema.sql").queue(msg -> { try { Files.delete(SCHEMA_FILE); } catch (IOException e) { - e.printStackTrace(); + ExceptionLogger.capture(e, getClass().getSimpleName()); event.getHook().sendMessage("An error occurred, and the export could not be made: " + e.getMessage()).queue(); } }); } } catch (SQLException e) { - e.printStackTrace(); + ExceptionLogger.capture(e, getClass().getSimpleName()); event.getHook().sendMessage("An error occurred, and the export could not be made: " + e.getMessage()).queue(); } }); - return event.deferReply(true); } } diff --git a/src/main/java/net/javadiscord/javabot/data/h2db/commands/ExportTableSubcommand.java b/src/main/java/net/javadiscord/javabot/data/h2db/commands/ExportTableSubcommand.java index ee80d2a48..60181d7dc 100644 --- a/src/main/java/net/javadiscord/javabot/data/h2db/commands/ExportTableSubcommand.java +++ b/src/main/java/net/javadiscord/javabot/data/h2db/commands/ExportTableSubcommand.java @@ -1,51 +1,82 @@ package net.javadiscord.javabot.data.h2db.commands; +import com.dynxsty.dih4jda.interactions.commands.SlashCommand; +import net.dv8tion.jda.api.Permission; import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; import net.dv8tion.jda.api.interactions.commands.OptionMapping; -import net.dv8tion.jda.api.requests.restaction.interactions.ReplyCallbackAction; +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; import net.javadiscord.javabot.Bot; -import net.javadiscord.javabot.command.Responses; -import net.javadiscord.javabot.command.interfaces.SlashCommand; +import net.javadiscord.javabot.util.ExceptionLogger; +import net.javadiscord.javabot.util.Responses; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.sql.Connection; +import java.sql.PreparedStatement; import java.sql.SQLException; /** + *

This class represents the /db-admin export-table command.

* This subcommand exports a single database table to a file, and uploads that file * to the channel in which the command was received. */ -public class ExportTableSubcommand implements SlashCommand { +public class ExportTableSubcommand extends SlashCommand.Subcommand { private static final Path TABLE_FILE = Path.of("___table.sql"); + /** + * The constructor of this class, which sets the corresponding {@link SubcommandData}. + */ + public ExportTableSubcommand() { + setSubcommandData(new SubcommandData("export-table", "(ADMIN ONLY) Export a single database table") + .addOptions(new OptionData(OptionType.STRING, "table", "What table should be exported", true) + .addChoice("Custom Tags", "CUSTOM_TAGS") + .addChoice("Help Account", "HELP_ACCOUNT") + .addChoice("Help Channel Thanks", "HELP_CHANNEL_THANKS") + .addChoice("Help Transactions", "HELP_TRANSACTION") + .addChoice("Message Cache", "MESSAGE_CACHE") + .addChoice("Question of the Week Accounts", "QOTW_POINTS") + .addChoice("Question of the Week Questions", "QOTW_QUESTION") + .addChoice("Question of the Week Submissions", "QOTW_SUBMISSIONS") + .addChoice("Reserved Help Channels", "RESERVED_HELP_CHANNELS") + .addChoice("Starboard", "STARBOARD") + .addChoice("Warns", "WARN"), + new OptionData(OptionType.BOOLEAN, "include-data", "Should data be included in the export?"))); + requireUsers(Bot.config.getSystems().getAdminConfig().getAdminUsers()); + requirePermissions(Permission.MANAGE_SERVER); + } + @Override - public ReplyCallbackAction handleSlashCommandInteraction(SlashCommandInteractionEvent event) { - var tableNameOption = event.getOption("table"); + public void execute(SlashCommandInteractionEvent event) { + OptionMapping tableOption = event.getOption("table"); boolean includeData = event.getOption("include-data", false, OptionMapping::getAsBoolean); - if (tableNameOption == null) return Responses.error(event, "Missing required Choice Option"); + if (tableOption == null) { + Responses.replyMissingArguments(event).queue(); + return; + } + event.deferReply(false).queue(); Bot.asyncPool.submit(() -> { - try (var con = Bot.dataSource.getConnection()) { - var stmt = con.createStatement(); - boolean success = stmt.execute(String.format("SCRIPT %s TO '%s' TABLE %s;", includeData ? "COLUMNS" : "NODATA", TABLE_FILE, tableNameOption.getAsString())); + try (Connection con = Bot.dataSource.getConnection()) { + PreparedStatement stmt = con.prepareStatement(String.format("SCRIPT %s TO '%s' TABLE %s;", includeData ? "COLUMNS" : "NODATA", TABLE_FILE, tableOption.getAsString())); + boolean success = stmt.execute(); if (!success) { event.getHook().sendMessage("Exporting the table was not successful.").queue(); } else { - event.getHook().sendMessage("The export was successful.").queue(); - event.getChannel().sendFile(TABLE_FILE.toFile(), "table.sql").queue(msg -> { + event.getHook().sendMessage("The export was successful.").addFile(TABLE_FILE.toFile(), "table.sql").queue(msg -> { try { Files.delete(TABLE_FILE); } catch (IOException e) { - e.printStackTrace(); + ExceptionLogger.capture(e, getClass().getSimpleName()); event.getHook().sendMessageFormat("An error occurred, and the export could not be made: ```\n%s\n```", e.getMessage()).queue(); } }); } } catch (SQLException e) { - e.printStackTrace(); + ExceptionLogger.capture(e, getClass().getSimpleName()); event.getHook().sendMessageFormat("An error occurred, and the export could not be made: ```\n%s\n```", e.getMessage()).queue(); } }); - return event.deferReply(true); } } diff --git a/src/main/java/net/javadiscord/javabot/data/h2db/commands/MessageCacheInfoSubcommand.java b/src/main/java/net/javadiscord/javabot/data/h2db/commands/MessageCacheInfoSubcommand.java index 71977cfcc..8b168b907 100644 --- a/src/main/java/net/javadiscord/javabot/data/h2db/commands/MessageCacheInfoSubcommand.java +++ b/src/main/java/net/javadiscord/javabot/data/h2db/commands/MessageCacheInfoSubcommand.java @@ -1,33 +1,42 @@ package net.javadiscord.javabot.data.h2db.commands; +import com.dynxsty.dih4jda.interactions.commands.SlashCommand; import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.Permission; import net.dv8tion.jda.api.entities.MessageEmbed; import net.dv8tion.jda.api.entities.User; import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; -import net.dv8tion.jda.api.interactions.InteractionHook; -import net.dv8tion.jda.api.requests.restaction.interactions.InteractionCallbackAction; +import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; import net.javadiscord.javabot.Bot; -import net.javadiscord.javabot.command.ResponseException; -import net.javadiscord.javabot.command.interfaces.SlashCommand; import net.javadiscord.javabot.data.config.GuildConfig; import net.javadiscord.javabot.data.h2db.DbActions; +import net.javadiscord.javabot.util.Responses; /** * Allows staff members to get more detailed information about the message cache. */ -public class MessageCacheInfoSubcommand implements SlashCommand { +public class MessageCacheInfoSubcommand extends SlashCommand.Subcommand { + /** + * The constructor of this class, which sets the corresponding {@link SubcommandData}. + */ + public MessageCacheInfoSubcommand() { + setSubcommandData(new SubcommandData("info", "Displays some info about the Message Cache.")); + requireUsers(Bot.config.getSystems().getAdminConfig().getAdminUsers()); + requirePermissions(Permission.MANAGE_SERVER); + } + @Override - public InteractionCallbackAction handleSlashCommandInteraction(SlashCommandInteractionEvent event) throws ResponseException { - return event.replyEmbeds(this.buildInfoEmbed(Bot.config.get(event.getGuild()), event.getUser())); + public void execute(SlashCommandInteractionEvent event) { + event.replyEmbeds(buildInfoEmbed(Bot.config.get(event.getGuild()), event.getUser())).queue(); } private MessageEmbed buildInfoEmbed(GuildConfig config, User author) { long messages = DbActions.count("SELECT count(*) FROM message_cache"); - int maxMessages = config.getMessageCache().getMaxCachedMessages(); + int maxMessages = config.getMessageCacheConfig().getMaxCachedMessages(); return new EmbedBuilder() .setAuthor(author.getAsTag(), null, author.getEffectiveAvatarUrl()) .setTitle("Message Cache Info") - .setColor(config.getSlashCommand().getDefaultColor()) + .setColor(Responses.Type.DEFAULT.getColor()) .addField("Table Size", DbActions.getLogicalSize("message_cache") + " bytes", false) .addField("Message Count", String.valueOf(Bot.messageCache.messageCount), true) .addField("Cached (Memory)", String.format("%s/%s (%.2f%%)", Bot.messageCache.cache.size(), maxMessages, ((float) Bot.messageCache.cache.size() / maxMessages) * 100), true) diff --git a/src/main/java/net/javadiscord/javabot/data/h2db/commands/MigrateSubcommand.java b/src/main/java/net/javadiscord/javabot/data/h2db/commands/MigrateSubcommand.java index 72f5a00d7..e8fa3ba13 100644 --- a/src/main/java/net/javadiscord/javabot/data/h2db/commands/MigrateSubcommand.java +++ b/src/main/java/net/javadiscord/javabot/data/h2db/commands/MigrateSubcommand.java @@ -1,24 +1,35 @@ package net.javadiscord.javabot.data.h2db.commands; +import com.dynxsty.dih4jda.interactions.commands.AutoCompletable; +import com.dynxsty.dih4jda.interactions.commands.SlashCommand; +import com.dynxsty.dih4jda.util.AutoCompleteUtils; +import net.dv8tion.jda.api.Permission; import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent; import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.AutoCompleteQuery; import net.dv8tion.jda.api.interactions.commands.Command; -import net.dv8tion.jda.api.requests.restaction.interactions.ReplyCallbackAction; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; import net.javadiscord.javabot.Bot; -import net.javadiscord.javabot.command.Responses; -import net.javadiscord.javabot.command.interfaces.SlashCommand; import net.javadiscord.javabot.data.h2db.MigrationUtils; +import net.javadiscord.javabot.util.ExceptionLogger; +import net.javadiscord.javabot.util.Responses; +import org.jetbrains.annotations.NotNull; import java.io.IOException; import java.net.URISyntaxException; import java.nio.file.Files; import java.nio.file.Path; +import java.sql.Connection; import java.sql.SQLException; +import java.sql.Statement; import java.util.ArrayList; import java.util.List; import java.util.Objects; +import java.util.stream.Stream; /** + *

This class represents the /db-admin migrate command.

* This subcommand is responsible for executing SQL migrations on the bot's * schema. *

@@ -28,9 +39,36 @@ * character, and then proceed to execute each statement. *

*/ -public class MigrateSubcommand implements SlashCommand { +public class MigrateSubcommand extends SlashCommand.Subcommand implements AutoCompletable { + /** + * The constructor of this class, which sets the corresponding {@link SubcommandData}. + */ + public MigrateSubcommand() { + setSubcommandData(new SubcommandData("migrate", "(ADMIN ONLY) Run a single database migration") + .addOption(OptionType.STRING, "name", "The migration's filename", true, true)); + requireUsers(Bot.config.getSystems().getAdminConfig().getAdminUsers()); + requirePermissions(Permission.MANAGE_SERVER); + } + + /** + * Replies with all available migrations to run. + * + * @param event The {@link CommandAutoCompleteInteractionEvent} that was fired. + * @return A {@link List} with all Option Choices. + */ + public static @NotNull List replyMigrations(CommandAutoCompleteInteractionEvent event) { + List choices = new ArrayList<>(25); + try (Stream s = Files.list(MigrationUtils.getMigrationsDirectory())) { + List paths = s.filter(path -> path.getFileName().toString().endsWith(".sql")).toList(); + paths.forEach(path -> choices.add(new Command.Choice(path.getFileName().toString(), path.getFileName().toString()))); + } catch (IOException | URISyntaxException e) { + ExceptionLogger.capture(e, MigrateSubcommand.class.getSimpleName()); + } + return choices; + } + @Override - public ReplyCallbackAction handleSlashCommandInteraction(SlashCommandInteractionEvent event) { + public void execute(@NotNull SlashCommandInteractionEvent event) { String migrationName = Objects.requireNonNull(event.getOption("name")).getAsString(); if (!migrationName.endsWith(".sql")) { migrationName = migrationName + ".sql"; @@ -39,55 +77,49 @@ public ReplyCallbackAction handleSlashCommandInteraction(SlashCommandInteraction Path migrationsDir = MigrationUtils.getMigrationsDirectory(); Path migrationFile = migrationsDir.resolve(migrationName); if (Files.notExists(migrationFile)) { - return Responses.warning(event, "The specified migration `" + migrationName + "` does not exist."); + Responses.error(event, "The specified migration `" + migrationName + "` does not exist.").queue(); + return; } String sql = Files.readString(migrationFile); String[] statements = sql.split("\\s*;\\s*"); if (statements.length == 0) { - return Responses.warning(event, "The migration `" + migrationName + "` does not contain any statements. Please remove or edit it before running again."); + Responses.error(event, "The migration `" + migrationName + "` does not contain any statements. Please remove or edit it before running again.").queue(); + return; } + event.deferReply().queue(); Bot.asyncPool.submit(() -> { - try (var con = Bot.dataSource.getConnection()) { + try (Connection con = Bot.dataSource.getConnection()) { for (int i = 0; i < statements.length; i++) { if (statements[i].isBlank()) { - event.getChannel().sendMessage("Skipping statement " + (i + 1) + "; it is blank.").queue(); + event.getHook().sendMessage("Skipping statement " + (i + 1) + "; it is blank.").queue(); continue; } - try (var stmt = con.createStatement()) { + try (Statement stmt = con.createStatement()) { int rowsUpdated = stmt.executeUpdate(statements[i]); - event.getChannel().sendMessageFormat( + event.getHook().sendMessageFormat( "Executed statement %d of %d:\n```sql\n%s\n```\nRows Updated: `%d`", i + 1, statements.length, statements[i], rowsUpdated ).queue(); } catch (SQLException e) { - e.printStackTrace(); - event.getChannel().sendMessage("Error while executing statement " + (i + 1) + ": " + e.getMessage()).queue(); + ExceptionLogger.capture(e, getClass().getSimpleName()); + event.getHook().sendMessage("Error while executing statement " + (i + 1) + ": " + e.getMessage()).queue(); return; } } } catch (SQLException e) { - event.getChannel().sendMessage("Could not obtain a connection to the database.").queue(); + ExceptionLogger.capture(e, getClass().getSimpleName()); + event.getHook().sendMessage("Could not obtain a connection to the database.").queue(); } }); - return Responses.info(event, "Migration Started", "Execution of the migration `" + migrationName + "` has been started. " + statements.length + " statements will be executed."); + Responses.info(event.getHook(), "Migration Started", + "Execution of the migration `" + migrationName + "` has been started. " + statements.length + " statements will be executed.").queue(); } catch (IOException | URISyntaxException e) { - return Responses.error(event, e.getMessage()); + ExceptionLogger.capture(e, getClass().getSimpleName()); + Responses.error(event.getHook(), e.getMessage()).queue(); } } - /** - * Replies with all available migrations to run. - * - * @param event The {@link CommandAutoCompleteInteractionEvent} that was fired. - * @return A {@link List} with all Option Choices. - */ - public static List replyMigrations(CommandAutoCompleteInteractionEvent event) { - List choices = new ArrayList<>(25); - try (var s = Files.list(MigrationUtils.getMigrationsDirectory())) { - var paths = s.filter(path -> path.getFileName().toString().endsWith(".sql")).toList(); - paths.forEach(path -> choices.add(new Command.Choice(path.getFileName().toString(), path.getFileName().toString()))); - } catch (IOException | URISyntaxException e) { - e.printStackTrace(); - } - return choices; + @Override + public void handleAutoComplete(@NotNull CommandAutoCompleteInteractionEvent event, @NotNull AutoCompleteQuery target) { + event.replyChoices(AutoCompleteUtils.handleChoices(event, MigrateSubcommand::replyMigrations)).queue(); } } diff --git a/src/main/java/net/javadiscord/javabot/data/h2db/commands/MigrationsListSubcommand.java b/src/main/java/net/javadiscord/javabot/data/h2db/commands/MigrationsListSubcommand.java index a68141419..6308c3c13 100644 --- a/src/main/java/net/javadiscord/javabot/data/h2db/commands/MigrationsListSubcommand.java +++ b/src/main/java/net/javadiscord/javabot/data/h2db/commands/MigrationsListSubcommand.java @@ -1,30 +1,47 @@ package net.javadiscord.javabot.data.h2db.commands; +import com.dynxsty.dih4jda.interactions.commands.SlashCommand; import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.Permission; import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; -import net.dv8tion.jda.api.requests.restaction.interactions.ReplyCallbackAction; -import net.javadiscord.javabot.command.Responses; -import net.javadiscord.javabot.command.interfaces.SlashCommand; +import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; +import net.javadiscord.javabot.Bot; import net.javadiscord.javabot.data.h2db.MigrationUtils; +import net.javadiscord.javabot.util.ExceptionLogger; +import net.javadiscord.javabot.util.Responses; import java.io.IOException; import java.net.URISyntaxException; import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.stream.Stream; /** + *

This class represents the /db-admin migrations-list command.

* This subcommand shows a list of all available migrations, and a short preview * of their source code. */ -public class MigrationsListSubcommand implements SlashCommand { +public class MigrationsListSubcommand extends SlashCommand.Subcommand { + /** + * The constructor of this class, which sets the corresponding {@link SubcommandData}. + */ + public MigrationsListSubcommand() { + setSubcommandData(new SubcommandData("migrations-list", "(ADMIN ONLY) Shows a list with all available database migrations.")); + requireUsers(Bot.config.getSystems().getAdminConfig().getAdminUsers()); + requirePermissions(Permission.MANAGE_SERVER); + } + @Override - public ReplyCallbackAction handleSlashCommandInteraction(SlashCommandInteractionEvent event) { - try (var s = Files.list(MigrationUtils.getMigrationsDirectory())) { + public void execute(SlashCommandInteractionEvent event) { + try (Stream s = Files.list(MigrationUtils.getMigrationsDirectory())) { EmbedBuilder embedBuilder = new EmbedBuilder() .setTitle("List of Runnable Migrations"); - var paths = s.filter(path -> path.getFileName().toString().endsWith(".sql")).toList(); + List paths = s.filter(path -> path.getFileName().toString().endsWith(".sql")).toList(); if (paths.isEmpty()) { embedBuilder.setDescription("There are no migrations to run. Please add them to the `/migrations/` resource directory."); - return event.replyEmbeds(embedBuilder.build()); + event.replyEmbeds(embedBuilder.build()).queue(); + return; } paths.forEach(path -> { StringBuilder sb = new StringBuilder(150); @@ -34,15 +51,16 @@ public ReplyCallbackAction handleSlashCommandInteraction(SlashCommandInteraction sb.append(sql, 0, Math.min(sql.length(), 100)); if (sql.length() > 100) sb.append("..."); } catch (IOException e) { - e.printStackTrace(); + ExceptionLogger.capture(e, getClass().getSimpleName()); sb.append("Error: Could not read SQL: ").append(e.getMessage()); } sb.append("\n```"); embedBuilder.addField(path.getFileName().toString(), sb.toString(), false); }); - return event.replyEmbeds(embedBuilder.build()); + event.replyEmbeds(embedBuilder.build()).queue(); } catch (IOException | URISyntaxException e) { - return Responses.error(event, e.getMessage()); + ExceptionLogger.capture(e, getClass().getSimpleName()); + Responses.error(event, e.getMessage()).queue(); } } } diff --git a/src/main/java/net/javadiscord/javabot/data/h2db/commands/QuickMigrateSubcommand.java b/src/main/java/net/javadiscord/javabot/data/h2db/commands/QuickMigrateSubcommand.java new file mode 100644 index 000000000..ab3ce8c1b --- /dev/null +++ b/src/main/java/net/javadiscord/javabot/data/h2db/commands/QuickMigrateSubcommand.java @@ -0,0 +1,110 @@ +package net.javadiscord.javabot.data.h2db.commands; + +import com.dynxsty.dih4jda.interactions.commands.SlashCommand; +import com.dynxsty.dih4jda.interactions.components.ModalHandler; +import net.dv8tion.jda.api.Permission; +import net.dv8tion.jda.api.entities.TextChannel; +import net.dv8tion.jda.api.events.interaction.ModalInteractionEvent; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; +import net.dv8tion.jda.api.interactions.components.ActionRow; +import net.dv8tion.jda.api.interactions.components.Modal; +import net.dv8tion.jda.api.interactions.components.text.TextInput; +import net.dv8tion.jda.api.interactions.components.text.TextInputStyle; +import net.dv8tion.jda.api.interactions.modals.ModalMapping; +import net.javadiscord.javabot.Bot; +import net.javadiscord.javabot.util.ExceptionLogger; +import net.javadiscord.javabot.util.Responses; +import org.jetbrains.annotations.NotNull; + +import java.sql.Connection; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.List; + +/** + *

This class represents the /db-admin quick-migrate command.

+ * This subcommand is responsible for executing quick SQL migrations on the bot's + * schema. + */ +public class QuickMigrateSubcommand extends SlashCommand.Subcommand implements ModalHandler { + /** + * The constructor of this class, which sets the corresponding {@link SubcommandData}. + */ + public QuickMigrateSubcommand() { + setSubcommandData(new SubcommandData("quick-migrate", "(ADMIN ONLY) Run a single quick database migration")); + requireUsers(Bot.config.getSystems().getAdminConfig().getAdminUsers()); + requirePermissions(Permission.MANAGE_SERVER); + } + + @Override + public void execute(@NotNull SlashCommandInteractionEvent event) { + event.replyModal(buildQuickMigrateModal()).queue(); + } + + @Override + public void handleModal(@NotNull ModalInteractionEvent event, List values) { + ModalMapping statementMapping = event.getValue("sql"); + ModalMapping confirmMapping = event.getValue("confirmation"); + event.deferReply(false).queue(); + if (statementMapping == null || confirmMapping == null) { + Responses.replyMissingArguments(event.getHook()).queue(); + return; + } + if (!confirmMapping.getAsString().equals("CONFIRM")) { + Responses.error(event.getHook(), "Invalid confirmation. Please try again.").queue(); + return; + } + String sql = statementMapping.getAsString(); + String[] statements = sql.split("\\s*;\\s*"); + if (statements.length == 0) { + Responses.error(event.getHook(), "The provided migration does not contain any statements. Please remove or edit it before running again.").queue(); + return; + } + Bot.asyncPool.submit(() -> { + TextChannel channel = event.getChannel().asTextChannel(); + try (Connection con = Bot.dataSource.getConnection()) { + for (int i = 0; i < statements.length; i++) { + if (statements[i].isBlank()) { + channel.sendMessage("Skipping statement " + (i + 1) + "; it is blank.").queue(); + continue; + } + try (Statement stmt = con.createStatement()) { + int rowsUpdated = stmt.executeUpdate(statements[i]); + channel.sendMessageFormat( + "Executed statement %d of %d:\n```sql\n%s\n```\nRows Updated: `%d`", i + 1, statements.length, statements[i], rowsUpdated + ).queue(); + } catch (SQLException e) { + ExceptionLogger.capture(e, getClass().getSimpleName()); + channel.sendMessage("Error while executing statement " + (i + 1) + ": " + e.getMessage()).queue(); + return; + } + } + } catch (SQLException e) { + ExceptionLogger.capture(e, getClass().getSimpleName()); + channel.sendMessage("Could not obtain a connection to the database.").queue(); + } + }); + Responses.info(event.getHook(), "Quick Migration Started", + "Execution of the quick migration has been started. " + statements.length + " statements will be executed.").queue(); + } + + private @NotNull Modal buildQuickMigrateModal() { + TextInput sqlInput = TextInput.create("sql", "SQL-Statement (H2)", TextInputStyle.PARAGRAPH) + .setPlaceholder(""" + CREATE TABLE my_table ( + thread_id BIGINT PRIMARY KEY, + [...] + ); + """) + .setRequired(true) + .build(); + TextInput confirmInput = TextInput.create("confirmation", "Confirmation", TextInputStyle.SHORT) + .setPlaceholder("Type 'CONFIRM' to confirm this action") + .setRequired(true) + .build(); + return Modal.create("quick-migrate", "Quick Migrate") + .addActionRows(ActionRow.of(sqlInput), ActionRow.of(confirmInput)) + .build(); + } +} diff --git a/src/main/java/net/javadiscord/javabot/data/h2db/message_cache/MessageCache.java b/src/main/java/net/javadiscord/javabot/data/h2db/message_cache/MessageCache.java index 17645f52e..f2fbfe381 100644 --- a/src/main/java/net/javadiscord/javabot/data/h2db/message_cache/MessageCache.java +++ b/src/main/java/net/javadiscord/javabot/data/h2db/message_cache/MessageCache.java @@ -10,7 +10,9 @@ import net.javadiscord.javabot.data.h2db.DbHelper; import net.javadiscord.javabot.data.h2db.message_cache.dao.MessageCacheRepository; import net.javadiscord.javabot.data.h2db.message_cache.model.CachedMessage; -import net.javadiscord.javabot.systems.commands.IdCalculatorCommand; +import net.javadiscord.javabot.systems.user_commands.IdCalculatorCommand; +import net.javadiscord.javabot.util.ExceptionLogger; +import net.javadiscord.javabot.util.Responses; import net.javadiscord.javabot.util.TimeUtils; import java.io.ByteArrayInputStream; @@ -48,6 +50,7 @@ public MessageCache() { try (Connection con = Bot.dataSource.getConnection()) { cache = new MessageCacheRepository(con).getAll(); } catch (SQLException e) { + ExceptionLogger.capture(e, getClass().getSimpleName()); log.error("Something went wrong during retrieval of stored messages."); } } @@ -70,7 +73,7 @@ public void synchronize() { * @param message The message to cache. */ public void cache(Message message) { - MessageCacheConfig config = Bot.config.get(message.getGuild()).getMessageCache(); + MessageCacheConfig config = Bot.config.get(message.getGuild()).getMessageCacheConfig(); if (cache.size() + 1 > config.getMaxCachedMessages()) { cache.remove(0); } @@ -88,12 +91,14 @@ public void cache(Message message) { * @param before The {@link CachedMessage}. */ public void sendUpdatedMessageToLog(Message updated, CachedMessage before) { + MessageCacheConfig config = Bot.config.get(updated.getGuild()).getMessageCacheConfig(); + if (config.getMessageCacheLogChannel() == null) return; if (updated.getContentRaw().trim().equals(before.getMessageContent())) return; - MessageAction action = Bot.config.get(updated.getGuild()).getMessageCache().getMessageCacheLogChannel() - .sendMessageEmbeds(this.buildMessageEditEmbed(updated.getGuild(), updated.getAuthor(), updated.getChannel(), before, updated)) + MessageAction action = config.getMessageCacheLogChannel() + .sendMessageEmbeds(buildMessageEditEmbed(updated.getGuild(), updated.getAuthor(), updated.getChannel(), before, updated)) .setActionRow(Button.link(updated.getJumpUrl(), "Jump to Message")); if (before.getMessageContent().length() > MessageEmbed.VALUE_MAX_LENGTH || updated.getContentRaw().length() > MessageEmbed.VALUE_MAX_LENGTH) { - action.addFile(this.buildEditedMessageFile(updated.getAuthor(), before, updated), before.getMessageId() + ".txt"); + action.addFile(buildEditedMessageFile(updated.getAuthor(), before, updated), before.getMessageId() + ".txt"); } action.queue(); } @@ -106,11 +111,12 @@ public void sendUpdatedMessageToLog(Message updated, CachedMessage before) { * @param message The {@link CachedMessage}. */ public void sendDeletedMessageToLog(Guild guild, MessageChannel channel, CachedMessage message) { + MessageCacheConfig config = Bot.config.get(guild).getMessageCacheConfig(); + if (config.getMessageCacheLogChannel() == null) return; guild.getJDA().retrieveUserById(message.getAuthorId()).queue(author -> { - MessageAction action = Bot.config.get(guild).getMessageCache().getMessageCacheLogChannel() - .sendMessageEmbeds(this.buildMessageDeleteEmbed(guild, author, channel, message)); + MessageAction action = config.getMessageCacheLogChannel().sendMessageEmbeds(buildMessageDeleteEmbed(guild, author, channel, message)); if (message.getMessageContent().length() > MessageEmbed.VALUE_MAX_LENGTH) { - action.addFile(this.buildDeletedMessageFile(author, message), message.getMessageId() + ".txt"); + action.addFile(buildDeletedMessageFile(author, message), message.getMessageId() + ".txt"); } action.queue(); }); @@ -129,7 +135,7 @@ private EmbedBuilder buildMessageCacheEmbed(MessageChannel channel, User author, private MessageEmbed buildMessageEditEmbed(Guild guild, User author, MessageChannel channel, CachedMessage before, Message after) { return buildMessageCacheEmbed(channel, author, before) .setTitle("Message Edited") - .setColor(Bot.config.get(guild).getSlashCommand().getWarningColor()) + .setColor(Responses.Type.WARN.getColor()) .addField("Before", before.getMessageContent().substring(0, Math.min( before.getMessageContent().length(), MessageEmbed.VALUE_MAX_LENGTH)), false) @@ -142,7 +148,7 @@ private MessageEmbed buildMessageEditEmbed(Guild guild, User author, MessageChan private MessageEmbed buildMessageDeleteEmbed(Guild guild, User author, MessageChannel channel, CachedMessage message) { return buildMessageCacheEmbed(channel, author, message) .setTitle("Message Deleted") - .setColor(Bot.config.get(guild).getSlashCommand().getErrorColor()) + .setColor(Responses.Type.ERROR.getColor()) .addField("Message Content", message.getMessageContent().substring(0, Math.min( message.getMessageContent().length(), diff --git a/src/main/java/net/javadiscord/javabot/data/h2db/message_cache/MessageCacheListener.java b/src/main/java/net/javadiscord/javabot/data/h2db/message_cache/MessageCacheListener.java index fd49702a3..ae76688b3 100644 --- a/src/main/java/net/javadiscord/javabot/data/h2db/message_cache/MessageCacheListener.java +++ b/src/main/java/net/javadiscord/javabot/data/h2db/message_cache/MessageCacheListener.java @@ -68,7 +68,7 @@ public void onMessageDelete(@NotNull MessageDeleteEvent event) { */ private boolean ignoreMessageCache(Message message) { if (!message.isFromGuild()) return true; - MessageCacheConfig config = Bot.config.get(message.getGuild()).getMessageCache(); + MessageCacheConfig config = Bot.config.get(message.getGuild()).getMessageCacheConfig(); return message.getAuthor().isBot() || message.getAuthor().isSystem() || config.getExcludedUsers().contains(message.getAuthor().getIdLong()) || config.getExcludedChannels().contains(message.getChannel().getIdLong()); diff --git a/src/main/java/net/javadiscord/javabot/data/h2db/message_cache/dao/MessageCacheRepository.java b/src/main/java/net/javadiscord/javabot/data/h2db/message_cache/dao/MessageCacheRepository.java index 0d2977e52..dd9b1e925 100644 --- a/src/main/java/net/javadiscord/javabot/data/h2db/message_cache/dao/MessageCacheRepository.java +++ b/src/main/java/net/javadiscord/javabot/data/h2db/message_cache/dao/MessageCacheRepository.java @@ -43,7 +43,7 @@ public void insertList(List messages) throws SQLException { try (PreparedStatement stmt = con.prepareStatement("INSERT INTO message_cache (message_id, author_id, message_content) VALUES (?, ?, ?)", Statement.RETURN_GENERATED_KEYS )) { - for (CachedMessage msg:messages) { + for (CachedMessage msg : messages) { stmt.setLong(1, msg.getMessageId()); stmt.setLong(2, msg.getAuthorId()); stmt.setString(3, msg.getMessageContent()); @@ -89,12 +89,13 @@ public boolean delete(long messageId) throws SQLException { /** * Gets all Messages from the Database. + * * @return A {@link List} of {@link CachedMessage}s. * @throws SQLException If anything goes wrong. */ public List getAll() throws SQLException { try (PreparedStatement s = con.prepareStatement("SELECT * FROM message_cache")) { - var rs = s.executeQuery(); + ResultSet rs = s.executeQuery(); List cachedMessages = new ArrayList<>(); while (rs.next()) { cachedMessages.add(this.read(rs)); @@ -111,7 +112,7 @@ public List getAll() throws SQLException { * @throws SQLException If anything goes wrong. */ public boolean delete(int amount) throws SQLException { - try ( PreparedStatement stmt = con.prepareStatement("DELETE FROM message_cache LIMIT ?", + try (PreparedStatement stmt = con.prepareStatement("DELETE FROM message_cache LIMIT ?", Statement.RETURN_GENERATED_KEYS )) { stmt.setInt(1, amount); diff --git a/src/main/java/net/javadiscord/javabot/listener/DIH4JDAListener.java b/src/main/java/net/javadiscord/javabot/listener/DIH4JDAListener.java new file mode 100644 index 000000000..299358005 --- /dev/null +++ b/src/main/java/net/javadiscord/javabot/listener/DIH4JDAListener.java @@ -0,0 +1,98 @@ +package net.javadiscord.javabot.listener; + +import com.dynxsty.dih4jda.events.DIH4JDAEventListener; +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.Permission; +import net.dv8tion.jda.api.entities.MessageEmbed; +import net.dv8tion.jda.api.interactions.ModalInteraction; +import net.dv8tion.jda.api.interactions.callbacks.IReplyCallback; +import net.dv8tion.jda.api.interactions.commands.CommandInteraction; +import net.dv8tion.jda.api.interactions.components.ComponentInteraction; +import net.dv8tion.jda.api.utils.MarkdownUtil; +import net.javadiscord.javabot.util.ExceptionLogger; +import net.javadiscord.javabot.util.Responses; +import org.jetbrains.annotations.NotNull; + +import java.time.Instant; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Listener class for various events provided by {@link com.dynxsty.dih4jda.DIH4JDA}. + */ +public class DIH4JDAListener implements DIH4JDAEventListener { + @Override + public void onCommandException(CommandInteraction interaction, Exception e) { + ExceptionLogger.capture(e, getClass().getSimpleName()); + handleReply(interaction, buildExceptionEmbed(e)); + } + + @Override + public void onComponentException(ComponentInteraction interaction, Exception e) { + ExceptionLogger.capture(e, getClass().getSimpleName()); + handleReply(interaction, buildExceptionEmbed(e)); + } + + @Override + public void onModalException(ModalInteraction interaction, Exception e) { + ExceptionLogger.capture(e, getClass().getSimpleName()); + handleReply(interaction, buildExceptionEmbed(e)); + } + + @Override + public void onInvalidUser(CommandInteraction interaction, Set userIds) { + handleReply(interaction, buildNoAccessEmbed()); + } + + @Override + public void onInvalidRole(CommandInteraction interaction, Set userIds) { + handleReply(interaction, buildNoAccessEmbed()); + } + + @Override + public void onInsufficientPermissions(CommandInteraction interaction, Set permissions) { + handleReply(interaction, buildInsufficientPermissionsEmbed(permissions)); + } + + /** + * Handles the reply to the {@link IReplyCallback} and acknowledges it, if not already done. + * + * @param callback The {@link IReplyCallback} to reply to. + * @param embed The {@link MessageEmbed} to send. + */ + private void handleReply(@NotNull IReplyCallback callback, MessageEmbed embed) { + if (!callback.isAcknowledged()) { + callback.deferReply(true).queue(); + } + callback.getHook().sendMessageEmbeds(embed).queue(); + } + + private @NotNull EmbedBuilder buildErrorEmbed() { + return new EmbedBuilder() + .setTitle("An Error occurred!") + .setColor(Responses.Type.ERROR.getColor()) + .setTimestamp(Instant.now()); + } + + private @NotNull MessageEmbed buildExceptionEmbed(@NotNull Exception e) { + return buildErrorEmbed() + .setDescription(e.getMessage() == null ? "An error occurred." : MarkdownUtil.codeblock(e.getMessage())) + .setFooter(e.getClass().getSimpleName()) + .build(); + } + + private @NotNull MessageEmbed buildInsufficientPermissionsEmbed(@NotNull Set permissions) { + String perms = permissions.stream().map(Permission::getName).collect(Collectors.joining(", ")); + return buildErrorEmbed() + .setDescription(String.format( + "You're not allowed to use this command. " + + "In order to execute this command, you'll need the following permissions: `%s`", perms)) + .build(); + } + + private @NotNull MessageEmbed buildNoAccessEmbed() { + return buildErrorEmbed() + .setDescription("You're not allowed to use this command.") + .build(); + } +} diff --git a/src/main/java/net/javadiscord/javabot/listener/GitHubLinkListener.java b/src/main/java/net/javadiscord/javabot/listener/GitHubLinkListener.java index 14a9c6369..ea4895e88 100644 --- a/src/main/java/net/javadiscord/javabot/listener/GitHubLinkListener.java +++ b/src/main/java/net/javadiscord/javabot/listener/GitHubLinkListener.java @@ -1,11 +1,13 @@ package net.javadiscord.javabot.listener; +import com.dynxsty.dih4jda.util.Pair; import net.dv8tion.jda.api.events.message.MessageReceivedEvent; import net.dv8tion.jda.api.hooks.ListenerAdapter; import net.dv8tion.jda.api.interactions.components.buttons.Button; +import net.javadiscord.javabot.util.ExceptionLogger; import net.javadiscord.javabot.util.InteractionUtils; -import net.javadiscord.javabot.util.Pair; import net.javadiscord.javabot.util.StringUtils; +import org.jetbrains.annotations.Contract; import org.jetbrains.annotations.NotNull; import java.io.BufferedReader; @@ -30,9 +32,9 @@ public void onMessageReceived(@NotNull MessageReceivedEvent event) { if (event.getAuthor().isBot() || event.getAuthor().isSystem()) return; Matcher matcher = GITHUB_LINK_PATTERN.matcher(event.getMessage().getContentRaw()); if (matcher.find()) { - Pair content = this.parseGithubUrl(matcher.group()); - if (!content.first().isBlank() && !content.second().isBlank()) { - event.getMessage().reply(String.format("```%s\n%s\n```", content.second(), StringUtils.standardSanitizer().compute(content.first()))) + Pair content = parseGithubUrl(matcher.group()); + if (!content.getFirst().isBlank() && !content.getSecond().isBlank()) { + event.getMessage().reply(String.format("```%s\n%s\n```", content.getSecond(), StringUtils.standardSanitizer().compute(content.getFirst()))) .allowedMentions(List.of()) .setActionRow(Button.secondary(InteractionUtils.DELETE_ORIGINAL_TEMPLATE, "\uD83D\uDDD1️"), Button.link(matcher.group(), "View on GitHub")) .queue(); @@ -46,7 +48,8 @@ public void onMessageReceived(@NotNull MessageReceivedEvent event) { * @param link The initial input url. * @return A {@link Pair} containing the files content & extension. */ - private Pair parseGithubUrl(String link) { + @Contract("_ -> new") + private @NotNull Pair parseGithubUrl(@NotNull String link) { String[] arr = link.split("/"); // Removes all unnecessary elements String[] segments = Arrays.copyOfRange(arr, 3, arr.length); @@ -56,6 +59,9 @@ private Pair parseGithubUrl(String link) { .map(line -> line.replace("-", "")) .filter(line -> line.matches("-?\\d+")) // check if the given link is a number .map(Integer::valueOf).sorted().toArray(Integer[]::new); + if (lines.length == 0) { + return new Pair<>("", ""); + } int to = lines.length != 2 ? lines[0] : lines[1]; String reqUrl = String.format("https://raw.githubusercontent.com/%s/%s/%s/%s", segments[0], segments[1], @@ -65,6 +71,7 @@ private Pair parseGithubUrl(String link) { try { content = this.getContentFromRawGitHubUrl(reqUrl, lines[0], to); } catch (IOException e) { + ExceptionLogger.capture(e, getClass().getSimpleName()); content = e.getMessage(); } if (content.equals(reqUrl)) content = "Unable to fetch content."; diff --git a/src/main/java/net/javadiscord/javabot/listener/GuildJoinListener.java b/src/main/java/net/javadiscord/javabot/listener/GuildJoinListener.java index f7aae6039..6f45c002f 100644 --- a/src/main/java/net/javadiscord/javabot/listener/GuildJoinListener.java +++ b/src/main/java/net/javadiscord/javabot/listener/GuildJoinListener.java @@ -3,6 +3,7 @@ import net.dv8tion.jda.api.events.guild.GuildJoinEvent; import net.dv8tion.jda.api.hooks.ListenerAdapter; import net.javadiscord.javabot.Bot; +import net.javadiscord.javabot.util.ExceptionLogger; /** * Listens for {@link GuildJoinEvent}. @@ -11,8 +12,10 @@ public class GuildJoinListener extends ListenerAdapter { @Override public void onGuildJoin(GuildJoinEvent event) { Bot.config.addGuild(event.getGuild()); - for (var guild : event.getJDA().getGuilds()) { - Bot.interactionHandler.registerCommands(guild); + try { + Bot.dih4jda.registerInteractions(); + } catch (ReflectiveOperationException e) { + ExceptionLogger.capture(e, getClass().getSimpleName()); } } } diff --git a/src/main/java/net/javadiscord/javabot/listener/HugListener.java b/src/main/java/net/javadiscord/javabot/listener/HugListener.java index 216d2a26f..a1764172d 100644 --- a/src/main/java/net/javadiscord/javabot/listener/HugListener.java +++ b/src/main/java/net/javadiscord/javabot/listener/HugListener.java @@ -1,18 +1,14 @@ package net.javadiscord.javabot.listener; -import javax.annotation.Nonnull; - import lombok.extern.slf4j.Slf4j; -import net.dv8tion.jda.api.entities.ChannelType; -import net.dv8tion.jda.api.entities.GuildMessageChannel; -import net.dv8tion.jda.api.entities.Message; -import net.dv8tion.jda.api.entities.TextChannel; -import net.dv8tion.jda.api.entities.Webhook; +import net.dv8tion.jda.api.entities.*; import net.dv8tion.jda.api.events.message.MessageReceivedEvent; import net.dv8tion.jda.api.hooks.ListenerAdapter; import net.javadiscord.javabot.Bot; import net.javadiscord.javabot.util.WebhookUtil; +import javax.annotation.Nonnull; + /** * Replaces all occurences of 'fuck' in incoming messages with 'hug'. */ @@ -32,16 +28,16 @@ public void onMessageReceived(@Nonnull MessageReceivedEvent event) { if (event.isWebhookMessage()) { return; } - if (event.getChannel().getIdLong() == Bot.config.get(event.getGuild()).getModeration() + if (event.getChannel().getIdLong() == Bot.config.get(event.getGuild()).getModerationConfig() .getSuggestionChannelId()) { return; } TextChannel tc = null; if (event.isFromType(ChannelType.TEXT)) { - tc = event.getTextChannel(); + tc = event.getChannel().asTextChannel(); } if (event.isFromThread()) { - GuildMessageChannel parentChannel = event.getThreadChannel().getParentMessageChannel(); + GuildMessageChannel parentChannel = event.getChannel().asThreadChannel().getParentMessageChannel(); if (parentChannel instanceof TextChannel textChannel) { tc = textChannel; } @@ -53,7 +49,7 @@ public void onMessageReceived(@Nonnull MessageReceivedEvent event) { String content = event.getMessage().getContentRaw(); String lowerCaseContent = content.toLowerCase(); if (lowerCaseContent.contains("fuck")) { - long threadId = event.isFromThread() ? event.getThreadChannel().getIdLong() : 0; + long threadId = event.isFromThread() ? event.getChannel().getIdLong() : 0; StringBuilder sb = new StringBuilder(content.length()); int index = 0; int indexBkp = index; @@ -69,7 +65,7 @@ public void onMessageReceived(@Nonnull MessageReceivedEvent event) { } } - sb.append(content.substring(indexBkp, content.length())); + sb.append(content.substring(indexBkp)); WebhookUtil.ensureWebhookExists(textChannel, wh -> sendWebhookMessage(wh, event.getMessage(), sb.toString(), threadId), e -> log.error("Webhook lookup/creation failed", e)); diff --git a/src/main/java/net/javadiscord/javabot/listener/InteractionListener.java b/src/main/java/net/javadiscord/javabot/listener/InteractionListener.java deleted file mode 100644 index 135a656f2..000000000 --- a/src/main/java/net/javadiscord/javabot/listener/InteractionListener.java +++ /dev/null @@ -1,74 +0,0 @@ -package net.javadiscord.javabot.listener; - -import net.dv8tion.jda.api.events.interaction.ModalInteractionEvent; -import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent; -import net.dv8tion.jda.api.events.interaction.component.SelectMenuInteractionEvent; -import net.dv8tion.jda.api.hooks.ListenerAdapter; -import net.javadiscord.javabot.command.Responses; -import net.javadiscord.javabot.systems.help.HelpChannelInteractionManager; -import net.javadiscord.javabot.systems.commands.subcommands.leaderboard.ExperienceLeaderboardSubcommand; -import net.javadiscord.javabot.systems.moderation.ReportCommand; -import net.javadiscord.javabot.systems.qotw.subcommands.questions_queue.AddQuestionSubcommand; -import net.javadiscord.javabot.systems.qotw.submissions.SubmissionInteractionManager; -import net.javadiscord.javabot.systems.staff.self_roles.SelfRoleInteractionManager; -import net.javadiscord.javabot.util.InteractionUtils; -import org.jetbrains.annotations.NotNull; - -/** - * Listens for Interaction Events and handles them. - */ -public class InteractionListener extends ListenerAdapter { - - /** - * Handles the {@link ModalInteractionEvent} and executes the corresponding interaction, based on the id. - * - * @param event The {@link ModalInteractionEvent} that was fired. - */ - @Override - public void onModalInteraction(@NotNull ModalInteractionEvent event) { - if (event.getUser().isBot()) return; - String[] id = event.getModalId().split(":"); - switch (id[0]) { - case "self-role" -> SelfRoleInteractionManager.handleModalSubmit(event, id); - case "report" -> new ReportCommand().handleModalSubmit(event, id); - case "qotw-add-question" -> AddQuestionSubcommand.handleModalSubmit(event).queue(); - default -> Responses.error(event.getHook(), "Unknown Interaction").queue(); - } - } - - /** - * Handles the {@link SelectMenuInteractionEvent} and executes the corresponding interaction, based on the id. - * - * @param event The {@link SelectMenuInteractionEvent} that was fired. - */ - @Override - public void onSelectMenuInteraction(SelectMenuInteractionEvent event) { - if (event.getUser().isBot()) return; - String[] id = event.getComponentId().split(":"); - switch (id[0]) { - case "qotw-submission-select" -> SubmissionInteractionManager.handleSelectMenu(id, event); - default -> Responses.error(event.getHook(), "Unknown Interaction").queue(); - } - } - - /** - * Handles the {@link ButtonInteractionEvent} and executes the corresponding interaction, based on the id. - * - * @param event The {@link ButtonInteractionEvent} that was fired. - */ - @Override - public void onButtonInteraction(ButtonInteractionEvent event) { - if (event.getUser().isBot()) return; - String[] id = event.getComponentId().split(":"); - switch (id[0]) { - case "experience-leaderboard" -> ExperienceLeaderboardSubcommand.handleButtons(event, id); - case "qotw-submission" -> SubmissionInteractionManager.handleButton(event, id); - case "resolve-report" -> new ReportCommand().markAsResolved(event, id[1]); - case "self-role" -> SelfRoleInteractionManager.handleButton(event, id); - case "help-channel" -> new HelpChannelInteractionManager().handleHelpChannel(event, id[1], id[2]); - case "help-thank" -> new HelpChannelInteractionManager().handleHelpThank(event, id[1], id[2]); - case "utils" -> InteractionUtils.handleButton(event, id); - default -> Responses.error(event.getHook(), "Unknown Interaction").queue(); - } - } -} diff --git a/src/main/java/net/javadiscord/javabot/listener/JobChannelVoteListener.java b/src/main/java/net/javadiscord/javabot/listener/JobChannelVoteListener.java index d80768232..b037f13fa 100644 --- a/src/main/java/net/javadiscord/javabot/listener/JobChannelVoteListener.java +++ b/src/main/java/net/javadiscord/javabot/listener/JobChannelVoteListener.java @@ -11,12 +11,12 @@ public class JobChannelVoteListener extends MessageVoteListener { @Override protected TextChannel getChannel(Guild guild) { - return Bot.config.get(guild).getModeration().getJobChannel(); + return Bot.config.get(guild).getModerationConfig().getJobChannel(); } @Override protected int getMessageDeleteVoteThreshold(Guild guild) { - return Bot.config.get(guild).getModeration().getJobChannelMessageDeleteThreshold(); + return Bot.config.get(guild).getModerationConfig().getJobChannelMessageDeleteThreshold(); } @Override diff --git a/src/main/java/net/javadiscord/javabot/listener/MessageLinkListener.java b/src/main/java/net/javadiscord/javabot/listener/MessageLinkListener.java index 7d67c5dfe..1567665a2 100644 --- a/src/main/java/net/javadiscord/javabot/listener/MessageLinkListener.java +++ b/src/main/java/net/javadiscord/javabot/listener/MessageLinkListener.java @@ -1,14 +1,17 @@ package net.javadiscord.javabot.listener; -import net.dv8tion.jda.api.EmbedBuilder; +import club.minnced.discord.webhook.send.component.ActionRow; +import club.minnced.discord.webhook.send.component.Button; import net.dv8tion.jda.api.JDA; -import net.dv8tion.jda.api.entities.*; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.GuildChannel; +import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.entities.MessageChannel; import net.dv8tion.jda.api.events.message.MessageReceivedEvent; import net.dv8tion.jda.api.hooks.ListenerAdapter; -import net.dv8tion.jda.api.interactions.components.buttons.Button; import net.dv8tion.jda.api.requests.RestAction; -import net.javadiscord.javabot.Bot; -import net.javadiscord.javabot.util.InteractionUtils; +import net.javadiscord.javabot.util.ExceptionLogger; +import net.javadiscord.javabot.util.WebhookUtil; import org.jetbrains.annotations.NotNull; import java.util.Arrays; @@ -21,34 +24,22 @@ */ public class MessageLinkListener extends ListenerAdapter { - private final Pattern MESSAGE_URL_PATTERN = Pattern.compile("https://((?:canary|ptb)\\.)?discord.com/channels/[0-9]+/[0-9]+/[0-9]+"); + private static final Pattern MESSAGE_URL_PATTERN = Pattern.compile("https://((?:canary|ptb)\\.)?discord.com/channels/[0-9]+/[0-9]+/[0-9]+"); @Override public void onMessageReceived(@NotNull MessageReceivedEvent event) { if (event.getAuthor().isBot() || event.getAuthor().isSystem()) return; Matcher matcher = MESSAGE_URL_PATTERN.matcher(event.getMessage().getContentRaw()); if (matcher.find()) { - Optional> optional = this.parseMessageUrl(matcher.group(), event.getJDA()); + Optional> optional = parseMessageUrl(matcher.group(), event.getJDA()); optional.ifPresent(action -> action.queue( - m -> event.getMessage().replyEmbeds(this.buildUrlEmbed(m)) - .setActionRow(Button.secondary(InteractionUtils.DELETE_ORIGINAL_TEMPLATE, "\uD83D\uDDD1️"), Button.link(m.getJumpUrl(), "View Original")) - .queue(), - e -> {} + m -> WebhookUtil.ensureWebhookExists(event.getChannel().asTextChannel(), + wh -> WebhookUtil.mirrorMessageToWebhook(wh, m, m.getContentRaw(), 0, ActionRow.of(Button.link(m.getJumpUrl(), "Jump to Message"))) + ), e -> ExceptionLogger.capture(e, getClass().getSimpleName()) )); } } - private MessageEmbed buildUrlEmbed(Message m) { - User author = m.getAuthor(); - return new EmbedBuilder() - .setAuthor(author.getAsTag(), m.getJumpUrl(), author.getEffectiveAvatarUrl()) - .setColor(Bot.config.get(m.getGuild()).getSlashCommand().getDefaultColor()) - .setDescription(m.getContentRaw()) - .setTimestamp(m.getTimeCreated()) - .setFooter("#" + m.getChannel().getName()) - .build(); - } - /** * Tries to parse a Discord Message Link to the corresponding Message object. * @@ -56,7 +47,7 @@ private MessageEmbed buildUrlEmbed(Message m) { * @param jda The {@link JDA} instance. * @return An {@link Optional} containing the {@link RestAction} which retrieves the corresponding Message. */ - private Optional> parseMessageUrl(String url, JDA jda) { + private Optional> parseMessageUrl(@NotNull String url, @NotNull JDA jda) { RestAction optional = null; String[] arr = url.split("/"); String[] segments = Arrays.copyOfRange(arr, 4, arr.length); diff --git a/src/main/java/net/javadiscord/javabot/listener/MessageVoteListener.java b/src/main/java/net/javadiscord/javabot/listener/MessageVoteListener.java index 0498b3727..e02791195 100644 --- a/src/main/java/net/javadiscord/javabot/listener/MessageVoteListener.java +++ b/src/main/java/net/javadiscord/javabot/listener/MessageVoteListener.java @@ -1,12 +1,15 @@ package net.javadiscord.javabot.listener; +import net.dv8tion.jda.api.JDA; import net.dv8tion.jda.api.entities.*; +import net.dv8tion.jda.api.entities.emoji.Emoji; import net.dv8tion.jda.api.events.message.MessageReceivedEvent; import net.dv8tion.jda.api.events.message.react.GenericMessageReactionEvent; import net.dv8tion.jda.api.events.message.react.MessageReactionAddEvent; import net.dv8tion.jda.api.events.message.react.MessageReactionRemoveEvent; import net.dv8tion.jda.api.hooks.ListenerAdapter; import net.javadiscord.javabot.Bot; +import net.javadiscord.javabot.util.StringUtils; import org.jetbrains.annotations.NotNull; /** @@ -29,8 +32,8 @@ public abstract class MessageVoteListener extends ListenerAdapter { * downvotes there are than upvotes. If this value is higher than or equal * to the threshold value returned by this method, the message is deleted. *

- * Note that usually, you want to return a positive value, to indicate - * that the message should have more downvotes than upvotes. + * Note that usually, you want to return a positive value, to indicate + * that the message should have more downvotes than upvotes. *

* * @param guild The guild to get the threshold for. @@ -54,21 +57,21 @@ protected boolean isMessageEligibleForVoting(Message message) { /** * Gets the emote that's used for casting upvotes. * - * @param guild The guild to get the emote for. + * @param jda The {@link JDA} instance to get the emoji for. * @return The emote. */ - protected Emote getUpvoteEmote(Guild guild) { - return Bot.config.get(guild).getEmote().getUpvoteEmote(); + protected Emoji getUpvoteEmote(JDA jda) { + return Bot.config.getSystems().getEmojiConfig().getUpvoteEmote(jda); } /** * Gets the emote that's used for casting downvotes. * - * @param guild The guild to get the emote for. + * @param jda The {@link JDA} instance to get the emoji for. * @return The emote. */ - protected Emote getDownvoteEmote(Guild guild) { - return Bot.config.get(guild).getEmote().getDownvoteEmote(); + protected Emoji getDownvoteEmote(JDA jda) { + return Bot.config.getSystems().getEmojiConfig().getDownvoteEmote(jda); } /** @@ -85,8 +88,8 @@ protected boolean shouldAddInitialEmotes(Guild guild) { @Override public void onMessageReceived(@NotNull MessageReceivedEvent event) { if (isMessageReceivedEventValid(event) && shouldAddInitialEmotes(event.getGuild())) { - event.getMessage().addReaction(getUpvoteEmote(event.getGuild())).queue(); - event.getMessage().addReaction(getDownvoteEmote(event.getGuild())).queue(); + event.getMessage().addReaction(getUpvoteEmote(event.getJDA())).queue(); + event.getMessage().addReaction(getDownvoteEmote(event.getJDA())).queue(); } } @@ -107,10 +110,11 @@ public void onMessageReactionRemove(@NotNull MessageReactionRemoveEvent event) { * @return True if the event is valid, meaning that it is relevant for this * vote listener to add the voting emotes to it. */ - private boolean isMessageReceivedEventValid(MessageReceivedEvent event) { + private boolean isMessageReceivedEventValid(@NotNull MessageReceivedEvent event) { if (event.getAuthor().isBot() || event.getAuthor().isSystem() || event.getMessage().getType() == MessageType.THREAD_CREATED) { return false; } + if (getChannel(event.getGuild()) == null) return false; return event.getChannel().getId().equals(getChannel(event.getGuild()).getId()) && isMessageEligibleForVoting(event.getMessage()); } @@ -124,12 +128,12 @@ private boolean isMessageReceivedEventValid(MessageReceivedEvent event) { * @return True if the event is valid, meaning that this listener should * proceed to check the votes on the message. */ - private boolean isReactionEventValid(GenericMessageReactionEvent event) { + private boolean isReactionEventValid(@NotNull GenericMessageReactionEvent event) { if (!event.getChannel().getId().equals(getChannel(event.getGuild()).getId())) return false; - String reactionId = event.getReactionEmote().getId(); + Emoji reaction = event.getEmoji(); if ( - !reactionId.equals(getUpvoteEmote(event.getGuild()).getId()) && - !reactionId.equals(getDownvoteEmote(event.getGuild()).getId()) + !reaction.equals(getUpvoteEmote(event.getJDA())) && + !reaction.equals(getDownvoteEmote(event.getJDA())) ) { return false; } @@ -153,32 +157,21 @@ private void handleReactionEvent(GenericMessageReactionEvent event) { } } - private void checkVotes(Message msg, Guild guild) { - String upvoteId = getUpvoteEmote(guild).getId(); - String downvoteId = getDownvoteEmote(guild).getId(); + private void checkVotes(Message msg, @NotNull Guild guild) { + Emoji upvoteId = getUpvoteEmote(guild.getJDA()); + Emoji downvoteId = getDownvoteEmote(guild.getJDA()); - int upvotes = countReactions(msg, upvoteId); - int downvotes = countReactions(msg, downvoteId); + int upvotes = StringUtils.countReactions(msg, upvoteId); + int downvotes = StringUtils.countReactions(msg, downvoteId); int downvoteDifference = downvotes - upvotes; if (downvoteDifference >= getMessageDeleteVoteThreshold(guild)) { msg.delete().queue(); msg.getAuthor().openPrivateChannel() .queue( - s -> s.sendMessageFormat( - "Your message in %s has been removed due to community feedback.", - getChannel(guild).getAsMention() - ).queue(), + s -> s.sendMessageFormat("Your message in %s has been removed due to community feedback.", getChannel(guild).getAsMention()).queue(), e -> {} ); } } - - private int countReactions(Message msg, String id) { - MessageReaction reaction = msg.getReactionById(id); - if (reaction == null) return 0; - return (int) reaction.retrieveUsers().stream() - .filter(user -> !user.isBot() && !user.isSystem()) - .count(); - } } diff --git a/src/main/java/net/javadiscord/javabot/listener/PingableNameListener.java b/src/main/java/net/javadiscord/javabot/listener/PingableNameListener.java index 4d1d996eb..cbee962fb 100644 --- a/src/main/java/net/javadiscord/javabot/listener/PingableNameListener.java +++ b/src/main/java/net/javadiscord/javabot/listener/PingableNameListener.java @@ -6,11 +6,15 @@ import net.dv8tion.jda.api.events.guild.member.update.GuildMemberUpdateNicknameEvent; import net.dv8tion.jda.api.hooks.ListenerAdapter; import net.javadiscord.javabot.systems.notification.GuildNotificationService; +import net.javadiscord.javabot.util.ExceptionLogger; import net.javadiscord.javabot.util.StringUtils; import java.io.IOException; import java.net.URL; -import java.util.*; +import java.util.ArrayList; +import java.util.List; +import java.util.Random; +import java.util.Scanner; import java.util.stream.Collectors; /** @@ -108,6 +112,7 @@ private static List readStrings(String url) { try (Scanner scan = new Scanner(new URL(url).openStream()).useDelimiter("\\n")) { list = scan.tokens().collect(Collectors.toList()); } catch (IOException e) { + ExceptionLogger.capture(e, PingableNameListener.class.getSimpleName()); log.error("Error during retrieval of words."); list = new ArrayList<>(); } diff --git a/src/main/java/net/javadiscord/javabot/listener/ShareKnowledgeVoteListener.java b/src/main/java/net/javadiscord/javabot/listener/ShareKnowledgeVoteListener.java index 1513aaa60..5790b2f1b 100644 --- a/src/main/java/net/javadiscord/javabot/listener/ShareKnowledgeVoteListener.java +++ b/src/main/java/net/javadiscord/javabot/listener/ShareKnowledgeVoteListener.java @@ -11,11 +11,11 @@ public class ShareKnowledgeVoteListener extends MessageVoteListener { @Override protected TextChannel getChannel(Guild guild) { - return Bot.config.get(guild).getModeration().getShareKnowledgeChannel(); + return Bot.config.get(guild).getModerationConfig().getShareKnowledgeChannel(); } @Override protected int getMessageDeleteVoteThreshold(Guild guild) { - return Bot.config.get(guild).getModeration().getShareKnowledgeMessageDeleteThreshold(); + return Bot.config.get(guild).getModerationConfig().getShareKnowledgeMessageDeleteThreshold(); } } diff --git a/src/main/java/net/javadiscord/javabot/listener/StartupListener.java b/src/main/java/net/javadiscord/javabot/listener/StartupListener.java deleted file mode 100644 index dc0213064..000000000 --- a/src/main/java/net/javadiscord/javabot/listener/StartupListener.java +++ /dev/null @@ -1,58 +0,0 @@ -package net.javadiscord.javabot.listener; - - -import lombok.extern.slf4j.Slf4j; -import net.dv8tion.jda.api.entities.Guild; -import net.dv8tion.jda.api.events.ReadyEvent; -import net.dv8tion.jda.api.hooks.ListenerAdapter; -import net.javadiscord.javabot.Bot; -import net.javadiscord.javabot.Constants; -import net.javadiscord.javabot.systems.help.HelpChannelUpdater; -import net.javadiscord.javabot.systems.help.checks.SimpleGreetingCheck; -import net.javadiscord.javabot.systems.notification.GuildNotificationService; -import net.javadiscord.javabot.util.GuildUtils; - -import java.util.Comparator; -import java.util.List; -import java.util.concurrent.TimeUnit; - -/** - * Listens for the {@link ReadyEvent}. - */ -@Slf4j -public class StartupListener extends ListenerAdapter { - - /** - * The default guild, that is chosen upon startup based on the member count. - */ - public static Guild defaultGuild; - - @Override - public void onReady(ReadyEvent event) { - // Initialize all guild-specific configuration. - Bot.config.loadGuilds(event.getJDA().getGuilds()); - Bot.config.flush(); - log.info("Logged in as {}{}{}", Constants.TEXT_WHITE, event.getJDA().getSelfUser().getAsTag(), Constants.TEXT_RESET); - log.info("Guilds: " + GuildUtils.getGuildList(event.getJDA().getGuilds(), true, true)); - var optionalGuild = event.getJDA().getGuilds().stream().max(Comparator.comparing(Guild::getMemberCount)); - optionalGuild.ifPresent(guild -> defaultGuild = guild); - - log.info("Starting Guild initialization\n"); - for (var guild : event.getJDA().getGuilds()) { - Bot.interactionHandler.registerCommands(guild); - // TODO: Reimplement this. - //new StarboardManager().updateAllStarboardEntries(guild); - // Schedule the help channel updater to run periodically for each guild. - var helpConfig = Bot.config.get(guild).getHelp(); - Bot.asyncPool.scheduleAtFixedRate( - new HelpChannelUpdater(event.getJDA(), helpConfig, List.of( - new SimpleGreetingCheck() - )), - 5, - helpConfig.getUpdateIntervalSeconds(), - TimeUnit.SECONDS - ); - new GuildNotificationService(guild).sendLogChannelNotification("I have just been booted up!"); - } - } -} diff --git a/src/main/java/net/javadiscord/javabot/listener/StateListener.java b/src/main/java/net/javadiscord/javabot/listener/StateListener.java new file mode 100644 index 000000000..7e170a6f2 --- /dev/null +++ b/src/main/java/net/javadiscord/javabot/listener/StateListener.java @@ -0,0 +1,78 @@ +package net.javadiscord.javabot.listener; + + +import lombok.extern.slf4j.Slf4j; +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.MessageEmbed; +import net.dv8tion.jda.api.events.ReadyEvent; +import net.dv8tion.jda.api.events.ReconnectedEvent; +import net.dv8tion.jda.api.events.ShutdownEvent; +import net.dv8tion.jda.api.hooks.ListenerAdapter; +import net.javadiscord.javabot.Bot; +import net.javadiscord.javabot.data.config.guild.HelpConfig; +import net.javadiscord.javabot.systems.help.HelpChannelUpdater; +import net.javadiscord.javabot.systems.help.checks.SimpleGreetingCheck; +import net.javadiscord.javabot.systems.notification.GuildNotificationService; +import net.javadiscord.javabot.util.ExceptionLogger; +import net.javadiscord.javabot.util.StringUtils; +import org.jetbrains.annotations.NotNull; + +import java.sql.SQLException; +import java.time.Instant; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +/** + * Listens for the {@link ReadyEvent}. + */ +@Slf4j +public class StateListener extends ListenerAdapter { + @Override + public void onReady(@NotNull ReadyEvent event) { + // Initialize all guild-specific configuration. + Bot.config.loadGuilds(event.getJDA().getGuilds()); + Bot.config.flush(); + log.info("Logged in as " + event.getJDA().getSelfUser().getAsTag()); + log.info("Guilds: " + event.getJDA().getGuilds().stream().map(Guild::getName).collect(Collectors.joining(", "))); + for (Guild guild : event.getJDA().getGuilds()) { + // Schedule the help channel updater to run periodically for each guild. + HelpConfig helpConfig = Bot.config.get(guild).getHelpConfig(); + Bot.asyncPool.scheduleAtFixedRate( + new HelpChannelUpdater(event.getJDA(), helpConfig, List.of( + new SimpleGreetingCheck() + )), + 5, + helpConfig.getUpdateIntervalSeconds(), + TimeUnit.SECONDS + ); + new GuildNotificationService(guild).sendLogChannelNotification(buildBootedUpEmbed()); + } + try { + Bot.customTagManager.init(); + } catch (SQLException e) { + ExceptionLogger.capture(e, getClass().getSimpleName()); + log.error("Could not initialize CustomCommandManager: ", e); + } + } + + @Override + public void onReconnected(@NotNull ReconnectedEvent event) { + Bot.config.loadGuilds(event.getJDA().getGuilds()); + Bot.config.flush(); + } + + @Override + public void onShutdown(@NotNull ShutdownEvent event) { + Bot.config.flush(); + } + + private @NotNull MessageEmbed buildBootedUpEmbed() { + return new EmbedBuilder() + .setTitle("I've just been booted up!") + .addField("Operating System", StringUtils.getOperatingSystem(), true) + .setTimestamp(Instant.now()) + .build(); + } +} diff --git a/src/main/java/net/javadiscord/javabot/listener/SuggestionListener.java b/src/main/java/net/javadiscord/javabot/listener/SuggestionListener.java index fb50343a3..0b1549f63 100644 --- a/src/main/java/net/javadiscord/javabot/listener/SuggestionListener.java +++ b/src/main/java/net/javadiscord/javabot/listener/SuggestionListener.java @@ -7,8 +7,9 @@ import net.dv8tion.jda.api.hooks.ListenerAdapter; import net.dv8tion.jda.api.requests.RestAction; import net.javadiscord.javabot.Bot; -import net.javadiscord.javabot.data.config.guild.SlashCommandConfig; +import net.javadiscord.javabot.data.config.SystemsConfig; import net.javadiscord.javabot.util.MessageActionUtils; +import net.javadiscord.javabot.util.Responses; import org.jetbrains.annotations.NotNull; import java.time.Instant; @@ -26,10 +27,9 @@ public void onMessageReceived(@NotNull MessageReceivedEvent event) { event.getMessage().delete().queue(); return; } - var config = Bot.config.get(event.getGuild()); - MessageEmbed embed = this.buildSuggestionEmbed(event.getMessage(), config.getSlashCommand()); + MessageEmbed embed = buildSuggestionEmbed(event.getMessage()); MessageActionUtils.addAttachmentsAndSend(event.getMessage(), event.getChannel().sendMessageEmbeds(embed)).thenAccept(message -> { - this.addReactions(message).queue(); + addReactions(message).queue(); event.getMessage().delete().queue(); message.createThreadChannel(String.format("%s — Suggestion", event.getAuthor().getName())) .flatMap(thread -> thread.addThreadMember(event.getAuthor())) @@ -47,11 +47,11 @@ public void onMessageReceived(@NotNull MessageReceivedEvent event) { * @param event The {@link MessageReceivedEvent} that is fired upon sending a message. * @return Whether the message author is eligible to create new suggestions. */ - private boolean canCreateSuggestion(MessageReceivedEvent event) { + private boolean canCreateSuggestion(@NotNull MessageReceivedEvent event) { if (event.getChannelType() == ChannelType.PRIVATE) return false; return !event.getAuthor().isBot() && !event.getAuthor().isSystem() && !event.getMember().isTimedOut() && event.getMessage().getType() != MessageType.THREAD_CREATED && - event.getChannel().equals(Bot.config.get(event.getGuild()).getModeration().getSuggestionChannel()); + event.getChannel().getIdLong() == Bot.config.get(event.getGuild()).getModerationConfig().getSuggestionChannelId(); } /** @@ -61,23 +61,23 @@ private boolean canCreateSuggestion(MessageReceivedEvent event) { * @return A {@link RestAction}. */ private RestAction addReactions(Message message) { - var config = Bot.config.get(message.getGuild()).getEmote(); + SystemsConfig.EmojiConfig config = Bot.config.getSystems().getEmojiConfig(); return RestAction.allOf( - message.addReaction(config.getUpvoteEmote()), - message.addReaction(config.getDownvoteEmote()) + message.addReaction(config.getUpvoteEmote(message.getJDA())), + message.addReaction(config.getDownvoteEmote(message.getJDA())) ); } - private MessageEmbed buildSuggestionEmbed(Message message, SlashCommandConfig config) { + private MessageEmbed buildSuggestionEmbed(Message message) { Member member = message.getMember(); // Note: member will never be null in practice. This is to satisfy code analysis tools. if (member == null) throw new IllegalStateException("Member was null when building suggestion embed."); return new EmbedBuilder() .setTitle("Suggestion") .setAuthor(member.getEffectiveName(), null, member.getEffectiveAvatarUrl()) - .setColor(config.getDefaultColor()) + .setColor(Responses.Type.DEFAULT.getColor()) .setTimestamp(Instant.now()) .setDescription(message.getContentRaw()) .build(); diff --git a/src/main/java/net/javadiscord/javabot/listener/UserLeaveListener.java b/src/main/java/net/javadiscord/javabot/listener/UserLeaveListener.java index d1df3270a..5cd42595d 100644 --- a/src/main/java/net/javadiscord/javabot/listener/UserLeaveListener.java +++ b/src/main/java/net/javadiscord/javabot/listener/UserLeaveListener.java @@ -1,11 +1,13 @@ package net.javadiscord.javabot.listener; import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.TextChannel; import net.dv8tion.jda.api.entities.User; import net.dv8tion.jda.api.events.guild.member.GuildMemberRemoveEvent; import net.dv8tion.jda.api.hooks.ListenerAdapter; import net.javadiscord.javabot.Bot; import net.javadiscord.javabot.systems.help.HelpChannelManager; +import net.javadiscord.javabot.util.ExceptionLogger; import java.sql.SQLException; @@ -16,8 +18,8 @@ public class UserLeaveListener extends ListenerAdapter { @Override public void onGuildMemberRemove(GuildMemberRemoveEvent event) { if (event.getUser().isBot() || event.getUser().isSystem()) return; - if (!Bot.config.get(event.getGuild()).getServerLock().isLocked()) { - this.unreserveAllChannels(event.getUser(), event.getGuild()); + if (!Bot.config.get(event.getGuild()).getServerLockConfig().isLocked()) { + unreserveAllChannels(event.getUser(), event.getGuild()); } } @@ -29,11 +31,11 @@ public void onGuildMemberRemove(GuildMemberRemoveEvent event) { */ private void unreserveAllChannels(User user, Guild guild) { try { - var manager = new HelpChannelManager(Bot.config.get(guild).getHelp()); + HelpChannelManager manager = new HelpChannelManager(Bot.config.get(guild).getHelpConfig()); manager.unreserveAllOwnedChannels(user); } catch (SQLException e) { - e.printStackTrace(); - var logChannel = Bot.config.get(guild).getModeration().getLogChannel(); + ExceptionLogger.capture(e, getClass().getSimpleName()); + TextChannel logChannel = Bot.config.get(guild).getModerationConfig().getLogChannel(); logChannel.sendMessage("Database error while unreserving channels for a user who left: " + e.getMessage()).queue(); } } diff --git a/src/main/java/net/javadiscord/javabot/systems/commands/AvatarCommand.java b/src/main/java/net/javadiscord/javabot/systems/commands/AvatarCommand.java deleted file mode 100644 index 69b0dfa82..000000000 --- a/src/main/java/net/javadiscord/javabot/systems/commands/AvatarCommand.java +++ /dev/null @@ -1,37 +0,0 @@ -package net.javadiscord.javabot.systems.commands; - -import net.dv8tion.jda.api.EmbedBuilder; -import net.dv8tion.jda.api.entities.Guild; -import net.dv8tion.jda.api.entities.Member; -import net.dv8tion.jda.api.entities.MessageEmbed; -import net.dv8tion.jda.api.entities.User; -import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; -import net.dv8tion.jda.api.interactions.commands.OptionMapping; -import net.dv8tion.jda.api.requests.restaction.interactions.ReplyCallbackAction; -import net.javadiscord.javabot.Bot; -import net.javadiscord.javabot.command.Responses; -import net.javadiscord.javabot.command.interfaces.SlashCommand; - -/** - * Command for displaying a full-size version of a user's avatar. - */ -public class AvatarCommand implements SlashCommand { - @Override - public ReplyCallbackAction handleSlashCommandInteraction(SlashCommandInteractionEvent event) { - Member member = event.getOption("user", event::getMember, OptionMapping::getAsMember); - if (member == null) { - return Responses.warning(event, "Sorry, this command can only be used in servers."); - } - return event.replyEmbeds(buildAvatarEmbed(member.getGuild(), member.getUser())); - } - - private MessageEmbed buildAvatarEmbed(Guild guild, User createdBy) { - return new EmbedBuilder() - .setColor(Bot.config.get(guild).getSlashCommand().getDefaultColor()) - .setAuthor(createdBy.getAsTag(), null, createdBy.getEffectiveAvatarUrl()) - .setTitle("Avatar") - .setImage(createdBy.getEffectiveAvatarUrl() + "?size=4096") - .build(); - } - -} diff --git a/src/main/java/net/javadiscord/javabot/systems/commands/BotInfoCommand.java b/src/main/java/net/javadiscord/javabot/systems/commands/BotInfoCommand.java deleted file mode 100644 index 3e1a960a2..000000000 --- a/src/main/java/net/javadiscord/javabot/systems/commands/BotInfoCommand.java +++ /dev/null @@ -1,69 +0,0 @@ -package net.javadiscord.javabot.systems.commands; - -import lombok.extern.slf4j.Slf4j; -import net.dv8tion.jda.api.EmbedBuilder; -import net.dv8tion.jda.api.JDA; -import net.dv8tion.jda.api.entities.MessageEmbed; -import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; -import net.dv8tion.jda.api.interactions.components.buttons.Button; -import net.dv8tion.jda.api.requests.restaction.interactions.ReplyCallbackAction; -import net.javadiscord.javabot.Bot; -import net.javadiscord.javabot.Constants; -import net.javadiscord.javabot.command.interfaces.SlashCommand; -import net.javadiscord.javabot.data.config.guild.SlashCommandConfig; - -import java.io.BufferedReader; -import java.io.IOException; -import java.io.InputStreamReader; -import java.time.Instant; - -/** - * Command that provides some basic info about the bot. - */ -@Slf4j -public class BotInfoCommand implements SlashCommand { - @Override - public ReplyCallbackAction handleSlashCommandInteraction(SlashCommandInteractionEvent event) { - var embed = buildBotInfoEmbed(event.getJDA(), Bot.config.get(event.getGuild()).getSlashCommand()); - return event.replyEmbeds(embed).addActionRow(Button.link(Constants.GITHUB_LINK, "View on GitHub") - ); - } - - private MessageEmbed buildBotInfoEmbed(JDA jda, SlashCommandConfig config) { - var bot = jda.getSelfUser(); - return new EmbedBuilder() - .setColor(config.getDefaultColor()) - .setThumbnail(bot.getEffectiveAvatarUrl()) - .setAuthor(bot.getAsTag(), null, bot.getEffectiveAvatarUrl()) - .setTitle("Info") - .addField("OS", String.format("```%s```", getOperatingSystem()), true) - .addField("Library", "```JDA```", true) - .addField("JDK", String.format("```%s```", System.getProperty("java.version")), true) - .addField("Gateway Ping", String.format("```%sms```", jda.getGatewayPing()), true) - .addField("Uptime", String.format("```%s```", new UptimeCommand().getUptime()), true) - .setTimestamp(Instant.now()) - .build(); - } - - private String getOperatingSystem() { - String os = System.getProperty("os.name"); - if(os.equals("Linux")) { - try { - String[] cmd = {"/bin/sh", "-c", "cat /etc/*-release" }; - Process p = Runtime.getRuntime().exec(cmd); - BufferedReader bri = new BufferedReader(new InputStreamReader(p.getInputStream())); - - String line = ""; - while ((line = bri.readLine()) != null) { - if (line.startsWith("PRETTY_NAME")) { - return line.split("\"")[1]; - } - } - } catch (IOException e) { - log.error("Error while getting Linux Distribution."); - } - - } - return os; - } -} diff --git a/src/main/java/net/javadiscord/javabot/systems/commands/ChangeMyMindCommand.java b/src/main/java/net/javadiscord/javabot/systems/commands/ChangeMyMindCommand.java deleted file mode 100644 index 96c94222f..000000000 --- a/src/main/java/net/javadiscord/javabot/systems/commands/ChangeMyMindCommand.java +++ /dev/null @@ -1,76 +0,0 @@ -package net.javadiscord.javabot.systems.commands; - -import com.mashape.unirest.http.HttpResponse; -import com.mashape.unirest.http.JsonNode; -import com.mashape.unirest.http.Unirest; -import com.mashape.unirest.http.async.Callback; -import com.mashape.unirest.http.exceptions.UnirestException; -import net.dv8tion.jda.api.EmbedBuilder; -import net.dv8tion.jda.api.entities.MessageEmbed; -import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; -import net.dv8tion.jda.api.requests.restaction.interactions.ReplyCallbackAction; -import net.javadiscord.javabot.Bot; -import net.javadiscord.javabot.command.interfaces.SlashCommand; -import org.json.JSONException; - -import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; -import java.time.Instant; -import java.util.Objects; - -/** - * Command that generates the "Change my mind" meme with the given text input. - */ -@Deprecated -public class ChangeMyMindCommand implements SlashCommand { - /** - * The maximum acceptable length for texts to send to the API. Technically, - * the API supports up to but not including 2000, but we'll make that much - * lower to avoid issues, since the API fails with a 501 error. People - * shouldn't really be putting paragraphs into this anyway. - */ - private static final int MAX_SEARCH_TERM_LENGTH = 500; - - @Override - public ReplyCallbackAction handleSlashCommandInteraction(SlashCommandInteractionEvent event) { - var hook = event.getHook(); - String encodedSearchTerm; - encodedSearchTerm = URLEncoder.encode(Objects.requireNonNull(event.getOption("text")).getAsString(), StandardCharsets.UTF_8); - if (encodedSearchTerm.length() > MAX_SEARCH_TERM_LENGTH) { - return event.reply("The text you provided is too long. It may not be more than " + MAX_SEARCH_TERM_LENGTH + " characters."); - } - - Unirest.get("https://nekobot.xyz/api/imagegen?type=changemymind&text=" + encodedSearchTerm).asJsonAsync(new Callback<>() { - @Override - public void completed(HttpResponse hr) { - - MessageEmbed e; - try { - e = new EmbedBuilder() - .setColor(Bot.config.get(event.getGuild()).getSlashCommand().getDefaultColor()) - .setImage(hr.getBody().getObject().getString("message")) - .setFooter(event.getUser().getAsTag(), event.getUser().getEffectiveAvatarUrl()) - .setTimestamp(Instant.now()) - .build(); - hook.sendMessageEmbeds(e).queue(); - } catch (JSONException jsonException) { - jsonException.printStackTrace(); - hook.sendMessage("The response from the ChangeMyMind API was not properly formatted.").queue(); - } - } - - @Override - public void failed(UnirestException ue) { - // Shouldn't happen - ue.printStackTrace(); - hook.sendMessage("The request to the ChangeMyMind API failed.").queue(); - } - - @Override - public void cancelled() { - // Shouldn't happen - } - }); - return event.deferReply(false); - } -} \ No newline at end of file diff --git a/src/main/java/net/javadiscord/javabot/systems/commands/FormatCodeCommand.java b/src/main/java/net/javadiscord/javabot/systems/commands/FormatCodeCommand.java deleted file mode 100644 index 894f19398..000000000 --- a/src/main/java/net/javadiscord/javabot/systems/commands/FormatCodeCommand.java +++ /dev/null @@ -1,68 +0,0 @@ -package net.javadiscord.javabot.systems.commands; - -import net.dv8tion.jda.api.entities.Message; -import net.dv8tion.jda.api.events.interaction.command.MessageContextInteractionEvent; -import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; -import net.dv8tion.jda.api.interactions.commands.OptionMapping; -import net.dv8tion.jda.api.interactions.components.ActionRow; -import net.dv8tion.jda.api.interactions.components.buttons.Button; -import net.dv8tion.jda.api.requests.restaction.interactions.InteractionCallbackAction; -import net.dv8tion.jda.api.requests.restaction.interactions.ReplyCallbackAction; -import net.javadiscord.javabot.command.Responses; -import net.javadiscord.javabot.command.interfaces.MessageContextCommand; -import net.javadiscord.javabot.command.interfaces.SlashCommand; -import net.javadiscord.javabot.util.InteractionUtils; -import net.javadiscord.javabot.util.StringUtils; - -import java.util.Collections; -import java.util.List; - -/** - * Command that allows members to format messages. - */ -public class FormatCodeCommand implements SlashCommand, MessageContextCommand { - @Override - public ReplyCallbackAction handleSlashCommandInteraction(SlashCommandInteractionEvent event) { - var idOption = event.getOption("message-id"); - String format = event.getOption("format", "java", OptionMapping::getAsString); - if (idOption == null) { - event.getChannel().getHistory() - .retrievePast(10) - .queue(messages -> { - Message target = null; - Collections.reverse(messages); - for (Message m : messages) { - if (!m.getAuthor().isBot()) target = m; - } - if (target != null) { - event.getHook().sendMessageFormat("```%s\n%s\n```", format, StringUtils.standardSanitizer().compute(target.getContentRaw())) - .allowedMentions(List.of()) - .addActionRows(this.buildActionRow(target)) - .queue(); - } else { - Responses.error(event.getHook(), "Missing required arguments.").queue(); - } - }); - } else { - long messageId = idOption.getAsLong(); - event.getTextChannel().retrieveMessageById(messageId).queue( - m -> event.getHook().sendMessageFormat("```%s\n%s\n```", format, StringUtils.standardSanitizer().compute(m.getContentRaw())) - .allowedMentions(List.of()) - .addActionRows(this.buildActionRow(m)) - .queue(), - e -> Responses.error(event.getHook(), "Could not retrieve message with id: " + messageId).queue()); - } - return event.deferReply(); - } - - @Override - public InteractionCallbackAction handleMessageContextCommandInteraction(MessageContextInteractionEvent event) { - return event.replyFormat("```java\n%s\n```", StringUtils.standardSanitizer().compute(event.getTarget().getContentRaw())) - .allowedMentions(List.of()) - .addActionRows(this.buildActionRow(event.getTarget())); - } - - private ActionRow buildActionRow(Message target) { - return ActionRow.of(Button.secondary(InteractionUtils.DELETE_ORIGINAL_TEMPLATE, "\uD83D\uDDD1️"), Button.link(target.getJumpUrl(), "View Original")); - } -} diff --git a/src/main/java/net/javadiscord/javabot/systems/commands/IdCalculatorCommand.java b/src/main/java/net/javadiscord/javabot/systems/commands/IdCalculatorCommand.java deleted file mode 100644 index b5b36be1f..000000000 --- a/src/main/java/net/javadiscord/javabot/systems/commands/IdCalculatorCommand.java +++ /dev/null @@ -1,46 +0,0 @@ -package net.javadiscord.javabot.systems.commands; - -import net.dv8tion.jda.api.EmbedBuilder; -import net.dv8tion.jda.api.entities.MessageEmbed; -import net.dv8tion.jda.api.entities.User; -import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; -import net.dv8tion.jda.api.requests.restaction.interactions.ReplyCallbackAction; -import net.javadiscord.javabot.Bot; -import net.javadiscord.javabot.command.Responses; -import net.javadiscord.javabot.command.interfaces.SlashCommand; -import net.javadiscord.javabot.data.config.guild.SlashCommandConfig; - -import java.time.Instant; - -/** - * Command that allows users to convert discord ids into a human-readable timestamp. - */ -public class IdCalculatorCommand implements SlashCommand { - @Override - public ReplyCallbackAction handleSlashCommandInteraction(SlashCommandInteractionEvent event) { - var idOption = event.getOption("id"); - if (idOption == null) { - return Responses.error(event, "Missing required arguments"); - } - long id = idOption.getAsLong(); - var config = Bot.config.get(event.getGuild()).getSlashCommand(); - return event.replyEmbeds(buildIdCalcEmbed(event.getUser(), id, IdCalculatorCommand.getTimestampFromId(id), config)); - } - - public static long getTimestampFromId(long id) { - return id / 4194304 + 1420070400000L; - } - - private MessageEmbed buildIdCalcEmbed(User author, long id, long unixTimestamp, SlashCommandConfig config) { - Instant instant = Instant.ofEpochMilli(unixTimestamp / 1000); - return new EmbedBuilder() - .setAuthor(author.getAsTag(), null, author.getEffectiveAvatarUrl()) - .setTitle("ID-Calculator") - .setColor(config.getDefaultColor()) - .addField("Input", String.format("`%s`", id), false) - .addField("Unix-Timestamp", String.format("`%s`", unixTimestamp), true) - .addField("Unix-Timestamp (+ milliseconds)", String.format("`%s`", unixTimestamp / 1000), true) - .addField("Date", String.format("", instant.getEpochSecond()), false) - .build(); - } -} \ No newline at end of file diff --git a/src/main/java/net/javadiscord/javabot/systems/commands/LeaderboardCommand.java b/src/main/java/net/javadiscord/javabot/systems/commands/LeaderboardCommand.java deleted file mode 100644 index b6e277418..000000000 --- a/src/main/java/net/javadiscord/javabot/systems/commands/LeaderboardCommand.java +++ /dev/null @@ -1,20 +0,0 @@ -package net.javadiscord.javabot.systems.commands; - -import net.javadiscord.javabot.command.DelegatingCommandHandler; -import net.javadiscord.javabot.systems.commands.subcommands.leaderboard.ExperienceLeaderboardSubcommand; -import net.javadiscord.javabot.systems.commands.subcommands.leaderboard.ThanksLeaderboardSubcommand; -import net.javadiscord.javabot.systems.commands.subcommands.leaderboard.QOTWLeaderboardSubcommand; - -/** - * Single command housing all leaderboards. - */ -public class LeaderboardCommand extends DelegatingCommandHandler { - /** - * Leaderboard command handler. - */ - public LeaderboardCommand() { - this.addSubcommand("qotw", new QOTWLeaderboardSubcommand()); - this.addSubcommand("thanks", new ThanksLeaderboardSubcommand()); - this.addSubcommand("help-xp", new ExperienceLeaderboardSubcommand()); - } -} diff --git a/src/main/java/net/javadiscord/javabot/systems/commands/PingCommand.java b/src/main/java/net/javadiscord/javabot/systems/commands/PingCommand.java deleted file mode 100644 index 2eaeaa440..000000000 --- a/src/main/java/net/javadiscord/javabot/systems/commands/PingCommand.java +++ /dev/null @@ -1,23 +0,0 @@ -package net.javadiscord.javabot.systems.commands; - -import net.dv8tion.jda.api.EmbedBuilder; -import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; -import net.dv8tion.jda.api.requests.restaction.interactions.ReplyCallbackAction; -import net.javadiscord.javabot.Bot; -import net.javadiscord.javabot.command.interfaces.SlashCommand; - -/** - * Command that displays the current Gateway ping. - */ -public class PingCommand implements SlashCommand { - @Override - public ReplyCallbackAction handleSlashCommandInteraction(SlashCommandInteractionEvent event) { - long gatewayPing = event.getJDA().getGatewayPing(); - String botImage = event.getJDA().getSelfUser().getAvatarUrl(); - var e = new EmbedBuilder() - .setAuthor(gatewayPing + "ms", null, botImage) - .setColor(Bot.config.get(event.getGuild()).getSlashCommand().getDefaultColor()) - .build(); - return event.replyEmbeds(e); - } -} \ No newline at end of file diff --git a/src/main/java/net/javadiscord/javabot/systems/commands/PollCommand.java b/src/main/java/net/javadiscord/javabot/systems/commands/PollCommand.java deleted file mode 100644 index e8383bb68..000000000 --- a/src/main/java/net/javadiscord/javabot/systems/commands/PollCommand.java +++ /dev/null @@ -1,46 +0,0 @@ -package net.javadiscord.javabot.systems.commands; - -import net.dv8tion.jda.api.EmbedBuilder; -import net.dv8tion.jda.api.entities.Emoji; -import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; -import net.dv8tion.jda.api.requests.restaction.interactions.ReplyCallbackAction; -import net.javadiscord.javabot.Bot; -import net.javadiscord.javabot.command.Responses; -import net.javadiscord.javabot.command.interfaces.SlashCommand; - -import java.time.Instant; - -/** - * Command that allows user to create polls with up to 10 options. - */ -public class PollCommand implements SlashCommand { - - private final String[] EMOTES = new String[]{"0️⃣", "1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣", "6️⃣", "7️⃣", "8️⃣", "9️⃣"}; - private final int MAX_OPTIONS = 10; - - @Override - public ReplyCallbackAction handleSlashCommandInteraction(SlashCommandInteractionEvent event) { - var titleOption = event.getOption("title"); - if (titleOption == null) { - return Responses.error(event, "Missing required arguments"); - } - var embed = new EmbedBuilder() - .setAuthor(event.getUser().getAsTag(), null, event.getUser().getEffectiveAvatarUrl()) - .setTitle(titleOption.getAsString()) - .setColor(Bot.config.get(event.getGuild()).getSlashCommand().getDefaultColor()) - .setTimestamp(Instant.now()); - event.getHook().sendMessageEmbeds(embed.build()).queue(m -> { - for (int i = 1; i < MAX_OPTIONS + 1; i++) { - var optionMap = event.getOption("option-" + i); - if (optionMap != null) { - embed.getDescriptionBuilder() - .append(String.format("%s %s\n", EMOTES[i - 1], optionMap.getAsString())); - m.addReaction(Emoji.fromMarkdown(EMOTES[i - 1]).getAsMention()).queue(); - } - } - m.editMessageEmbeds(embed.build()).queue(); - }); - - return event.deferReply(); - } -} diff --git a/src/main/java/net/javadiscord/javabot/systems/commands/ProfileCommand.java b/src/main/java/net/javadiscord/javabot/systems/commands/ProfileCommand.java deleted file mode 100644 index e52f1d857..000000000 --- a/src/main/java/net/javadiscord/javabot/systems/commands/ProfileCommand.java +++ /dev/null @@ -1,135 +0,0 @@ -package net.javadiscord.javabot.systems.commands; - -import net.dv8tion.jda.api.EmbedBuilder; -import net.dv8tion.jda.api.entities.Activity; -import net.dv8tion.jda.api.entities.Member; -import net.dv8tion.jda.api.entities.MessageEmbed; -import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; -import net.dv8tion.jda.api.interactions.commands.OptionMapping; -import net.dv8tion.jda.api.requests.restaction.interactions.ReplyCallbackAction; -import net.javadiscord.javabot.Bot; -import net.javadiscord.javabot.command.Responses; -import net.javadiscord.javabot.command.interfaces.SlashCommand; -import net.javadiscord.javabot.systems.help.HelpExperienceService; -import net.javadiscord.javabot.systems.moderation.ModerationService; -import net.javadiscord.javabot.systems.moderation.warn.model.Warn; -import net.javadiscord.javabot.systems.commands.subcommands.leaderboard.QOTWLeaderboardSubcommand; -import net.javadiscord.javabot.systems.qotw.dao.QuestionPointsRepository; - -import java.sql.Connection; -import java.sql.SQLException; -import java.time.Instant; - -/** - * Command that allows members to display info about themselves or other users. - */ -public class ProfileCommand implements SlashCommand { - @Override - public ReplyCallbackAction handleSlashCommandInteraction(SlashCommandInteractionEvent event) { - Member member = event.getOption("user", event::getMember, OptionMapping::getAsMember); - try { - return event.replyEmbeds(buildProfileEmbed(member, Bot.dataSource.getConnection())); - } catch (SQLException e) { - e.printStackTrace(); - return Responses.error(event, "Could not load profile."); - } - } - - private MessageEmbed buildProfileEmbed(Member member, Connection con) throws SQLException { - var config = Bot.config.get(member.getGuild()); - var warns = new ModerationService(member.getJDA(), config).getWarns(member.getIdLong()); - var points = new QuestionPointsRepository(con).getAccountByUserId(member.getIdLong()).getPoints(); - var roles = member.getRoles(); - var status = member.getOnlineStatus().name(); - var helpXP = new HelpExperienceService(Bot.dataSource).getOrCreateAccount(member.getIdLong()).getExperience(); - var embed = new EmbedBuilder() - .setTitle("Profile") - .setAuthor(member.getUser().getAsTag(), null, member.getEffectiveAvatarUrl()) - .setDescription(getDescription(member)) - .setColor(member.getColor()) - .setThumbnail(member.getUser().getEffectiveAvatarUrl() + "?size=4096") - .setTimestamp(Instant.now()) - .addField("User", member.getAsMention(), true) - .addField("Status", - status.substring(0, 1).toUpperCase() + - status.substring(1).toLowerCase().replace("_", " "), true) - .addField("ID", member.getId(), true); - if (!roles.isEmpty()) { - embed.addField(String.format("Roles (+%s other)", roles.size() - 1), roles.get(0).getAsMention(), true); - } - embed.addField("Warns", String.format("`%s (%s/%s)`", - warns.size(), - warns.stream().mapToLong(Warn::getSeverityWeight).sum(), - config.getModeration().getMaxWarnSeverity()), true) - .addField("QOTW-Points", String.format("`%s point%s (#%s)`", - points, - points == 1 ? "" : "s", - QOTWLeaderboardSubcommand.getQOTWRank(member, member.getGuild())), true) - .addField("Total Help XP", String.format("`%.2f XP`", helpXP), true) - .addField("Server joined", String.format("", member.getTimeJoined().toEpochSecond()), true) - .addField("Account created", String.format("", member.getUser().getTimeCreated().toEpochSecond()), true); - - if (member.getTimeBoosted() != null) { - embed.addField("Boosted since", String.format("", member.getTimeBoosted().toEpochSecond()), true); - } - return embed.build(); - } - - private String getDescription(Member member) { - StringBuilder sb = new StringBuilder(); - if (getCustomActivity(member) != null) { - sb.append("\n`").append(getCustomActivity(member).getName()).append("`"); - } - if (getGameActivity(member) != null) { - sb.append(String.format("\n%s %s", - getGameActivityType(getGameActivity(member)), - getGameActivityDetails(getGameActivity(member)))); - } - return sb.toString(); - } - - private Activity getCustomActivity(Member member) { - Activity activity = null; - for (var act : member.getActivities()) { - if (act.getType().name().equals("CUSTOM_STATUS")) { - activity = act; - break; - } - } - return activity; - } - - private Activity getGameActivity(Member member) { - Activity activity = null; - for (var act : member.getActivities()) { - if (act.getType().name().equals("CUSTOM_STATUS")) { - continue; - } else { - activity = act; - } - break; - } - return activity; - } - - private String getGameActivityType(Activity activity) { - return activity.getType().name().toLowerCase() - .replace("listening", "Listening to") - .replace("default", "Playing"); - } - - private String getGameActivityDetails(Activity activity) { - StringBuilder sb = new StringBuilder(); - if (activity.getName().equals("Spotify")) { - var rp = activity.asRichPresence(); - String spotifyURL = "https://open.spotify.com/track/" + rp.getSyncId(); - sb.append(String.format("[`\"%s\"", rp.getDetails())); - if (rp.getState() != null) sb.append(" by " + rp.getState()); - sb.append(String.format("`](%s)", spotifyURL)); - } else { - sb.append(String.format("`%s`", activity.getName())); - } - return sb.toString(); - } - -} diff --git a/src/main/java/net/javadiscord/javabot/systems/commands/RegexCommand.java b/src/main/java/net/javadiscord/javabot/systems/commands/RegexCommand.java deleted file mode 100644 index 94895ea6c..000000000 --- a/src/main/java/net/javadiscord/javabot/systems/commands/RegexCommand.java +++ /dev/null @@ -1,53 +0,0 @@ -package net.javadiscord.javabot.systems.commands; - -import com.google.re2j.Pattern; -import com.google.re2j.PatternSyntaxException; -import net.dv8tion.jda.api.EmbedBuilder; -import net.dv8tion.jda.api.entities.Guild; -import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; -import net.dv8tion.jda.api.requests.restaction.interactions.ReplyCallbackAction; -import net.javadiscord.javabot.Bot; -import net.javadiscord.javabot.command.ResponseException; -import net.javadiscord.javabot.command.Responses; -import net.javadiscord.javabot.command.interfaces.SlashCommand; - -/** - * Command that allows members to test regex patterns. - */ -public class RegexCommand implements SlashCommand { - @Override - public ReplyCallbackAction handleSlashCommandInteraction(SlashCommandInteractionEvent event) throws ResponseException { - var patternOption = event.getOption("regex"); - var stringOption = event.getOption("string"); - if (patternOption == null) return Responses.warning(event, "Missing required regex pattern."); - if (stringOption == null) return Responses.warning(event, "Missing required string."); - - Pattern pattern; - try { - pattern = Pattern.compile(patternOption.getAsString()); - } catch (PatternSyntaxException e) { - return Responses.error(event, "Invalid Regex-Pattern."); - } - String string = stringOption.getAsString(); - if (patternOption.getAsString().length() > 1018 || string.length() > 1018) { - return Responses.warning(event, "Pattern and String cannot be longer than 1018 Characters each."); - } - return event.replyEmbeds(buildRegexEmbed(pattern.matcher(string).matches(), pattern, string, event.getGuild()).build()); - } - - private EmbedBuilder buildRegexEmbed(boolean matches, Pattern pattern, String string, Guild guild) { - EmbedBuilder eb = new EmbedBuilder() - .addField("Regex:", String.format("```%s```", pattern.toString()), true) - .addField("String:", String.format("```%s```", string), true); - var config = Bot.config.get(guild).getSlashCommand(); - if (matches) { - eb.setTitle("Regex Tester | ✓ Match"); - eb.setColor(config.getSuccessColor()); - } else { - eb.setTitle("Regex Tester | ✗ No Match"); - eb.setColor(config.getErrorColor()); - } - return eb; - } - -} diff --git a/src/main/java/net/javadiscord/javabot/systems/commands/SearchCommand.java b/src/main/java/net/javadiscord/javabot/systems/commands/SearchCommand.java deleted file mode 100644 index 95faa2af1..000000000 --- a/src/main/java/net/javadiscord/javabot/systems/commands/SearchCommand.java +++ /dev/null @@ -1,132 +0,0 @@ -package net.javadiscord.javabot.systems.commands; - -import com.google.gson.JsonArray; -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; -import net.dv8tion.jda.api.EmbedBuilder; -import net.dv8tion.jda.api.entities.Guild; -import net.dv8tion.jda.api.entities.MessageEmbed; -import net.dv8tion.jda.api.events.interaction.command.MessageContextInteractionEvent; -import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; -import net.dv8tion.jda.api.requests.restaction.interactions.InteractionCallbackAction; -import net.dv8tion.jda.api.requests.restaction.interactions.ReplyCallbackAction; -import net.javadiscord.javabot.Bot; -import net.javadiscord.javabot.command.Responses; -import net.javadiscord.javabot.command.interfaces.MessageContextCommand; -import net.javadiscord.javabot.command.interfaces.SlashCommand; -import net.javadiscord.javabot.data.config.GuildConfig; - -import java.io.IOException; -import java.net.HttpURLConnection; -import java.net.URL; -import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Scanner; - -/** - * Command that allows members to search the internet using the bing api. - */ -public class SearchCommand implements SlashCommand, MessageContextCommand { - - private static final String HOST = "https://api.bing.microsoft.com"; - private static final String PATH = "/v7.0/search"; - - private SearchResults searchWeb(String searchQuery) throws IOException { - // Construct the URL. - URL url = new URL(HOST + PATH + "?q=" + URLEncoder.encode(searchQuery, StandardCharsets.UTF_8.toString()) + "&mkt=" + "en-US" + "&safeSearch=Strict"); - - // Open the connection. - HttpURLConnection connection = (HttpURLConnection) url.openConnection(); - connection.setRequestProperty("Ocp-Apim-Subscription-Key", Bot.config.getSystems().azureSubscriptionKey); - - // Receive the JSON response body. - String response; - try(Scanner scan = new Scanner(connection.getInputStream()).useDelimiter("\\A")){ - response = scan.next(); - } - - // Construct the result object. - SearchResults results = new SearchResults(new HashMap<>(), response); - - // Extract Bing-related HTTP headers. - Map> headers = connection.getHeaderFields(); - for (String header : headers.keySet()) { - if (header == null) continue; // may have null key - if (header.startsWith("BingAPIs-") || header.startsWith("X-MSEdge-")) { - results.relevantHeaders.put(header, headers.get(header).get(0)); - } - } - return results; - } - - private MessageEmbed handleSearch(String searchTerm, Guild guild) { - GuildConfig config = Bot.config.get(guild); - String name; - String url; - String snippet; - var embed = new EmbedBuilder() - .setColor(config.getSlashCommand().getDefaultColor()) - .setTitle("Search Results"); - try { - SearchResults result = searchWeb(searchTerm); - JsonObject json = JsonParser.parseString(result.jsonResponse).getAsJsonObject(); - JsonArray urls = json.get("webPages").getAsJsonObject().get("value").getAsJsonArray(); - StringBuilder resultString = new StringBuilder(); - for (int i = 0; i < Math.min(3, urls.size()); i++) { - JsonObject object = urls.get(i).getAsJsonObject(); - name = object.get("name").getAsString(); - url = object.get("url").getAsString(); - snippet = object.get("snippet").getAsString(); - if (object.get("snippet").getAsString().length() > 320) { - snippet = snippet.substring(0, 320); - int snippetLastPeriod = snippet.lastIndexOf('.'); - if (snippetLastPeriod != -1) { - snippet = snippet.substring(0, snippetLastPeriod + 1); - } else { - snippet = snippet.concat("..."); - } - } - resultString.append("**").append(i + 1).append(". [").append(name).append("](") - .append(url).append(")** \n").append(snippet).append("\n\n"); - } - embed.setDescription(resultString); - } catch (IOException e) { - return new EmbedBuilder() - .setColor(config.getSlashCommand().getDefaultColor()) - .setTitle("No Results") - .setDescription("There were no results for your search. This might be due to safe-search or because your search was too complex. Please try again.") - .build(); - } - return embed.build(); - } - - @Override - public ReplyCallbackAction handleSlashCommandInteraction(SlashCommandInteractionEvent event) { - var query = event.getOption("query"); - if (query == null) { - return Responses.warning(event, "No Query", "Missing Required Query"); - } - return event.replyEmbeds(handleSearch(query.getAsString(), event.getGuild())); - } - - @Override - public InteractionCallbackAction handleMessageContextCommandInteraction(MessageContextInteractionEvent event) { - String query = event.getTarget().getContentDisplay(); - if (query.equals("")) { - return Responses.warning(event, "No Content", "Message doesn't have any content."); - } - return event.replyEmbeds(handleSearch(query, event.getGuild())); - } - - /** - * Simple record class, that represents the search results. - * - * @param relevantHeaders The most relevant headers. - * @param jsonResponse The HTTP Response, formatted as a JSON. - */ - public record SearchResults(Map relevantHeaders, String jsonResponse) { - } -} diff --git a/src/main/java/net/javadiscord/javabot/systems/commands/ServerInfoCommand.java b/src/main/java/net/javadiscord/javabot/systems/commands/ServerInfoCommand.java deleted file mode 100644 index d70dcd32e..000000000 --- a/src/main/java/net/javadiscord/javabot/systems/commands/ServerInfoCommand.java +++ /dev/null @@ -1,51 +0,0 @@ -package net.javadiscord.javabot.systems.commands; - -import net.dv8tion.jda.api.EmbedBuilder; -import net.dv8tion.jda.api.entities.Guild; -import net.dv8tion.jda.api.entities.MessageEmbed; -import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; -import net.dv8tion.jda.api.interactions.components.buttons.Button; -import net.dv8tion.jda.api.requests.restaction.interactions.ReplyCallbackAction; -import net.javadiscord.javabot.Bot; -import net.javadiscord.javabot.Constants; -import net.javadiscord.javabot.command.Responses; -import net.javadiscord.javabot.command.interfaces.SlashCommand; -import net.javadiscord.javabot.data.config.guild.SlashCommandConfig; - -import java.time.Instant; - -/** - * Command that displays some server information. - */ -public class ServerInfoCommand implements SlashCommand { - @Override - public ReplyCallbackAction handleSlashCommandInteraction(SlashCommandInteractionEvent event) { - if (event.getGuild() == null) return Responses.warning(event, "This can only be used in a guild."); - var embed = buildServerInfoEmbed(event.getGuild(), Bot.config.get(event.getGuild()).getSlashCommand()); - return event.replyEmbeds(embed).addActionRow(Button.link(Constants.WEBSITE_LINK, "Website")); - } - - private MessageEmbed buildServerInfoEmbed(Guild guild, SlashCommandConfig config) { - long textChannels = guild.getTextChannels().size(); - long voiceChannels = guild.getVoiceChannels().size(); - long categories = guild.getCategories().size(); - long channels = guild.getChannels().size() - categories; - return new EmbedBuilder() - .setColor(config.getDefaultColor()) - .setThumbnail(guild.getIconUrl()) - .setAuthor(guild.getName(), null, guild.getIconUrl()) - .setTitle("Server Information") - .addField("Owner", guild.getOwner().getAsMention(), true) - .addField("Member Count", guild.getMemberCount() + " members", true) - .addField("Roles", String.format("%s Roles", guild.getRoles().size() - 1L), true) - .addField("ID", String.format("```%s```", guild.getIdLong()), false) - .addField("Channel Count", - String.format( - "```%s Channels, %s Categories" + - "\n→ Text: %s" + - "\n→ Voice: %s```", channels, categories, textChannels, voiceChannels), false) - .addField("Server created on", String.format("", guild.getTimeCreated().toInstant().getEpochSecond()), false) - .setTimestamp(Instant.now()) - .build(); - } -} diff --git a/src/main/java/net/javadiscord/javabot/systems/commands/UptimeCommand.java b/src/main/java/net/javadiscord/javabot/systems/commands/UptimeCommand.java deleted file mode 100644 index eeebc5408..000000000 --- a/src/main/java/net/javadiscord/javabot/systems/commands/UptimeCommand.java +++ /dev/null @@ -1,48 +0,0 @@ -package net.javadiscord.javabot.systems.commands; - -import net.dv8tion.jda.api.EmbedBuilder; -import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; -import net.dv8tion.jda.api.requests.restaction.interactions.ReplyCallbackAction; -import net.javadiscord.javabot.Bot; -import net.javadiscord.javabot.command.interfaces.SlashCommand; - -import java.lang.management.ManagementFactory; -import java.lang.management.RuntimeMXBean; -import java.util.concurrent.TimeUnit; - -/** - * Command that displays the bot's uptime. - */ -public class UptimeCommand implements SlashCommand { - - /** - * Calculates the Uptimes and returns a formatted String. - * - * @return The current Uptime as a String. - */ - public String getUptime() { - RuntimeMXBean rb = ManagementFactory.getRuntimeMXBean(); - long uptimeMS = rb.getUptime(); - - long uptimeDAYS = TimeUnit.MILLISECONDS.toDays(uptimeMS); - uptimeMS -= TimeUnit.DAYS.toMillis(uptimeDAYS); - long uptimeHRS = TimeUnit.MILLISECONDS.toHours(uptimeMS); - uptimeMS -= TimeUnit.HOURS.toMillis(uptimeHRS); - long uptimeMIN = TimeUnit.MILLISECONDS.toMinutes(uptimeMS); - uptimeMS -= TimeUnit.MINUTES.toMillis(uptimeMIN); - long uptimeSEC = TimeUnit.MILLISECONDS.toSeconds(uptimeMS); - - return String.format("%sd %sh %smin %ss", - uptimeDAYS, uptimeHRS, uptimeMIN, uptimeSEC); - } - - @Override - public ReplyCallbackAction handleSlashCommandInteraction(SlashCommandInteractionEvent event) { - String botImage = event.getJDA().getSelfUser().getAvatarUrl(); - var e = new EmbedBuilder() - .setColor(Bot.config.get(event.getGuild()).getSlashCommand().getDefaultColor()) - .setAuthor(getUptime(), null, botImage); - - return event.replyEmbeds(e.build()); - } -} \ No newline at end of file diff --git a/src/main/java/net/javadiscord/javabot/systems/commands/subcommands/leaderboard/QOTWLeaderboardSubcommand.java b/src/main/java/net/javadiscord/javabot/systems/commands/subcommands/leaderboard/QOTWLeaderboardSubcommand.java deleted file mode 100644 index c816e18b2..000000000 --- a/src/main/java/net/javadiscord/javabot/systems/commands/subcommands/leaderboard/QOTWLeaderboardSubcommand.java +++ /dev/null @@ -1,261 +0,0 @@ -package net.javadiscord.javabot.systems.commands.subcommands.leaderboard; - -import net.dv8tion.jda.api.EmbedBuilder; -import net.dv8tion.jda.api.entities.Guild; -import net.dv8tion.jda.api.entities.Member; -import net.dv8tion.jda.api.entities.MessageEmbed; -import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; -import net.dv8tion.jda.api.requests.restaction.interactions.ReplyCallbackAction; -import net.javadiscord.javabot.Bot; -import net.javadiscord.javabot.command.interfaces.SlashCommand; -import net.javadiscord.javabot.systems.qotw.dao.QuestionPointsRepository; -import net.javadiscord.javabot.systems.qotw.model.QOTWAccount; -import net.javadiscord.javabot.util.ImageGenerationUtils; - -import javax.imageio.ImageIO; -import java.awt.*; -import java.awt.image.BufferedImage; -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.sql.SQLException; -import java.time.Instant; -import java.util.List; -import java.util.Objects; - -import static net.javadiscord.javabot.Bot.imageCache; - -/** - * Command for QOTW Leaderboard. - */ -public class QOTWLeaderboardSubcommand extends ImageGenerationUtils implements SlashCommand { - private final Color BACKGROUND_COLOR = Color.decode("#011E2F"); - private final Color PRIMARY_COLOR = Color.WHITE; - private final Color SECONDARY_COLOR = Color.decode("#414A52"); - - private final int DISPLAY_COUNT = 10; - - private final int MARGIN = 40; - private final int WIDTH = 3000; - - @Override - public ReplyCallbackAction handleSlashCommandInteraction(SlashCommandInteractionEvent event) { - Bot.asyncPool.submit(() -> { - try { - var action = event.getHook().sendMessageEmbeds(buildLeaderboardRankEmbed(event.getMember())); - byte[] array; - if (imageCache.isCached(getCacheName())) { - array = getOutputStreamFromImage(imageCache.getCachedImage(getCacheName())).toByteArray(); - } else { - array = generateLeaderboard(event.getGuild()).toByteArray(); - } - action.addFile(new ByteArrayInputStream(array), Instant.now().getEpochSecond() + ".png").queue(); - } catch (IOException e) { - e.printStackTrace(); - } - }); - return event.deferReply(); - } - - /** - * Gets the given user's QOTW-Rank. - * - * @param member The member whose rank should be returned. - * @param guild The current guild. - * @return The QOTW-Rank as an integer. - */ - public static int getQOTWRank(Member member, Guild guild) { - try (var con = Bot.dataSource.getConnection()) { - var repo = new QuestionPointsRepository(con); - var accounts = repo.getAllAccountsSortedByPoints(); - return accounts.stream() - .map(QOTWAccount::getUserId) - .map(guild::getMemberById) - .filter(Objects::nonNull) - .toList().indexOf(member) + 1; - } catch (SQLException e) { - e.printStackTrace(); - return 0; - } - } - - /** - * Gets the top N members based on their QOTW-Points. - * - * @param n The amount of members to get. - * @param guild The current guild. - * @return A {@link List} with the top member ids. - */ - private List getTopNMembers(int n, Guild guild) { - try (var con = Bot.dataSource.getConnection()) { - var repo = new QuestionPointsRepository(con); - var accounts = repo.getAllAccountsSortedByPoints(); - return accounts.stream() - .map(QOTWAccount::getUserId) - .map(guild::getMemberById) - .filter(Objects::nonNull) - .limit(n) - .toList(); - } catch (SQLException e) { - e.printStackTrace(); - return List.of(); - } - } - - /** - * Gets the given user's QOTW-Points. - * - * @param userId The id of the user. - * @return The user's total QOTW-Points - */ - private long getPoints(long userId) { - try (var con = Bot.dataSource.getConnection()) { - var repo = new QuestionPointsRepository(con); - return repo.getAccountByUserId(userId).getPoints(); - } catch (SQLException e) { - e.printStackTrace(); - return 0; - } - } - - /** - * Builds the Leaderboard Rank {@link MessageEmbed}. - * - * @param member The member which executed the command. - * @return A {@link MessageEmbed} object. - */ - private MessageEmbed buildLeaderboardRankEmbed(Member member) { - var rank = getQOTWRank(member, member.getGuild()); - var rankSuffix = switch (rank % 10) { - case 1 -> "st"; - case 2 -> "nd"; - case 3 -> "rd"; - default -> "th"; - }; - var points = getPoints(member.getIdLong()); - var pointsText = points == 1 ? "point" : "points"; - return new EmbedBuilder() - .setAuthor(member.getUser().getAsTag(), null, member.getEffectiveAvatarUrl()) - .setTitle("Question of the Week Leaderboard") - .setDescription(String.format("You're currently in `%s` place with `%s` %s.", - rank + rankSuffix, points, pointsText)) - .setTimestamp(Instant.now()) - .build(); - } - - /** - * Draws a single "user card" at the given coordinates. - * - * @param g2d Graphics object. - * @param guild The current Guild. - * @param member The member. - * @param y The y-position. - * @param left Whether the card should be drawn left or right. - * @throws IOException If an error occurs. - */ - private void drawUserCard(Graphics2D g2d, Guild guild, Member member, int y, boolean left) throws IOException { - var card = getResourceImage("images/leaderboard/LBCard.png"); - int x; - if (left) { - x = MARGIN * 5; - } else { - x = WIDTH - (MARGIN * 5) - card.getWidth(); - } - - - g2d.drawImage(getImageFromUrl(member.getUser().getEffectiveAvatarUrl() + "?size=4096"), x + 185, y + 43, 200, 200, null); - var displayName = member.getUser().getAsTag(); - g2d.drawImage(card, x, y, null); - g2d.setColor(PRIMARY_COLOR); - g2d.setFont(getResourceFont("fonts/Uni-Sans-Heavy.ttf", 65).orElseThrow()); - - int stringWidth = g2d.getFontMetrics().stringWidth(displayName); - while (stringWidth > 750) { - var currentFont = g2d.getFont(); - var newFont = currentFont.deriveFont(currentFont.getSize() - 1F); - g2d.setFont(newFont); - stringWidth = g2d.getFontMetrics().stringWidth(displayName); - } - g2d.drawString(displayName, x + 430, y + 130); - g2d.setColor(SECONDARY_COLOR); - g2d.setFont(getResourceFont("fonts/Uni-Sans-Heavy.ttf", 72).orElseThrow()); - - var points = getPoints(member.getIdLong()); - String text = points + (points > 1 ? " points" : " point"); - String rank = "#" + getQOTWRank(member, member.getGuild()); - g2d.drawString(text, x + 430, y + 210); - int stringLength = (int) g2d.getFontMetrics().getStringBounds(rank, g2d).getWidth(); - int start = 185 / 2 - stringLength / 2; - g2d.drawString(rank, x + start, y + 173); - } - - /** - * Draws and constructs the leaderboard image. - * - * @param guild The current guild. - * @return The finished image as a {@link ByteArrayInputStream}. - * @throws IOException If an error occurs. - */ - private ByteArrayOutputStream generateLeaderboard(Guild guild) throws IOException { - var logo = getResourceImage("images/leaderboard/Logo.png"); - var card = getResourceImage("images/leaderboard/LBCard.png"); - - var topMembers = getTopNMembers(DISPLAY_COUNT, guild); - int height = (logo.getHeight() + MARGIN * 3) + - (getResourceImage("images/leaderboard/LBCard.png").getHeight() + MARGIN) * (Math.min(DISPLAY_COUNT, topMembers.size()) / 2) + MARGIN; - var image = new BufferedImage(WIDTH, height, BufferedImage.TYPE_INT_RGB); - Graphics2D g2d = image.createGraphics(); - - g2d.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_LCD_HRGB); - g2d.setRenderingHint(RenderingHints.KEY_FRACTIONALMETRICS, RenderingHints.VALUE_FRACTIONALMETRICS_ON); - g2d.setPaint(BACKGROUND_COLOR); - g2d.fillRect(0, 0, WIDTH, height); - g2d.drawImage(logo, WIDTH / 2 - logo.getWidth() / 2, MARGIN, null); - - boolean left = true; - int y = logo.getHeight() + 3 * MARGIN; - for (var m : topMembers) { - drawUserCard(g2d, guild, m, y, left); - left = !left; - if (left) y = y + card.getHeight() + MARGIN; - } - g2d.dispose(); - imageCache.removeCachedImagesByKeyword("qotw_leaderboard"); - imageCache.cacheImage(getCacheName(), image); - return getOutputStreamFromImage(image); - } - - /** - * Builds the cached image's name. - * - * @return The image's cache name. - */ - private String getCacheName() { - try (var con = Bot.dataSource.getConnection()) { - var repo = new QuestionPointsRepository(con); - var accounts = repo.getAllAccountsSortedByPoints().stream().limit(DISPLAY_COUNT).toList(); - StringBuilder sb = new StringBuilder("qotw_leaderboard_"); - for (var a : accounts) { - sb.append(":").append(a.getUserId()) - .append(":").append(a.getPoints()); - } - return sb.toString(); - } catch (SQLException e) { - e.printStackTrace(); - return ""; - } - } - - /** - * Retrieves the image's {@link ByteArrayOutputStream}. - * - * @param image The image. - * @return The image's {@link ByteArrayOutputStream}. - * @throws IOException If an error occurs. - */ - private ByteArrayOutputStream getOutputStreamFromImage(BufferedImage image) throws IOException { - var outputStream = new ByteArrayOutputStream(); - ImageIO.write(image, "png", outputStream); - return outputStream; - } -} diff --git a/src/main/java/net/javadiscord/javabot/systems/configuration/ConfigCommand.java b/src/main/java/net/javadiscord/javabot/systems/configuration/ConfigCommand.java new file mode 100644 index 000000000..1dfffc915 --- /dev/null +++ b/src/main/java/net/javadiscord/javabot/systems/configuration/ConfigCommand.java @@ -0,0 +1,22 @@ +package net.javadiscord.javabot.systems.configuration; + +import com.dynxsty.dih4jda.interactions.commands.SlashCommand; +import net.dv8tion.jda.api.interactions.commands.build.Commands; +import net.javadiscord.javabot.Bot; +import net.javadiscord.javabot.systems.moderation.CommandModerationPermissions; + +/** + * The main command for interacting with the bot's configuration at runtime via + * slash commands. + */ +public class ConfigCommand extends SlashCommand implements CommandModerationPermissions { + /** + * The constructor of this class, which sets the corresponding {@link net.dv8tion.jda.api.interactions.commands.build.SlashCommandData}. + */ + public ConfigCommand() { + setModerationSlashCommandData(Commands.slash("config", "Administrative Commands for managing the bot's configuration.")); + addSubcommands(new ExportConfigSubcommand(), new GetConfigSubcommand(), new SetConfigSubcommand()); + requireUsers(Bot.config.getSystems().getAdminConfig().getAdminUsers()); + } +} + diff --git a/src/main/java/net/javadiscord/javabot/systems/configuration/ConfigCommandHandler.java b/src/main/java/net/javadiscord/javabot/systems/configuration/ConfigCommandHandler.java deleted file mode 100644 index 8d0a3c17c..000000000 --- a/src/main/java/net/javadiscord/javabot/systems/configuration/ConfigCommandHandler.java +++ /dev/null @@ -1,22 +0,0 @@ -package net.javadiscord.javabot.systems.configuration; - -import net.javadiscord.javabot.command.DelegatingCommandHandler; -import net.javadiscord.javabot.systems.configuration.subcommands.GetSubcommand; -import net.javadiscord.javabot.systems.configuration.subcommands.ListSubcommand; -import net.javadiscord.javabot.systems.configuration.subcommands.SetSubcommand; - -/** - * The main command for interacting with the bot's configuration at runtime via - * slash commands. - */ -public class ConfigCommandHandler extends DelegatingCommandHandler { - /** - * Adds all subcommands {@link DelegatingCommandHandler#addSubcommand}. - */ - public ConfigCommandHandler() { - addSubcommand("list", new ListSubcommand()); - addSubcommand("get", new GetSubcommand()); - addSubcommand("set", new SetSubcommand()); - } -} - diff --git a/src/main/java/net/javadiscord/javabot/systems/configuration/ConfigSubcommand.java b/src/main/java/net/javadiscord/javabot/systems/configuration/ConfigSubcommand.java new file mode 100644 index 000000000..722abc0be --- /dev/null +++ b/src/main/java/net/javadiscord/javabot/systems/configuration/ConfigSubcommand.java @@ -0,0 +1,39 @@ +package net.javadiscord.javabot.systems.configuration; + +import com.dynxsty.dih4jda.interactions.commands.SlashCommand; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.requests.restaction.interactions.ReplyCallbackAction; +import net.javadiscord.javabot.Bot; +import net.javadiscord.javabot.data.config.GuildConfig; +import net.javadiscord.javabot.data.config.UnknownPropertyException; +import net.javadiscord.javabot.util.Checks; +import net.javadiscord.javabot.util.Responses; +import org.jetbrains.annotations.NotNull; + +import javax.annotation.Nonnull; + +/** + * An abstraction of {@link com.dynxsty.dih4jda.interactions.commands.SlashCommand.Subcommand} which handles all + * config-related commands. + */ +public abstract class ConfigSubcommand extends SlashCommand.Subcommand { + @Override + public void execute(@NotNull SlashCommandInteractionEvent event) { + if (event.getGuild() == null || event.getMember() == null) { + Responses.replyGuildOnly(event).queue(); + return; + } + if (!Checks.hasAdminRole(event.getGuild(), event.getMember())) { + Responses.replyAdminOnly(event, event.getGuild()).queue(); + return; + } + try { + handleConfigSubcommand(event, Bot.config.get(event.getGuild())).queue(); + } catch (UnknownPropertyException e) { + Responses.warning(event, "Unknown Property", "The provided property could not be found.") + .queue(); + } + } + + protected abstract ReplyCallbackAction handleConfigSubcommand(@Nonnull SlashCommandInteractionEvent event, @Nonnull GuildConfig config) throws UnknownPropertyException; +} diff --git a/src/main/java/net/javadiscord/javabot/systems/configuration/ExportConfigSubcommand.java b/src/main/java/net/javadiscord/javabot/systems/configuration/ExportConfigSubcommand.java new file mode 100644 index 000000000..85c803ee9 --- /dev/null +++ b/src/main/java/net/javadiscord/javabot/systems/configuration/ExportConfigSubcommand.java @@ -0,0 +1,27 @@ +package net.javadiscord.javabot.systems.configuration; + +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; +import net.dv8tion.jda.api.requests.restaction.interactions.ReplyCallbackAction; +import net.javadiscord.javabot.Bot; +import net.javadiscord.javabot.data.config.GuildConfig; + +import javax.annotation.Nonnull; +import java.io.File; + +/** + * Shows a list of all known configuration properties, their type, and their + * current value. + */ +public class ExportConfigSubcommand extends ConfigSubcommand { + public ExportConfigSubcommand() { + setSubcommandData(new SubcommandData("export", "Exports a list of all configuration properties, and their current values.")); + requireUsers(Bot.config.getSystems().getAdminConfig().getAdminUsers()); + } + + @Override + public ReplyCallbackAction handleConfigSubcommand(@Nonnull SlashCommandInteractionEvent event, @Nonnull GuildConfig config) { + return event.deferReply() + .addFile(new File("config/" + event.getGuild().getId() + ".json")); + } +} diff --git a/src/main/java/net/javadiscord/javabot/systems/configuration/GetConfigSubcommand.java b/src/main/java/net/javadiscord/javabot/systems/configuration/GetConfigSubcommand.java new file mode 100644 index 000000000..357de3e81 --- /dev/null +++ b/src/main/java/net/javadiscord/javabot/systems/configuration/GetConfigSubcommand.java @@ -0,0 +1,39 @@ +package net.javadiscord.javabot.systems.configuration; + +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; +import net.dv8tion.jda.api.requests.restaction.interactions.ReplyCallbackAction; +import net.javadiscord.javabot.Bot; +import net.javadiscord.javabot.data.config.GuildConfig; +import net.javadiscord.javabot.data.config.UnknownPropertyException; +import net.javadiscord.javabot.util.Responses; + +import javax.annotation.Nonnull; + +/** + * Subcommand that allows staff-members to get a single property variable from the guild config. + */ +public class GetConfigSubcommand extends ConfigSubcommand { + /** + * The constructor of this class, which sets the corresponding {@link SubcommandData}. + */ + public GetConfigSubcommand() { + setSubcommandData(new SubcommandData("get", "Get the current value of a configuration property.") + .addOption(OptionType.STRING, "property", "The name of a property.", true) + ); + requireUsers(Bot.config.getSystems().getAdminConfig().getAdminUsers()); + } + + @Override + public ReplyCallbackAction handleConfigSubcommand(@Nonnull SlashCommandInteractionEvent event, @Nonnull GuildConfig config) throws UnknownPropertyException { + OptionMapping propertyOption = event.getOption("property"); + if (propertyOption == null) { + return Responses.replyMissingArguments(event); + } + String property = propertyOption.getAsString().trim(); + Object value = config.resolve(property); + return Responses.info(event, "Configuration Property", "The value of the property `%s` is:\n```\n%s\n```", property, value); + } +} diff --git a/src/main/java/net/javadiscord/javabot/systems/configuration/SetConfigSubcommand.java b/src/main/java/net/javadiscord/javabot/systems/configuration/SetConfigSubcommand.java new file mode 100644 index 000000000..7d9818cd5 --- /dev/null +++ b/src/main/java/net/javadiscord/javabot/systems/configuration/SetConfigSubcommand.java @@ -0,0 +1,42 @@ +package net.javadiscord.javabot.systems.configuration; + +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; +import net.dv8tion.jda.api.requests.restaction.interactions.ReplyCallbackAction; +import net.javadiscord.javabot.Bot; +import net.javadiscord.javabot.data.config.GuildConfig; +import net.javadiscord.javabot.data.config.UnknownPropertyException; +import net.javadiscord.javabot.util.Responses; + +import javax.annotation.Nonnull; + +/** + * Subcommand that allows staff-members to edit the bot's configuration. + */ +public class SetConfigSubcommand extends ConfigSubcommand { + /** + * The constructor of this class, which sets the corresponding {@link SubcommandData}. + */ + public SetConfigSubcommand() { + setSubcommandData(new SubcommandData("set", "Sets the value of a configuration property.") + .addOption(OptionType.STRING, "property", "The name of a property.", true) + .addOption(OptionType.STRING, "value", "The value to set for the property.", true) + ); + requireUsers(Bot.config.getSystems().getAdminConfig().getAdminUsers()); + } + + @Override + public ReplyCallbackAction handleConfigSubcommand(@Nonnull SlashCommandInteractionEvent event, @Nonnull GuildConfig config) throws UnknownPropertyException { + OptionMapping propertyOption = event.getOption("property"); + OptionMapping valueOption = event.getOption("value"); + if (propertyOption == null || valueOption == null) { + return Responses.replyMissingArguments(event); + } + String property = propertyOption.getAsString().trim(); + String valueString = valueOption.getAsString().trim(); + Bot.config.get(event.getGuild()).set(property, valueString); + return Responses.success(event, "Configuration Updated", "The property `%s` has been set to `%s`.", property, valueString); + } +} diff --git a/src/main/java/net/javadiscord/javabot/systems/configuration/subcommands/GetSubcommand.java b/src/main/java/net/javadiscord/javabot/systems/configuration/subcommands/GetSubcommand.java deleted file mode 100644 index 18c402ad2..000000000 --- a/src/main/java/net/javadiscord/javabot/systems/configuration/subcommands/GetSubcommand.java +++ /dev/null @@ -1,28 +0,0 @@ -package net.javadiscord.javabot.systems.configuration.subcommands; - -import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; -import net.dv8tion.jda.api.requests.restaction.interactions.ReplyCallbackAction; -import net.javadiscord.javabot.Bot; -import net.javadiscord.javabot.command.Responses; -import net.javadiscord.javabot.command.interfaces.SlashCommand; -import net.javadiscord.javabot.data.config.UnknownPropertyException; - -/** - * Subcommand that allows staff-members to get a single property variable from the guild config. - */ -public class GetSubcommand implements SlashCommand { - @Override - public ReplyCallbackAction handleSlashCommandInteraction(SlashCommandInteractionEvent event) { - var propertyOption = event.getOption("property"); - if (propertyOption == null) { - return Responses.warning(event, "Missing required property argument."); - } - String property = propertyOption.getAsString().trim(); - try { - Object value = Bot.config.get(event.getGuild()).resolve(property); - return Responses.info(event, "Configuration Property", String.format("The value of the property `%s` is:\n```\n%s\n```", property, value)); - } catch (UnknownPropertyException e) { - return Responses.warning(event, "Unknown Property", "The property `" + property + "` could not be found."); - } - } -} diff --git a/src/main/java/net/javadiscord/javabot/systems/configuration/subcommands/ListSubcommand.java b/src/main/java/net/javadiscord/javabot/systems/configuration/subcommands/ListSubcommand.java deleted file mode 100644 index ceb5563c4..000000000 --- a/src/main/java/net/javadiscord/javabot/systems/configuration/subcommands/ListSubcommand.java +++ /dev/null @@ -1,19 +0,0 @@ -package net.javadiscord.javabot.systems.configuration.subcommands; - -import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; -import net.dv8tion.jda.api.requests.restaction.interactions.ReplyCallbackAction; -import net.javadiscord.javabot.command.interfaces.SlashCommand; - -import java.io.File; - -/** - * Shows a list of all known configuration properties, their type, and their - * current value. - */ -public class ListSubcommand implements SlashCommand { - @Override - public ReplyCallbackAction handleSlashCommandInteraction(SlashCommandInteractionEvent event) { - return event.deferReply() - .addFile(new File("config/" + event.getGuild().getId() + ".json")); - } -} diff --git a/src/main/java/net/javadiscord/javabot/systems/configuration/subcommands/SetSubcommand.java b/src/main/java/net/javadiscord/javabot/systems/configuration/subcommands/SetSubcommand.java deleted file mode 100644 index f6a6f2a14..000000000 --- a/src/main/java/net/javadiscord/javabot/systems/configuration/subcommands/SetSubcommand.java +++ /dev/null @@ -1,30 +0,0 @@ -package net.javadiscord.javabot.systems.configuration.subcommands; - -import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; -import net.dv8tion.jda.api.requests.restaction.interactions.ReplyCallbackAction; -import net.javadiscord.javabot.Bot; -import net.javadiscord.javabot.command.Responses; -import net.javadiscord.javabot.command.interfaces.SlashCommand; -import net.javadiscord.javabot.data.config.UnknownPropertyException; - -/** - * Subcommand that allows staff-members to edit the bot's configuration. - */ -public class SetSubcommand implements SlashCommand { - @Override - public ReplyCallbackAction handleSlashCommandInteraction(SlashCommandInteractionEvent event) { - var propertyOption = event.getOption("property"); - var valueOption = event.getOption("value"); - if (propertyOption == null || valueOption == null) { - return Responses.warning(event, "Missing required arguments."); - } - String property = propertyOption.getAsString().trim(); - String valueString = valueOption.getAsString().trim(); - try { - Bot.config.get(event.getGuild()).set(property, valueString); - return Responses.success(event, "Configuration Updated", String.format("The property `%s` has been set to `%s`.", property, valueString)); - } catch (UnknownPropertyException e) { - return Responses.warning(event, "Unknown Property", "The property `" + property + "` could not be found."); - } - } -} diff --git a/src/main/java/net/javadiscord/javabot/systems/help/HelpChannelInteractionManager.java b/src/main/java/net/javadiscord/javabot/systems/help/HelpChannelInteractionManager.java index 5b2631135..cbe614f19 100644 --- a/src/main/java/net/javadiscord/javabot/systems/help/HelpChannelInteractionManager.java +++ b/src/main/java/net/javadiscord/javabot/systems/help/HelpChannelInteractionManager.java @@ -1,95 +1,30 @@ package net.javadiscord.javabot.systems.help; +import com.dynxsty.dih4jda.interactions.ComponentIdBuilder; +import com.dynxsty.dih4jda.interactions.components.ButtonHandler; import lombok.extern.slf4j.Slf4j; import net.dv8tion.jda.api.entities.TextChannel; import net.dv8tion.jda.api.entities.User; import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent; +import net.dv8tion.jda.api.interactions.components.buttons.Button; import net.javadiscord.javabot.Bot; -import net.javadiscord.javabot.command.Responses; import net.javadiscord.javabot.data.config.guild.HelpConfig; import net.javadiscord.javabot.data.h2db.DbActions; import net.javadiscord.javabot.systems.help.model.ChannelReservation; import net.javadiscord.javabot.systems.help.model.HelpTransactionMessage; +import net.javadiscord.javabot.util.ExceptionLogger; +import net.javadiscord.javabot.util.Responses; +import org.jetbrains.annotations.NotNull; import java.sql.SQLException; +import java.util.List; +import java.util.Optional; /** * Handles various interactions regarding the help channel system. */ @Slf4j -public class HelpChannelInteractionManager { - - /** - * Handles button interactions for help channel activity checks. - * - * @param event The button event. - * @param reservationId The help channel's reservation id. - * @param action The data extracted from the button id. - */ - public void handleHelpChannel(ButtonInteractionEvent event, String reservationId, String action) { - event.deferEdit().queue(); - var config = Bot.config.get(event.getGuild()).getHelp(); - var channelManager = new HelpChannelManager(config); - var optionalReservation = channelManager.getReservation(Long.parseLong(reservationId)); - if (optionalReservation.isEmpty()) { - event.reply("Could not find reservation data for this channel. Perhaps it's no longer reserved?") - .setEphemeral(true).queue(); - event.getMessage().delete().queue(); - return; - } - var reservation = optionalReservation.get(); - TextChannel channel = event.getTextChannel(); - if (!event.isAcknowledged()) { - event.deferReply(true).queue(); - } - var owner = channelManager.getReservedChannelOwner(channel); - // If a reserved channel doesn't have an owner, it's in an invalid state, but the system will handle it later automatically. - if (owner == null) { - // Remove the original message, just to make sure no more interactions are sent. - event.getInteraction().getHook().sendMessage("Uh oh! It looks like this channel is no longer reserved, so these buttons can't be used.") - .setEphemeral(true).queue(); - event.getMessage().delete().queue(); - return; - } - - if (owner.getIdLong() != reservation.getUserId() || channel.getIdLong() != reservation.getChannelId()) { - event.getInteraction().getHook().sendMessage("The reservation data for this channel doesn't match up with Discord's information.") - .setEphemeral(true).queue(); - event.getMessage().delete().queue(); - return; - } - - // Check that the user is allowed to do the interaction. - if ( - event.getUser().equals(owner) || - event.getMember() != null && event.getMember().getRoles().contains(Bot.config.get(event.getGuild()).getModeration().getStaffRole()) - ) { - if (action.equals("done")) { - event.getMessage().delete().queue(); - if (event.getUser().equals(owner)) {// If the owner is unreserving their own channel, handle it separately. - channelManager.unreserveChannelByUser(channel, owner, null, event); - } else { - channelManager.unreserveChannel(channel).queue(); - } - } else if (action.equals("not-done")) { - log.info("Removing timeout check message in {} because it was marked as not-done.", channel.getAsMention()); - event.getMessage().delete().queue(); - try { - int nextTimeout = channelManager.getNextTimeout(channel); - channelManager.setTimeout(channel, nextTimeout); - channel.sendMessage(String.format( - "Okay, we'll keep this channel reserved for you, and check again in **%d** minutes.", - nextTimeout - )).queue(); - } catch (SQLException e) { - Responses.error(event.getHook(), "An error occurred while managing this help channel.").queue(); - } - } - } else { - event.getInteraction().getHook().sendMessage("Sorry, only the person who reserved this channel or moderators are allowed to use these buttons.") - .setEphemeral(true).queue(); - } - } +public class HelpChannelInteractionManager implements ButtonHandler { /** * Handles button interactions pertaining to the interaction provided to @@ -100,23 +35,23 @@ public void handleHelpChannel(ButtonInteractionEvent event, String reservationId * @param reservationId The help channel's reservation id. * @param action The data extracted from the button's id. */ - public void handleHelpThank(ButtonInteractionEvent event, String reservationId, String action) { + private void handleHelpThankButton(@NotNull ButtonInteractionEvent event, String reservationId, String action) { event.deferEdit().queue(); - var config = Bot.config.get(event.getGuild()).getHelp(); - var channelManager = new HelpChannelManager(config); - var optionalReservation = channelManager.getReservation(Long.parseLong(reservationId)); + HelpConfig config = Bot.config.get(event.getGuild()).getHelpConfig(); + HelpChannelManager channelManager = new HelpChannelManager(config); + Optional optionalReservation = channelManager.getReservation(Long.parseLong(reservationId)); if (optionalReservation.isEmpty()) { event.getInteraction().getHook().sendMessage("Could not find reservation data for this channel. Perhaps it's no longer reserved?") .setEphemeral(true).queue(); event.getMessage().delete().queue(); return; } - var reservation = optionalReservation.get(); - TextChannel channel = event.getTextChannel(); + ChannelReservation reservation = optionalReservation.get(); + TextChannel channel = event.getChannel().asTextChannel(); if (!event.isAcknowledged()) { event.deferReply(true).queue(); } - var owner = channelManager.getReservedChannelOwner(channel); + User owner = channelManager.getReservedChannelOwner(channel); if (owner == null) { event.getInteraction().getHook().sendMessage("Sorry, but this channel is currently unreserved.").setEphemeral(true).queue(); event.getMessage().delete().queue(); @@ -140,7 +75,7 @@ public void handleHelpThank(ButtonInteractionEvent event, String reservationId, try { channelManager.setTimeout(channel, config.getInactivityTimeouts().get(0)); } catch (SQLException e) { - e.printStackTrace(); + ExceptionLogger.capture(e, getClass().getSimpleName()); } } else { long helperId = Long.parseLong(action); @@ -151,8 +86,8 @@ public void handleHelpThank(ButtonInteractionEvent event, String reservationId, } } - private void thankHelper(ButtonInteractionEvent event, TextChannel channel, User owner, long helperId, ChannelReservation reservation, HelpChannelManager channelManager) { - var btn = event.getButton(); + private void thankHelper(@NotNull ButtonInteractionEvent event, TextChannel channel, User owner, long helperId, ChannelReservation reservation, HelpChannelManager channelManager) { + Button btn = event.getButton(); long thankCount = DbActions.count( "SELECT COUNT(id) FROM help_channel_thanks WHERE reservation_id = ? AND helper_id = ?", s -> { @@ -177,14 +112,14 @@ private void thankHelper(ButtonInteractionEvent event, TextChannel channel, User helper.getIdLong() ); event.getInteraction().getHook().sendMessageFormat("You thanked %s", helper.getAsTag()).setEphemeral(true).queue(); - HelpConfig config = Bot.config.get(event.getGuild()).getHelp(); + HelpConfig config = Bot.config.get(event.getGuild()).getHelpConfig(); HelpExperienceService service = new HelpExperienceService(Bot.dataSource); // Perform experience transactions service.performTransaction(helper.getIdLong(), config.getThankedExperience(), HelpTransactionMessage.GOT_THANKED, event.getGuild()); service.performTransaction(owner.getIdLong(), config.getThankExperience(), HelpTransactionMessage.THANKED_USER, event.getGuild()); } catch (SQLException e) { - e.printStackTrace(); - Bot.config.get(event.getGuild()).getModeration().getLogChannel().sendMessageFormat( + ExceptionLogger.capture(e, getClass().getSimpleName()); + Bot.config.get(event.getGuild()).getModerationConfig().getLogChannel().sendMessageFormat( "Could not record user %s thanking %s for help in channel %s: %s", owner.getAsTag(), helper.getAsTag(), @@ -194,7 +129,7 @@ private void thankHelper(ButtonInteractionEvent event, TextChannel channel, User } // Then disable the button, or unreserve the channel if there's nobody else to thank. if (btn != null) { - var activeButtons = event.getMessage().getButtons().stream() + List