diff --git a/src/main/java/tk/sciwhiz12/concord/ChatBot.java b/src/main/java/tk/sciwhiz12/concord/ChatBot.java index b3218f5f..81e85a25 100644 --- a/src/main/java/tk/sciwhiz12/concord/ChatBot.java +++ b/src/main/java/tk/sciwhiz12/concord/ChatBot.java @@ -37,6 +37,8 @@ import net.minecraftforge.common.MinecraftForge; import org.slf4j.Marker; import org.slf4j.MarkerFactory; +import tk.sciwhiz12.concord.command.ConcordDiscordCommand; +import tk.sciwhiz12.concord.command.discord.CommandDispatcher; import tk.sciwhiz12.concord.msg.MessageListener; import tk.sciwhiz12.concord.msg.Messaging; import tk.sciwhiz12.concord.msg.PlayerListener; @@ -56,6 +58,7 @@ public class ChatBot extends ListenerAdapter { private final MessageListener msgListener; private final PlayerListener playerListener; private final StatusListener statusListener; + private final CommandDispatcher dispatcher; ChatBot(JDA discord, MinecraftServer server) { this.discord = discord; @@ -65,6 +68,12 @@ public class ChatBot extends ListenerAdapter { playerListener = new PlayerListener(this); statusListener = new StatusListener(this); + // Initialize Discord-side commands + dispatcher = new CommandDispatcher(); + discord.addEventListener(dispatcher); + + ConcordDiscordCommand.initialize(dispatcher); + // Prevent any mentions not explicitly specified MessageAction.setDefaultMentions(Collections.emptySet()); } @@ -77,6 +86,8 @@ public MinecraftServer getServer() { return server; } + public CommandDispatcher getDispatcher() { return dispatcher; } + @Override public void onReady(ReadyEvent event) { discord.getPresence().setPresence(OnlineStatus.ONLINE, Activity.playing("some Minecraft")); diff --git a/src/main/java/tk/sciwhiz12/concord/Concord.java b/src/main/java/tk/sciwhiz12/concord/Concord.java index 54aa31d2..04297576 100644 --- a/src/main/java/tk/sciwhiz12/concord/Concord.java +++ b/src/main/java/tk/sciwhiz12/concord/Concord.java @@ -45,6 +45,7 @@ import tk.sciwhiz12.concord.command.ConcordCommand; import tk.sciwhiz12.concord.command.EmoteCommandHook; import tk.sciwhiz12.concord.command.ReportCommand; +import tk.sciwhiz12.concord.command.ConcordDiscordCommand; import tk.sciwhiz12.concord.command.SayCommandHook; import tk.sciwhiz12.concord.msg.Messaging; import tk.sciwhiz12.concord.util.Messages; @@ -76,7 +77,7 @@ public Concord() { MinecraftForge.EVENT_BUS.addListener(EmoteCommandHook::onRegisterCommands); } - + public void onServerStarting(ServerStartingEvent event) { if (!event.getServer().isDedicatedServer() && !ConcordConfig.ENABLE_INTEGRATED.get()) { LOGGER.info("Discord integration for integrated servers is disabled in server config."); @@ -146,6 +147,7 @@ public static void enable(MinecraftServer server) { try { final JDA jda = jdaBuilder.build(); BOT = new ChatBot(jda, server); + ConcordDiscordCommand.postInit(); } catch (LoginException e) { LOGGER.error("Error while trying to login to Discord; integration will not be enabled.", e); } diff --git a/src/main/java/tk/sciwhiz12/concord/ConcordConfig.java b/src/main/java/tk/sciwhiz12/concord/ConcordConfig.java index 7d93ff2d..aba40d77 100644 --- a/src/main/java/tk/sciwhiz12/concord/ConcordConfig.java +++ b/src/main/java/tk/sciwhiz12/concord/ConcordConfig.java @@ -39,6 +39,7 @@ public class ConcordConfig { public static final ForgeConfigSpec.ConfigValue TOKEN; public static final ForgeConfigSpec.ConfigValue GUILD_ID; + public static final ForgeConfigSpec.ConfigValue MODERATOR_ROLE_ID; public static final ForgeConfigSpec.ConfigValue CHAT_CHANNEL_ID; public static final ForgeConfigSpec.ConfigValue REPORT_CHANNEL_ID; @@ -110,6 +111,10 @@ public static void register() { REPORT_CHANNEL_ID = builder.comment("The snowflake ID of the channel where this bot will post reports from in-game users.", "If empty, reports will be disabled.") .define("report_channel_id", ""); + MODERATOR_ROLE_ID = builder.comment("The snowflake ID of the role that will be treated as a moderator role.", + "This role will be able to use Concord's Moderation slash commands on Discord - /kick, /ban, etc.", + "This should not be treated as an alternative to proper Discord permissions configuration, but exists as a safeguard so that random users may not ban you while you're setting up.") + .define("moderator_role_id", ""); builder.pop(); } diff --git a/src/main/java/tk/sciwhiz12/concord/command/ConcordDiscordCommand.java b/src/main/java/tk/sciwhiz12/concord/command/ConcordDiscordCommand.java new file mode 100644 index 00000000..c9dc10f6 --- /dev/null +++ b/src/main/java/tk/sciwhiz12/concord/command/ConcordDiscordCommand.java @@ -0,0 +1,118 @@ +package tk.sciwhiz12.concord.command; + +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.JDA; +import net.dv8tion.jda.api.events.interaction.SlashCommandEvent; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.util.Mth; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.fml.loading.FMLLoader; +import tk.sciwhiz12.concord.Concord; +import tk.sciwhiz12.concord.command.discord.BanCommand; +import tk.sciwhiz12.concord.command.discord.CommandDispatcher; +import tk.sciwhiz12.concord.command.discord.KickCommand; +import tk.sciwhiz12.concord.command.discord.WhitelistCommand; + +import java.awt.*; +import java.time.Instant; + +/** + * The Discord command hub. + * + * Has several sub-commands, some of which are usable by everyone: + * - list; lists players on the connected server. + * - tps; displays ticks per second in a format similar to /forge tps. + * - help; displays help autogenerated from the registered commands. + * Some commands are only usable by administrators: + * - kick [reason]; remove a user from the server, optionally with the specified reason. See @link{KickCommand}. + * - ban [reason]; ban a user from the server, optionally with the specified reason. See @link{BanCommand} + * - whitelist ; add or remove a user from the server's whitelist. See @link{WhitelistCommand} + * The above commands are implemented separately, due to requirements of the option system. + * - stop; stop and shutdown the connected server. Disabled on singleplayer worlds. + * + * @author Curle + */ +public class ConcordDiscordCommand { + private static JDA bot; + private static MinecraftServer server; + + private static void tpsCommand(SlashCommandEvent tpsEvent) { + double meanTickTime = Mth.average(server.tickTimes) * 1.0E-6D; + double meanTPS = Math.min(1000.0/meanTickTime, 20); + + StringBuilder builder = new StringBuilder(); + + for (ServerLevel dim : server.getAllLevels()) { + long[] times = server.getTickTime(dim.dimension()); + + if (times == null) + times = new long[]{0}; + + double worldTickTime = Mth.average(times) * 1.0E-6D; + double worldTPS = Math.min(1000.0 / worldTickTime, 20); + + builder.append(dim.dimension().location()).append(": Mean tick time: ").append(worldTickTime).append(" ms. Mean TPS: ").append(worldTPS).append("\n"); + } + + tpsEvent.replyEmbeds(new EmbedBuilder() + .setTitle("Concord integrations") + .setDescription("TPS Performance Report") + .addField("Overall performance", "Mean tick time: " + meanTickTime + " ms. Mean TPS: " + meanTPS, false) + .addField("Performance per dimension", builder.toString(), false) + .setColor(Color.ORANGE) + .setTimestamp(Instant.now()) + .build() + ).setEphemeral(true).queue(); + } + + private static void helpCommand(SlashCommandEvent helpEvent) { + var dispatcher = Concord.BOT.getDispatcher(); + var commands = dispatcher.getCommands(); + + var builder = new EmbedBuilder().setTitle("Concord Commands") + .setDescription("There are " + commands.size() + " registered commands."); + + for (var command : commands) { + builder.addField(command.getName(), command.getHelpString(), true); + } + + helpEvent.replyEmbeds(builder.setTimestamp(Instant.now()).setColor(Color.GREEN).build()).setEphemeral(false).queue(); + } + + public static void initialize(CommandDispatcher dispatcher) { + dispatcher.registerSingle("list", "List all online users.", "Show a count of online users, and their names.", (listEvent) -> { + listEvent.replyEmbeds(new EmbedBuilder() + .setTitle("Concord Integrations") + .setDescription("There are currently " + server.getPlayerCount() + " people online.") + .addField("Online Players", String.join("\n", server.getPlayerNames()), false) + .setTimestamp(Instant.now()) + .setColor(Color.CYAN) + .build() + ).setEphemeral(true).queue(); + }); + + dispatcher.registerSingle("tps", "Show the performance of the server.", "Display a breakdown of server performance, in current, average and separated by dimension.", ConcordDiscordCommand::tpsCommand); + dispatcher.registerSingle("help", "Show detailed information about every single available command.", "Show the help information you are currently reading.", ConcordDiscordCommand::helpCommand); + dispatcher.registerSingle("stop", "Shut down your Minecraft server.", "Immediately schedule the shutdown of the Minecraft server, akin to /stop from in-game.", (event) -> { + // Short-circuit if on integrated server + if(FMLLoader.getDist() == Dist.CLIENT) { + event.reply("Sorry! This command is disabled on Integrated servers.").setEphemeral(true).queue(); + return; + } + + event.reply("Shutting the server down..").queue(); + Concord.BOT.getServer().halt(false); + }); + + + dispatcher.registerSingle(KickCommand.INSTANCE); + dispatcher.registerSingle(BanCommand.INSTANCE); + dispatcher.registerSingle(WhitelistCommand.INSTANCE); + } + + public static void postInit() { + bot = Concord.BOT.getDiscord(); + server = Concord.BOT.getServer(); + } +} diff --git a/src/main/java/tk/sciwhiz12/concord/command/discord/BanCommand.java b/src/main/java/tk/sciwhiz12/concord/command/discord/BanCommand.java new file mode 100644 index 00000000..20f04398 --- /dev/null +++ b/src/main/java/tk/sciwhiz12/concord/command/discord/BanCommand.java @@ -0,0 +1,108 @@ +package tk.sciwhiz12.concord.command.discord; + +import net.dv8tion.jda.api.events.interaction.SlashCommandEvent; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; +import net.dv8tion.jda.api.requests.restaction.CommandCreateAction; +import net.minecraft.network.chat.Component; +import net.minecraft.server.players.UserBanListEntry; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.fml.loading.FMLLoader; +import tk.sciwhiz12.concord.Concord; +import tk.sciwhiz12.concord.ConcordConfig; + +import java.util.Date; +import java.util.List; + + +/** + * This command takes the form: + * /ban [reason] + * + * It removes a user from the server and prevents them from joining again (known as a ban, or kickban), + * optionally with the specified reason. + * + * @author Curle + */ +public class BanCommand extends SlashCommand { + private static final OptionData USER_OPTION = new OptionData(OptionType.STRING, "user", "The name of the user to ban from the server", true); + private static final OptionData REASON_OPTION = new OptionData(OptionType.STRING, "reason", "The reason for the user to be banned.", false); + + public static BanCommand INSTANCE = new BanCommand(); + + public BanCommand() { + setName("ban"); + setDescription("Ban a player from your Minecraft server"); + setHelpString("Remove a player from the server, and prevent them from joining again, optionally with a reason. The reason is for moderation purposes, and is not shown to the user."); + } + + @Override + public void execute(SlashCommandEvent event) { + var roleConfig = ConcordConfig.MODERATOR_ROLE_ID.get(); + if (!roleConfig.isEmpty()) { + var role = Concord.BOT.getDiscord().getRoleById(roleConfig); + // If no role, then it's non-empty and invalid; disable the command + if (role == null) { + event.reply("Sorry, but this command is disabled by configuration. Check the moderator_role_id option in the config.").setEphemeral(true).queue(); + return; + } else { + // If the member doesn't have the moderator role, then deny them the ability to use the command. + if (!event.getMember().getRoles().contains(role)) { + event.reply("Sorry, but you don't have permission to use this command.").setEphemeral(true).queue(); + return; + } + // Fall-through; member has the role, so they can use the command. + } + // Fall-through; the role is empty, so all permissions are handled by Discord. + } + + var user = event.getOption(USER_OPTION.getName()).getAsString(); + var server = Concord.BOT.getServer(); + + + // Short-circuit for integrated servers. + if (!ConcordConfig.ENABLE_INTEGRATED.get() && FMLLoader.getDist() == Dist.CLIENT) { + event.reply("Sorry, but this command is disabled on Integrated Servers. Check the enable_integrated option in the Concord Config.").setEphemeral(true).queue(); + return; + } + + var reasonMapping = event.getOption(REASON_OPTION.getName()); + + // The Reason Option is optional, so default to "Reason Not Specified" if it isn't. + var reason = ""; + if (reasonMapping == null) + reason = "Reason Not Specified"; + else + reason = reasonMapping.getAsString(); + + + if (List.of(server.getPlayerNames()).contains(user)) { + var player = server.getPlayerList().getPlayerByName(user); + var profile = player.getGameProfile(); + + // If they're not already banned.. + if (!server.getPlayerList().getBans().isBanned(profile)) { + // Prevent them from rejoining + UserBanListEntry userbanlistentry = new UserBanListEntry(profile, (Date) null, "Discord User " + event.getMember().getEffectiveName(), (Date) null, reason); + server.getPlayerList().getBans().add(userbanlistentry); + // Kick them + player.connection.disconnect( + reasonMapping == null ? + Component.translatable("multiplayer.disconnect.banned") : + Component.literal(reasonMapping.getAsString()) + ); + + event.reply("User " + user + " banned successfully.").queue(); + return; + } + event.reply("The user " + user + " is already banned on this server.").setEphemeral(true).queue(); + return; + } + event.reply("The user " + user + " is not connected to the server.").setEphemeral(true).queue(); + } + + @Override + public CommandCreateAction setup(CommandCreateAction action) { + return action.addOptions(USER_OPTION, REASON_OPTION); + } +} diff --git a/src/main/java/tk/sciwhiz12/concord/command/discord/CommandDispatcher.java b/src/main/java/tk/sciwhiz12/concord/command/discord/CommandDispatcher.java new file mode 100644 index 00000000..b11dcd6e --- /dev/null +++ b/src/main/java/tk/sciwhiz12/concord/command/discord/CommandDispatcher.java @@ -0,0 +1,169 @@ +package tk.sciwhiz12.concord.command.discord; + +import com.mojang.brigadier.Command; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.events.ReadyEvent; +import net.dv8tion.jda.api.events.interaction.SlashCommandEvent; +import net.dv8tion.jda.api.hooks.ListenerAdapter; +import net.dv8tion.jda.api.requests.restaction.CommandCreateAction; +import org.checkerframework.checker.nullness.qual.Nullable; +import org.jetbrains.annotations.NotNull; +import tk.sciwhiz12.concord.ConcordConfig; + +import java.util.*; +import java.util.function.Consumer; + +/** + * Dispatches slash commands to the appropriate classes. + * Serves as a wrapper around many individual ListenerAdapters. + * + * Register your commands to this dispatcher with CommandDispatcher.register(...). + * If the command is not already, it will be automatically upserted to all valid guilds. + * You may set .testGuild(...) if you wish to only upsert to a single guild, but this is global. + * + * When the command is invoked, the parameter to the register call will be invoked, and passed down the SlashCommandEvent. + * + * Information such as parameters, help info, description and others will be automatically taken from the command parameter. + * + * @author Curle + */ +public class CommandDispatcher extends ListenerAdapter { + + // The list of valid commands, to be upserted and listened for. + private final List commands; + + // The Map that powers the command listener + private final Map commandsByName; + + // Whether this Dispatcher should only listen on a single guild, in a testing configuration. + private boolean testMode; + + // The Guild to upsert commands to, if testMode is enabled + @Nullable + private Guild testGuild; + + /** + * Create a new Command Dispatcher, pre-programmed with its' list of commands. + * This should be the preferred way of creating a dispatcher. + * @param commands the list of commands to listen for. + */ + public CommandDispatcher(SlashCommand... commands) { + this.commands = Arrays.stream(commands).toList(); + + this.commandsByName = new HashMap<>(); + for (SlashCommand command : this.commands) { + this.commandsByName.put(command.getName(), command); + } + + this.testMode = false; + this.testGuild = null; + } + + /** + * Create an empty CommandDispatcher. + * Only useful if you want to primarily add commands via lambda using addSingle. + * Please try not to use this too much. + */ + public CommandDispatcher() { + this.commands = new ArrayList<>(); + this.commandsByName = new HashMap<>(); + this.testMode = false; + this.testGuild = null; + } + + /** + * Get a list of all registered commands. + * Exposed primarily for the help command to be able to read details on all other commands. + */ + public List getCommands() { + return commands; + } + + /** + * A condensed way of adding a command, that doesn't require creating a new class. + * Provide the necessary information along with a Consumer and the rest will be handled for you. + * @param commandName the name of the command to add + * @param commandDescription description of the command to add + * @param help extra information to be shown in the help command + * @param consumer the action to perform when the command is invoked + * @return the modified CommandDispatcher + */ + public CommandDispatcher registerSingle(String commandName, String commandDescription, String help, Consumer consumer) { + var command = new SlashCommand() { + { + setName(commandName); + setDescription(commandDescription); + setHelpString(help); + } + + @Override + public void execute(SlashCommandEvent event) { + consumer.accept(event); + } + + @Override + public CommandCreateAction setup(CommandCreateAction action) { + return action; + } + }; + + this.commands.add(command); + this.commandsByName.put(commandName, command); + + return this; + } + + /** + * A short way to add a single command to the dispatcher. + * This is not the recommended way to do this - use the variadic constructor instead. + * @param command the command to add + * @return the new Dispatcher, for chaining. + */ + public CommandDispatcher registerSingle(SlashCommand command) { + this.commands.add(command); + this.commandsByName.put(command.getName(), command); + return this; + } + + /** + * Set this Dispatcher to a test mode, which will only upsert commands to the specified guild. + * This effectively allows for immediate usage of specified commands, rather than the 1 hour delay in regular mode. + * However, it is limited to a single guild in this mode, so do not use it for production usage. + * @param guild the guild to upsert commands to. + * @return The updated CommandDispatcher, for chaining + */ + public CommandDispatcher testGuild(Guild guild) { + this.testMode = true; + this.testGuild = guild; + + return this; + } + + @Override + public void onReady(@NotNull ReadyEvent event) { + if (!ConcordConfig.GUILD_ID.get().isEmpty()) + this.testGuild(event.getJDA().getGuildById(ConcordConfig.GUILD_ID.get())); + + for (SlashCommand command : this.commands) { + // If in test mode, upsert to the configured Test Guild, otherwise to the whole JDA instance. + var action = this.testMode ? + this.testGuild.upsertCommand(command.getName(), command.getDescription()) : + event.getJDA().upsertCommand(command.getName(), command.getDescription()); + // Let the command set up extra information + command.setup(action); + // Queue the upsert + action.queue(); + } + } + + @Override + public void onSlashCommand(@NotNull SlashCommandEvent event) { + SlashCommand command = this.commandsByName.get(event.getName()); + + // Sanity check + if (command == null) throw new IllegalStateException("Attempted to invoke command " + event.getName() + " but it does not exist"); + + // Dispatch to the registered command + command.execute(event); + } +} diff --git a/src/main/java/tk/sciwhiz12/concord/command/discord/KickCommand.java b/src/main/java/tk/sciwhiz12/concord/command/discord/KickCommand.java new file mode 100644 index 00000000..55983a6f --- /dev/null +++ b/src/main/java/tk/sciwhiz12/concord/command/discord/KickCommand.java @@ -0,0 +1,91 @@ +package tk.sciwhiz12.concord.command.discord; + +import net.dv8tion.jda.api.events.interaction.SlashCommandEvent; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; +import net.dv8tion.jda.api.requests.restaction.CommandCreateAction; +import net.minecraft.network.chat.Component; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.fml.loading.FMLLoader; +import tk.sciwhiz12.concord.Concord; +import tk.sciwhiz12.concord.ConcordConfig; + +import java.util.List; + +/** + * This command takes the form: + * /kick [reason] + * + * It removes a user from the server, optionally with the specified reason. + * + * @author Curle + */ +public class KickCommand extends SlashCommand { + private static final OptionData USER_OPTION = new OptionData(OptionType.STRING, "user", "The username of the Minecraft user to kick from the server", true); + private static final OptionData REASON_OPTION = new OptionData(OptionType.STRING, "reason", "Why the user is being kicked from the server.", false); + + // Static instance. + public static KickCommand INSTANCE = new KickCommand(); + + public KickCommand() { + setName("kick"); + setDescription("Kick a user from your Minecraft server"); + setHelpString("Remove a user from the server, optionally with a reason. The reason will be shown to the user in the disconnection screen."); + } + + @Override + public void execute(SlashCommandEvent event) { + // Check permissions. + var roleConfig = ConcordConfig.MODERATOR_ROLE_ID.get(); + if (!roleConfig.isEmpty()) { + var role = Concord.BOT.getDiscord().getRoleById(roleConfig); + // If no role, then it's non-empty and invalid; disable the command + if (role == null) { + event.reply("Sorry, but this command is disabled by configuration. Check the moderator_role_id option in the config.").setEphemeral(true).queue(); + return; + } else { + // If the member doesn't have the moderator role, then deny them the ability to use the command. + if (!event.getMember().getRoles().contains(role)) { + event.reply("Sorry, but you don't have permission to use this command.").setEphemeral(true).queue(); + return; + } + // Fall-through; member has the role, so they can use the command. + } + // Fall-through; the role is empty, so all permissions are handled by Discord. + } + + var user = event.getOption(USER_OPTION.getName()).getAsString(); + var server = Concord.BOT.getServer(); + + // Short-circuit for integrated servers. + if (!ConcordConfig.ENABLE_INTEGRATED.get() && FMLLoader.getDist() == Dist.CLIENT) { + event.reply("Sorry, but this command is disabled on Integrated Servers. Check the enable_integrated option in the Concord Config.").setEphemeral(true).queue(); + return; + } + + var reasonMapping = event.getOption(REASON_OPTION.getName()); + + // Check whether the user is online + if (List.of(server.getPlayerNames()).contains(user)) { + var player = server.getPlayerList().getPlayerByName(user); + // If they are, kick them with the message. + player.connection.disconnect( + reasonMapping == null ? + Component.translatable("multiplayer.disconnect.kicked") : + Component.literal(reasonMapping.getAsString()) + ); + + // Reply to the user. + event.reply("User " + user + " kicked successfully.").queue(); + return; + } + + // Reply with a failure message. + event.reply("The user " + user + " is not connected to the server.").queue(); + } + + @Override + public CommandCreateAction setup(CommandCreateAction action) { + return action.addOptions(USER_OPTION, REASON_OPTION); + } +} diff --git a/src/main/java/tk/sciwhiz12/concord/command/discord/SlashCommand.java b/src/main/java/tk/sciwhiz12/concord/command/discord/SlashCommand.java new file mode 100644 index 00000000..5bdd166b --- /dev/null +++ b/src/main/java/tk/sciwhiz12/concord/command/discord/SlashCommand.java @@ -0,0 +1,84 @@ +package tk.sciwhiz12.concord.command.discord; + +import net.dv8tion.jda.api.events.interaction.SlashCommandEvent; +import net.dv8tion.jda.api.requests.restaction.CommandCreateAction; + +/** + *

Represents a primary Slash Command.

+ * + * A command has optional additions: + *
    + *
  • Sub-commands
  • + *
  • Options
  • + *
+ * A command also has required information: + *
    + *
  • Name
  • + *
  • Description
  • + *
  • Help information
  • + *
+ * + *

Pass this command to the CommandDispatcher for upsert.

+ * + *

When this command is invoked by a user, all information will be passed to the execute method via the Event parameter.

+ * + * @author Curle + */ +public abstract class SlashCommand { + // The primary command string + private String name; + + // Extra information shown to the user in the command list. + private String description; + + // Information shown in the help command, about what this command does. + private String helpString; + + public void setName(String name) { + this.name = name; + } + + public void setDescription(String description) { + this.description = description; + } + + public void setHelpString(String helpString) { + this.helpString = helpString; + } + + /** + * @return the name of this command + */ + public String getName() { + return name; + } + + /** + * @return extra information about this command + */ + public String getDescription() { + return description; + } + + /** + * @return a detailed description for the help command. + */ + public String getHelpString() { + return helpString; + } + + /** + * Called when a user invokes this command. + * All options and extra information is provided via the event parameter. + * @param event the SlashCommandEvent for extra information about the command invocation + */ + public abstract void execute(SlashCommandEvent event); + + /** + * Add extra things to the command - such as sub-commands and options. + * @param action the action that represents this command + * @return the modified action to submit + */ + public abstract CommandCreateAction setup(CommandCreateAction action); + +} diff --git a/src/main/java/tk/sciwhiz12/concord/command/discord/WhitelistCommand.java b/src/main/java/tk/sciwhiz12/concord/command/discord/WhitelistCommand.java new file mode 100644 index 00000000..ce3b525a --- /dev/null +++ b/src/main/java/tk/sciwhiz12/concord/command/discord/WhitelistCommand.java @@ -0,0 +1,113 @@ +package tk.sciwhiz12.concord.command.discord; + +import com.mojang.authlib.GameProfile; +import net.dv8tion.jda.api.events.interaction.SlashCommandEvent; +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.dv8tion.jda.api.requests.restaction.CommandCreateAction; +import net.minecraft.server.players.UserWhiteListEntry; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.fml.loading.FMLLoader; +import tk.sciwhiz12.concord.Concord; +import tk.sciwhiz12.concord.ConcordConfig; + +import java.util.Optional; + + +/** + * This command takes the form: + * /whitelist + * + * Depending on the second term, it will add or remove the specified user from the server whitelist. + * This command is disabled on integrated servers, even if enable_integrated is specified. + * + * @author Curle + */ +public class WhitelistCommand extends SlashCommand { + private static final OptionData USER_OPTION = new OptionData(OptionType.STRING, "user", "The user to change", true); + private static final SubcommandData ADD_SUBCOMMAND = new SubcommandData("add", "Add a user to the whitelist").addOptions(USER_OPTION); + private static final SubcommandData REMOVE_SUBCOMMAND = new SubcommandData("remove", "Remove a user from the whitelist").addOptions(USER_OPTION); + + public static WhitelistCommand INSTANCE = new WhitelistCommand(); + + public WhitelistCommand() { + setName("whitelist"); + setDescription("Add or remove a player from the server's whitelist."); + setHelpString("Contains two subcommands; add and remove. Each takes a user argument and will add or remove the player from the whitelist respectively."); + } + + @Override + public void execute(SlashCommandEvent event) { + var roleConfig = ConcordConfig.MODERATOR_ROLE_ID.get(); + if (!roleConfig.isEmpty()) { + var role = Concord.BOT.getDiscord().getRoleById(roleConfig); + // If no role, then it's non-empty and invalid; disable the command + if (role == null) { + event.reply("Sorry, but this command is disabled by configuration. Check the moderator_role_id option in the config.").setEphemeral(true).queue(); + return; + } else { + // If the member doesn't have the moderator role, then deny them the ability to use the command. + if (!event.getMember().getRoles().contains(role)) { + event.reply("Sorry, but you don't have permission to use this command.").setEphemeral(true).queue(); + return; + } + // Fall-through; member has the role, so they can use the command. + } + // Fall-through; the role is empty, so all permissions are handled by Discord. + } + + var server = Concord.BOT.getServer(); + + // Short circuit for singleplayer worlds + if (FMLLoader.getDist() == Dist.CLIENT) { + event.reply("Sorry, but this command is disabled on Integrated Servers").setEphemeral(true).queue(); + return; + } + + // Figure out which subcommand we're running + var subcommand = event.getSubcommandName(); + switch (subcommand) { + case "add": + var player = event.getOption(USER_OPTION.getName()).getAsString(); + var whitelist = server.getPlayerList().getWhiteList(); + Optional optional = server.getProfileCache().get(player); + var profile = optional.orElseThrow(); + + if (!whitelist.isWhiteListed(profile)) { + UserWhiteListEntry userwhitelistentry = new UserWhiteListEntry(profile); + whitelist.add(userwhitelistentry); + + event.reply("User " + player + " successfully added to the whitelist.").setEphemeral(true).queue(); + return; + } + + event.reply("User " + player + " is already whitelisted.").setEphemeral(true).queue(); + return; + case "remove": + player = event.getOption(USER_OPTION.getName()).getAsString(); + whitelist = server.getPlayerList().getWhiteList(); + optional = server.getProfileCache().get(player); + profile = optional.orElseThrow(); + + if (whitelist.isWhiteListed(profile)) { + whitelist.remove(profile); + + event.reply("User " + player + " successfully removed from the whitelist.").setEphemeral(true).queue(); + return; + } + + event.reply("User " + player + " is not whitelisted.").setEphemeral(true).queue(); + return; + } + + // No recognized subcommand. Fall through to a safe default. + event.reply("Unrecognized subcommand.").setEphemeral(true).queue(); + + } + + @Override + public CommandCreateAction setup(CommandCreateAction action) { + return action.addSubcommands(ADD_SUBCOMMAND).addSubcommands(REMOVE_SUBCOMMAND); + } +}