Split command resolution part of help command

This commit is contained in:
WatDuhHekBro 2021-04-09 23:06:16 -05:00
parent 20fb2135c7
commit 72ff144cc0
12 changed files with 146 additions and 86 deletions

View File

@ -1,61 +1,28 @@
import {Command, NamedCommand, loadableCommands, categories, getPermissionName, CHANNEL_TYPE} from "../../core";
import {toTitleCase, requireAllCasesHandledFor} from "../../lib";
import {Command, NamedCommand, CHANNEL_TYPE, getPermissionName, getCommandList, getCommandInfo} from "../../core";
import {requireAllCasesHandledFor} from "../../lib";
export default new NamedCommand({
description: "Lists all commands. If a command is specified, their arguments are listed as well.",
usage: "([command, [subcommand/type], ...])",
aliases: ["h"],
async run({message, channel, guild, author, member, client, args}) {
const commands = await loadableCommands;
const commands = await getCommandList();
let output = `Legend: \`<type>\`, \`[list/of/stuff]\`, \`(optional)\`, \`(<optional type>)\`, \`([optional/list/...])\``;
for (const [category, headers] of categories) {
let tmp = `\n\n===[ ${toTitleCase(category)} ]===`;
// Ignore empty categories, including ["test"].
let hasActualCommands = false;
for (const header of headers) {
if (header !== "test") {
const command = commands.get(header)!;
tmp += `\n- \`${header}\`: ${command.description}`;
hasActualCommands = true;
}
}
if (hasActualCommands) output += tmp;
for (const [category, commandList] of commands) {
output += `\n\n===[ ${category} ]===`;
for (const command of commandList) output += `\n- \`${command.name}\`: ${command.description}`;
}
channel.send(output, {split: true});
},
any: new Command({
async run({message, channel, guild, author, member, client, args}) {
// Setup the root command
const commands = await loadableCommands;
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 instanceof NamedCommand))
return channel.send(`Command is not a proper instance of NamedCommand.`);
if (command.name) header = command.name;
// Search categories
let category = "Unknown";
for (const [referenceCategory, headers] of categories) {
if (headers.includes(header)) {
category = toTitleCase(referenceCategory);
break;
}
}
// Gather info
const result = await command.resolveInfo(args);
if (result.type === "error") return channel.send(result.message);
const [result, category] = await getCommandInfo(args);
if (typeof result === "string") return channel.send(result);
let append = "";
command = result.command;
if (result.args.length > 0) header += " " + result.args.join(" ");
const command = result.command;
const header = result.args.length > 0 ? `${result.header} ${result.args.join(" ")}` : result.header;
if (command.usage === "") {
const list: string[] = [];

View File

@ -102,7 +102,7 @@ export default new NamedCommand({
description: "Display info about a guild by finding its name or ID.",
async run({message, channel, guild, author, member, client, args}) {
// If a guild ID is provided (avoid the "number" subcommand because of inaccuracies), search for that guild
if (args.length === 1 && /^\d{17,19}$/.test(args[0])) {
if (args.length === 1 && /^\d{17,}$/.test(args[0])) {
const id = args[0];
const targetGuild = client.guilds.cache.get(id);

View File

@ -16,7 +16,7 @@ export default new NamedCommand({
"Filters emotes by via a regular expression. Flags can be added by adding a dash at the end. For example, to do a case-insensitive search, do %prefix%lsemotes somepattern -i",
async run({message, channel, guild, author, member, client, args}) {
// If a guild ID is provided, filter all emotes by that guild (but only if there aren't any arguments afterward)
if (args.length === 1 && /^\d{17,19}$/.test(args[0])) {
if (args.length === 1 && /^\d{17,}$/.test(args[0])) {
const guildID: string = args[0];
displayEmoteList(

View File

@ -17,8 +17,8 @@ export default new NamedCommand({
// handles reacts by message id/distance
else if (args.length >= 2) {
const last = args[args.length - 1]; // Because this is optional, do not .pop() unless you're sure it's a message link indicator.
const URLPattern = /^(?:https:\/\/discord.com\/channels\/(\d{17,19})\/(\d{17,19})\/(\d{17,19}))$/;
const copyIDPattern = /^(?:(\d{17,19})-(\d{17,19}))$/;
const URLPattern = /^(?:https:\/\/discord.com\/channels\/(\d{17,})\/(\d{17,})\/(\d{17,}))$/;
const copyIDPattern = /^(?:(\d{17,})-(\d{17,}))$/;
// https://discord.com/channels/<Guild ID>/<Channel ID>/<Message ID> ("Copy Message Link" Button)
if (URLPattern.test(last)) {
@ -70,7 +70,7 @@ export default new NamedCommand({
args.pop();
}
// <Message ID>
else if (/^\d{17,19}$/.test(last)) {
else if (/^\d{17,}$/.test(last)) {
try {
target = await channel.messages.fetch(last);
} catch {

View File

@ -30,14 +30,16 @@ import {parseVars, requireAllCasesHandledFor} from "../lib";
*/
// RegEx patterns used for identifying/extracting each type from a string argument.
// The reason why \d{17,} is used is because the max safe number for JS numbers is 16 characters when stringified (decimal). Beyond that are IDs.
const patterns = {
channel: /^<#(\d{17,19})>$/,
role: /^<@&(\d{17,19})>$/,
emote: /^<a?:.*?:(\d{17,19})>$/,
messageLink: /^https?:\/\/(?:ptb\.|canary\.)?discord(?:app)?\.com\/channels\/(?:\d{17,19}|@me)\/(\d{17,19})\/(\d{17,19})$/,
messagePair: /^(\d{17,19})-(\d{17,19})$/,
user: /^<@!?(\d{17,19})>$/,
id: /^(\d{17,19})$/
channel: /^<#(\d{17,})>$/,
role: /^<@&(\d{17,})>$/,
emote: /^<a?:.*?:(\d{17,})>$/,
// The message type won't include <username>#<tag>. At that point, you may as well just use a search usernames function. Even then, tags would only be taken into account to differentiate different users with identical usernames.
messageLink: /^https?:\/\/(?:ptb\.|canary\.)?discord(?:app)?\.com\/channels\/(?:\d{17,}|@me)\/(\d{17,})\/(\d{17,})$/,
messagePair: /^(\d{17,})-(\d{17,})$/,
user: /^<@!?(\d{17,})>$/,
id: /^(\d{17,})$/
};
// Maybe add a guild redirect... somehow?
@ -106,9 +108,10 @@ interface ExecuteCommandMetadata {
permission: number;
nsfw: boolean;
channelType: CHANNEL_TYPE;
symbolicArgs: string[]; // i.e. <channel> instead of <#...>
}
interface CommandInfo {
export interface CommandInfo {
readonly type: "info";
readonly command: Command;
readonly subcommandInfo: Collection<string, Command>;
@ -117,6 +120,7 @@ interface CommandInfo {
readonly nsfw: boolean;
readonly channelType: CHANNEL_TYPE;
readonly args: string[];
readonly header: string;
}
interface CommandInfoError {
@ -131,14 +135,9 @@ interface CommandInfoMetadata {
args: string[];
usage: string;
readonly originalArgs: string[];
readonly header: string;
}
export const defaultMetadata = {
permission: 0,
nsfw: false,
channelType: CHANNEL_TYPE.ANY
};
// Each Command instance represents a block that links other Command instances under it.
export class Command {
public readonly description: string;
@ -298,12 +297,15 @@ export class Command {
// 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.channel.send(
parseVars(
this.run,
{
author: menu.author.toString(),
prefix: getPrefix(menu.guild)
prefix: getPrefix(menu.guild),
command: `${metadata.header} ${metadata.symbolicArgs.join(", ")}`
},
"???"
)
@ -332,6 +334,7 @@ export class Command {
const isMessagePair = patterns.messagePair.test(param);
if (this.subcommands.has(param)) {
metadata.symbolicArgs.push(param);
return this.subcommands.get(param)!.execute(args, menu, metadata);
} else if (this.channel && patterns.channel.test(param)) {
const id = patterns.channel.exec(param)![1];
@ -339,6 +342,7 @@ export class Command {
// Users can only enter in this format for text channels, so this restricts it to that.
if (channel instanceof TextChannel) {
metadata.symbolicArgs.push("<channel>");
menu.args.push(channel);
return this.channel.execute(args, menu, metadata);
} else {
@ -358,6 +362,7 @@ export class Command {
const role = menu.guild.roles.cache.get(id);
if (role) {
metadata.symbolicArgs.push("<role>");
menu.args.push(role);
return this.role.execute(args, menu, metadata);
} else {
@ -370,6 +375,7 @@ export class Command {
const emote = menu.client.emojis.cache.get(id);
if (emote) {
metadata.symbolicArgs.push("<emote>");
menu.args.push(emote);
return this.emote.execute(args, menu, metadata);
} else {
@ -395,6 +401,7 @@ export class Command {
if (channel instanceof TextChannel || channel instanceof DMChannel) {
try {
metadata.symbolicArgs.push("<message>");
menu.args.push(await channel.messages.fetch(messageID));
return this.message.execute(args, menu, metadata);
} catch {
@ -411,6 +418,7 @@ export class Command {
const id = patterns.user.exec(param)![1];
try {
metadata.symbolicArgs.push("<user>");
menu.args.push(await menu.client.users.fetch(id));
return this.user.execute(args, menu, metadata);
} catch {
@ -419,6 +427,7 @@ export class Command {
};
}
} else if (this.id && this.idType && patterns.id.test(param)) {
metadata.symbolicArgs.push("<id>");
const id = patterns.id.exec(param)![1];
// Probably modularize the findXByY code in general in libd.
@ -486,9 +495,11 @@ export class Command {
requireAllCasesHandledFor(this.idType);
}
} else if (this.number && !Number.isNaN(Number(param)) && param !== "Infinity" && param !== "-Infinity") {
metadata.symbolicArgs.push("<number>");
menu.args.push(Number(param));
return this.number.execute(args, menu, metadata);
} else if (this.any) {
metadata.symbolicArgs.push("<any>");
menu.args.push(param);
return this.any.execute(args, menu, metadata);
} else {
@ -502,8 +513,16 @@ 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[]): Promise<CommandInfo | CommandInfoError> {
return this.resolveInfoInternal(args, {...defaultMetadata, args: [], usage: "", originalArgs: [...args]});
public async resolveInfo(args: string[], header: string): Promise<CommandInfo | CommandInfoError> {
return this.resolveInfoInternal(args, {
permission: 0,
nsfw: false,
channelType: CHANNEL_TYPE.ANY,
header,
args: [],
usage: "",
originalArgs: [...args]
});
}
private async resolveInfoInternal(

View File

@ -1,6 +1,5 @@
import {Client, Permissions, Message, TextChannel, DMChannel, NewsChannel} from "discord.js";
import {loadableCommands} from "./loader";
import {defaultMetadata} from "./command";
import {getPrefix} from "./interface";
// For custom message events that want to cancel the command handler on certain conditions.
@ -20,6 +19,13 @@ const lastCommandInfo: {
channel: null
};
const defaultMetadata = {
permission: 0,
nsfw: false,
channelType: 0, // CHANNEL_TYPE.ANY, apparently isn't initialized at this point yet
symbolicArgs: []
};
// Note: client.user is only undefined before the bot logs in, so by this point, client.user cannot be undefined.
// Note: guild.available will never need to be checked because the message starts in either a DM channel or an already-available guild.
export function attachMessageHandlerToClient(client: Client) {

View File

@ -1,3 +1,4 @@
// Onion Lasers Command Handler //
export {Command, NamedCommand, CHANNEL_TYPE} from "./command";
export {addInterceptRule} from "./handler";
export {launch} from "./interface";
@ -12,5 +13,5 @@ export {
getMemberByUsername,
callMemberByUsername
} from "./libd";
export {loadableCommands, categories} from "./loader";
export {getCommandList, getCommandInfo} from "./loader";
export {hasPermission, getPermissionLevel, getPermissionName} from "./permissions";

View File

@ -25,6 +25,11 @@ export function botHasPermission(guild: Guild | null, permission: number): boole
// Pagination function that allows for customization via a callback.
// Define your own pages outside the function because this only manages the actual turning of pages.
const FIVE_BACKWARDS_EMOJI = "⏪";
const BACKWARDS_EMOJI = "⬅️";
const FORWARDS_EMOJI = "➡️";
const FIVE_FORWARDS_EMOJI = "⏩";
/**
* Takes a message and some additional parameters and makes a reaction page with it. All the pagination logic is taken care of but nothing more, the page index is returned and you have to send a callback to do something with it.
*/
@ -43,30 +48,42 @@ export async function paginate(
const turn = (amount: number) => {
page += amount;
if (page < 0) page += total;
else if (page >= total) page -= total;
if (page >= total) {
page %= total;
} else if (page < 0) {
// Assuming 3 total pages, it's a bit tricker, but if we just take the modulo of the absolute value (|page| % total), we get (1 2 0 ...), and we just need the pattern (2 1 0 ...). It needs to reverse order except for when it's 0. I want to find a better solution, but for the time being... total - (|page| % total) unless (|page| % total) = 0, then return 0.
const flattened = Math.abs(page) % total;
if (flattened !== 0) page = total - flattened;
}
message.edit(callback(page, true));
};
const BACKWARDS_EMOJI = "⬅️";
const FORWARDS_EMOJI = "➡️";
const handle = (emote: string, reacterID: string) => {
if (senderID === reacterID) {
switch (emote) {
case FIVE_BACKWARDS_EMOJI:
if (total > 5) turn(-5);
break;
case BACKWARDS_EMOJI:
turn(-1);
break;
case FORWARDS_EMOJI:
turn(1);
break;
case FIVE_FORWARDS_EMOJI:
if (total > 5) turn(5);
break;
}
}
};
// Listen for reactions and call the handler.
let backwardsReactionFive = total > 5 ? await message.react(FIVE_BACKWARDS_EMOJI) : null;
let backwardsReaction = await message.react(BACKWARDS_EMOJI);
let forwardsReaction = await message.react(FORWARDS_EMOJI);
let forwardsReactionFive = total > 5 ? await message.react(FIVE_FORWARDS_EMOJI) : null;
unreactEventListeners.set(message.id, handle);
const collector = message.createReactionCollector(
(reaction, user) => {
if (user.id === senderID) {
@ -88,8 +105,10 @@ export async function paginate(
// When time's up, remove the bot's own reactions.
collector.on("end", () => {
unreactEventListeners.delete(message.id);
backwardsReactionFive?.users.remove(message.author);
backwardsReaction.users.remove(message.author);
forwardsReaction.users.remove(message.author);
forwardsReactionFive?.users.remove(message.author);
});
}
}

View File

@ -1,13 +1,14 @@
import {Collection} from "discord.js";
import glob from "glob";
import {Command, NamedCommand} from "./command";
import {NamedCommand, CommandInfo} from "./command";
import {toTitleCase} from "../lib";
// Internally, it'll keep its original capitalization. It's up to you to convert it to title case when you make a help command.
export const categories = new Collection<string, string[]>();
const categories = new Collection<string, string[]>();
/** Returns the cache of the commands if it exists and searches the directory if not. */
export const loadableCommands = (async () => {
const commands = new Collection<string, Command>();
const commands = new Collection<string, NamedCommand>();
// Include all .ts files recursively in "src/commands/".
const files = await globP("src/commands/**/*.ts");
// Extract the usable parts from "src/commands/" if:
@ -79,3 +80,53 @@ function globP(path: string) {
});
});
}
/**
* Returns a list of categories and their associated commands.
*/
export async function getCommandList(): Promise<Collection<string, NamedCommand[]>> {
const list = new Collection<string, NamedCommand[]>();
const commands = await loadableCommands;
for (const [category, headers] of categories) {
const commandList: NamedCommand[] = [];
for (const header of headers.filter((header) => header !== "test")) commandList.push(commands.get(header)!);
// Ignore empty categories like "miscellaneous" (if it's empty).
if (commandList.length > 0) list.set(toTitleCase(category), commandList);
}
return list;
}
/**
* Resolves a command based on the arguments given.
* - Returns a string if there was an error.
* - Returns a CommandInfo/category tuple if it was a success.
*/
export async function getCommandInfo(args: string[]): Promise<[CommandInfo, string] | string> {
// Use getCommandList() instead if you're just getting the list of all commands.
if (args.length === 0) return "No arguments were provided!";
// Setup the root command
const commands = await loadableCommands;
let header = args.shift()!;
const command = commands.get(header);
if (!command || header === "test") return `No command found by the name \`${header}\`.`;
if (!(command instanceof NamedCommand)) return "Command is not a proper instance of NamedCommand.";
// If it's an alias, set the header to the original command name.
if (command.name) header = command.name;
// Search categories
let category = "Unknown";
for (const [referenceCategory, headers] of categories) {
if (headers.includes(header)) {
category = toTitleCase(referenceCategory);
break;
}
}
// Gather info
const result = await command.resolveInfo(args, header);
if (result.type === "error") return result.message;
else return [result, category];
}

View File

@ -144,14 +144,13 @@ export abstract class GenericStructure {
constructor(tag?: string) {
this.__meta__ = tag || this.__meta__;
Object.defineProperty(this, "__meta__", {
enumerable: false
});
}
public save(asynchronous = true) {
const tag = this.__meta__;
/// @ts-ignore
delete this.__meta__;
FileManager.write(tag, this, asynchronous);
this.__meta__ = tag;
FileManager.write(this.__meta__, this, asynchronous);
}
}

View File

@ -57,7 +57,7 @@ client.on("message", async (message) => {
export function extractFirstMessageLink(message: string): [string, string, string] | null {
const messageLinkMatch = message.match(
/([!<])?https?:\/\/(?:ptb\.|canary\.)?discord(?:app)?\.com\/channels\/(\d{17,19})\/(\d{17,19})\/(\d{17,19})(>)?/
/([!<])?https?:\/\/(?:ptb\.|canary\.)?discord(?:app)?\.com\/channels\/(\d{17,})\/(\d{17,})\/(\d{17,})(>)?/
);
if (messageLinkMatch === null) return null;
const [, leftToken, guildID, channelID, messageID, rightToken] = messageLinkMatch;

View File

@ -91,15 +91,13 @@ class StorageStructure extends GenericStructure {
super("storage");
this.users = {};
this.guilds = {};
for (let id in data.users) if (/\d{17,19}/g.test(id)) this.users[id] = new User(data.users[id]);
for (let id in data.guilds) if (/\d{17,19}/g.test(id)) this.guilds[id] = new Guild(data.guilds[id]);
for (let id in data.users) if (/\d{17,}/g.test(id)) this.users[id] = new User(data.users[id]);
for (let id in data.guilds) if (/\d{17,}/g.test(id)) this.guilds[id] = new Guild(data.guilds[id]);
}
/** Gets a user's profile if they exist and generate one if not. */
public getUser(id: string): User {
if (!/\d{17,19}/g.test(id))
if (!/\d{17,}/g.test(id))
console.warn(`"${id}" is not a valid user ID! It will be erased when the data loads again.`);
if (id in this.users) return this.users[id];
@ -112,7 +110,7 @@ class StorageStructure extends GenericStructure {
/** Gets a guild's settings if they exist and generate one if not. */
public getGuild(id: string): Guild {
if (!/\d{17,19}/g.test(id))
if (!/\d{17,}/g.test(id))
console.warn(`"${id}" is not a valid guild ID! It will be erased when the data loads again.`);
if (id in this.guilds) return this.guilds[id];