From 9c00f051d6e8166afeb15c461ee99b2eccbe7969 Mon Sep 17 00:00:00 2001 From: Cole <3ef4c1bb@opayq.com> Date: Wed, 10 Jul 2019 03:28:21 -0500 Subject: [PATCH] More abstraction, a few test commands, and a ton of other changes Full changelog: * Most JavaDocs have been added; all the least-straightforward code has been documented * Command is now renamed to Module * Further abstraction of Module, new UX features, more elegant and redundant class code - New ModuleInfo container class for command module metadata, with a Builder included - Added command descriptions - Separated required permissions into bot permissions and user permissions * package-info.java for all packages and package-level categorization via @CommandPackage(CommandType) * CommandType is now automatically populated in ModuleInfo * Added 'help', 'purge', and 'claimoperator' modules * Operator commands no longer rely on a hardcoded ID to check operator status; use 'claimoperator' to set and change bot operator * User permissions are now properly calculated & checked * CommandType now provides Emoji icons for each type * Migrated property configuration from Spring to Owner * Added '--debug' command-line option to make lower-level logging accessible * CommandDispatcher now uses an ExecutorService to start command threads instead of manually creating threads * Module scanning & instantiation moved to separate CommandRegistry class * CommandContext now provides the bot's Member as well as the invoker's * Upgrade to Java 10 and modularize the application - Note: 11 is definitely preferable, since it's the current LTS, but Hibernate depends on the java.activation module which was removed in 11. Once I can find a way to get jlink to recognize either the module from the Java 10 JDK or the non-modular Maven artifact, ghost2 will be migrated to 11 immediately. --- .gitignore | 6 +- README.md | 2 +- build.gradle | 28 +++- gradle/wrapper/gradle-wrapper.properties | 3 +- settings.gradle | 3 +- .../coleb1911/ghost2/Ghost2Application.java | 103 ++++++++++-- .../github/coleb1911/ghost2/GhostConfig.java | 25 +-- .../ghost2/commands/CommandDispatcher.java | 95 +++++++---- .../ghost2/commands/CommandRegistry.java | 95 +++++++++-- .../ghost2/commands/meta/Command.java | 43 ----- .../ghost2/commands/meta/CommandContext.java | 12 +- .../ghost2/commands/meta/CommandPackage.java | 17 ++ .../ghost2/commands/meta/CommandType.java | 24 ++- .../ghost2/commands/meta/Module.java | 43 +++++ .../ghost2/commands/meta/ModuleInfo.java | 151 ++++++++++++++++++ .../ghost2/commands/modules/CommandPing.java | 22 --- .../commands/modules/CommandShutdown.java | 24 --- .../commands/modules/config/package-info.java | 5 + .../commands/modules/fun/package-info.java | 5 + .../commands/modules/info/ModuleHelp.java | 91 +++++++++++ .../commands/modules/info/package-info.java | 5 + .../modules/moderation/ModulePurge.java | 38 +++++ .../modules/moderation/package-info.java | 5 + .../commands/modules/music/package-info.java | 5 + .../modules/operator/ModuleClaimOperator.java | 91 +++++++++++ .../modules/operator/ModuleShutdown.java | 20 +++ .../modules/operator/package-info.java | 5 + .../commands/modules/utility/ModulePing.java | 18 +++ .../modules/utility/package-info.java | 5 + .../ghost2/database/entities/GuildMeta.java | 8 +- .../database/repos/GuildMetaRepository.java | 3 +- src/main/java/module-info.java | 16 ++ src/main/resources/application.properties | 1 + 33 files changed, 830 insertions(+), 187 deletions(-) delete mode 100644 src/main/java/com/github/coleb1911/ghost2/commands/meta/Command.java create mode 100644 src/main/java/com/github/coleb1911/ghost2/commands/meta/CommandPackage.java create mode 100644 src/main/java/com/github/coleb1911/ghost2/commands/meta/Module.java create mode 100644 src/main/java/com/github/coleb1911/ghost2/commands/meta/ModuleInfo.java delete mode 100644 src/main/java/com/github/coleb1911/ghost2/commands/modules/CommandPing.java delete mode 100644 src/main/java/com/github/coleb1911/ghost2/commands/modules/CommandShutdown.java create mode 100644 src/main/java/com/github/coleb1911/ghost2/commands/modules/config/package-info.java create mode 100644 src/main/java/com/github/coleb1911/ghost2/commands/modules/fun/package-info.java create mode 100644 src/main/java/com/github/coleb1911/ghost2/commands/modules/info/ModuleHelp.java create mode 100644 src/main/java/com/github/coleb1911/ghost2/commands/modules/info/package-info.java create mode 100644 src/main/java/com/github/coleb1911/ghost2/commands/modules/moderation/ModulePurge.java create mode 100644 src/main/java/com/github/coleb1911/ghost2/commands/modules/moderation/package-info.java create mode 100644 src/main/java/com/github/coleb1911/ghost2/commands/modules/music/package-info.java create mode 100644 src/main/java/com/github/coleb1911/ghost2/commands/modules/operator/ModuleClaimOperator.java create mode 100644 src/main/java/com/github/coleb1911/ghost2/commands/modules/operator/ModuleShutdown.java create mode 100644 src/main/java/com/github/coleb1911/ghost2/commands/modules/operator/package-info.java create mode 100644 src/main/java/com/github/coleb1911/ghost2/commands/modules/utility/ModulePing.java create mode 100644 src/main/java/com/github/coleb1911/ghost2/commands/modules/utility/package-info.java create mode 100644 src/main/java/module-info.java diff --git a/.gitignore b/.gitignore index 4ff2c62..0632941 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ # ghost2 specific files -*.db +**/*.db **/ghost.properties -log/*.txt +**/log/*.txt ### Gradle template .gradle @@ -46,8 +46,6 @@ hs_err_pid* # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 -.idea/**/* - # Gradle .idea/**/gradle.xml .idea/**/libraries diff --git a/README.md b/README.md index 4d6b728..816e9d8 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ ![ghost2](https://github.com/cbryant02/cbryant02.github.io/raw/master/media/ghost2_banner.png) # ghost2 -[ghost](https://github.com/cbryant02/ghost)'s spritual successor, featuring magical Spring JPA-powered database code, a super-lightweight embedded H2 database, more flexible command modules, Discord4J 3, and approximately 85.4% less spaghetti. +[ghost](https://github.com/cbryant02/ghost)'s spritual successor, featuring magical Spring JPA-powered database code, a super-lightweight embedded H2 database, more flexible module modules, Discord4J 3, and approximately 85.4% less spaghetti. Pull requests are still welcome. JavaDocs are not written yet, but the codebase isn't yet large, complicated, or spaghettified. With some knowledge of Spring, you'll find your way around fine. diff --git a/build.gradle b/build.gradle index 7242b92..af2447d 100644 --- a/build.gradle +++ b/build.gradle @@ -1,14 +1,16 @@ plugins { id 'java' id 'java-library' + id 'application' id 'org.springframework.boot' version '2.1.6.RELEASE' } +mainClassName = "com.github.coleb1911.ghost2.Ghost2Application" group 'com.github.cbryant02' version '0.1' -sourceCompatibility = 1.8 -targetCompatibility = 1.8 +sourceCompatibility = JavaVersion.VERSION_1_10 +targetCompatibility = JavaVersion.VERSION_1_10 repositories { mavenCentral() @@ -17,6 +19,7 @@ repositories { dependencies { // Misc libraries implementation group: 'com.discord4j', name: 'discord4j-core', version: '3.0.7' + implementation group: 'org.aeonbits.owner', name: 'owner', version: '1.0.10' implementation group: 'org.reflections', name: 'reflections', version: '0.9.11' implementation group: 'org.tinylog', name: 'tinylog', version: '1.3.6' implementation group: 'org.tinylog', name: 'slf4j-binding', version: '1.3.6' @@ -31,6 +34,23 @@ dependencies { api group: 'org.springframework.data', name: 'spring-data-jpa', version: '2.1.9.RELEASE' // Hibernate - api group: 'org.hibernate', name: 'hibernate-core', version: '5.4.3.Final' - api group: 'org.hibernate', name: 'hibernate-entitymanager', version: '5.4.3.Final' + implementation group: 'org.hibernate', name: 'hibernate-core', version: '5.4.3.Final' + implementation group: 'org.hibernate', name: 'hibernate-entitymanager', version: '5.4.3.Final' +} + +bootJar { + exclude('ghost.properties') + manifest { + attributes 'Main-Class': 'org.springframework.boot.loader.PropertiesLauncher' + } +} + +compileJava { + inputs.property("moduleName", "ghost2.main") + doFirst { + options.compilerArgs = [ + '--module-path', classpath.asPath + ] + classpath = files() + } } \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 9330025..a28e5a4 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,4 +1,5 @@ -#Thu May 16 08:49:33 CDT 2019 +# Thu May 16 08:49:33 CDT 2019 +# suppress inspection "UnusedProperty" for whole file distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME diff --git a/settings.gradle b/settings.gradle index 7493444..2272e9c 100644 --- a/settings.gradle +++ b/settings.gradle @@ -3,5 +3,4 @@ pluginManagement { gradlePluginPortal() } } -rootProject.name = 'ghost2' - +rootProject.name = 'ghost2' \ No newline at end of file diff --git a/src/main/java/com/github/coleb1911/ghost2/Ghost2Application.java b/src/main/java/com/github/coleb1911/ghost2/Ghost2Application.java index 75c0148..26b2cf1 100644 --- a/src/main/java/com/github/coleb1911/ghost2/Ghost2Application.java +++ b/src/main/java/com/github/coleb1911/ghost2/Ghost2Application.java @@ -5,11 +5,13 @@ import com.github.coleb1911.ghost2.database.repos.GuildMetaRepository; import discord4j.core.DiscordClient; import discord4j.core.DiscordClientBuilder; +import discord4j.core.event.domain.guild.GuildCreateEvent; import discord4j.core.event.domain.message.MessageCreateEvent; import discord4j.core.object.entity.Guild; import discord4j.core.object.presence.Activity; import discord4j.core.object.presence.Presence; import discord4j.core.object.util.Snowflake; +import org.aeonbits.owner.ConfigFactory; import org.pmw.tinylog.Configurator; import org.pmw.tinylog.Level; import org.pmw.tinylog.Logger; @@ -23,17 +25,26 @@ import org.springframework.context.ConfigurableApplicationContext; import org.springframework.context.annotation.ComponentScan; +import java.io.IOException; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.function.Predicate; +/** + * Application entry point + */ @ComponentScan @SpringBootApplication @EnableAutoConfiguration public class Ghost2Application implements ApplicationRunner { + private static final String MESSAGE_SET_OPERATOR = "No operator has been set for this bot instance. Use the \'claimoperator\' command to set one; until then, operator commands won't work."; + private static final String CONNECTION_ERROR = "General connection error. Check your internet connection and try again."; + private static final String CONFIG_ERROR = "ghost.properties is missing or does not contain a bot token. Read ghost2's README for info on how to set up the bot."; + + private static Ghost2Application applicationInstance; private static ConfigurableApplicationContext ctx; - private static DiscordClient client; - @Autowired + private DiscordClient client; + private long operatorId; private GhostConfig config; @Autowired private CommandDispatcher dispatcher; @@ -44,12 +55,20 @@ public static void main(String[] args) { ctx = SpringApplication.run(Ghost2Application.class, args); } - public static void exit(int status) { + public static Ghost2Application getApplicationInstance() { + return applicationInstance; + } + + /** + * Closes all resources, logs out the bot, and terminates the application gracefully. + * + * @param status Status code + */ + public void exit(int status) { // Log out bot client.logout().block(); // Close Spring application context - ctx.close(); SpringApplication.exit(ctx, () -> status); // Exit @@ -57,36 +76,98 @@ public static void exit(int status) { System.exit(status); } + /** + * Starts the application. + *
+ * This should only be called by Spring Boot. + * + * @param args Arguments passed to the application + */ @Override public void run(ApplicationArguments args) { + // Set instance + applicationInstance = this; + + // Fetch config + config = ConfigFactory.create(GhostConfig.class); + String token = config.token(); + if (null == token) { + Logger.error(CONFIG_ERROR); + return; + } + // Set up TinyLog String dateString = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH''mm''ss").format(LocalDateTime.now()); String logFileName = String.format("log/log_%s.txt", dateString); Configurator.defaultConfig() - .level(Level.INFO) + .level(args.containsOption("debug") ? Level.DEBUG : Level.INFO) .addWriter(new FileWriter(logFileName)) .writingThread(true) .activate(); // Create client - client = new DiscordClientBuilder(config.getToken()) + client = new DiscordClientBuilder(token) .setInitialPresence(Presence.online(Activity.listening("your commands."))) .build(); - // Subscribe CommandDispatcher to MessageCreateEvents + // Send MessageCreateEvents to CommandDispatcher client.getEventDispatcher().on(MessageCreateEvent.class) + .filter(e -> { + if (e.getMember().isPresent()) + return !e.getMember().get().isBot(); + return false; + }) .subscribe(dispatcher::onMessageEvent); - // Update guild database with any new guilds - client.getGuilds() + // Add any new guilds to database + client.getEventDispatcher().on(GuildCreateEvent.class) + .map(GuildCreateEvent::getGuild) .map(Guild::getId) .map(Snowflake::asLong) .filter(((Predicate) guildRepo::existsById).negate()) .map(id -> new GuildMeta(id, GuildMeta.DEFAULT_PREFIX)) .subscribe(guildRepo::save); + // Get current bot operator, log notice if null + operatorId = config.operatorId(); + if (operatorId == -1) + Logger.info(MESSAGE_SET_OPERATOR); + + // Log in and block main thread until bot logs out + client.login() + .retry(5L) + .doOnError(throwable -> { + if (throwable instanceof IOException) { + Logger.error(CONNECTION_ERROR); + exit(1); + } + }).block(); + } + + /** + * Reloads the application config & all related values. + *
+ * Note: The application will exit if a token change occurred. Don't change it at runtime. + */ + public void reloadConfig() { + config.reload(); + if (!config.token().equals(client.getConfig().getToken())) { + Logger.info("Token changed on config reload. Exiting."); + exit(0); + return; + } + operatorId = config.operatorId(); + } + + public DiscordClient getClient() { + return client; + } + + public long getOperatorId() { + return operatorId; + } - // Block thread until bot logs out - client.login().block(); + public GhostConfig getConfig() { + return config; } } \ No newline at end of file diff --git a/src/main/java/com/github/coleb1911/ghost2/GhostConfig.java b/src/main/java/com/github/coleb1911/ghost2/GhostConfig.java index d9da5b7..ad6ab31 100644 --- a/src/main/java/com/github/coleb1911/ghost2/GhostConfig.java +++ b/src/main/java/com/github/coleb1911/ghost2/GhostConfig.java @@ -1,16 +1,17 @@ package com.github.coleb1911.ghost2; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.PropertySource; -import org.springframework.stereotype.Component; +import org.aeonbits.owner.Accessible; +import org.aeonbits.owner.Config; +import org.aeonbits.owner.Config.Sources; +import org.aeonbits.owner.Mutable; +import org.aeonbits.owner.Reloadable; -@Component -@PropertySource(value = {"classpath:ghost.properties"}) -class GhostConfig { - @Value("${ghost.token}") - private transient String token; +@Sources("classpath:ghost.properties") +public interface GhostConfig extends Config, Accessible, Mutable, Reloadable { + @Key("ghost.token") + String token(); - String getToken() { - return token; - } -} + @Key("ghost.operatorid") + @DefaultValue("-1") + Long operatorId(); +} \ No newline at end of file diff --git a/src/main/java/com/github/coleb1911/ghost2/commands/CommandDispatcher.java b/src/main/java/com/github/coleb1911/ghost2/commands/CommandDispatcher.java index 6f5b837..75107c1 100644 --- a/src/main/java/com/github/coleb1911/ghost2/commands/CommandDispatcher.java +++ b/src/main/java/com/github/coleb1911/ghost2/commands/CommandDispatcher.java @@ -1,12 +1,13 @@ package com.github.coleb1911.ghost2.commands; -import com.github.coleb1911.ghost2.commands.meta.Command; +import com.github.coleb1911.ghost2.Ghost2Application; import com.github.coleb1911.ghost2.commands.meta.CommandContext; import com.github.coleb1911.ghost2.commands.meta.CommandType; +import com.github.coleb1911.ghost2.commands.meta.Module; +import com.github.coleb1911.ghost2.commands.modules.operator.ModuleClaimOperator; import com.github.coleb1911.ghost2.database.entities.GuildMeta; import com.github.coleb1911.ghost2.database.repos.GuildMetaRepository; import discord4j.core.event.domain.message.MessageCreateEvent; -import discord4j.core.object.entity.Role; import discord4j.core.object.util.Permission; import discord4j.core.object.util.PermissionSet; import org.pmw.tinylog.Logger; @@ -14,71 +15,99 @@ import org.springframework.beans.factory.annotation.Configurable; import org.springframework.stereotype.Component; -import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +/** + * Processes {@link MessageCreateEvent}s from the main application class, and calls {@link Module#invoke invoke} + * when a valid command is invoked in chat. + */ @Component @Configurable public class CommandDispatcher { - private static final long OPERATOR_ID = 195635151005417472L; - @Autowired private GuildMetaRepository guildRepo; - private Set> modules; - private CommandRegistry registry; + private ExecutorService commandExecutor; + /** + * Construct a new CommandDispatcher. + */ public CommandDispatcher() { + // Load CommandRegistry & create a thread pool try { - registry = new CommandRegistry(); - } catch (ReflectiveOperationException e) { - Logger.error(e); + Class.forName("com.github.coleb1911.ghost2.commands.CommandRegistry"); + } catch (ClassNotFoundException e) { + Logger.error("FATAL: Couldn't locate CommandRegistry on classpath"); + Ghost2Application.getApplicationInstance().exit(1); } + commandExecutor = Executors.newCachedThreadPool(); } + /** + * Processes {@link MessageCreateEvent}s and {@link Module#invoke invoke}s commands. + *

+ * Should NOT be called by anything other than {@link Ghost2Application}. + * + * @param ev Event to process + */ public void onMessageEvent(MessageCreateEvent ev) { // Build command context final CommandContext ctx = new CommandContext(ev); // Fetch prefix from database - // GuildMeta shouldn't be null, but in a few rare edge cases it can be + // GuildMeta shouldn't be null, otherwise we wouldn't have received the event; + // we still null-check to be safe and get rid of the warning GuildMeta meta = guildRepo.findById(ctx.getGuild().getId().asLong()).orElse(null); if (null == meta) return; String prefix = meta.getPrefix(); - // Check for prefix, strip it if it exists + // Check for prefix & isolate the command name if present String trigger = ctx.getTrigger(); String commandName; if (trigger.indexOf(prefix) == 0) commandName = trigger.replace(prefix, ""); else return; - // Get command instance - final Command command; - try { - command = registry.getCommandInstance(commandName); - } catch (ReflectiveOperationException e) { - Logger.error(e); + // Get & null-check module instance + final Module module = CommandRegistry.getCommandInstance(commandName); + if (null == module) return; + + // Check user's permissions + PermissionSet invokerPerms = ctx.getInvoker().getBasePermissions().block(); + if (null == invokerPerms) { + ctx.reply(Module.REPLY_GENERAL_ERROR); return; } + for (Permission required : module.getInfo().getUserPermissions()) { + if (!invokerPerms.contains(required)) { + ctx.reply(Module.REPLY_INSUFFICIENT_PERMISSIONS_USER); + return; + } + } - // Check operator permissions - if ((command.getType() == CommandType.OPERATOR) && (ctx.getInvoker().getId().asLong() != OPERATOR_ID)) { - ctx.reply(Command.REPLY_INSUFFICIENT_PERMISSIONS); - return; + // Check user's ID if command is an operator command + // An exception is made for ModuleClaimOperator + if (!(module instanceof ModuleClaimOperator)) { + if ((module.getInfo().getType() == CommandType.OPERATOR) && (ctx.getInvoker().getId().asLong() != Ghost2Application.getApplicationInstance().getOperatorId())) { + ctx.reply(Module.REPLY_INSUFFICIENT_PERMISSIONS_USER); + return; + } } - // Check role permissions - PermissionSet invokerPerms = ctx.getInvoker().getHighestRole() - .map(Role::getPermissions) - .block(); - for (Permission perm : command.getRequiredPermissions()) { - assert invokerPerms != null; - if (!invokerPerms.contains(perm)) { - ctx.reply(Command.REPLY_INSUFFICIENT_PERMISSIONS); + // Check bot's permissions + PermissionSet botPerms = ctx.getSelf().getBasePermissions().block(); + if (null == botPerms) { + ctx.reply(Module.REPLY_GENERAL_ERROR); + return; + } + for (Permission required : module.getInfo().getBotPermissions()) { + if (!invokerPerms.contains(required)) { + ctx.reply(Module.REPLY_INSUFFICIENT_PERMISSIONS_BOT); return; } } - // Kick off command thread - new Thread(() -> command.invoke(ctx)).run(); + // Finally kick off command thread if all checks are passed + commandExecutor.execute(() -> module.invoke(ctx)); } -} +} \ No newline at end of file diff --git a/src/main/java/com/github/coleb1911/ghost2/commands/CommandRegistry.java b/src/main/java/com/github/coleb1911/ghost2/commands/CommandRegistry.java index d690d87..0c186ee 100644 --- a/src/main/java/com/github/coleb1911/ghost2/commands/CommandRegistry.java +++ b/src/main/java/com/github/coleb1911/ghost2/commands/CommandRegistry.java @@ -1,40 +1,101 @@ package com.github.coleb1911.ghost2.commands; -import com.github.coleb1911.ghost2.commands.meta.Command; +import com.github.coleb1911.ghost2.commands.meta.Module; +import com.github.coleb1911.ghost2.commands.meta.ModuleInfo; import org.reflections.Reflections; +import java.util.ArrayList; import java.util.LinkedList; import java.util.List; import java.util.Set; -class CommandRegistry { +/** + * Scans the {@link com.github.coleb1911.ghost2.commands commands} package for valid command {@link Module}s + * & maintains a {@link List} of instances of each Module. + *

+ * This class serves two main purposes: + *

    + *
  1. Gets an instance of a Module by name
  2. + *
  3. Gets the {@link ModuleInfo} associated with a Module by name
  4. + *
+ */ +public class CommandRegistry { private static final String MODULE_PACKAGE = "com.github.coleb1911.ghost2.commands.modules"; + private static final String INSTANTIATION_ERROR_FORMAT = "Cannot construct an instance of %s; Module implementations must have a public constructor to function"; - private Set> modules; - private List instantiated; + private static final Set> modules; + private static final List instantiated; - CommandRegistry() throws ReflectiveOperationException { + static { // Create instantiated object "cache" instantiated = new LinkedList<>(); - // Find all Command modules + // Find all Modules Reflections reflector = new Reflections(MODULE_PACKAGE); - modules = reflector.getSubTypesOf(Command.class); + modules = reflector.getSubTypesOf(Module.class); - // Construct an instance of each module - for (Class module : modules) { - instantiated.add(module.newInstance()); + // Construct an instance of each Module + for (Class module : modules) { + try { + instantiated.add(module.getDeclaredConstructor().newInstance()); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException(String.format(INSTANTIATION_ERROR_FORMAT, instantiated.getClass().getName())); + } + } + } + + private CommandRegistry() { + } + + // TODO: Handle command aliases in CommandRegistry#getCommandInstance and CommandRegistry#getInfo + + /** + * Gets a {@link Module} instance by name. + * + * @param name Command name + * @return Command instance, or null if no command with that name exists + */ + static Module getCommandInstance(String name) { + for (Module module : instantiated) { + if (name.equals(module.getInfo().getName())) { + instantiated.remove(module); + try { + instantiated.add(module.getClass().getDeclaredConstructor().newInstance()); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException(String.format(INSTANTIATION_ERROR_FORMAT, instantiated.getClass().getName())); + } + return module; + } } + return null; } - Command getCommandInstance(String name) throws ReflectiveOperationException { - for (Command command : instantiated) { - if (name.equals(command.getName())) { - instantiated.remove(command); - instantiated.add(command.getClass().newInstance()); - return command; + /** + * Get the {@link ModuleInfo} for a {@link Module} by name + * + * @param name Command name + * @return Associated CommandInfo, or null if no command with that name exists + * @see #getAllInfo() + */ + public static ModuleInfo getInfo(String name) { + for (Module module : instantiated) { + if (name.equals(module.getInfo().getName())) { + return module.getInfo(); } } return null; } -} + + /** + * Get the {@link ModuleInfo} for every {@link Module} found on the classpath + * + * @return Associated CommandInfo for all available Modules + */ + public static List getAllInfo() { + List ret = new ArrayList<>(); + for (Module module : instantiated) { + ret.add(module.getInfo()); + } + return ret; + } +} \ No newline at end of file diff --git a/src/main/java/com/github/coleb1911/ghost2/commands/meta/Command.java b/src/main/java/com/github/coleb1911/ghost2/commands/meta/Command.java deleted file mode 100644 index ea704dd..0000000 --- a/src/main/java/com/github/coleb1911/ghost2/commands/meta/Command.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.github.coleb1911.ghost2.commands.meta; - -import discord4j.core.object.util.PermissionSet; - -public abstract class Command { - public static final String REPLY_INSUFFICIENT_PERMISSIONS = "You don't have permission to run that command."; - - private String name; - private CommandType type; - private String[] aliases; - - protected Command(String name, CommandType type, String... aliases) { - this.name = name; - this.type = type; - this.aliases = aliases; - } - - public String getName() { - return name; - } - - public CommandType getType() { - return type; - } - - public String[] getAliases() { - return aliases; - } - - public abstract void invoke(CommandContext ctx); - - public abstract PermissionSet getRequiredPermissions(); - - @Override - public boolean equals(Object other) { - return other instanceof Command && hashCode() == other.hashCode(); - } - - @Override - public int hashCode() { - return name.hashCode(); - } -} \ No newline at end of file diff --git a/src/main/java/com/github/coleb1911/ghost2/commands/meta/CommandContext.java b/src/main/java/com/github/coleb1911/ghost2/commands/meta/CommandContext.java index 08bd053..ad901ed 100644 --- a/src/main/java/com/github/coleb1911/ghost2/commands/meta/CommandContext.java +++ b/src/main/java/com/github/coleb1911/ghost2/commands/meta/CommandContext.java @@ -11,12 +11,13 @@ import java.util.List; /** - * POJO class containing relevant information for when a command is invoked + * Contains relevant information for when a command is invoked. */ public class CommandContext { private final Guild guild; private final MessageChannel channel; private final Member invoker; + private final Member self; private final Message message; private final List args; private final String trigger; @@ -25,6 +26,7 @@ public CommandContext(MessageCreateEvent event) { this.guild = event.getGuild().block(); this.channel = event.getMessage().getChannel().block(); this.invoker = event.getMember().orElse(null); + this.self = event.getClient().getSelf().block().asMember(guild.getId()).block(); this.message = event.getMessage(); this.args = extractArgs(message); this.trigger = args.remove(0); @@ -42,6 +44,10 @@ public Member getInvoker() { return invoker; } + public Member getSelf() { + return self; + } + public Message getMessage() { return message; } @@ -58,6 +64,10 @@ public void reply(String message) { channel.createMessage(message).subscribe(); } + public void replyBlocking(String message) { + channel.createMessage(message).block(); + } + private List extractArgs(Message message) { String content = message.getContent().orElse(""); // Arrays.asList returns an immutable list implementation, so we need to wrap it in an actual ArrayList diff --git a/src/main/java/com/github/coleb1911/ghost2/commands/meta/CommandPackage.java b/src/main/java/com/github/coleb1911/ghost2/commands/meta/CommandPackage.java new file mode 100644 index 0000000..dabee95 --- /dev/null +++ b/src/main/java/com/github/coleb1911/ghost2/commands/meta/CommandPackage.java @@ -0,0 +1,17 @@ +package com.github.coleb1911.ghost2.commands.meta; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Provides the {@link CommandType} for all {@link Module}s in a package. + *
+ * This annotation is used by {@link ModuleInfo.Builder} to automatically fill in the type metadata. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.PACKAGE) +public @interface CommandPackage { + CommandType value(); +} diff --git a/src/main/java/com/github/coleb1911/ghost2/commands/meta/CommandType.java b/src/main/java/com/github/coleb1911/ghost2/commands/meta/CommandType.java index 9046ce2..3c721af 100644 --- a/src/main/java/com/github/coleb1911/ghost2/commands/meta/CommandType.java +++ b/src/main/java/com/github/coleb1911/ghost2/commands/meta/CommandType.java @@ -1,13 +1,23 @@ package com.github.coleb1911.ghost2.commands.meta; public enum CommandType { - OPERATOR, - CONFIGURATION, - FUN, - INFORMATION, - MODERATION, - MUSIC, - UTILITY; + OPERATOR("\u26D4"), + CONFIG("\u2699"), + FUN("\uD83C\uDF89"), + INFO("\u2139"), + MODERATION("\uD83D\uDEE1"), + MUSIC("\uD83C\uDFB5"), + UTILITY("\uD83D\uDD27"); + + private String icon; + + CommandType(String icon) { + this.icon = icon; + } + + public String getIcon() { + return icon; + } public String getFormattedName() { return name().charAt(0) + name().substring(1).toLowerCase(); diff --git a/src/main/java/com/github/coleb1911/ghost2/commands/meta/Module.java b/src/main/java/com/github/coleb1911/ghost2/commands/meta/Module.java new file mode 100644 index 0000000..1ee9c32 --- /dev/null +++ b/src/main/java/com/github/coleb1911/ghost2/commands/meta/Module.java @@ -0,0 +1,43 @@ +package com.github.coleb1911.ghost2.commands.meta; + +/** + * A single command module. + *

+ * To implement a command, simply extend {@code Module} and provide at least the following: + *

    + *
  • A {@code public} constructor
  • + *
  • {@linkplain ModuleInfo.Builder#withName Command name}
  • + *
  • {@linkplain ModuleInfo.Builder#withDescription Command description}
  • + *
  • {@link Module#invoke invoke} implementation
  • + *
+ * {@link com.github.coleb1911.ghost2.commands.modules.utility.ModulePing ModulePing} provides a good bare-minimum example of a working Module. + */ +public abstract class Module { + public static final String REPLY_INSUFFICIENT_PERMISSIONS_USER = "You don't have permission to run that command."; + public static final String REPLY_INSUFFICIENT_PERMISSIONS_BOT = "I don't have sufficient permissions to run that command."; + public static final String REPLY_COMMAND_INVALID = "That command doesn't exist. See \'g!help\' for a list of valid commands and their arguments."; + public static final String REPLY_ARGUMENT_INVALID = "Invalid argument. See \'g!help\' for a list of valid commands and their arguments."; + public static final String REPLY_GENERAL_ERROR = "Whoops! An error occurred somewhere along the line. My operator has been notified."; + + private final ModuleInfo info; + + protected Module(ModuleInfo.Builder info) { + this.info = info.build(); + } + + public ModuleInfo getInfo() { + return info; + } + + public abstract void invoke(CommandContext ctx); + + @Override + public boolean equals(Object other) { + return other instanceof Module && hashCode() == other.hashCode(); + } + + @Override + public int hashCode() { + return info.getName().hashCode(); + } +} \ No newline at end of file diff --git a/src/main/java/com/github/coleb1911/ghost2/commands/meta/ModuleInfo.java b/src/main/java/com/github/coleb1911/ghost2/commands/meta/ModuleInfo.java new file mode 100644 index 0000000..53eb095 --- /dev/null +++ b/src/main/java/com/github/coleb1911/ghost2/commands/meta/ModuleInfo.java @@ -0,0 +1,151 @@ +package com.github.coleb1911.ghost2.commands.meta; + +import discord4j.core.object.util.PermissionSet; + +/** + * Contains metadata for a command module; everything needed to invoke it and/or display help for it + */ +public class ModuleInfo { + private final String name; + private final String description; + private final PermissionSet botPermissions; + private final PermissionSet userPermissions; + private final CommandType type; + private final String[] aliases; + + private ModuleInfo(String name, String description, PermissionSet botPermissions, PermissionSet userPermissions, CommandType type, String[] aliases) { + this.name = name; + this.description = description; + this.botPermissions = botPermissions; + this.userPermissions = userPermissions; + this.type = type; + this.aliases = aliases; + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + public PermissionSet getBotPermissions() { + return botPermissions; + } + + public PermissionSet getUserPermissions() { + return userPermissions; + } + + public CommandType getType() { + return type; + } + + public String[] getAliases() { + return aliases; + } + + public static class Builder { + private static final String ERROR_INVALID = "Invalid CommandInfo object; must provide at least name and description."; + + private String name; + private String description; + private PermissionSet botPermissions; + private PermissionSet userPermissions; + private CommandType type; + private String[] aliases; + + /** + * Constructs a new CommandInfo builder. + *
+ * This builder should only be utilized by a {@link Module} to provide its own metadata. + * + * @param self Actual class of the Module calling the constructor. Used to get {@link CommandType} from the Module's package. + */ + public Builder(Class self) { + name = ""; + description = ""; + botPermissions = PermissionSet.none(); + userPermissions = PermissionSet.none(); + type = self.getPackage().getAnnotation(CommandPackage.class).value(); + aliases = new String[0]; + } + + /** + * Sets the command name to the given value. + * + * @param name Command name + * @return this Builder + */ + public Builder withName(String name) { + this.name = name; + return this; + } + + /** + * Sets the command description to the given value. + * + * @param description Command description + * @return this Builder + */ + public Builder withDescription(String description) { + this.description = description; + return this; + } + + /** + * Sets the required bot permissions to the given PermissionSet. + * + * @param botPermissions Required bot permissions + * @return this Builder + */ + public Builder withBotPermissions(PermissionSet botPermissions) { + this.botPermissions = botPermissions; + return this; + } + + /** + * Sets the required user permissions to the given PermissionSet. + * + * @param userPermissions Required user permissions + * @return this Builder + */ + public Builder withUserPermissions(PermissionSet userPermissions) { + this.userPermissions = userPermissions; + return this; + } + + /** + * Sets the command aliases to the given values. + * + * @param aliases Command aliases + * @return this Builder + */ + public Builder withAliases(String[] aliases) { + this.aliases = aliases; + return this; + } + + /** + * Builds the {@code CommandInfo}. + * + * @return A {@code CommandInfo} object built from the parameters provided to this Builder + */ + ModuleInfo build() { + if (!checkValid()) throw new IllegalStateException(ERROR_INVALID); + return new ModuleInfo(name, description, botPermissions, userPermissions, type, aliases); + } + + /** + * Ensures all the necessary parameters in this Builder are populated + * + * @return Whether or not all the necessary parameters in this Builder are populated + */ + private boolean checkValid() { + return !name.isEmpty() && + !description.isEmpty() && + type != null; + } + } +} \ No newline at end of file diff --git a/src/main/java/com/github/coleb1911/ghost2/commands/modules/CommandPing.java b/src/main/java/com/github/coleb1911/ghost2/commands/modules/CommandPing.java deleted file mode 100644 index 9fe8561..0000000 --- a/src/main/java/com/github/coleb1911/ghost2/commands/modules/CommandPing.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.github.coleb1911.ghost2.commands.modules; - -import com.github.coleb1911.ghost2.commands.meta.Command; -import com.github.coleb1911.ghost2.commands.meta.CommandContext; -import com.github.coleb1911.ghost2.commands.meta.CommandType; -import discord4j.core.object.util.PermissionSet; - -public class CommandPing extends Command { - public CommandPing() { - super("ping", CommandType.UTILITY); - } - - @Override - public void invoke(CommandContext ctx) { - ctx.reply("Pong!"); - } - - @Override - public PermissionSet getRequiredPermissions() { - return PermissionSet.none(); - } -} diff --git a/src/main/java/com/github/coleb1911/ghost2/commands/modules/CommandShutdown.java b/src/main/java/com/github/coleb1911/ghost2/commands/modules/CommandShutdown.java deleted file mode 100644 index 54d20b8..0000000 --- a/src/main/java/com/github/coleb1911/ghost2/commands/modules/CommandShutdown.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.github.coleb1911.ghost2.commands.modules; - -import com.github.coleb1911.ghost2.Ghost2Application; -import com.github.coleb1911.ghost2.commands.meta.Command; -import com.github.coleb1911.ghost2.commands.meta.CommandContext; -import com.github.coleb1911.ghost2.commands.meta.CommandType; -import discord4j.core.object.util.PermissionSet; - -public class CommandShutdown extends Command { - public CommandShutdown() { - super("shutdown", CommandType.OPERATOR); - } - - @Override - public void invoke(CommandContext ctx) { - ctx.reply("Bye!"); - Ghost2Application.exit(0); - } - - @Override - public PermissionSet getRequiredPermissions() { - return PermissionSet.none(); - } -} diff --git a/src/main/java/com/github/coleb1911/ghost2/commands/modules/config/package-info.java b/src/main/java/com/github/coleb1911/ghost2/commands/modules/config/package-info.java new file mode 100644 index 0000000..d22ac25 --- /dev/null +++ b/src/main/java/com/github/coleb1911/ghost2/commands/modules/config/package-info.java @@ -0,0 +1,5 @@ +@CommandPackage(CommandType.CONFIG) +package com.github.coleb1911.ghost2.commands.modules.config; + +import com.github.coleb1911.ghost2.commands.meta.CommandPackage; +import com.github.coleb1911.ghost2.commands.meta.CommandType; \ No newline at end of file diff --git a/src/main/java/com/github/coleb1911/ghost2/commands/modules/fun/package-info.java b/src/main/java/com/github/coleb1911/ghost2/commands/modules/fun/package-info.java new file mode 100644 index 0000000..e0a82e8 --- /dev/null +++ b/src/main/java/com/github/coleb1911/ghost2/commands/modules/fun/package-info.java @@ -0,0 +1,5 @@ +@CommandPackage(CommandType.FUN) +package com.github.coleb1911.ghost2.commands.modules.fun; + +import com.github.coleb1911.ghost2.commands.meta.CommandPackage; +import com.github.coleb1911.ghost2.commands.meta.CommandType; \ No newline at end of file diff --git a/src/main/java/com/github/coleb1911/ghost2/commands/modules/info/ModuleHelp.java b/src/main/java/com/github/coleb1911/ghost2/commands/modules/info/ModuleHelp.java new file mode 100644 index 0000000..730947a --- /dev/null +++ b/src/main/java/com/github/coleb1911/ghost2/commands/modules/info/ModuleHelp.java @@ -0,0 +1,91 @@ +package com.github.coleb1911.ghost2.commands.modules.info; + +import com.github.coleb1911.ghost2.Ghost2Application; +import com.github.coleb1911.ghost2.commands.CommandRegistry; +import com.github.coleb1911.ghost2.commands.meta.CommandContext; +import com.github.coleb1911.ghost2.commands.meta.CommandType; +import com.github.coleb1911.ghost2.commands.meta.Module; +import com.github.coleb1911.ghost2.commands.meta.ModuleInfo; +import discord4j.core.DiscordClient; +import discord4j.core.object.entity.User; + +import java.time.Instant; +import java.util.*; + +// TODO: Add a text-only help list if the bot is unable to send embeds +// Apparently there's a way to disable embeds through permissions, but I can't find the related permission. +// I'd like to just get all of this new code pushed out instead of wasting my time digging around in +// Discord's awful API documentation for 2 hours just to fix one module. +public class ModuleHelp extends Module { + public ModuleHelp() { + super(new ModuleInfo.Builder(ModuleHelp.class) + .withName("help") + .withDescription("List commands or get help with a specific command")); + } + + @Override + public void invoke(CommandContext ctx) { + DiscordClient client = Ghost2Application.getApplicationInstance().getClient(); + User self = client.getSelf().block(); + assert self != null; + + // Single-command help + if (ctx.getArgs().size() > 0) { + // Fetch & null-check CommandInfo + ModuleInfo info = CommandRegistry.getInfo(ctx.getArgs().get(0)); + if (null == info) { + ctx.reply(Module.REPLY_COMMAND_INVALID); + return; + } + + // Build and send embed + ctx.getChannel().createMessage(messageSpec -> messageSpec.setEmbed(embedSpec -> { + String aliasList; + if (info.getAliases().length == 0) { + aliasList = "n/a"; + } else { + StringJoiner joiner = new StringJoiner(", "); + for (String alias : info.getAliases()) + joiner.add(alias); + aliasList = joiner.toString(); + } + + embedSpec.setTitle("Command help"); + embedSpec.addField("Name", info.getName(), false); + embedSpec.addField("Description", info.getDescription(), false); + embedSpec.addField("Aliases", aliasList, false); + embedSpec.addField("Category", info.getType().getFormattedName(), false); + })).block(); + // Full command list + } else { + // Categorize all available modules + Map> modules = new LinkedHashMap<>(); + for (CommandType type : CommandType.values()) modules.put(type, new ArrayList<>()); + for (ModuleInfo info : CommandRegistry.getAllInfo()) modules.get(info.getType()).add(info); + + // Build and send embed + ctx.getChannel().createMessage(messageSpec -> messageSpec.setEmbed(embedSpec -> { + embedSpec.setTitle("Help"); + embedSpec.setAuthor(self.getUsername(), null, self.getAvatarUrl()); + embedSpec.setFooter("See g!help for help with specific commands", null); + embedSpec.setTimestamp(Instant.now()); + + for (Map.Entry> module : modules.entrySet()) { + String commandList; + if (module.getValue().isEmpty()) { + commandList = "No commands (...yet)"; + } else { + StringJoiner joiner = new StringJoiner(", "); + for (ModuleInfo info : module.getValue()) + joiner.add(String.format("`%s`", info.getName())); + commandList = joiner.toString(); + } + + CommandType type = module.getKey(); + embedSpec.addField(type.getIcon() + " " + type.getFormattedName(), commandList, false); + } + })).block(); + } + } + +} diff --git a/src/main/java/com/github/coleb1911/ghost2/commands/modules/info/package-info.java b/src/main/java/com/github/coleb1911/ghost2/commands/modules/info/package-info.java new file mode 100644 index 0000000..9b61f60 --- /dev/null +++ b/src/main/java/com/github/coleb1911/ghost2/commands/modules/info/package-info.java @@ -0,0 +1,5 @@ +@CommandPackage(CommandType.INFO) +package com.github.coleb1911.ghost2.commands.modules.info; + +import com.github.coleb1911.ghost2.commands.meta.CommandPackage; +import com.github.coleb1911.ghost2.commands.meta.CommandType; \ No newline at end of file diff --git a/src/main/java/com/github/coleb1911/ghost2/commands/modules/moderation/ModulePurge.java b/src/main/java/com/github/coleb1911/ghost2/commands/modules/moderation/ModulePurge.java new file mode 100644 index 0000000..718cf0d --- /dev/null +++ b/src/main/java/com/github/coleb1911/ghost2/commands/modules/moderation/ModulePurge.java @@ -0,0 +1,38 @@ +package com.github.coleb1911.ghost2.commands.modules.moderation; + +import com.github.coleb1911.ghost2.commands.meta.CommandContext; +import com.github.coleb1911.ghost2.commands.meta.Module; +import com.github.coleb1911.ghost2.commands.meta.ModuleInfo; +import discord4j.core.object.util.Permission; +import discord4j.core.object.util.PermissionSet; + +public class ModulePurge extends Module { + public ModulePurge() { + super(new ModuleInfo.Builder(ModulePurge.class) + .withName("purge") + .withDescription("Clear messages from a channel") + .withBotPermissions(PermissionSet.of(Permission.MANAGE_MESSAGES))); + } + + @Override + public void invoke(CommandContext ctx) { + ctx.getMessage().delete().subscribe(); + + // Try to parse number argument, default to 10 + int count; + try { + count = Integer.parseInt(ctx.getArgs().get(0)); + } catch (NumberFormatException e) { + ctx.reply(Module.REPLY_ARGUMENT_INVALID); + return; + } catch (IndexOutOfBoundsException e) { + count = 10; + } + + ctx.getChannel().getMessagesBefore(ctx.getMessage().getId()) + .take(count) + .map(message -> message.delete().subscribe()) + .retry(5L) + .blockLast(); + } +} \ No newline at end of file diff --git a/src/main/java/com/github/coleb1911/ghost2/commands/modules/moderation/package-info.java b/src/main/java/com/github/coleb1911/ghost2/commands/modules/moderation/package-info.java new file mode 100644 index 0000000..b1358a5 --- /dev/null +++ b/src/main/java/com/github/coleb1911/ghost2/commands/modules/moderation/package-info.java @@ -0,0 +1,5 @@ +@CommandPackage(CommandType.MODERATION) +package com.github.coleb1911.ghost2.commands.modules.moderation; + +import com.github.coleb1911.ghost2.commands.meta.CommandPackage; +import com.github.coleb1911.ghost2.commands.meta.CommandType; \ No newline at end of file diff --git a/src/main/java/com/github/coleb1911/ghost2/commands/modules/music/package-info.java b/src/main/java/com/github/coleb1911/ghost2/commands/modules/music/package-info.java new file mode 100644 index 0000000..3f05f0b --- /dev/null +++ b/src/main/java/com/github/coleb1911/ghost2/commands/modules/music/package-info.java @@ -0,0 +1,5 @@ +@CommandPackage(CommandType.MUSIC) +package com.github.coleb1911.ghost2.commands.modules.music; + +import com.github.coleb1911.ghost2.commands.meta.CommandPackage; +import com.github.coleb1911.ghost2.commands.meta.CommandType; \ No newline at end of file diff --git a/src/main/java/com/github/coleb1911/ghost2/commands/modules/operator/ModuleClaimOperator.java b/src/main/java/com/github/coleb1911/ghost2/commands/modules/operator/ModuleClaimOperator.java new file mode 100644 index 0000000..d6eb185 --- /dev/null +++ b/src/main/java/com/github/coleb1911/ghost2/commands/modules/operator/ModuleClaimOperator.java @@ -0,0 +1,91 @@ +package com.github.coleb1911.ghost2.commands.modules.operator; + +import com.github.coleb1911.ghost2.Ghost2Application; +import com.github.coleb1911.ghost2.GhostConfig; +import com.github.coleb1911.ghost2.commands.meta.CommandContext; +import com.github.coleb1911.ghost2.commands.meta.Module; +import com.github.coleb1911.ghost2.commands.meta.ModuleInfo; +import discord4j.core.event.domain.message.MessageCreateEvent; +import org.pmw.tinylog.Logger; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.time.Duration; +import java.time.temporal.ChronoUnit; +import java.util.Objects; +import java.util.Random; + +public class ModuleClaimOperator extends Module { + private static final String REPLY_PROMPT = "A key has been generated and logged to the console. Paste it in chat to claim operator. (30s timeout)"; + private static final String REPLY_VALID = "Key valid. Hello, guardian."; + private static final String REPLY_TIMEOUT = "Operator claim timed out."; + + private static Random rng; + + static { + try { + rng = SecureRandom.getInstanceStrong(); + } catch (NoSuchAlgorithmException e) { + Logger.error(e); + } + } + + public ModuleClaimOperator() { + super(new ModuleInfo.Builder(ModuleClaimOperator.class) + .withName("claimoperator") + .withDescription("Claim operator for this bot instance")); + } + + @Override + public void invoke(CommandContext ctx) { + // Generate key & fetch app instance + String key = generateRandomString(); + Ghost2Application app = Ghost2Application.getApplicationInstance(); + + // Log key and prompt user for it + ctx.replyBlocking(REPLY_PROMPT); + Logger.info("Your key: " + key); + + // Listen for & validate key + // If valid, the new operator's ID gets saved to ghost.properties and the config values are updated + app.getClient().getEventDispatcher().on(MessageCreateEvent.class) + .filter(event -> event.getMember().isPresent()) + .filter(event -> event.getMember().get().getId().equals(ctx.getInvoker().getId())) + .filter(event -> event.getMessage().getChannelId().equals(ctx.getChannel().getId())) + .take(1) + .doOnNext(event -> { + if (event.getMessage().getContent().orElse("").equals(key)) { + ctx.reply(REPLY_VALID); + GhostConfig cfg = Ghost2Application.getApplicationInstance().getConfig(); + cfg.setProperty("ghost.operatorid", event.getMember().get().getId().asString()); + URI cfgUri; + try { + cfgUri = Objects.requireNonNull(Ghost2Application.getApplicationInstance().getClass().getClassLoader().getResource("ghost.properties")).toURI(); + try (FileOutputStream f = new FileOutputStream(new File(cfgUri), false)) { + cfg.store(f, "ghost2 properties"); + f.flush(); + } + } catch (IOException | URISyntaxException e) { + Logger.error(e); + } + } + }) + .timeout(Duration.of(30L, ChronoUnit.SECONDS), s -> ctx.reply(REPLY_TIMEOUT)) + .blockFirst(); + app.reloadConfig(); + } + + private String generateRandomString() { + StringBuilder ret = new StringBuilder(); + String[] validChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890".split(""); + while (ret.length() < 15) { + ret.append(validChars[rng.nextInt(validChars.length)]); + } + return ret.toString(); + } +} diff --git a/src/main/java/com/github/coleb1911/ghost2/commands/modules/operator/ModuleShutdown.java b/src/main/java/com/github/coleb1911/ghost2/commands/modules/operator/ModuleShutdown.java new file mode 100644 index 0000000..c716d2e --- /dev/null +++ b/src/main/java/com/github/coleb1911/ghost2/commands/modules/operator/ModuleShutdown.java @@ -0,0 +1,20 @@ +package com.github.coleb1911.ghost2.commands.modules.operator; + +import com.github.coleb1911.ghost2.Ghost2Application; +import com.github.coleb1911.ghost2.commands.meta.CommandContext; +import com.github.coleb1911.ghost2.commands.meta.Module; +import com.github.coleb1911.ghost2.commands.meta.ModuleInfo; + +public class ModuleShutdown extends Module { + public ModuleShutdown() { + super(new ModuleInfo.Builder(ModuleShutdown.class) + .withName("shutdown") + .withDescription("Shut down the bot")); + } + + @Override + public void invoke(CommandContext ctx) { + ctx.reply("Bye!"); + Ghost2Application.getApplicationInstance().exit(0); + } +} diff --git a/src/main/java/com/github/coleb1911/ghost2/commands/modules/operator/package-info.java b/src/main/java/com/github/coleb1911/ghost2/commands/modules/operator/package-info.java new file mode 100644 index 0000000..de67c81 --- /dev/null +++ b/src/main/java/com/github/coleb1911/ghost2/commands/modules/operator/package-info.java @@ -0,0 +1,5 @@ +@CommandPackage(CommandType.OPERATOR) +package com.github.coleb1911.ghost2.commands.modules.operator; + +import com.github.coleb1911.ghost2.commands.meta.CommandPackage; +import com.github.coleb1911.ghost2.commands.meta.CommandType; \ No newline at end of file diff --git a/src/main/java/com/github/coleb1911/ghost2/commands/modules/utility/ModulePing.java b/src/main/java/com/github/coleb1911/ghost2/commands/modules/utility/ModulePing.java new file mode 100644 index 0000000..3d3ea82 --- /dev/null +++ b/src/main/java/com/github/coleb1911/ghost2/commands/modules/utility/ModulePing.java @@ -0,0 +1,18 @@ +package com.github.coleb1911.ghost2.commands.modules.utility; + +import com.github.coleb1911.ghost2.commands.meta.CommandContext; +import com.github.coleb1911.ghost2.commands.meta.Module; +import com.github.coleb1911.ghost2.commands.meta.ModuleInfo; + +public class ModulePing extends Module { + public ModulePing() { + super(new ModuleInfo.Builder(ModulePing.class) + .withName("ping") + .withDescription("Check for bot responsiveness")); + } + + @Override + public void invoke(CommandContext ctx) { + ctx.reply("Pong!"); + } +} \ No newline at end of file diff --git a/src/main/java/com/github/coleb1911/ghost2/commands/modules/utility/package-info.java b/src/main/java/com/github/coleb1911/ghost2/commands/modules/utility/package-info.java new file mode 100644 index 0000000..1c03f6f --- /dev/null +++ b/src/main/java/com/github/coleb1911/ghost2/commands/modules/utility/package-info.java @@ -0,0 +1,5 @@ +@CommandPackage(CommandType.UTILITY) +package com.github.coleb1911.ghost2.commands.modules.utility; + +import com.github.coleb1911.ghost2.commands.meta.CommandPackage; +import com.github.coleb1911.ghost2.commands.meta.CommandType; \ No newline at end of file diff --git a/src/main/java/com/github/coleb1911/ghost2/database/entities/GuildMeta.java b/src/main/java/com/github/coleb1911/ghost2/database/entities/GuildMeta.java index 929b9e8..38a2377 100644 --- a/src/main/java/com/github/coleb1911/ghost2/database/entities/GuildMeta.java +++ b/src/main/java/com/github/coleb1911/ghost2/database/entities/GuildMeta.java @@ -13,11 +13,13 @@ public class GuildMeta { public static final String DEFAULT_PREFIX = "g!"; @Id - private long id; + @Column(name = "ID", unique = true, nullable = false) + private Long id; @Column(name = "PREFIX", nullable = false) private String prefix = DEFAULT_PREFIX; + // Hibernate requires a default constructor; fields are set with the setters instead of constructor public GuildMeta() { } @@ -26,11 +28,11 @@ public GuildMeta(long id, String prefix) { this.prefix = prefix; } - public long getId() { + public Long getId() { return id; } - public void setId(long id) { + public void setId(Long id) { this.id = id; } diff --git a/src/main/java/com/github/coleb1911/ghost2/database/repos/GuildMetaRepository.java b/src/main/java/com/github/coleb1911/ghost2/database/repos/GuildMetaRepository.java index c5ac195..b668734 100644 --- a/src/main/java/com/github/coleb1911/ghost2/database/repos/GuildMetaRepository.java +++ b/src/main/java/com/github/coleb1911/ghost2/database/repos/GuildMetaRepository.java @@ -11,8 +11,7 @@ @Repository public interface GuildMetaRepository extends CrudRepository { @Override - @SuppressWarnings("unchecked") - GuildMeta save(GuildMeta guild); + S save(S entity); @Override @Cacheable(value = "guilds", key = "#p0") diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java new file mode 100644 index 0000000..afab406 --- /dev/null +++ b/src/main/java/module-info.java @@ -0,0 +1,16 @@ +module ghost2.main { + exports com.github.coleb1911.ghost2; + requires java.persistence; + requires spring.boot; + requires spring.boot.autoconfigure; + requires spring.beans; + requires spring.data.commons; + requires spring.context; + requires discord4j.core; + requires discord4j.rest; + requires reactor.core; + requires org.reactivestreams; + requires tinylog; + requires owner; + requires reflections; +} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 2e201e0..3edcb9b 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,4 +1,5 @@ # Do not touch. This file defines crucial settings for Spring/Hibernate DDL. +# suppress inspection "UnusedProperty" for whole file spring.jpa.database-platform=org.hibernate.dialect.H2Dialect spring.jpa.hibernate.ddl-auto=update spring.datasource.driverClassName=org.h2.Driver