Added command aliases

This commit is contained in:
WatDuhHekBro 2020-08-14 11:35:53 -05:00
parent 139630ce9f
commit 877a41fac2
6 changed files with 174 additions and 80 deletions

View file

@ -35,3 +35,8 @@ This list starts from `src`/`dist`.
- I want to make attaching subcommands more flexible, so you can either add subcommands in the constructor or by using a method. However, you have to add all other properties when instantiating a command.
- All commands should have only one parameter. This parameter is meant to be flexible so you can add properties without making a laundry list of parameters. It also has convenience functions too so you don't have to import the library for each command.
- The objects in `core/structures` are initialized into a special object once and then cached into memory automatically due to an import system. This means you don't have to keep loading JSON files over and over again even without the stack storage system. Because a JSON file resolves into an object, any extra keys are removed automatically (as it isn't initialized into the data) and any property that doesn't yet exist on the JSON object will be initialized into something. You can then have specific functions like `addUser` onto objects with a specific structure.
- There were several possible ways to go about implementing aliases and subaliases.
- Two properties on the `Command` class, `aliases: string[]` and `subaliases: {[key: string]: string[]}`.
- Exporting a const named `aliases` which would handle top-level aliases.
- For `subaliases`, either making subaliases work like redirects (Instead of doing `new Command(...)`, you'd do `"nameOfSubcommand"`), or define properties on `Command`.
- What I ended up doing for aliases is making an `aliases` property on `Command` and then converting those string arrays to a more usable structure with strings pointing to the original commands. `aliases` at the very top will determine global aliases and is pretty much the exception in the program's logic. `aliases` elsewhere will provide aliases per subcommand. For anything other than the top-level or `subcommands`, `aliases` does nothing (plus you'll get a warning about it).

View file

@ -3,15 +3,14 @@ import {CommonLibrary} from "../core/lib";
import {loadCommands, categories} from "../core/command";
import {PermissionNames} from "../core/permissions";
const types = ["user", "number", "any"];
export default new Command({
description: "Lists all commands. If a command is specified, their arguments are listed as well.",
usage: "([command, [subcommand/type], ...])",
aliases: ["h"],
async run($: CommonLibrary): Promise<any>
{
const commands = await loadCommands();
let output = `Legend: \`<type>\`, \`[list/of/subcommands]\`, \`(optional)\`, \`(<optional type>)\`, \`([optional/list/...])\``;
let output = `Legend: \`<type>\`, \`[list/of/stuff]\`, \`(optional)\`, \`(<optional type>)\`, \`([optional/list/...])\``;
for(const [category, headers] of categories)
{
@ -37,23 +36,43 @@ export default new Command({
async run($: CommonLibrary): Promise<any>
{
const commands = await loadCommands();
let header = $.args.shift();
let header = $.args.shift() as string;
let command = commands.get(header);
if(!command || header === "test")
return $.channel.send(`No command found by the name \`${header}\`!`);
if(command.originalCommandName)
header = command.originalCommandName;
else
$.warn(`originalCommandName isn't defined for ${header}?!`);
let permLevel = command.permission ?? Command.PERMISSIONS.NONE;
let usage = command.usage;
let invalid = false;
let selectedCategory = "Unknown";
for(const [category, headers] of categories)
{
if(headers.includes(header))
{
if(selectedCategory !== "Unknown")
$.warn(`Command "${header}" is somehow in multiple categories. This means that the command loading stage probably failed in properly adding categories.`);
else
selectedCategory = category;
}
}
for(const param of $.args)
{
const type = command.resolve(param);
command = command.get(param);
permLevel = command.permission ?? permLevel;
switch(type)
{
case Command.TYPES.SUBCOMMAND: header += ` ${param}`; break;
case Command.TYPES.SUBCOMMAND: header += ` ${command.originalCommandName}`; break;
case Command.TYPES.USER: header += " <user>" ; break;
case Command.TYPES.NUMBER: header += " <number>" ; break;
case Command.TYPES.ANY: header += " <any>" ; break;
@ -65,9 +84,6 @@ export default new Command({
invalid = true;
break;
}
command = command.get(param);
permLevel = command.permission ?? permLevel;
}
if(invalid)
@ -79,29 +95,47 @@ export default new Command({
{
const list: string[] = [];
for(const subtag in command.subcommands)
{
const subcmd = command.subcommands[subtag];
const customUsage = subcmd.usage ? ` ${subcmd.usage}` : "";
list.push(`- \`${header} ${subtag}${customUsage}\` - ${subcmd.description}`);
}
command.subcommands.forEach((subcmd, subtag) => {
// Don't capture duplicates generated from aliases.
if(subcmd.originalCommandName === subtag) {
const customUsage = subcmd.usage ? ` ${subcmd.usage}` : "";
list.push(`- \`${header} ${subtag}${customUsage}\` - ${subcmd.description}`);
}
});
for(const type of types)
{
if(command[type])
{
const cmd = command[type];
const addDynamicType = (cmd: Command|null, type: string) => {
if(cmd) {
const customUsage = cmd.usage ? ` ${cmd.usage}` : "";
list.push(`- \`${header} <${type}>${customUsage}\` - ${cmd.description}`);
}
}
};
addDynamicType(command.user, "user");
addDynamicType(command.number, "number");
addDynamicType(command.any, "any");
append = "Usages:" + (list.length > 0 ? `\n${list.join('\n')}` : " None.");
}
else
append = `Usage: \`${header} ${usage}\``;
$.channel.send(`Command: \`${header}\`\nPermission Required: \`${PermissionNames[permLevel]}\` (${permLevel})\nDescription: ${command.description}\n${append}`, {split: true});
let aliases = "None";
if(command.aliases.length > 0)
{
aliases = "";
for(let i = 0; i < command.aliases.length; i++)
{
const alias = command.aliases[i];
aliases += `\`${alias}\``;
if(i !== command.aliases.length-1)
aliases += ", ";
}
}
$.channel.send(`Command: \`${header}\`\nAliases: ${aliases}\nCategory: \`${selectedCategory}\`\nPermission Required: \`${PermissionNames[permLevel]}\` (${permLevel})\nDescription: ${command.description}\n${append}`, {split: true});
}
})
});

View file

@ -10,7 +10,8 @@ interface CommandOptions
description?: string;
endpoint?: boolean;
usage?: string;
permission?: PERMISSIONS;
permission?: PERMISSIONS|null;
aliases?: string[];
run?: Function|string;
subcommands?: {[key: string]: Command};
user?: Command;
@ -26,12 +27,13 @@ export default class Command
public readonly endpoint: boolean;
public readonly usage: string;
public readonly permission: PERMISSIONS|null;
public readonly aliases: string[]; // This is to keep the array intact for parent Command instances to use. It'll also be used when loading top-level aliases.
public originalCommandName: string|null; // If the command is an alias, what's the original name?
private run: Function|string;
public subcommands: {[key: string]: Command}|null;
public readonly subcommands: Collection<string, Command>; // This is the final data structure you'll actually use to work with the commands the aliases point to.
public user: Command|null;
public number: Command|null;
public any: Command|null;
[key: string]: any; // Allow for dynamic indexing. The CommandOptions interface will still prevent users from adding unused properties though.
public static readonly TYPES = TYPES;
public static readonly PERMISSIONS = PERMISSIONS;
@ -41,11 +43,48 @@ export default class Command
this.endpoint = options?.endpoint || false;
this.usage = options?.usage || "";
this.permission = options?.permission ?? null;
this.aliases = options?.aliases ?? [];
this.originalCommandName = null;
this.run = options?.run || "No action was set on this command!";
this.subcommands = options?.subcommands || null;
this.subcommands = new Collection(); // Populate this collection after setting subcommands.
this.user = options?.user || null;
this.number = options?.number || null;
this.any = options?.any || null;
if(options?.subcommands)
{
const baseSubcommands = Object.keys(options.subcommands);
// Loop once to set the base subcommands.
for(const name in options.subcommands)
this.subcommands.set(name, options.subcommands[name]);
// Then loop again to make aliases point to the base subcommands and warn if something's not right.
// This shouldn't be a problem because I'm hoping that JS stores these as references that point to the same object.
for(const name in options.subcommands)
{
const subcmd = options.subcommands[name];
subcmd.originalCommandName = name;
const aliases = subcmd.aliases;
for(const alias of aliases)
{
if(baseSubcommands.includes(alias))
$.warn(`"${alias}" in subcommand "${name}" was attempted to be declared as an alias but it already exists in the base commands! (Look at the next "Loading Command" line to see which command is affected.)`);
else if(this.subcommands.has(alias))
$.warn(`Duplicate alias "${alias}" at subcommand "${name}"! (Look at the next "Loading Command" line to see which command is affected.)`);
else
this.subcommands.set(alias, subcmd);
}
}
}
if(this.user && this.user.aliases.length > 0)
$.warn(`There are aliases defined for a "user"-type subcommand, but those aliases won't be used. (Look at the next "Loading Command" line to see which command is affected.)`);
if(this.number && this.number.aliases.length > 0)
$.warn(`There are aliases defined for a "number"-type subcommand, but those aliases won't be used. (Look at the next "Loading Command" line to see which command is affected.)`);
if(this.any && this.any.aliases.length > 0)
$.warn(`There are aliases defined for an "any"-type subcommand, but those aliases won't be used. (Look at the next "Loading Command" line to see which command is affected.)`);
}
public execute($: CommonLibrary)
@ -61,27 +100,9 @@ export default class Command
(this.run as Function)($).catch($.handler.bind($));
}
/**
* Set what happens when the command is called.
* - If the command is a function, run it with one argument (the common library).
* - If the command is a string, it'll be sent as a message with %variables% replaced.
*/
public set(run: Function|string)
{
this.run = run;
}
/** The safe way to attach a named subcommand. */
public attach(key: string, command: Command)
{
if(!this.subcommands)
this.subcommands = {};
this.subcommands[key] = command;
}
public resolve(param: string): TYPES
{
if(this.subcommands?.[param])
if(this.subcommands.has(param))
return TYPES.SUBCOMMAND;
// Any Discord ID format will automatically format to a user ID.
else if(this.user && (/\d{17,19}/.test(param)))
@ -98,11 +119,11 @@ export default class Command
public get(param: string): Command
{
const type = this.resolve(param);
let command;
let command: Command;
switch(type)
{
case TYPES.SUBCOMMAND: command = this.subcommands![param]; break;
case TYPES.SUBCOMMAND: command = this.subcommands.get(param) as Command; break;
case TYPES.USER: command = this.user as Command; break;
case TYPES.NUMBER: command = this.number as Command; break;
case TYPES.ANY: command = this.any as Command; break;
@ -115,6 +136,7 @@ export default class Command
let commands: Collection<string, Command>|null = null;
export const categories: Collection<string, string[]> = new Collection();
export const aliases: Collection<string, string> = new Collection(); // Top-level aliases only.
/** Returns the cache of the commands if it exists and searches the directory if not. */
export async function loadCommands(): Promise<Collection<string, Command>>
@ -139,7 +161,7 @@ export async function loadCommands(): Promise<Collection<string, Command>>
continue;
const subdir = await ffs.opendir(`dist/commands/${selected.name}`);
const category = getTitleCase(selected.name);
const category = $(selected.name).toTitleCase();
const list: string[] = [];
let cmd;
@ -153,36 +175,14 @@ export async function loadCommands(): Promise<Collection<string, Command>>
$.warn(`You can't have multiple levels of directories! From: "dist/commands/${cmd.name}"`);
}
else
{
const header = cmd.name.substring(0, cmd.name.indexOf(".js"));
const command = (await import(`../commands/${selected.name}/${header}`)).default;
list.push(header);
if(commands.has(header))
$.warn(`Command "${header}" already exists! Make sure to make each command uniquely identifiable across categories!`);
else
commands.set(header, command);
$.log(`Loading Command: ${header} (${category})`);
}
loadCommand(cmd.name, list, selected.name);
}
subdir.close();
categories.set(category, list);
}
else
{
const header = selected.name.substring(0, selected.name.indexOf(".js"));
const command = (await import(`../commands/${header}`)).default;
listMisc.push(header);
if(commands.has(header))
$.warn(`Command "${header}" already exists! Make sure to make each command uniquely identifiable across categories.`);
else
commands.set(header, command);
$.log(`Loading Command: ${header} (Miscellaneous)`);
}
loadCommand(selected.name, listMisc);
}
dir.close();
@ -191,12 +191,35 @@ export async function loadCommands(): Promise<Collection<string, Command>>
return commands;
}
function getTitleCase(name: string): string
async function loadCommand(filename: string, list: string[], category?: string)
{
if(name.length < 1)
return name;
const first = name[0].toUpperCase();
return first + name.substring(1);
if(!commands)
return $.error(`Function "loadCommand" was called without first initializing commands!`);
const prefix = category ?? "";
const header = filename.substring(0, filename.indexOf(".js"));
const command = (await import(`../commands/${prefix}/${header}`)).default as Command|undefined;
if(!command)
return $.warn(`Command "${header}" has no default export which is a Command instance!`);
command.originalCommandName = header;
list.push(header);
if(commands.has(header))
$.warn(`Command "${header}" already exists! Make sure to make each command uniquely identifiable across categories!`);
else
commands.set(header, command);
for(const alias of command.aliases)
{
if(commands.has(alias))
$.warn(`Top-level alias "${alias}" from command "${header}" already exists either as a command or alias!`);
else
commands.set(alias, command);
}
$.log(`Loading Command: ${header} (${category ? $(category).toTitleCase() : "Miscellaneous"})`);
}
// The template should be built with a reductionist mentality.
@ -210,6 +233,8 @@ export default new Command({
description: "This is a template/testing command providing common functionality. Remove what you don't need, and rename/delete this file to generate a fresh command file here. This command should be automatically excluded from the help command. The \\"usage\\" parameter (string) overrides the default usage for the help command. The \\"endpoint\\" parameter (boolean) prevents further arguments from being passed. Also, as long as you keep the run function async, it'll return a promise allowing the program to automatically catch any synchronous errors. However, you'll have to do manual error handling if you go the then and catch route.",
endpoint: false,
usage: '',
permission: null,
aliases: [],
async run($: CommonLibrary): Promise<any> {
},
@ -218,6 +243,8 @@ export default new Command({
description: "This is a named subcommand, meaning that the key name is what determines the keyword to use. With default settings for example, \\"$test layer\\".",
endpoint: false,
usage: '',
permission: null,
aliases: [],
async run($: CommonLibrary): Promise<any> {
}
@ -227,6 +254,7 @@ export default new Command({
description: "This is the subcommand for getting users by pinging them or copying their ID. With default settings for example, \\"$test 237359961842253835\\". The argument will be a user object and won't run if no user is found by that ID.",
endpoint: false,
usage: '',
permission: null,
async run($: CommonLibrary): Promise<any> {
}
@ -235,6 +263,7 @@ export default new Command({
description: "This is a numeric subcommand, meaning that any type of number (excluding Infinity/NaN) will route to this command if present. With default settings for example, \\"$test -5.2\\". The argument with the number is already parsed so you can just use it without converting it.",
endpoint: false,
usage: '',
permission: null,
async run($: CommonLibrary): Promise<any> {
}
@ -243,6 +272,7 @@ export default new Command({
description: "This is a generic subcommand, meaning that if there isn't a more specific subcommand that's called, it falls to this. With default settings for example, \\"$test reeee\\".",
endpoint: false,
usage: '',
permission: null,
async run($: CommonLibrary): Promise<any> {
}

View file

@ -1,4 +1,4 @@
import {GenericWrapper, NumberWrapper, ArrayWrapper} from "./wrappers";
import {GenericWrapper, NumberWrapper, StringWrapper, ArrayWrapper} from "./wrappers";
import {Client, Message, TextChannel, DMChannel, NewsChannel, Guild, User, GuildMember, Permissions} from "discord.js";
import chalk from "chalk";
import FileManager from "./storage";
@ -11,6 +11,7 @@ export interface CommonLibrary
// Wrapper Object //
/** Wraps the value you enter with an object that provides extra functionality and provides common utility functions. */
(value: number): NumberWrapper;
(value: string): StringWrapper;
<T>(value: T[]): ArrayWrapper<T>;
<T>(value: T): GenericWrapper<T>;
@ -36,12 +37,15 @@ export interface CommonLibrary
}
export default function $(value: number): NumberWrapper;
export default function $(value: string): StringWrapper;
export default function $<T>(value: T[]): ArrayWrapper<T>;
export default function $<T>(value: T): GenericWrapper<T>;
export default function $(value: any)
{
if(isType(value, Number))
return new NumberWrapper(value);
else if(isType(value, String))
return new StringWrapper(value);
else if(isType(value, Array))
return new ArrayWrapper(value);
else
@ -311,7 +315,7 @@ export function perforate<T>(list: T[], lengthOfEachSection: number): T[][]
return sections;
}
export function isType(value: any, type: Function): boolean
export function isType(value: any, type: any): boolean
{
if(value === undefined && type === undefined)
return true;

View file

@ -41,6 +41,24 @@ export class NumberWrapper extends GenericWrapper<number>
}
}
export class StringWrapper extends GenericWrapper<string>
{
public replaceAll(before: string, after: string): string
{
let result = this.value;
while(result.indexOf(before) !== -1)
result = result.replace(before, after);
return result;
}
public toTitleCase(): string
{
return this.value.replace(/([^\W_]+[^\s-]*) */g, txt => txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase());
}
}
export class ArrayWrapper<T> extends GenericWrapper<T[]>
{
/** Returns a random element from this array. */

View file

@ -32,6 +32,7 @@ export default new Event<"message">({
if(!commands.has(header))
return;
if(message.channel.type === "text" && !message.channel.permissionsFor(message.client.user || "")?.has(Permissions.FLAGS.SEND_MESSAGES))
{
let status;
@ -57,7 +58,7 @@ export default new Event<"message">({
{
if(command.endpoint)
{
if(command.subcommands || command.user || command.number || command.any)
if(command.subcommands.size > 0 || command.user || command.number || command.any)
$.warn(`An endpoint cannot have subcommands! Check ${prefix}${header} again.`);
isEndpoint = true;
break;
@ -81,11 +82,13 @@ export default new Event<"message">({
if(!message.member)
return $.warn("This command was likely called from a DM channel meaning the member object is null.");
if(!hasPermission(message.member, permLevel))
{
const userPermLevel = getPermissionLevel(message.member);
return message.channel.send(`You don't have access to this command! Your permission level is \`${PermissionNames[userPermLevel]}\` (${userPermLevel}), but this command requires a permission level of \`${PermissionNames[permLevel]}\` (${permLevel}).`);
}
if(isEndpoint)
return message.channel.send("Too many arguments!");