Improve argument parsing in MessageCommandContext

Revised argument parsing to handle edge cases like text concatenation, invalid numeric input, and mention handling more robustly. Introduced utility methods for validating argument input and resolving mentions to improve readability and maintainability. Added tests (`UsageValidatorTest`) to verify proper execution of subcommand and argument validation.
This commit is contained in:
anthorob committed 2026-09-26 10:47:21 -04:00
1 parent ffc15e8044
commit 35fc415d91
4 files changed
+1558 -1159

No files matched your search

@@ -10,6 +10,7 @@ import me.bobert.netneutrality.core.command.options.MessageCommandOption;
import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.core.context.MessageContext;
import me.bobert.netneutrality.utils.SettingsProvider;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.entities.Mentions;
import net.dv8tion.jda.api.entities.Message;
@@ -22,11 +23,20 @@ import net.dv8tion.jda.api.interactions.commands.OptionType;
import net.dv8tion.jda.api.interactions.commands.build.OptionData;
import java.util.ArrayList;
import java.util.EnumSet;
import java.util.List;
import java.util.Set;
import java.util.StringJoiner;
import java.util.regex.Pattern;
public class MessageCommandContext extends CommandContext implements MessageContext {
private static final Pattern INTEGER_PATTERN = Pattern.compile("[-+]?\\d+");
private static final Pattern NUMBER_PATTERN = Pattern.compile("[-+]?\\d+(\\.\\d+)?");
private static final Pattern MENTION_PATTERN = Pattern.compile("<(@[!&]?|#)\\d+>");
private static final Set<OptionType> MENTION_TYPES =
EnumSet.of(OptionType.USER, OptionType.ROLE, OptionType.MENTIONABLE, OptionType.CHANNEL);
private static final OptionData LEFTOVER_ARGUMENTS = new OptionData(OptionType.STRING, "arguments", "arguments");
private final MessageReceivedEvent event;
private final Message message;
@@ -61,7 +71,6 @@ public class MessageCommandContext extends CommandContext implements MessageCont
@Override
public List<CommandOption> parseOptions(String[] args, List<OptionData> commandOptions) {
List<CommandOption> options = new ArrayList<>();
int required = commandOptions.stream().filter(OptionData::isRequired).toList().size();
Mentions mentions = message.getMentions();
int roleMentionIndex = 0;
int userMentionIndex = 0;
@@ -69,239 +78,251 @@ public class MessageCommandContext extends CommandContext implements MessageCont
int channelMentionIndex = 0;
int index = 0;
for (OptionData data : commandOptions) {
for (int i = 0; i < commandOptions.size(); i++) {
OptionData data = commandOptions.get(i);
OptionType type = data.getType();
Set<ChannelType> requiredChannelTypes = data.getChannelTypes();
List<Command.Choice> choices = data.getChoices();
int position = index + 1;
Object value = null;
try {
switch (type) {
case STRING -> {
int currentOptionIndex = commandOptions.indexOf(data);
boolean nextOptionIsString = false;
if (currentOptionIndex + 1 < commandOptions.size()) {
OptionData nextOption = commandOptions.get(currentOptionIndex + 1);
nextOptionIsString = nextOption.getType() == OptionType.STRING;
if (index >= args.length) {
if (data.isRequired()) {
invalidateContext(data, "missing required argument at index " + position);
}
continue;
}
switch (type) {
case STRING -> {
String str;
if (i + 1 < commandOptions.size() && commandOptions.get(i + 1).getType() == OptionType.STRING) {
// The next option is also a string, so only the current argument belongs to this one
str = args[index++];
} else {
StringJoiner joiner = new StringJoiner(" ");
while (index < args.length && !stopsTextCapture(args[index], commandOptions, i)) {
joiner.add(args[index++]);
}
StringBuilder strBuilder = new StringBuilder();
if (!nextOptionIsString) {
while (index < args.length) {
String nextArg = args[index];
if ((nextArg.contains("<@") || nextArg.contains("<#") || nextArg.contains("<@&")) && nextArg.contains(">")) {
break;
}
if (nextArg.equalsIgnoreCase("true") || nextArg.equalsIgnoreCase("false")) {
break;
}
try {
Long.parseLong(nextArg);
break;
} catch (NumberFormatException ignored) {}
try {
Double.parseDouble(nextArg);
break;
} catch (NumberFormatException ignored) {}
if (!strBuilder.isEmpty()) {
strBuilder.append(" ");
}
strBuilder.append(nextArg);
index++;
}
// Update index to skip the concatenated strings
index = index - 1;
} else {
// Next option is also a string, so just take the current argument
if (index < args.length) {
strBuilder.append(args[index]);
}
}
if (strBuilder.isEmpty()) {
break;
}
String str = strBuilder.toString();
if (!choices.isEmpty()) {
List<Command.Choice> 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;
str = joiner.toString();
}
case INTEGER -> {
long num = Long.parseLong(args[index]);
if (!choices.isEmpty()) {
List<Command.Choice> 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();
if (str.isEmpty()) {
if (data.isRequired()) {
invalidateContext(data, "missing required argument at index " + position);
}
break;
}
case BOOLEAN -> {
String str = args[index];
if (str.equalsIgnoreCase("true") || str.equalsIgnoreCase("false")) {
value = Boolean.parseBoolean(str);
break;
}
if (!choices.isEmpty() && choices.stream().noneMatch(choice -> choice.getAsString().equalsIgnoreCase(str))) {
invalidateContext(data, "not a valid choice, valid choices are: " + validChoices(choices));
break;
}
Integer min = data.getMinLength();
Integer max = data.getMaxLength();
if (min != null && str.length() < min) {
invalidateContext(data, "minimum length for argument is " + min);
break;
}
if (max != null && str.length() > max) {
invalidateContext(data, "maximum length for argument is " + max);
break;
}
value = str;
}
case INTEGER -> {
String str = args[index++];
if (!INTEGER_PATTERN.matcher(str).matches()) {
invalidateContext(data, "the argument provided is not a number.");
break;
}
long num = Long.parseLong(str);
if (!choices.isEmpty() && choices.stream().noneMatch(choice -> choice.getAsLong() == num)) {
invalidateContext(data, "not a valid choice, valid choices are: " + validChoices(choices));
break;
}
Number min = data.getMinValue();
Number max = data.getMaxValue();
if (min != null && num < min.longValue()) {
invalidateContext(data, "minimum value for argument is " + min.longValue());
break;
}
if (max != null && num > max.longValue()) {
invalidateContext(data, "maximum value for argument is " + max.longValue());
break;
}
value = num;
}
case BOOLEAN -> {
String str = args[index++];
if (!str.equalsIgnoreCase("true") && !str.equalsIgnoreCase("false")) {
invalidateContext(data, "expected true or false");
break;
}
case NUMBER -> {
double num = Double.parseDouble(args[index]);
if (!choices.isEmpty()) {
List<Command.Choice> 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();
}
value = Boolean.parseBoolean(str);
}
case NUMBER -> {
String str = args[index++];
if (!NUMBER_PATTERN.matcher(str).matches()) {
invalidateContext(data, "the argument provided is not a number.");
break;
}
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 position " + (index + 1) + " is not a valid role.");
double num = Double.parseDouble(str);
if (!choices.isEmpty() && choices.stream().noneMatch(choice -> choice.getAsDouble() == num)) {
invalidateContext(data, "not a valid choice, valid choices are: " + validChoices(choices));
break;
}
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;
}
Number min = data.getMinValue();
Number max = data.getMaxValue();
invalidateContext(data, "provided argument at position " + (index + 1) + " is not a valid user.");
if (min != null && num < min.doubleValue()) {
invalidateContext(data, "minimum value for argument is " + min.doubleValue());
break;
}
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 at position " + (index + 1) + " is not a valid user or role.");
if (max != null && num > max.doubleValue()) {
invalidateContext(data, "maximum value for argument is " + max.doubleValue());
break;
}
case CHANNEL -> {
if (mentions.getChannels().size() > channelMentionIndex) {
GuildChannel channel = mentions.getChannels().get(channelMentionIndex++);
if (!requiredChannelTypes.isEmpty() && !requiredChannelTypes.contains(channel.getType())) {
invalidateContext(data, "not a valid channel type at index " + (index + 1) +
" , valid types are: " + validTypes(requiredChannelTypes));
break;
}
value = num;
}
case ROLE -> {
index++;
Role role = mentions.getRoles().size() > roleMentionIndex ? mentions.getRoles().get(roleMentionIndex++) : null;
if (role == null || !isFromCurrentGuild(role.getGuild())) {
invalidateContext(data, "provided argument at position " + position + " is not a valid role.");
break;
}
if (channel.getGuild().getId().equals(getGuild().getId())) {
value = channel;
break;
}
}
value = role;
}
case USER -> {
index++;
value = resolveUser(mentions, memberMentionIndex, userMentionIndex);
if (value instanceof Member) {
memberMentionIndex++;
} else if (value != null) {
userMentionIndex++;
}
invalidateContext(data, "provided argument at position " + (index + 1) + " is not a valid channel.");
if (value == null) {
invalidateContext(data, "provided argument at position " + position + " is not a valid user.");
}
}
} catch (IndexOutOfBoundsException e) {
if (options.size() < required) {
invalidateContext(data, "missing required argument at index " + (index + 1));
}
case MENTIONABLE -> {
index++;
if (mentions.getRoles().size() > roleMentionIndex) {
Role role = mentions.getRoles().get(roleMentionIndex++);
if (!isFromCurrentGuild(role.getGuild())) {
invalidateContext(data, "provided argument at position " + position + " is not a valid role.");
break;
}
if (data.isRequired() && index < required) {
// 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);
value = role;
break;
}
value = resolveUser(mentions, memberMentionIndex, userMentionIndex);
if (value instanceof Member) {
memberMentionIndex++;
} else if (value != null) {
userMentionIndex++;
}
if (value == null) {
invalidateContext(data, "provided argument at position " + position + " is not a valid user or role.");
}
}
} catch (NumberFormatException ignored) {
invalidateContext(data, "the argument provided is not a number.");
case CHANNEL -> {
index++;
GuildChannel channel = mentions.getChannels().size() > channelMentionIndex ? mentions.getChannels().get(channelMentionIndex++) : null;
if (channel == null || !isFromCurrentGuild(channel.getGuild())) {
invalidateContext(data, "provided argument at position " + position + " is not a valid channel.");
break;
}
if (!requiredChannelTypes.isEmpty() && !requiredChannelTypes.contains(channel.getType())) {
invalidateContext(data, "not a valid channel type at index " + position +
" , valid types are: " + validTypes(requiredChannelTypes));
break;
}
value = channel;
}
default -> index++;
}
if (value != null) {
options.add(new MessageCommandOption(data, value));
}
index++;
}
// Check if there are leftover arguments that weren't consumed
if (index < args.length) {
if (options.size() >= required) {
invalidateContext(commandOptions.getLast(), "too many arguments");
}
OptionData lastOption = commandOptions.isEmpty() ? LEFTOVER_ARGUMENTS : commandOptions.getLast();
invalidateContext(lastOption, "too many arguments");
}
return options;
}
/**
* A string option greedily consumes the remaining arguments, it has to stop as soon as an argument
* belongs to one of the following options.
*/
private boolean stopsTextCapture(String arg, List<OptionData> commandOptions, int currentOptionIndex) {
if (NUMBER_PATTERN.matcher(arg).matches()) {
return true;
}
List<OptionData> remaining = commandOptions.subList(currentOptionIndex + 1, commandOptions.size());
if (arg.equalsIgnoreCase("true") || arg.equalsIgnoreCase("false")) {
return remaining.stream().anyMatch(option -> option.getType() == OptionType.BOOLEAN);
}
if (MENTION_PATTERN.matcher(arg).matches()) {
return remaining.stream().anyMatch(option -> MENTION_TYPES.contains(option.getType()));
}
return false;
}
private Object resolveUser(Mentions mentions, int memberMentionIndex, int userMentionIndex) {
if (mentions.getMembers().size() > memberMentionIndex) {
Member member = mentions.getMembers().get(memberMentionIndex);
if (isFromCurrentGuild(member.getGuild())) {
return member;
}
}
if (mentions.getUsers().size() > userMentionIndex) {
return mentions.getUsers().get(userMentionIndex);
}
return null;
}
private boolean isFromCurrentGuild(Guild guild) {
return getGuild() != null && guild != null && guild.getId().equals(getGuild().getId());
}
private String validChoices(List<Command.Choice> choices) {
StringJoiner joiner = new StringJoiner(", ");
for (Command.Choice choice : choices) {