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
This commit is contained in:
anthorob committed 2026-08-24 17:03:49 -04:00
1 parent 247f217e2e
commit 15d58bba38
4 files changed
+393 -50

No files matched your search

@@ -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<OptionData> options;
private final List<SubcommandData> subcommands;
private final List<SubcommandGroupData> 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<OptionData> getOptions() {
return options;
}
public List<SubcommandData> getSubcommands() {
return subcommands;
}
public List<SubcommandGroupData> 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();
}
}
@@ -5,18 +5,26 @@ package me.bobert.netneutrality.context;
* https://discord.gg/qUa6zNFZsd * 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.core.context.CommandContext;
import me.bobert.netneutrality.utils.Settings; 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.events.message.MessageReceivedEvent;
import net.dv8tion.jda.api.interactions.InteractionHook; 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.components.LayoutComponent;
import net.dv8tion.jda.api.interactions.modals.Modal; import net.dv8tion.jda.api.interactions.modals.Modal;
import net.dv8tion.jda.api.utils.messages.MessageCreateData; import net.dv8tion.jda.api.utils.messages.MessageCreateData;
import java.util.HashMap; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Set;
import java.util.StringJoiner;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.function.Consumer; import java.util.function.Consumer;
@@ -35,35 +43,9 @@ public class MessageCommandContext extends CommandContext {
return event; return event;
} }
public String getArgument(int index) { @Override
return getArgs().get(index); public ChannelType getType() {
} return event.getChannelType();
public Map<Integer, String> getArgs() {
Map<Integer, String> 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 + ")( )?", "");
} }
public Message getMessage() { public Message getMessage() {
@@ -102,11 +84,6 @@ public class MessageCommandContext extends CommandContext {
} }
@Override
public void replyEphemeral(MessageCreateData messageCreateData) {
sendMessageInPrivate(messageCreateData);
}
@Override @Override
public void ack(boolean ephemeral) { public void ack(boolean ephemeral) {
@@ -114,19 +91,206 @@ public class MessageCommandContext extends CommandContext {
@Override @Override
public void deleteMessage() { public void deleteMessage() {
if (message != null) { if (message != null && getType() != ChannelType.PRIVATE) {
message.delete().queueAfter(1, TimeUnit.SECONDS); message.delete().queueAfter(1, TimeUnit.SECONDS);
} }
} }
public boolean isMessageFromOwner() { @Override
return getGuildUser().isOwner(); public List<CommandOption> parseOptions(String[] args, List<OptionData> commandOptions) {
List<CommandOption> 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<ChannelType> requiredChannelTypes = data.getChannelTypes();
List<Command.Choice> 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<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;
}
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();
}
value = num;
}
case BOOLEAN -> value = Boolean.parseBoolean(args[index]);
case NUMBER -> {
double num = Long.parseLong(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();
}
}
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() { private String validChoices(List<Command.Choice> choices) {
String creatorId = Settings.getInstance().get("NETNEUTRALITY_BOT_OWNER"); 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<ChannelType> types) {
StringJoiner joiner = new StringJoiner(", ");
for (ChannelType type : types) {
joiner.add(type.name());
}
return joiner.toString();
} }
@Override @Override
@@ -145,11 +309,10 @@ public class MessageCommandContext extends CommandContext {
} }
@Override @Override
public String getSubCommand() { public String getFullCommand() {
if (!getArgs().isEmpty()) { String rawMessage = message.getContentRaw().trim();
return getArgument(0); String prefix = Settings.getInstance().getPrefix();
}
return ""; return rawMessage.replaceFirst(String.format("^[%s\\s]{1,}", prefix), "").trim();
} }
} }
@@ -5,11 +5,18 @@ package me.bobert.netneutrality.context;
* https://discord.gg/qUa6zNFZsd * 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.CommandContext;
import me.bobert.netneutrality.core.context.InteractionContext; 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.events.interaction.command.SlashCommandInteractionEvent;
import net.dv8tion.jda.api.interactions.commands.build.OptionData;
import net.dv8tion.jda.api.interactions.modals.Modal; import net.dv8tion.jda.api.interactions.modals.Modal;
import java.util.List;
import java.util.stream.Collectors;
public class SlashCommandContext extends CommandContext implements InteractionContext { public class SlashCommandContext extends CommandContext implements InteractionContext {
private final SlashCommandInteractionEvent event; private final SlashCommandInteractionEvent event;
@@ -23,6 +30,11 @@ public class SlashCommandContext extends CommandContext implements InteractionCo
return event; return event;
} }
@Override
public ChannelType getType() {
return event.getChannelType();
}
@Override @Override
public void deleteMessage() { public void deleteMessage() {
// not implemented // not implemented
@@ -33,13 +45,28 @@ public class SlashCommandContext extends CommandContext implements InteractionCo
event.replyModal(modal).queue(); event.replyModal(modal).queue();
} }
@Override
public List<CommandOption> parseOptions(String[] args, List<OptionData> commandOptions) {
return event.getOptions().stream().map(SlashCommandOption::new).collect(Collectors.toUnmodifiableList());
}
@Override @Override
public String parseCommandName() { public String parseCommandName() {
return event.getName(); return event.getName();
} }
@Override @Override
public String getSubCommand() { public String getFullCommand() {
return event.getFullCommandName().split(" ")[1]; return event.getFullCommandName();
}
@Override
public String getSubcommandGroup() {
return getFullCommand().split(" ")[2];
}
@Override
public String getSubcommand() {
return getFullCommand().split(" ")[1];
} }
} }
@@ -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);
}
}