Skip to content

Commit

Permalink
feat!: add automatic message argument parsing
Browse files Browse the repository at this point in the history
* Message arguments are now automatically parsed

* Deprecated AbstractCommand#parseOptions()
  • Loading branch information
Amgelo563 committed Apr 15, 2022
1 parent 2b46f6e commit 2fb1f5c
Show file tree
Hide file tree
Showing 12 changed files with 416 additions and 123 deletions.
2 changes: 1 addition & 1 deletion README.md
Expand Up @@ -43,7 +43,7 @@ All of these features are included on the template. You can modify each of them
### Commands
* Both message and slash command support with only one declaration.
* Subcommand support, registered on Discord as well.
* Add your own argument parsing for message commands, and the one already provided by Discord for interactions.
* Automatic argument parsing on message commands to a [CommandInteractionResolver](https://discord.js.org/#/docs/main/stable/class/CommandInteractionOptionResolver).
* Command aliases (only message commands), command description and command syntax support.
* Multiple prefixes support.
* Command categories support based on folder names.
Expand Down
2 changes: 1 addition & 1 deletion README_es.md
Expand Up @@ -43,7 +43,7 @@ Todas estas características están incluidas en la plantilla. Puedes modificar
### Comandos
* Soporte de comandos de mensajes y slash commands con solo una declaración.
* Soporte de subcomandos, registrados también en Discord.
* Usa tu propio parseo de argumentos en comandos de texto, y el proveído por Discord en slash commands.
* Parseo automático de argumentos en comandos de texto a un [CommandInteractionOptionResolver](https://discord.js.org/#/docs/main/stable/class/CommandInteractionOptionResolver).
* Soporte de aliases de comandos (solo en comandos de mensaje), descripciones de comando y sintaxis de comandos.
* Soporte para múltiples prefixes.
* Soporte automático de categorías de comandos.
Expand Down
241 changes: 241 additions & 0 deletions src/MessageArgumentsParser.ts
@@ -0,0 +1,241 @@
import {
ApplicationCommandOptionData,
ApplicationCommandOptionType, Collection,
CommandInteractionOption, CommandInteractionOptionResolver, CommandInteractionResolvedData, GuildBasedChannel, GuildMember,
Message, Role, Snowflake, TextChannel, User,
} from 'discord.js';

import {
mentionToUser,
mentionToMember,
mentionToChannel,
mentionToRole,
} from './utils/DiscordUtils';
import SubcommandableCommand from './structures/commands/SubcommandableCommand';
import SubCommand from './structures/commands/SubCommand';
import Command from './structures/commands/Command';

// ? An async Array#every (https://advancedweb.hu/how-to-use-async-functions-with-array-some-and-every-in-javascript/)
async function asyncEvery<Type>(arr: Type[], predicate: (input: Type, index: number) => Promise<boolean>) {
for (const e of arr) {
// ? We need to await here on purpose so we don't iterate
// eslint-disable-next-line no-await-in-loop
if (!await predicate(e, arr.indexOf(e))) return false;
}
return true;
}

const truthyValues = ['yes', 'y', '1', 'true', 't'];

export default class MessageArgumentsParser {
/** The message where this command was called, used to extract context like Client and Guild */
private readonly message: Message<true>;

/** The args used to invoke the command */
private readonly args: string[];

/** The original option data, and the expected result at the same time */
private readonly original: ApplicationCommandOptionData[];

/** The name of the command */
private readonly command: typeof Command | typeof SubcommandableCommand;

constructor(
message: Message<true>,
args: string[],
command: typeof Command | typeof SubcommandableCommand,
original: ApplicationCommandOptionData[],
) {
this.message = message;
this.args = args;
this.command = command;
this.original = original;
}

async process(): Promise<CommandInteractionOptionResolver | false> {
const { client } = this.message;
const { guild } = this.message;
let foundSubcommand = false;

const optionsResult: CommandInteractionOption[] = [];
let resolvedResult: CommandInteractionResolvedData;

const success = await asyncEvery<string>(this.args, async (input, index) => {
// ? Because of the promises the function sometimes repeats itself, so this check is needed
// eslint-disable-next-line no-param-reassign
if (optionsResult[index]) index += 1;

if (index >= this.original.length) return true; // ? Ignore extra parameters

const expected = this.original[index] as ApplicationCommandOptionData;
if (!input && 'required' in expected && expected.required) return false;

const baseOption: CommandInteractionOption = { name: expected.name, type: (expected.type as ApplicationCommandOptionType) };

switch (expected.type) {
case 'STRING': {
baseOption.value = input;
optionsResult[index] = (baseOption);
return true;
}

case 'BOOLEAN': {
baseOption.value = truthyValues.includes(input.toLowerCase());
optionsResult[index] = (baseOption);
return true;
}

case 'CHANNEL': {
// ! There might be channels outside guilds? Still need to find an applicable use and how one could mention this in a message
if (!guild) return false;
const channel = await mentionToChannel(input, guild);

// ? Channel required but no channel found, exit
if (!channel && (expected.required || expected.channelTypes)) return false;

// ? Channel not required but found, channel types not specified, push
if (channel && !expected.channelTypes) {
baseOption.channel = channel as GuildBasedChannel;
baseOption.value = channel.id;
optionsResult[index] = baseOption;
return true;
}

// ? Channel found and of types specified, push
if (channel && channel.type !== 'UNKNOWN' && expected.channelTypes && expected.channelTypes.includes(channel.type)) {
baseOption.channel = channel as GuildBasedChannel;
baseOption.value = channel.id;
optionsResult[index] = baseOption;
return true;
}

// ? Channel found but not of types specified, exit
return false;
}

case 'INTEGER': {
const int = Number.parseInt(input, 10);
if (Number.isNaN(int) && expected.required) return false;
baseOption.value = int;
optionsResult[index] = baseOption;
return true;
}

case 'MENTIONABLE': {
// ? This is the order Discord.js returns it so ¯\_(ツ)_/¯
const mentionable = await mentionToMember(input, guild)
?? await mentionToUser(input, client)
?? await mentionToRole(input, guild)
?? null;
if (!mentionable && expected.required) return false;
if (mentionable instanceof GuildMember) baseOption.member = mentionable;
if (mentionable instanceof User) baseOption.user = mentionable;
if (mentionable instanceof Role) baseOption.role = mentionable;
baseOption.value = mentionable?.id;

if (mentionable) optionsResult[index] = baseOption;
return true;
}

case 'NUMBER': {
const number = Number.parseFloat(input);
if (Number.isNaN(number) && expected.required) return false;
baseOption.value = number;
optionsResult[index] = baseOption;
return true;
}

case 'ROLE': {
if (!guild) return false;
const role = await mentionToRole(input, guild);
if (!role && expected.required) return false;
if (!role) return true;
baseOption.role = role as Role;
baseOption.value = role.id;
optionsResult[index] = baseOption;
return true;
}

case 'SUB_COMMAND': {
const SubcommandClass: typeof SubCommand | undefined = (this.command as typeof SubcommandableCommand)
.getSubCommands()
.find((subcommand) => subcommand.data.names.includes(input));
if (!SubcommandClass) return false;
const MessageArgsParser = new MessageArgumentsParser(
this.message,
this.args.slice(1, this.args.length),
this.command,
SubcommandClass.getOptions(),
);
const options = await MessageArgsParser.process();
if (!options) return false;
baseOption.name = input;
baseOption.options = [...options.data];
optionsResult.unshift(baseOption);
resolvedResult = options.resolved;
foundSubcommand = true;

return false;
}

case 'USER': {
const user = await mentionToUser(input, this.message.client);
if (!user && expected.required) return false;
if (!user) return true;
baseOption.user = user as User;
baseOption.value = user.id;
if (guild) baseOption.member = await guild.members.fetch(user);
optionsResult[index] = baseOption;
return true;
}

default: {
// TODO: Still need an impl for subcommand groups
return true;
}
}
});

if (!foundSubcommand) resolvedResult = this.searchForResolved(optionsResult);

return (success || foundSubcommand)
// ? Typescript is right about this one, but I haven't found any other good way to achieve this
// @ts-ignore See above
? new CommandInteractionOptionResolver(this.message.client, optionsResult.filter((x) => x !== undefined), resolvedResult)
: false;
}

searchForResolved(options: CommandInteractionOption[]): CommandInteractionResolvedData {
const resolvedResult: CommandInteractionResolvedData = {};
options.forEach((option) => {
switch (option.type) {
case 'USER': {
resolvedResult.users ??= new Collection();
resolvedResult.users.set(<Snowflake> option.user?.id, <User> option.user);

if (!this.message.guild) break;
resolvedResult.members ??= new Collection();
resolvedResult.members.set(<Snowflake> option.user?.id, <GuildMember> option.member);
break;
}

case 'ROLE': {
resolvedResult.roles ??= new Collection();
resolvedResult.roles.set(<Snowflake> option.role?.id, <Role> option.role);
break;
}

case 'CHANNEL': {
resolvedResult.channels ??= new Collection();
resolvedResult.channels.set(<Snowflake> option.channel?.id, <TextChannel> option.channel);
break;
}

default: {
break;
}
}
});
return resolvedResult;
}
}
14 changes: 1 addition & 13 deletions src/commands/examplecategory/ExampleCommand.ts
@@ -1,6 +1,4 @@
import {
ApplicationCommandOptionData, CommandInteractionOption, MessageEmbed,
} from 'discord.js';
import { ApplicationCommandOptionData, MessageEmbed } from 'discord.js';
import Command from '../../structures/commands/Command';
import { CommandData } from '../../structures/types';

Expand Down Expand Up @@ -53,14 +51,4 @@ This example command is also marked as guildOnly, so it won't work on DMs.`)
return [];
}
}

override parseOptions(args: string[]): CommandInteractionOption[] {
return [
{
name: 'option',
type: 'STRING',
value: args.shift(),
},
];
}
}
16 changes: 0 additions & 16 deletions src/commands/examplecategory/HelpCommand.ts
@@ -1,6 +1,5 @@
import {
MessageEmbed,
CommandInteractionOption,
AutocompleteInteraction,
ApplicationCommandOptionData,
} from 'discord.js';
Expand Down Expand Up @@ -99,21 +98,6 @@ export default class HelpCommand extends Command {
];
}

override parseOptions(args: string[]): CommandInteractionOption[] {
return [
{
name: 'command',
type: 'STRING',
value: args.shift()?.toLowerCase(),
},
{
name: 'subcommand',
type: 'STRING',
value: args.shift()?.toLowerCase(),
},
];
}

static override getAutocomplete(option: string, interaction: AutocompleteInteraction, bot: Bot) {
switch (option) {
case 'command': {
Expand Down
@@ -1,4 +1,4 @@
import { ApplicationCommandOptionData, CommandInteractionOption } from 'discord.js';
import { ApplicationCommandOptionData } from 'discord.js';

import SubCommand from '../../../structures/commands/SubCommand';
import { CommandData } from '../../../structures/types';
Expand Down Expand Up @@ -33,14 +33,4 @@ export default class DatabaseSetSubcommand extends SubCommand {
},
];
}

override parseOptions(args: string[]): CommandInteractionOption[] {
return [
{
name: 'name',
type: 'STRING',
value: args.shift(),
},
];
}
}
2 changes: 1 addition & 1 deletion src/events/interactionCreate/SlashCommandsExecutor.ts
Expand Up @@ -35,7 +35,7 @@ ${missingPerms.missingPerms}

// FIXME The type is somehow wrong here, CommandClass appears as AbstractCommand, when it's actually a subclass of it.
// @ts-ignore See above
const cmd = new CommandClass(this.bot, name, source, '/');
const cmd = new CommandClass(this.bot, name, source, '/', interaction.options);
await cmd.execute();
}
}
27 changes: 25 additions & 2 deletions src/events/messageCreate/CommandsExecutor.ts
Expand Up @@ -2,9 +2,11 @@ import { Message, MessageEmbed } from 'discord.js';

import CommandSource from '../../structures/commands/CommandSource';
import { Colors, Symbols } from '../../utils/Constants';
import { sendTemporal, canMemberExecute } from '../../utils/DiscordUtils';
import { sendTemporal, canMemberExecute, getMessageOptions } from '../../utils/DiscordUtils';
import { getCommandName } from '../../utils/CommandUtils';
import Event from '../../structures/Event';
import MessageArgumentsParser from '../../MessageArgumentsParser';
import { split } from '../../utils/StringUtils';

export default class CommandsExecutor extends Event {
override async run(message: Message) {
Expand Down Expand Up @@ -33,11 +35,32 @@ ${missingPerms.missingPerms}
return;
}

if (!parsedMessage.prefix || !parsedMessage.name || !parsedMessage.args) return;

const source = new CommandSource(message);

const MessageArgsParser = new MessageArgumentsParser(
source.getRaw() as Message<true>,
split((source.getRaw() as Message<true>).content.substring(parsedMessage.prefix.length + parsedMessage.name.length)),
CommandClass,
CommandClass.getOptions(),
);

const optionResolver = await MessageArgsParser.process();
if (!optionResolver) {
await message.reply(getMessageOptions(CommandClass.data, source, CommandClass.getUsage(parsedMessage.prefix as string)));
return;
}

// FIXME The type is somehow wrong here, CommandClass appears as AbstractCommand, when it's actually a subclass of it.
// @ts-ignore See above
const cmd = new CommandClass(this.bot, parsedMessage.name, source, parsedMessage.prefix);
const cmd = new CommandClass(
this.bot,
parsedMessage.name,
source,
parsedMessage.prefix,
optionResolver,
);
await cmd.execute();
}
}

0 comments on commit 2fb1f5c

Please sign in to comment.