Added rest subcommand type

This commit is contained in:
WatDuhHekBro 2021-04-10 11:30:27 -05:00
parent e1e6910b1d
commit 26e0bb5824
6 changed files with 245 additions and 122 deletions

View File

@ -9,8 +9,9 @@
- `urban`: Bug fixes
- Changed `help` to display a paginated embed
- Various changes to core
- Added `guild` subcommand type (only accessible when `id` is set to `guild`)
- Added `guild` subcommand type (only accessible when `id: "guild"`)
- Further reduced `channel.send()` to `send()` because it's used in *every, single, command*
- Added `rest` subcommand type (only available when `endpoint: true`), declaratively states that the following command will do `args.join(" ")`, preventing any other subcommands from being added
# 3.2.0 - Internal refactor, more subcommand types, and more command type guards (2021-04-09)
- The custom logger changed: `$.log` no longer exists, it's just `console.log`. Now you don't have to do `import $ from "../core/lib"` at the top of every file that uses the custom logger.

View File

@ -33,8 +33,9 @@ export default new NamedCommand({
},
any: new Command({
async run({send, message, channel, guild, author, member, client, args}) {
const [result, category] = await getCommandInfo(args);
if (typeof result === "string") return send(result);
const resultingBlob = await getCommandInfo(args);
if (typeof resultingBlob === "string") return send(resultingBlob);
const [result, category] = resultingBlob;
let append = "";
const command = result.command;
const header = result.args.length > 0 ? `${result.header} ${result.args.join(" ")}` : result.header;
@ -52,6 +53,7 @@ export default new NamedCommand({
list.push(` \`${header} ${type}${customUsage}\` - ${subcommand.description}`);
}
if (result.hasRestCommand) list.push(` \`${header} <...>\``);
append = list.length > 0 ? list.join("\n") : "None";
} else {
append = `\`${header} ${command.usage}\``;

View File

@ -280,7 +280,7 @@ export default new NamedCommand({
}
} else {
// If it's a unique hour, just search through the tuple list and find the matching entry.
for (const [hourPoint, dayOffset, timezoneOffset] of timezoneTupleList) {
for (const [hourPoint, _dayOffset, timezoneOffset] of timezoneTupleList) {
if (hour === hourPoint) {
profile.timezone = timezoneOffset;
}

View File

@ -78,11 +78,12 @@ interface CommandOptionsBase {
readonly permission?: number;
readonly nsfw?: boolean;
readonly channelType?: CHANNEL_TYPE;
readonly run?: (($: CommandMenu) => Promise<any>) | string;
}
interface CommandOptionsEndpoint {
readonly endpoint: true;
readonly rest?: RestCommand;
readonly run?: (($: CommandMenu) => Promise<any>) | string;
}
// Prevents subcommands from being added by compile-time.
@ -100,10 +101,15 @@ interface CommandOptionsNonEndpoint {
readonly id?: ID;
readonly number?: Command;
readonly any?: Command;
readonly rest?: undefined; // Redeclare it here as undefined to prevent its use otherwise.
readonly run?: (($: CommandMenu) => Promise<any>) | string;
}
type CommandOptions = CommandOptionsBase & (CommandOptionsEndpoint | CommandOptionsNonEndpoint);
type NamedCommandOptions = CommandOptions & {aliases?: string[]};
type NamedCommandOptions = CommandOptions & {aliases?: string[]; nameOverride?: string};
type RestCommandOptions = CommandOptionsBase & {
run?: (($: CommandMenu & {readonly combined: string}) => Promise<any>) | string;
};
interface ExecuteCommandMetadata {
readonly header: string;
@ -116,9 +122,10 @@ interface ExecuteCommandMetadata {
export interface CommandInfo {
readonly type: "info";
readonly command: Command;
readonly command: BaseCommand;
readonly subcommandInfo: Collection<string, Command>;
readonly keyedSubcommandInfo: Collection<string, NamedCommand>;
readonly hasRestCommand: boolean;
readonly permission: number;
readonly nsfw: boolean;
readonly channelType: CHANNEL_TYPE;
@ -141,14 +148,26 @@ interface CommandInfoMetadata {
readonly header: string;
}
// Each Command instance represents a block that links other Command instances under it.
export class Command {
// An isolated command of just the metadata.
abstract class BaseCommand {
public readonly description: string;
public readonly endpoint: boolean;
public readonly usage: string;
public readonly permission: number; // -1 (default) indicates to inherit, 0 is the lowest rank, 1 is second lowest rank, and so on.
public readonly nsfw: boolean | null; // null (default) indicates to inherit
public readonly channelType: CHANNEL_TYPE | null; // null (default) indicates to inherit
constructor(options?: CommandOptionsBase) {
this.description = options?.description || "No description.";
this.usage = options?.usage ?? "";
this.permission = options?.permission ?? -1;
this.nsfw = options?.nsfw ?? null;
this.channelType = options?.channelType ?? null;
}
}
// Each Command instance represents a block that links other Command instances under it.
export class Command extends BaseCommand {
public readonly endpoint: boolean;
// The execute and subcommand properties are restricted to the class because subcommand recursion could easily break when manually handled.
// The class will handle checking for null fields.
private run: (($: CommandMenu) => Promise<any>) | string;
@ -163,14 +182,11 @@ export class Command {
private idType: ID | null;
private number: Command | null;
private any: Command | null;
private rest: RestCommand | null;
constructor(options?: CommandOptions) {
this.description = options?.description || "No description.";
super(options);
this.endpoint = !!options?.endpoint;
this.usage = options?.usage ?? "";
this.permission = options?.permission ?? -1;
this.nsfw = options?.nsfw ?? null;
this.channelType = options?.channelType ?? null;
this.run = options?.run || "No action was set on this command!";
this.subcommands = new Collection(); // Populate this collection after setting subcommands.
this.channel = null;
@ -183,44 +199,45 @@ export class Command {
this.idType = null;
this.number = null;
this.any = null;
this.rest = null;
if (options && !options.endpoint) {
if (options?.channel) this.channel = options.channel;
if (options?.role) this.role = options.role;
if (options?.emote) this.emote = options.emote;
if (options?.message) this.message = options.message;
if (options?.user) this.user = options.user;
if (options?.guild) this.guild = options.guild;
if (options?.number) this.number = options.number;
if (options?.any) this.any = options.any;
if (options?.id) this.idType = options.id;
if (options.channel) this.channel = options.channel;
if (options.role) this.role = options.role;
if (options.emote) this.emote = options.emote;
if (options.message) this.message = options.message;
if (options.user) this.user = options.user;
if (options.guild) this.guild = options.guild;
if (options.number) this.number = options.number;
if (options.any) this.any = options.any;
if (options.id) this.idType = options.id;
if (options?.id) {
switch (options.id) {
case "channel":
this.id = this.channel;
break;
case "role":
this.id = this.role;
break;
case "emote":
this.id = this.emote;
break;
case "message":
this.id = this.message;
break;
case "user":
this.id = this.user;
break;
case "guild":
this.id = this.guild;
break;
default:
requireAllCasesHandledFor(options.id);
}
switch (options.id) {
case "channel":
this.id = this.channel;
break;
case "role":
this.id = this.role;
break;
case "emote":
this.id = this.emote;
break;
case "message":
this.id = this.message;
break;
case "user":
this.id = this.user;
break;
case "guild":
this.id = this.guild;
break;
case undefined:
break;
default:
requireAllCasesHandledFor(options.id);
}
if (options?.subcommands) {
if (options.subcommands) {
const baseSubcommands = Object.keys(options.subcommands);
// Loop once to set the base subcommands.
@ -246,6 +263,8 @@ export class Command {
}
}
}
} else if (options && options.endpoint) {
if (options.rest) this.rest = options.rest;
}
}
@ -273,72 +292,49 @@ export class Command {
// If there are no arguments left, execute the current command. Otherwise, continue on.
if (param === undefined) {
// See if there is anything that'll prevent the user from executing the command.
const error = canExecute(menu, metadata);
if (error) return error;
// 1. Does this command specify a required channel type? If so, does the channel type match?
if (
metadata.channelType === CHANNEL_TYPE.GUILD &&
(!(menu.channel instanceof GuildChannel) || menu.guild === null || menu.member === null)
) {
return {content: "This command must be executed in a server."};
} else if (
metadata.channelType === CHANNEL_TYPE.DM &&
(menu.channel.type !== "dm" || menu.guild !== null || menu.member !== null)
) {
return {content: "This command must be executed as a direct message."};
}
// 2. Is this an NSFW command where the channel prevents such use? (DM channels bypass this requirement.)
if (metadata.nsfw && menu.channel.type !== "dm" && !menu.channel.nsfw) {
return {content: "This command must be executed in either an NSFW channel or as a direct message."};
}
// 3. Does the user have permission to execute the command?
if (!hasPermission(menu.author, menu.member, metadata.permission)) {
const userPermLevel = getPermissionLevel(menu.author, menu.member);
return {
content: `You don't have access to this command! Your permission level is \`${getPermissionName(
userPermLevel
)}\` (${userPermLevel}), but this command requires a permission level of \`${getPermissionName(
metadata.permission
)}\` (${metadata.permission}).`
};
}
// Then capture any potential errors.
try {
if (typeof this.run === "string") {
// Although I *could* add an option in the launcher to attach arbitrary variables to this var string...
// I'll just leave it like this, because instead of using var strings for user stuff, you could just make "run" a template string.
await menu.send(
parseVars(
this.run,
{
author: menu.author.toString(),
prefix: getPrefix(menu.guild),
command: `${metadata.header} ${metadata.symbolicArgs.join(", ")}`
},
"???"
)
);
} else {
if (typeof this.run === "string") {
// Although I *could* add an option in the launcher to attach arbitrary variables to this var string...
// I'll just leave it like this, because instead of using var strings for user stuff, you could just make "run" a template string.
await menu.send(
parseVars(
this.run,
{
author: menu.author.toString(),
prefix: getPrefix(menu.guild),
command: `${metadata.header} ${metadata.symbolicArgs.join(", ")}`
},
"???"
)
);
} else {
// Then capture any potential errors.
try {
await this.run(menu);
} catch (error) {
const errorMessage = error.stack ?? error;
console.error(`Command Error: ${metadata.header} (${metadata.args.join(", ")})\n${errorMessage}`);
return {
content: `There was an error while trying to execute that command!\`\`\`${errorMessage}\`\`\``
};
}
return null;
} catch (error) {
const errorMessage = error.stack ?? error;
console.error(`Command Error: ${metadata.header} (${metadata.args.join(", ")})\n${errorMessage}`);
return {
content: `There was an error while trying to execute that command!\`\`\`${errorMessage}\`\`\``
};
}
return null;
}
// If the current command is an endpoint but there are still some arguments left, don't continue.
if (this.endpoint) return {content: "Too many arguments!"};
// If the current command is an endpoint but there are still some arguments left, don't continue unless there's a RestCommand.
if (this.endpoint) {
if (this.rest) {
args.unshift(param);
return this.rest.execute(args.join(" "), menu, metadata);
} else {
return {content: "Too many arguments!"};
}
}
// Resolve the value of the current command's argument (adding it to the resolved args),
// then pass the thread of execution to whichever subcommand is valid (if any).
@ -532,7 +528,7 @@ export class Command {
}
// What this does is resolve the resulting subcommand as well as the inherited properties and the available subcommands.
public async resolveInfo(args: string[], header: string): Promise<CommandInfo | CommandInfoError> {
public resolveInfo(args: string[], header: string): CommandInfo | CommandInfoError {
return this.resolveInfoInternal(args, {
permission: 0,
nsfw: false,
@ -544,10 +540,7 @@ export class Command {
});
}
private async resolveInfoInternal(
args: string[],
metadata: CommandInfoMetadata
): Promise<CommandInfo | CommandInfoError> {
private resolveInfoInternal(args: string[], metadata: CommandInfoMetadata): CommandInfo | CommandInfoError {
// Update inherited properties if the current command specifies a property.
// In case there are no initial arguments, these should go first so that it can register.
if (this.permission !== -1) metadata.permission = this.permission;
@ -586,6 +579,7 @@ export class Command {
command: this,
keyedSubcommandInfo,
subcommandInfo,
hasRestCommand: !!this.rest,
...metadata
};
}
@ -652,6 +646,13 @@ export class Command {
} else {
return invalidSubcommandGenerator();
}
} else if (param === "<...>") {
if (this.rest) {
metadata.args.push("<...>");
return this.rest.resolveInfoFinale(metadata);
} else {
return invalidSubcommandGenerator();
}
} else if (this.subcommands?.has(param)) {
metadata.args.push(param);
return this.subcommands.get(param)!.resolveInfoInternal(args, metadata);
@ -668,7 +669,8 @@ export class NamedCommand extends Command {
constructor(options?: NamedCommandOptions) {
super(options);
this.aliases = options?.aliases || [];
this.originalCommandName = null;
// The name override exists in case a user wants to bypass filename restrictions.
this.originalCommandName = options?.nameOverride ?? null;
}
public get name(): string {
@ -681,4 +683,119 @@ export class NamedCommand extends Command {
throw new Error(`originalCommandName cannot be set twice! Attempted to set the value to "${value}".`);
else this.originalCommandName = value;
}
public isNameSet(): boolean {
return this.originalCommandName !== null;
}
}
// RestCommand is a declarative version of the common "any: args.join(' ')" pattern, basically the Command version of a rest parameter.
// This way, you avoid having extra subcommands when using this pattern.
// I'm probably not going to add a transformer function (a callback to automatically handle stuff like searching for usernames).
// I don't think the effort to figure this part out via generics or something is worth it.
export class RestCommand extends BaseCommand {
private run: (($: CommandMenu & {readonly combined: string}) => Promise<any>) | string;
constructor(options?: RestCommandOptions) {
super(options);
this.run = options?.run || "No action was set on this command!";
}
public async execute(
combined: string,
menu: CommandMenu,
metadata: ExecuteCommandMetadata
): Promise<SingleMessageOptions | null> {
// Update inherited properties if the current command specifies a property.
// In case there are no initial arguments, these should go first so that it can register.
if (this.permission !== -1) metadata.permission = this.permission;
if (this.nsfw !== null) metadata.nsfw = this.nsfw;
if (this.channelType !== null) metadata.channelType = this.channelType;
const error = canExecute(menu, metadata);
if (error) return error;
if (typeof this.run === "string") {
// Although I *could* add an option in the launcher to attach arbitrary variables to this var string...
// I'll just leave it like this, because instead of using var strings for user stuff, you could just make "run" a template string.
await menu.send(
parseVars(
this.run,
{
author: menu.author.toString(),
prefix: getPrefix(menu.guild),
command: `${metadata.header} ${metadata.symbolicArgs.join(", ")}`
},
"???"
)
);
} else {
// Then capture any potential errors.
try {
await this.run({...menu, combined});
} catch (error) {
const errorMessage = error.stack ?? error;
console.error(`Command Error: ${metadata.header} (${metadata.args.join(", ")})\n${errorMessage}`);
return {
content: `There was an error while trying to execute that command!\`\`\`${errorMessage}\`\`\``
};
}
}
return null;
}
public resolveInfoFinale(metadata: CommandInfoMetadata): CommandInfo {
if (this.permission !== -1) metadata.permission = this.permission;
if (this.nsfw !== null) metadata.nsfw = this.nsfw;
if (this.channelType !== null) metadata.channelType = this.channelType;
if (this.usage !== "") metadata.usage = this.usage;
return {
type: "info",
command: this,
keyedSubcommandInfo: new Collection<string, NamedCommand>(),
subcommandInfo: new Collection<string, Command>(),
hasRestCommand: false,
...metadata
};
}
}
// See if there is anything that'll prevent the user from executing the command.
// Returns null if successful, otherwise returns a message with the error.
function canExecute(menu: CommandMenu, metadata: ExecuteCommandMetadata): SingleMessageOptions | null {
// 1. Does this command specify a required channel type? If so, does the channel type match?
if (
metadata.channelType === CHANNEL_TYPE.GUILD &&
(!(menu.channel instanceof GuildChannel) || menu.guild === null || menu.member === null)
) {
return {content: "This command must be executed in a server."};
} else if (
metadata.channelType === CHANNEL_TYPE.DM &&
(menu.channel.type !== "dm" || menu.guild !== null || menu.member !== null)
) {
return {content: "This command must be executed as a direct message."};
}
// 2. Is this an NSFW command where the channel prevents such use? (DM channels bypass this requirement.)
if (metadata.nsfw && menu.channel.type !== "dm" && !menu.channel.nsfw) {
return {content: "This command must be executed in either an NSFW channel or as a direct message."};
}
// 3. Does the user have permission to execute the command?
if (!hasPermission(menu.author, menu.member, metadata.permission)) {
const userPermLevel = getPermissionLevel(menu.author, menu.member);
return {
content: `You don't have access to this command! Your permission level is \`${getPermissionName(
userPermLevel
)}\` (${userPermLevel}), but this command requires a permission level of \`${getPermissionName(
metadata.permission
)}\` (${metadata.permission}).`
};
}
return null;
}

View File

@ -1,5 +1,5 @@
// Onion Lasers Command Handler //
export {Command, NamedCommand, CHANNEL_TYPE} from "./command";
export {Command, NamedCommand, RestCommand, CHANNEL_TYPE} from "./command";
export {addInterceptRule} from "./handler";
export {launch} from "./interface";
export * from "./libd";

View File

@ -43,14 +43,16 @@ export async function loadCommands(commandsDir: string): Promise<Collection<stri
const command = (await import(files[i])).default as unknown;
if (command instanceof NamedCommand) {
command.name = commandName;
const isNameOverridden = command.isNameSet();
if (!isNameOverridden) command.name = commandName;
const header = command.name;
if (commands.has(commandName)) {
if (commands.has(header)) {
console.warn(
`Command "${commandName}" already exists! Make sure to make each command uniquely identifiable across categories!`
`Command "${header}" already exists! Make sure to make each command uniquely identifiable across categories!`
);
} else {
commands.set(commandName, command);
commands.set(header, command);
}
for (const alias of command.aliases) {
@ -64,9 +66,10 @@ export async function loadCommands(commandsDir: string): Promise<Collection<stri
}
if (!(category in lists)) lists[category] = [];
lists[category].push(commandName);
lists[category].push(header);
console.log(`Loaded Command: ${commandID}`);
if (isNameOverridden) console.log(`Loaded Command: "${commandID}" as "${header}"`);
else console.log(`Loaded Command: ${commandID}`);
} else {
console.warn(`Command "${commandID}" has no default export which is a NamedCommand instance!`);
}
@ -139,7 +142,7 @@ export async function getCommandInfo(args: string[]): Promise<[CommandInfo, stri
}
// Gather info
const result = await command.resolveInfo(args, header);
const result = command.resolveInfo(args, header);
if (result.type === "error") return result.message;
else return [result, category];
}