yuuko
yuuko copied to clipboard
Optional command argument parsing
Argument types with options that influence how they're parsed.
- [ ] Users
- by mention
- by
name#discrim - by id
- by just name?
- Options:
- Is in server? (reject without running command process if not)
- Is fuzzy? (enables the "just name" matching and takes nearest match, maybe?)
- Resolve to: User instance (Member instance?)
- [ ] Channels
- by mention
- by exact name
- by id
- Options:
- Is visible to caller? (should probably be default)
- Is text/voice/announcement/etc? (default to accepting any channel) (or maybe do this with separate types for text channels/voice channels?)
- Resolve to: Channel object
- [ ] Roles
- by mention
- by name (exact)
- by id
- Options:
- User can edit? (reject if the user isn't higher than that role, or if the user doesn't have "manage roles" permission)
- Mentionable? (meh)
- Resolve to: Role object
- [ ] Dates & Times
- Probably use moment as a peerdep to parse time strings; without moment, do basic
2d 3h 15mparsing for times - absolute dates/times should also take time deltas from the current time and resolve to the corresponding date
- Resolve to: Date objects or moment instances
- Probably use moment as a peerdep to parse time strings; without moment, do basic
- [ ] Number
- as you expect
- maybe a separate type for integers? idk
- options:
- min/max
- resolves to: a number duh (bigint for very large numbers?)
- [ ] Text
- Text with no line breaks or anything else fancy
- Handle quoting as expected; try not to let the user do stupid shit by mistake
- Interesting idea: If there's another argument after text, let the text expand until the next argument is met (make text the "default" argument type that fills in everything else as a single argument
- Resolves to a string
- [ ] LongText
- Text that can have line breaks and stuff
- Dunno how this should be formatted
- [ ] Word
- Like text but with no spaces or quoting or whatever
- [ ] Colors? Other stuff?
Also how do I make this extensible so others can define their own argument types?
This is an example of how I could imagine using it.
export default new Command(`warn`, async (message, args, context) => {
const [userArg, reasonArg] = args;
const user = context.client.arguments.parseUser(userArg)
const reasonArg = context.client.arguments.parseLongText(reasonArg)
This would also allow anyone to create their own arguments in the arguments directory.
Looking for some input on the user-facing interface for this. Some things I've considered so far:
-
Manually parsing arguments from
argsin the command processexport default new Command('ban', (msg, args) => { const [member, reason] = parseArgs(args, /* some specifier of what the expected arguments are */); // use member and reason, above call throws if args aren't matched or something? }, { permissions: ['manageMembers'] });This is probably the least invasive way of doing things. However, it's also not particularly clean, and involves a fair amount of boilerplate - especially when considering how to handle the "arguments aren't fulfilled" case. It's probably more intuitive for consumers if the process not run at all if the arguments aren't provided correctly, and having to handle that case explicitly is probably not great. Typing this method would probably be the easiest of the three ideas I've explored.
-
Introducing a utility that generates a process wrapped in argument checks
const process = processWithArgs(/* expected argument stuff */, (msg, [member, reason], context) => { // do stuff with member and reason }); export default new Command('ban', process, {permissions: ['manageMembers']});This would help with some of the issues above. By wrapping the user's command process in another function that takes charge of argument handling, the "arguments aren't fulfilled" case can be handled by the wrapping logic. A message can be sent to the user automatically, though this behavior could be overridden by a second argument to the function if the consumer wanted a custom message or more complex handling. However, it feels like a really clunky way of doing things and isn't particularly clean. The separation of arguments from other requirements is a bit weird. Maybe the wrapper could handle both, but then that would mean we have one permission system built into the
Commandclass, and another one built into a process wrapper... I don't like the thought of that. -
Reworking the
Commandinterface entirely with a builder designexport default new Command('ban') .requirePermission('manageMembers') .argument('member', ...) .argument('reason', ...) .process((msg, [member, reason], context) => { // do stuff with member and reason });I've never been a huge fan of the builder design pattern, but it has some advantages here. Argument definitions have a much clearer structure to them than with the other two patterns, since there's no need for a wrapping array to preserve order. However, it's very hard to get nice typings for the command process this way. I mocked it out a bit and it would rely on a lot of internal hacks on generics that I'm not really super keen on working with. Custom argument types would be impossible to do nicely in TS without
.argumentbeing typed as(...args: any[]) => ...(or otherwise expecting custom type providers to include ambient overload declarations for their own types).
I don't like this game.
I've thrown together a TS playground that demonstrates the basic way I want to implement this, corresponding with option 1 above and also inspired by the way Akairo handles it. This actually works out really nicely in Typescript - it's not perfect, but definitely gets the job done and allows for very nice type inference both when defining custom arg types and when consuming the parsed arguments.
Consumer code examples
Consumer code in JS will look something like this:
const {Command, parseArgs} = require('yuuko');
module.exports = new Command('ping', async (msg, args) => {
const [user, text] = await parseArgs(args, [
{
type: 'user',
inGuild: true,
},
{
type: 'string',
},
]);
msg.channel.createMessage(`<@${user.id}>, you've been pinged! ${text}`);
});
Consumer TS code will be similar, but will require a generic on the parseArgs call that explicitly lists all the argument types in order to allow for type inference of the output. (I don't think TS supports variadic generics, so the generic does need to be a type tuple like this.)
import {Command, UserArgument, StringArgument, parseArgs} from 'yuuko';
export default new Command('ping', async (msg, args) => {
const [user, text] = await parseArgs<[
UserArgument,
StringArgument,
]>(args, [
{
type: 'user',
inGuild: true,
},
{
type: 'string',
},
]);
msg.channel.createMessage(`<@${user.id}>, you've been pinged! ${text}`);
});
Custom argument type code samples
Registering a custom argument type to be recognized by parseArgs is pretty easy:
import {registerArgumentType} from 'yuuko';
// Define options for this argument type
type IntegerArgument = ArgType<{
type: 'integer',
/** The radix, or base, to use when parsing the number. Defaults to 10. */
radix?: number,
/** Enables parsing of hexadecimal numbers prefixed with "0x", overriding the set radix. */
allowExplicitHex?: boolean,
}, number>;
// Register an argument resolver to tell Yuuko how to parse this type
registerArgumentType<IntegerArgument>('integer', (args, {radix, allowExplicitHex = false}) => {
let result;
let thing = args.shift()!;
if (allowExplicitHex) {
let match = thing.match(/^0x([0-9a-f]+)$/i);
if (match) {
result = parseInt(match[1], 16);
}
}
if (result == null || isNaN(result)) {
result = parseInt(thing, radix);
}
return result;
});
So I guess at this point all that's really left to do is start implementing this in Yuuko itself, and start writing the default argument types for basic stuff like I outlined in the OP.