From 15d58bba38aace56b01b64e437b3ff58d8584417 Mon Sep 17 00:00:00 2001 From: anthorob Date: Wed, 11 Dec 2024 04:00:55 -0500 Subject: [PATCH] Create abstraction to combine slash commands and message commands Usage and options checking (arguments count, type checking) Options parsing for message commands (both types of commands are now registered like slash commands and the abstraction will handle the parsing of all options as if it was a slash command) Refactor of the help command for better readability and more helpful text, which is now based on the available options of a command --- .../netneutrality/command/CommandOptions.java | 129 +++++++++ .../context/MessageCommandContext.java | 259 ++++++++++++++---- .../context/SlashCommandContext.java | 31 ++- .../builder/CommandOptionsBuilder.java | 24 ++ 4 files changed, 393 insertions(+), 50 deletions(-) create mode 100644 src/main/java/me/bobert/netneutrality/command/CommandOptions.java create mode 100644 src/main/java/me/bobert/netneutrality/core/command/builder/CommandOptionsBuilder.java diff --git a/src/main/java/me/bobert/netneutrality/command/CommandOptions.java b/src/main/java/me/bobert/netneutrality/command/CommandOptions.java new file mode 100644 index 0000000..6eb9a59 --- /dev/null +++ b/src/main/java/me/bobert/netneutrality/command/CommandOptions.java @@ -0,0 +1,129 @@ +package me.bobert.netneutrality.command; +/* + * Copyright (c) anthorob + * + * https://discord.gg/qUa6zNFZsd + */ + +import net.dv8tion.jda.api.interactions.commands.build.OptionData; +import net.dv8tion.jda.api.interactions.commands.build.SlashCommandData; +import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; +import net.dv8tion.jda.api.interactions.commands.build.SubcommandGroupData; +import net.dv8tion.jda.internal.interactions.CommandDataImpl; + +import java.util.List; + +public class CommandOptions { + private final Command command; + private final SlashCommandData commandData; + private final List options; + private final List subcommands; + private final List subcommandGroups; + + public CommandOptions(Command command, CommandDataImpl commandData) { + this.command = command; + this.commandData = commandData; + this.options = commandData.getOptions(); + this.subcommands = commandData.getSubcommands(); + this.subcommandGroups = commandData.getSubcommandGroups(); + } + + public Command getCommand() { + return command; + } + + public SlashCommandData getSlashCommandData() { + return commandData; + } + + public List getOptions() { + return options; + } + + public List getSubcommands() { + return subcommands; + } + + public List getSubcommandGroups() { + return subcommandGroups; + } + + public boolean isEmpty() { + return options.isEmpty() && subcommands.isEmpty() && subcommandGroups.isEmpty(); + } + + public String printUsage() { + //TODO: show available choices if option has them + StringBuilder usage = new StringBuilder(); + if (!options.isEmpty()) { + usage.append("- Usage: /").append(command.getCommandName()); + for (OptionData option : options) { + usage.append(" <").append(option.getName()).append(">"); + } + + for (OptionData option : options) { + usage.append("\n - "); + if (option.isRequired()) { + usage.append("*"); + } + usage.append(option.getName()).append(": ").append(option.getDescription()); + } + } + + if (!subcommands.isEmpty()) { + for (SubcommandData subcommand : subcommands) { + usage.append("- Usage: /").append(command.getCommandName()).append(" ").append(subcommand.getName()); + for (OptionData option : subcommand.getOptions()) { + usage.append(" <").append(option.getName()).append(">"); + } + + for (OptionData option : subcommand.getOptions()) { + usage.append("\n - "); + if (option.isRequired()) { + usage.append("*"); + } + usage.append(option.getName()).append(": ").append(option.getDescription()); + } + + usage.append("\n"); + } + } + + if (!subcommandGroups.isEmpty()) { + for (SubcommandGroupData subcommandGroup : subcommandGroups) { + usage.append("- Subcommand: /").append(command.getCommandName()) + .append(" ").append(subcommandGroup.getName()) + .append(" (Description: ").append(subcommandGroup.getDescription()).append(")") + .append("\n"); + + for (SubcommandData subcommand : subcommandGroup.getSubcommands()) { + usage.append(" - Usage: /").append(command.getCommandName()) + .append(" ").append(subcommandGroup.getName()) + .append(" ").append(subcommand.getName()); + + for (OptionData option : subcommand.getOptions()) { + usage.append(" <").append(option.getName()).append(">"); + } + usage.append(" (Description: ").append(subcommand.getDescription()).append(")"); + + for (OptionData option : subcommand.getOptions()) { + usage.append("\n - "); + if (option.isRequired()) { + usage.append("*"); + } + usage.append(option.getName()).append(": ").append(option.getDescription()); + } + + usage.append("\n"); + + } + } + } + + if (!isEmpty()) { + usage.append("\n\n-# *: required argument"); + } + + return usage.toString(); + } +} diff --git a/src/main/java/me/bobert/netneutrality/context/MessageCommandContext.java b/src/main/java/me/bobert/netneutrality/context/MessageCommandContext.java index f59e823..c4ae060 100644 --- a/src/main/java/me/bobert/netneutrality/context/MessageCommandContext.java +++ b/src/main/java/me/bobert/netneutrality/context/MessageCommandContext.java @@ -5,18 +5,26 @@ package me.bobert.netneutrality.context; * https://discord.gg/qUa6zNFZsd */ +import me.bobert.netneutrality.core.command.options.CommandOption; +import me.bobert.netneutrality.core.command.options.MessageCommandOption; import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.utils.Settings; -import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.entities.*; +import net.dv8tion.jda.api.entities.channel.ChannelType; +import net.dv8tion.jda.api.entities.channel.middleman.GuildChannel; import net.dv8tion.jda.api.events.message.MessageReceivedEvent; import net.dv8tion.jda.api.interactions.InteractionHook; +import net.dv8tion.jda.api.interactions.commands.Command; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; import net.dv8tion.jda.api.interactions.components.LayoutComponent; import net.dv8tion.jda.api.interactions.modals.Modal; import net.dv8tion.jda.api.utils.messages.MessageCreateData; -import java.util.HashMap; +import java.util.ArrayList; import java.util.List; -import java.util.Map; +import java.util.Set; +import java.util.StringJoiner; import java.util.concurrent.TimeUnit; import java.util.function.Consumer; @@ -35,35 +43,9 @@ public class MessageCommandContext extends CommandContext { return event; } - public String getArgument(int index) { - return getArgs().get(index); - } - - public Map getArgs() { - Map args = new HashMap<>(); - - String msg = message.getContentDisplay().trim(); - msg = msg.substring(Settings.getInstance().getPrefix().length()).trim(); - String cmd = msg.split(" ", 2)[0]; - - String[] array = msg.replaceFirst("(" + cmd + ")(\\s+)?", "").split(" "); - for (int i = 0; i < array.length; i++) { - args.put(i, array[i]); - } - - if (args.size() == 1 && args.get(0).isEmpty()) { - args.remove(0); - } - - return args; - } - - public String getMessageText() { - String msg = message.getContentDisplay().trim(); - msg = msg.substring(Settings.getInstance().getPrefix().length()).trim(); - String cmd = msg.split(" ", 2)[0]; - - return msg.replaceFirst("(" + cmd + ")( )?", ""); + @Override + public ChannelType getType() { + return event.getChannelType(); } public Message getMessage() { @@ -102,11 +84,6 @@ public class MessageCommandContext extends CommandContext { } - @Override - public void replyEphemeral(MessageCreateData messageCreateData) { - sendMessageInPrivate(messageCreateData); - } - @Override public void ack(boolean ephemeral) { @@ -114,19 +91,206 @@ public class MessageCommandContext extends CommandContext { @Override public void deleteMessage() { - if (message != null) { + if (message != null && getType() != ChannelType.PRIVATE) { message.delete().queueAfter(1, TimeUnit.SECONDS); } } - public boolean isMessageFromOwner() { - return getGuildUser().isOwner(); + @Override + public List parseOptions(String[] args, List commandOptions) { + List options = new ArrayList<>(); + Mentions mentions = message.getMentions(); + int roleMentionIndex = 0; + int userMentionIndex = 0; + int memberMentionIndex = 0; + int channelMentionIndex = 0; + int index = 0; + + for (OptionData data : commandOptions) { + OptionType type = data.getType(); + Set requiredChannelTypes = data.getChannelTypes(); + List choices = data.getChoices(); + Object value = null; + + try { + switch (type) { + case STRING -> { + //TODO: check length + String str = args[index]; + + if (str.contains("<@") && str.contains(">")) { + invalidateContext(data, "expected a string at index " + index + " but a role/user/channel was provided"); + break; + } + + if (!choices.isEmpty()) { + List matched = choices.stream().filter(choice -> choice.getAsString().equalsIgnoreCase(str)).toList(); + if (matched.isEmpty()) { + invalidateContext(data, "not a valid choice, valid choices are: " + validChoices(choices)); + } + } + + Integer min = data.getMinLength(); + Integer max = data.getMaxLength(); + + if (min != null && str.length() < min) { + invalidateContext(data, "minimum length for argument is " + min); + } + + if (max != null && str.length() > max) { + invalidateContext(data, "maximum length for argument is " + max); + } + + value = str; + } + case INTEGER -> { + long num = Long.parseLong(args[index]); + if (!choices.isEmpty()) { + List matched = choices.stream().filter(choice -> choice.getAsLong() == num).toList(); + if (matched.isEmpty()) { + invalidateContext(data, "not a valid choice, valid choices are: " + validChoices(choices)); + } + } + value = num; + + Number max = data.getMaxValue(); + Number min = data.getMinValue(); + + if (min != null && num < min.longValue()) { + value = min.longValue(); + } else if (max != null && num > max.longValue()) { + value = max.longValue(); + } + + value = num; + } + case BOOLEAN -> value = Boolean.parseBoolean(args[index]); + case NUMBER -> { + double num = Long.parseLong(args[index]); + if (!choices.isEmpty()) { + List matched = choices.stream().filter(choice -> choice.getAsDouble() == num).toList(); + if (matched.isEmpty()) { + invalidateContext(data, "not a valid choice, valid choices are: " + validChoices(choices)); + break; + } + } + + Number max = data.getMaxValue(); + Number min = data.getMinValue(); + value = num; + + if (min != null && num < min.doubleValue()) { + value = min.doubleValue(); + } else if (max != null && num > max.doubleValue()) { + value = max.doubleValue(); + } + } + case ROLE -> { + if (mentions.getRoles().size() > roleMentionIndex) { + Role role = mentions.getRoles().get(roleMentionIndex++); + if (role.getGuild().getId().equals(getGuild().getId())) { + value = role; + } + + break; + } + + invalidateContext(data, "provided argument at index " + roleMentionIndex + " is not a valid role."); + } + case USER -> { + if (mentions.getMembers().size() > memberMentionIndex) { + Member member = mentions.getMembers().get(memberMentionIndex++); + if (member.getGuild().getId().equals(getGuild().getId())) { + value = member; + break; + } + } + + if (mentions.getUsers().size() > userMentionIndex) { + value = mentions.getUsers().get(userMentionIndex++); + break; + } + + invalidateContext(data, "provided argument is not a valid user."); + } + case MENTIONABLE -> { + if (mentions.getRoles().size() > roleMentionIndex) { + Role role = mentions.getRoles().get(roleMentionIndex++); + if (role.getGuild().getId().equals(getGuild().getId())) { + value = role; + } + + break; + } + + if (mentions.getMembers().size() > memberMentionIndex) { + Member member = mentions.getMembers().get(memberMentionIndex++); + if (member.getGuild().getId().equals(getGuild().getId())) { + value = member; + } + + break; + } + + if (mentions.getUsers().size() > userMentionIndex) { + value = mentions.getUsers().get(userMentionIndex++); + break; + } + + invalidateContext(data, "provided argument is not a valid user or role."); + } + case CHANNEL -> { + //TODO: check types + if (mentions.getChannels().size() > channelMentionIndex) { + GuildChannel channel = mentions.getChannels().get(channelMentionIndex++); + + if (!requiredChannelTypes.contains(channel.getType())) { + invalidateContext(data, "not a valid channel type at index " + channelMentionIndex + + " , valid types are: " + validTypes(requiredChannelTypes)); + break; + } + + if (channel.getGuild().getId().equals(getGuild().getId())) { + value = channel; + break; + } + } + + invalidateContext(data, "provided argument is not a valid channel."); + } + } + } catch (IndexOutOfBoundsException e) { + if (data.isRequired()) { + // This should never happen because required options are validated before calling parseOptions() + throw new IllegalArgumentException("Required option " + data.getName() + " was not found at index " + index); + } + } catch (NumberFormatException ignored) { + invalidateContext(data, "the argument provided was not a number."); + } + + options.add(new MessageCommandOption(data, value)); + index++; + } + + return options; } - public boolean isMessageFromCreator() { - String creatorId = Settings.getInstance().get("NETNEUTRALITY_BOT_OWNER"); + private String validChoices(List choices) { + StringJoiner joiner = new StringJoiner(", "); + for (Command.Choice choice : choices) { + joiner.add(choice.getAsString()); + } - return creatorId.equals(getDiscordUser().getId()); + return joiner.toString(); + } + + private String validTypes(Set types) { + StringJoiner joiner = new StringJoiner(", "); + for (ChannelType type : types) { + joiner.add(type.name()); + } + + return joiner.toString(); } @Override @@ -145,11 +309,10 @@ public class MessageCommandContext extends CommandContext { } @Override - public String getSubCommand() { - if (!getArgs().isEmpty()) { - return getArgument(0); - } + public String getFullCommand() { + String rawMessage = message.getContentRaw().trim(); + String prefix = Settings.getInstance().getPrefix(); - return ""; + return rawMessage.replaceFirst(String.format("^[%s\\s]{1,}", prefix), "").trim(); } } diff --git a/src/main/java/me/bobert/netneutrality/context/SlashCommandContext.java b/src/main/java/me/bobert/netneutrality/context/SlashCommandContext.java index 3b1c2db..62fb94e 100644 --- a/src/main/java/me/bobert/netneutrality/context/SlashCommandContext.java +++ b/src/main/java/me/bobert/netneutrality/context/SlashCommandContext.java @@ -5,11 +5,18 @@ package me.bobert.netneutrality.context; * https://discord.gg/qUa6zNFZsd */ +import me.bobert.netneutrality.core.command.options.CommandOption; +import me.bobert.netneutrality.core.command.options.SlashCommandOption; import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.InteractionContext; +import net.dv8tion.jda.api.entities.channel.ChannelType; import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; import net.dv8tion.jda.api.interactions.modals.Modal; +import java.util.List; +import java.util.stream.Collectors; + public class SlashCommandContext extends CommandContext implements InteractionContext { private final SlashCommandInteractionEvent event; @@ -23,6 +30,11 @@ public class SlashCommandContext extends CommandContext implements InteractionCo return event; } + @Override + public ChannelType getType() { + return event.getChannelType(); + } + @Override public void deleteMessage() { // not implemented @@ -33,13 +45,28 @@ public class SlashCommandContext extends CommandContext implements InteractionCo event.replyModal(modal).queue(); } + @Override + public List parseOptions(String[] args, List commandOptions) { + return event.getOptions().stream().map(SlashCommandOption::new).collect(Collectors.toUnmodifiableList()); + } + @Override public String parseCommandName() { return event.getName(); } @Override - public String getSubCommand() { - return event.getFullCommandName().split(" ")[1]; + public String getFullCommand() { + return event.getFullCommandName(); + } + + @Override + public String getSubcommandGroup() { + return getFullCommand().split(" ")[2]; + } + + @Override + public String getSubcommand() { + return getFullCommand().split(" ")[1]; } } diff --git a/src/main/java/me/bobert/netneutrality/core/command/builder/CommandOptionsBuilder.java b/src/main/java/me/bobert/netneutrality/core/command/builder/CommandOptionsBuilder.java new file mode 100644 index 0000000..801b681 --- /dev/null +++ b/src/main/java/me/bobert/netneutrality/core/command/builder/CommandOptionsBuilder.java @@ -0,0 +1,24 @@ +package me.bobert.netneutrality.core.command.builder; +/* + * Copyright (c) anthorob + * + * https://discord.gg/qUa6zNFZsd + */ + +import me.bobert.netneutrality.command.Command; +import me.bobert.netneutrality.command.CommandOptions; +import net.dv8tion.jda.internal.interactions.CommandDataImpl; + +public class CommandOptionsBuilder extends CommandDataImpl { + private final Command command; + + public CommandOptionsBuilder(Command command) { + super(command.getCommandName(), command.getDescription()); + this.command = command; + } + + public CommandOptions build() { + //TODO: add more command types (slash, context menu), constructors are not the same so we can't extend CommandDataImpl + return new CommandOptions(command, this); + } +}