From f4172751ea8fec1ff18f084607cf0d57c23410d5 Mon Sep 17 00:00:00 2001 From: Andrew Lalis Date: Fri, 5 Nov 2021 19:56:24 +0100 Subject: [PATCH 1/2] Added first warning implementation and option choices support. --- .../java/net/javadiscord/javabot2/Bot.java | 31 +++++- .../javabot2/command/ResponseException.java | 10 +- .../javabot2/command/Responses.java | 4 + .../command/SlashCommandListener.java | 31 +++--- .../javabot2/command/data/CommandConfig.java | 13 --- .../command/data/OptionChoiceConfig.java | 18 ++++ .../javabot2/command/data/OptionConfig.java | 20 ++-- .../command/data/SubCommandConfig.java | 11 --- .../command/data/SubCommandGroupConfig.java | 11 --- .../javabot2/config/SystemsConfig.java | 2 +- .../systems/moderation/WarnCommand.java | 99 +++++++++++++++++++ src/main/resources/commands/moderation.yaml | 28 ++++++ 12 files changed, 209 insertions(+), 69 deletions(-) create mode 100644 src/main/java/net/javadiscord/javabot2/command/data/OptionChoiceConfig.java create mode 100644 src/main/java/net/javadiscord/javabot2/systems/moderation/WarnCommand.java diff --git a/src/main/java/net/javadiscord/javabot2/Bot.java b/src/main/java/net/javadiscord/javabot2/Bot.java index 3297e88..1cb4f82 100644 --- a/src/main/java/net/javadiscord/javabot2/Bot.java +++ b/src/main/java/net/javadiscord/javabot2/Bot.java @@ -2,14 +2,22 @@ import com.mongodb.MongoClient; import com.mongodb.MongoClientURI; +import com.mongodb.client.MongoDatabase; +import com.mongodb.client.model.IndexModel; +import com.mongodb.client.model.IndexOptions; +import com.mongodb.client.model.Indexes; import com.zaxxer.hikari.HikariConfig; import com.zaxxer.hikari.HikariDataSource; import net.javadiscord.javabot2.command.SlashCommandListener; import net.javadiscord.javabot2.config.BotConfig; +import org.bson.BsonString; +import org.bson.Document; import org.javacord.api.DiscordApi; import org.javacord.api.DiscordApiBuilder; +import org.javacord.api.entity.intent.Intent; import java.nio.file.Path; +import java.util.List; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; @@ -25,7 +33,12 @@ public class Bot { /** * A thread-safe MongoDB client that can be used to interact with MongoDB. */ - public static MongoClient mongo; + public static MongoClient mongoClient; + + /** + * The single Mongo database where all bot data is stored. + */ + public static MongoDatabase mongoDb; /** * The bot's configuration. @@ -47,11 +60,15 @@ private Bot() {} public static void main(String[] args) { initDataSources(); asyncPool = Executors.newScheduledThreadPool(config.getSystems().getAsyncPoolSize()); - DiscordApi api = new DiscordApiBuilder().setToken(config.getSystems().getDiscordBotToken()).login().join(); + DiscordApi api = new DiscordApiBuilder() + .setToken(config.getSystems().getDiscordBotToken()) + .setAllIntentsExcept(Intent.GUILD_MESSAGE_TYPING, Intent.GUILD_PRESENCES, Intent.GUILD_VOICE_STATES) + .login().join(); config.loadGuilds(api.getServers()); // Once we've logged in, load all guild config files. config.flush(); // Flush to save any new config files that are generated for new guilds. SlashCommandListener commandListener = new SlashCommandListener( api, + args.length > 0 && args[0].equalsIgnoreCase("--register-commands"), "commands/moderation.yaml" ); api.addSlashCommandCreateListener(commandListener); @@ -75,6 +92,14 @@ private static void initDataSources() { hikariConfig.setMaximumPoolSize(hikariConfigSource.getMaximumPoolSize()); hikariConfig.setConnectionInitSql(hikariConfigSource.getConnectionInitSql()); hikariDataSource = new HikariDataSource(hikariConfig); - mongo = new MongoClient(new MongoClientURI(config.getSystems().getMongoDatabaseUrl())); + mongoDb = initMongoDatabase(); + } + + private static MongoDatabase initMongoDatabase() { + mongoClient = new MongoClient(new MongoClientURI(config.getSystems().getMongoDatabaseUrl())); + var db = mongoClient.getDatabase("javabot"); + var warnCollection = db.getCollection("warn"); + warnCollection.createIndex(Indexes.ascending("userId"), new IndexOptions().unique(false)); + return db; } } diff --git a/src/main/java/net/javadiscord/javabot2/command/ResponseException.java b/src/main/java/net/javadiscord/javabot2/command/ResponseException.java index 9b685a7..e2953b8 100644 --- a/src/main/java/net/javadiscord/javabot2/command/ResponseException.java +++ b/src/main/java/net/javadiscord/javabot2/command/ResponseException.java @@ -1,7 +1,6 @@ package net.javadiscord.javabot2.command; import lombok.Getter; -import org.javacord.api.interaction.callback.InteractionImmediateResponseBuilder; import java.util.function.Supplier; @@ -16,13 +15,14 @@ public class ResponseException extends Exception { * of an exception. */ @Getter - private final InteractionImmediateResponseBuilder responseBuilder; + private final Responses.ResponseBuilder responseBuilder; /** * Constructs the exception. * @param responseBuilder The response builder to use. */ - public ResponseException(InteractionImmediateResponseBuilder responseBuilder) { + public ResponseException(Responses.ResponseBuilder responseBuilder) { + super(responseBuilder.getMessage()); this.responseBuilder = responseBuilder; } @@ -33,7 +33,7 @@ public ResponseException(InteractionImmediateResponseBuilder responseBuilder) { * @return The exception supplier. */ public static Supplier warning(String message) { - return () -> new ResponseException(Responses.deferredWarningBuilder().message(message).build()); + return () -> new ResponseException(Responses.deferredWarningBuilder().message(message)); } /** @@ -43,6 +43,6 @@ public static Supplier warning(String message) { * @return The exception supplier. */ public static Supplier error(String message) { - return () -> new ResponseException(Responses.deferredErrorBuilder().message(message).build()); + return () -> new ResponseException(Responses.deferredErrorBuilder().message(message)); } } diff --git a/src/main/java/net/javadiscord/javabot2/command/Responses.java b/src/main/java/net/javadiscord/javabot2/command/Responses.java index 07c71e9..8d6d460 100644 --- a/src/main/java/net/javadiscord/javabot2/command/Responses.java +++ b/src/main/java/net/javadiscord/javabot2/command/Responses.java @@ -134,6 +134,10 @@ public ResponseBuilder message(String message) { return this; } + public String getMessage() { + return this.message; + } + /** * Makes this response publicly visible, i.e. not ephemeral. * @return The response builder. diff --git a/src/main/java/net/javadiscord/javabot2/command/SlashCommandListener.java b/src/main/java/net/javadiscord/javabot2/command/SlashCommandListener.java index 82588fc..1226cf9 100644 --- a/src/main/java/net/javadiscord/javabot2/command/SlashCommandListener.java +++ b/src/main/java/net/javadiscord/javabot2/command/SlashCommandListener.java @@ -21,30 +21,39 @@ @Slf4j public final class SlashCommandListener implements SlashCommandCreateListener { /** - * The set of all slash command handlers, mapped by their ids. + * The set of all slash command handlers, mapped by their names. */ - private final Map commandHandlers = new HashMap<>(); + private final Map commandHandlers; /** * Constructs a new slash command listener using the given Discord api, and * loads commands from configuration YAML files according to the list of * resources. * @param api The Discord api to use. + * @param sendUpdate Set to true if we should update the slash commands in + * the API, or false if we should just assume it's done. * @param resources The list of classpath resources to load commands from. */ - public SlashCommandListener(DiscordApi api, String... resources) { - registerSlashCommands(api, resources) - .thenAcceptAsync(commandHandlers::putAll); + public SlashCommandListener(DiscordApi api, boolean sendUpdate, String... resources) { + if (sendUpdate) { + this.commandHandlers = new HashMap<>(); + registerSlashCommands(api, resources) + .thenAcceptAsync(commandHandlers::putAll) + .thenRun(() -> log.info("Registered all slash commands.")); + } else { + this.commandHandlers = initializeHandlers(CommandDataLoader.load(resources)); + log.info("Registered all slash commands."); + } } @Override public void onSlashCommandCreate(SlashCommandCreateEvent event) { - var handler = commandHandlers.get(event.getSlashCommandInteraction().getCommandId()); + var handler = commandHandlers.get(event.getSlashCommandInteraction().getCommandName()); if (handler != null) { try { handler.handle(event.getSlashCommandInteraction()).respond(); } catch (ResponseException e) { - e.getResponseBuilder().respond(); + e.getResponseBuilder().respond(event.getSlashCommandInteraction()); } } else { Responses.warningBuilder(event) @@ -54,7 +63,7 @@ public void onSlashCommandCreate(SlashCommandCreateEvent event) { } } - private CompletableFuture> registerSlashCommands(DiscordApi api, String... resources) { + private CompletableFuture> registerSlashCommands(DiscordApi api, String... resources) { var commandConfigs = CommandDataLoader.load(resources); var handlers = initializeHandlers(commandConfigs); List commandBuilders = Arrays.stream(commandConfigs) @@ -62,17 +71,13 @@ private CompletableFuture> registerSlashCommands( return deleteAllSlashCommands(api) .thenComposeAsync(unused -> api.bulkOverwriteGlobalSlashCommands(commandBuilders)) .thenComposeAsync(slashCommands -> { - Map handlersById = new HashMap<>(); Map nameToId = new HashMap<>(); for (var slashCommand : slashCommands) { - var handler = handlers.get(slashCommand.getName()); - handlersById.put(slashCommand.getId(), handler); nameToId.put(slashCommand.getName(), slashCommand.getId()); } - log.info("Registered all slash commands."); return updatePermissions(api, commandConfigs, nameToId) .thenRun(() -> log.info("Updated permissions for all slash commands.")) - .thenApplyAsync(unused -> handlersById); + .thenApplyAsync(unused -> handlers); }); } diff --git a/src/main/java/net/javadiscord/javabot2/command/data/CommandConfig.java b/src/main/java/net/javadiscord/javabot2/command/data/CommandConfig.java index a8a2a2e..30ff8bd 100644 --- a/src/main/java/net/javadiscord/javabot2/command/data/CommandConfig.java +++ b/src/main/java/net/javadiscord/javabot2/command/data/CommandConfig.java @@ -3,7 +3,6 @@ import lombok.Data; import org.javacord.api.interaction.SlashCommandBuilder; -import java.util.Arrays; import java.util.Objects; /** @@ -47,18 +46,6 @@ public SlashCommandBuilder toData() { return builder; } - @Override - public String toString() { - return "CommandConfig{" + - "name='" + name + '\'' + - ", description='" + description + '\'' + - ", options=" + Arrays.toString(options) + - ", subCommands=" + Arrays.toString(subCommands) + - ", subCommandGroups=" + Arrays.toString(subCommandGroups) + - ", handler=" + handler + - '}'; - } - @Override public boolean equals(Object o) { if (this == o) return true; diff --git a/src/main/java/net/javadiscord/javabot2/command/data/OptionChoiceConfig.java b/src/main/java/net/javadiscord/javabot2/command/data/OptionChoiceConfig.java new file mode 100644 index 0000000..b918ebe --- /dev/null +++ b/src/main/java/net/javadiscord/javabot2/command/data/OptionChoiceConfig.java @@ -0,0 +1,18 @@ +package net.javadiscord.javabot2.command.data; + +import lombok.Data; +import org.javacord.api.interaction.SlashCommandOptionChoice; +import org.javacord.api.interaction.SlashCommandOptionChoiceBuilder; + +@Data +public class OptionChoiceConfig { + private String name; + private String value; + + public SlashCommandOptionChoice toData() { + return new SlashCommandOptionChoiceBuilder() + .setName(name) + .setValue(value) + .build(); + } +} diff --git a/src/main/java/net/javadiscord/javabot2/command/data/OptionConfig.java b/src/main/java/net/javadiscord/javabot2/command/data/OptionConfig.java index ff0ba0a..37268ce 100644 --- a/src/main/java/net/javadiscord/javabot2/command/data/OptionConfig.java +++ b/src/main/java/net/javadiscord/javabot2/command/data/OptionConfig.java @@ -2,9 +2,10 @@ import lombok.Data; import org.javacord.api.interaction.SlashCommandOptionBuilder; -import org.javacord.api.interaction.SlashCommandOptionChoiceBuilder; import org.javacord.api.interaction.SlashCommandOptionType; +import java.util.Arrays; + /** * Simple DTO representing an option that can be given to a Discord slash * command or subcommand. @@ -15,26 +16,21 @@ public class OptionConfig { private String description; private String type; private boolean required; + private OptionChoiceConfig[] choices; /** * Converts this config data into data that's ready for the Discord API. * @return The prepared data. */ public SlashCommandOptionBuilder toData() { - return new SlashCommandOptionBuilder() + var builder = new SlashCommandOptionBuilder() .setType(SlashCommandOptionType.valueOf(this.type.toUpperCase())) .setName(this.name) .setDescription(this.description) .setRequired(this.required); - } - - @Override - public String toString() { - return "OptionConfig{" + - "name='" + name + '\'' + - ", description='" + description + '\'' + - ", type='" + type + '\'' + - ", required=" + required + - '}'; + if (this.choices != null && this.choices.length > 0) { + builder.setChoices(Arrays.stream(this.choices).map(OptionChoiceConfig::toData).toList()); + } + return builder; } } diff --git a/src/main/java/net/javadiscord/javabot2/command/data/SubCommandConfig.java b/src/main/java/net/javadiscord/javabot2/command/data/SubCommandConfig.java index 142dd36..c2f099b 100644 --- a/src/main/java/net/javadiscord/javabot2/command/data/SubCommandConfig.java +++ b/src/main/java/net/javadiscord/javabot2/command/data/SubCommandConfig.java @@ -4,8 +4,6 @@ import org.javacord.api.interaction.SlashCommandOptionBuilder; import org.javacord.api.interaction.SlashCommandOptionType; -import java.util.Arrays; - /** * Simple DTO for a Discord subcommand. */ @@ -31,13 +29,4 @@ public SlashCommandOptionBuilder toData() { } return builder; } - - @Override - public String toString() { - return "SubCommandConfig{" + - "name='" + name + '\'' + - ", description='" + description + '\'' + - ", options=" + Arrays.toString(options) + - '}'; - } } diff --git a/src/main/java/net/javadiscord/javabot2/command/data/SubCommandGroupConfig.java b/src/main/java/net/javadiscord/javabot2/command/data/SubCommandGroupConfig.java index ec5a479..986a647 100644 --- a/src/main/java/net/javadiscord/javabot2/command/data/SubCommandGroupConfig.java +++ b/src/main/java/net/javadiscord/javabot2/command/data/SubCommandGroupConfig.java @@ -4,8 +4,6 @@ import org.javacord.api.interaction.SlashCommandOptionBuilder; import org.javacord.api.interaction.SlashCommandOptionType; -import java.util.Arrays; - /** * Simple DTO for a group of Discord subcommands. */ @@ -31,13 +29,4 @@ public SlashCommandOptionBuilder toData() { } return builder; } - - @Override - public String toString() { - return "SubCommandGroupConfig{" + - "name='" + name + '\'' + - ", description='" + description + '\'' + - ", subCommands=" + Arrays.toString(subCommands) + - '}'; - } } diff --git a/src/main/java/net/javadiscord/javabot2/config/SystemsConfig.java b/src/main/java/net/javadiscord/javabot2/config/SystemsConfig.java index 46b1c0d..710014c 100644 --- a/src/main/java/net/javadiscord/javabot2/config/SystemsConfig.java +++ b/src/main/java/net/javadiscord/javabot2/config/SystemsConfig.java @@ -18,7 +18,7 @@ public class SystemsConfig { /** * The URL used to log in to the MongoDB instance which this bot uses. */ - private String mongoDatabaseUrl = "mongodb://root:example@localhost:27171/javabot"; + private String mongoDatabaseUrl = "mongodb://root:example@localhost:27171"; /** * The number of threads to allocate to the bot's general purpose async diff --git a/src/main/java/net/javadiscord/javabot2/systems/moderation/WarnCommand.java b/src/main/java/net/javadiscord/javabot2/systems/moderation/WarnCommand.java new file mode 100644 index 0000000..7ba2ebe --- /dev/null +++ b/src/main/java/net/javadiscord/javabot2/systems/moderation/WarnCommand.java @@ -0,0 +1,99 @@ +package net.javadiscord.javabot2.systems.moderation; + +import com.mongodb.client.model.Filters; +import net.javadiscord.javabot2.Bot; +import net.javadiscord.javabot2.command.ResponseException; +import net.javadiscord.javabot2.command.Responses; +import net.javadiscord.javabot2.command.SlashCommandHandler; +import org.bson.Document; +import org.javacord.api.entity.message.embed.EmbedBuilder; +import org.javacord.api.entity.server.Server; +import org.javacord.api.entity.user.User; +import org.javacord.api.interaction.SlashCommandInteraction; +import org.javacord.api.interaction.callback.InteractionImmediateResponseBuilder; + +import java.awt.*; +import java.time.Instant; +import java.util.Map; + +public class WarnCommand implements SlashCommandHandler { + public static final int MAX_SEVERITY = 100; + + private enum Severity { + LOW(10), MEDIUM(20), HIGH(40); + + private final int weight; + + Severity(int weight) { + this.weight = weight; + } + + public int getWeight() { + return this.weight; + } + } + + private static record WarnData(User user, long timestamp, Severity severity, String reason, User warnedBy, int totalSeverity, Server server){} + + @Override + public InteractionImmediateResponseBuilder handle(SlashCommandInteraction interaction) throws ResponseException { + var server = interaction.getServer().orElseThrow(ResponseException.warning("This command can only be used in a server.")); + var user = interaction.getOptionUserValueByName("user") + .orElseThrow(ResponseException.warning("Missing required user.")); + var severityString = interaction.getOptionStringValueByName("severity") + .orElseThrow(ResponseException.warning("Missing required severity.")); + var severity = Severity.valueOf(severityString.trim().toUpperCase()); + var reason = interaction.getOptionStringValueByName("reason") + .orElseThrow(ResponseException.warning("Missing required reason.")); + var warns = Bot.mongoDb.getCollection("warn"); + long timestamp = System.currentTimeMillis(); + warns.insertOne(new Document(Map.of( + "userId", user.getId(), + "severity", severity.name(), + "reason", reason, + "warnedBy", interaction.getUser().getId(), + "createdAt", timestamp + ))); + int totalSeverity = 0; + for (var doc : warns.find(Filters.eq("userId", user.getId()))) { + totalSeverity += Severity.valueOf(doc.getString("severity")).getWeight(); + } + int finalTotalSeverity = totalSeverity; + if (finalTotalSeverity > MAX_SEVERITY) { + return banUser(interaction, user, server); + } else { + return warnUser(interaction, new WarnData(user, timestamp, severity, reason, interaction.getUser(), finalTotalSeverity, server)); + } + } + + private InteractionImmediateResponseBuilder warnUser(SlashCommandInteraction interaction, WarnData warn) { + EmbedBuilder embed = buildWarnEmbed(warn); + Bot.asyncPool.submit(() -> { + warn.user().openPrivateChannel().thenAcceptAsync(privateChannel -> privateChannel.sendMessage(embed)); + interaction.getChannel().orElseThrow().sendMessage(embed); + }); + return Responses.successBuilder(interaction) + .title("User Warned") + .message(String.format("User %s has been warned.", warn.user().getMentionTag())) + .build(); + } + + private EmbedBuilder buildWarnEmbed(WarnData warn) { + return new EmbedBuilder() + .setColor(Color.ORANGE) + .setTitle(String.format("%s | Warn (%d/%d)", warn.user().getDisplayName(warn.server()), warn.totalSeverity(), MAX_SEVERITY)) + .addField("Reason", warn.reason()) + .setTimestamp(Instant.ofEpochMilli(warn.timestamp())) + .addField("Severity", warn.severity().name()) + .setFooter(warn.warnedBy().getDisplayName(warn.server())); + } + + private InteractionImmediateResponseBuilder banUser(SlashCommandInteraction interaction, User user, Server server) { + user.openPrivateChannel().thenAcceptAsync(privateChannel -> privateChannel.sendMessage("You have been banned after receiving too many warnings.")); + server.banUser(user, 0, "Too many warnings."); + return Responses.successBuilder(interaction) + .title("User Banned") + .message(String.format("User %s was banned after receiving too many warnings.", user.getDisplayName(server))) + .build(); + } +} diff --git a/src/main/resources/commands/moderation.yaml b/src/main/resources/commands/moderation.yaml index 1920227..10c9e48 100644 --- a/src/main/resources/commands/moderation.yaml +++ b/src/main/resources/commands/moderation.yaml @@ -14,3 +14,31 @@ description: The user whose messages to remove. If left blank, messages from any user are removed. type: USER required: false + +- name: warn + description: Sends a warning to a user, and increases their warn severity rating. + handler: net.javadiscord.javabot2.systems.moderation.WarnCommand + enabledByDefault: false + privileges: + - type: ROLE + id: moderation.staffRoleId + options: + - name: user + description: The user to warn. + type: USER + required: true + - name: severity + description: How severe was the offense? + type: STRING + required: true + choices: + - name: Low + value: "LOW" + - name: Medium + value: "MEDIUM" + - name: High + value: "HIGH" + - name: reason + description: The reason for this user's warning. + type: STRING + required: true From 5b0ddcefab496cf49c45a4cdd5990c731d1a25c4 Mon Sep 17 00:00:00 2001 From: Andrew Lalis Date: Fri, 5 Nov 2021 19:59:11 +0100 Subject: [PATCH 2/2] Fixed checkstyle issues. --- .../command/data/OptionChoiceConfig.java | 7 ++++ .../systems/moderation/WarnCommand.java | 38 +++++++++++-------- 2 files changed, 29 insertions(+), 16 deletions(-) diff --git a/src/main/java/net/javadiscord/javabot2/command/data/OptionChoiceConfig.java b/src/main/java/net/javadiscord/javabot2/command/data/OptionChoiceConfig.java index b918ebe..fa8f30d 100644 --- a/src/main/java/net/javadiscord/javabot2/command/data/OptionChoiceConfig.java +++ b/src/main/java/net/javadiscord/javabot2/command/data/OptionChoiceConfig.java @@ -4,11 +4,18 @@ import org.javacord.api.interaction.SlashCommandOptionChoice; import org.javacord.api.interaction.SlashCommandOptionChoiceBuilder; +/** + * DTO for a choice that a slash command option can have. + */ @Data public class OptionChoiceConfig { private String name; private String value; + /** + * Converts this choice data into a Javacord object for use with the API. + * @return The Javacord option choice object. + */ public SlashCommandOptionChoice toData() { return new SlashCommandOptionChoiceBuilder() .setName(name) diff --git a/src/main/java/net/javadiscord/javabot2/systems/moderation/WarnCommand.java b/src/main/java/net/javadiscord/javabot2/systems/moderation/WarnCommand.java index 7ba2ebe..932b909 100644 --- a/src/main/java/net/javadiscord/javabot2/systems/moderation/WarnCommand.java +++ b/src/main/java/net/javadiscord/javabot2/systems/moderation/WarnCommand.java @@ -16,25 +16,15 @@ import java.time.Instant; import java.util.Map; +/** + * Command that warns a user, which is used by moderators to enforce rules. + */ public class WarnCommand implements SlashCommandHandler { + /** + * The maximum severity that a user can reach, after which they are banned. + */ public static final int MAX_SEVERITY = 100; - private enum Severity { - LOW(10), MEDIUM(20), HIGH(40); - - private final int weight; - - Severity(int weight) { - this.weight = weight; - } - - public int getWeight() { - return this.weight; - } - } - - private static record WarnData(User user, long timestamp, Severity severity, String reason, User warnedBy, int totalSeverity, Server server){} - @Override public InteractionImmediateResponseBuilder handle(SlashCommandInteraction interaction) throws ResponseException { var server = interaction.getServer().orElseThrow(ResponseException.warning("This command can only be used in a server.")); @@ -96,4 +86,20 @@ private InteractionImmediateResponseBuilder banUser(SlashCommandInteraction inte .message(String.format("User %s was banned after receiving too many warnings.", user.getDisplayName(server))) .build(); } + + private enum Severity { + LOW(10), MEDIUM(20), HIGH(40); + + private final int weight; + + Severity(int weight) { + this.weight = weight; + } + + public int getWeight() { + return this.weight; + } + } + + private static record WarnData(User user, long timestamp, Severity severity, String reason, User warnedBy, int totalSeverity, Server server){} }