From 72ff144cc0323d8770a009f0020b82bed1f8842b Mon Sep 17 00:00:00 2001 From: WatDuhHekBro <44940783+WatDuhHekBro@users.noreply.github.com> Date: Fri, 9 Apr 2021 23:06:16 -0500 Subject: [PATCH] Split command resolution part of help command --- src/commands/system/help.ts | 53 ++++++----------------------- src/commands/utility/info.ts | 2 +- src/commands/utility/lsemotes.ts | 2 +- src/commands/utility/react.ts | 6 ++-- src/core/command.ts | 53 +++++++++++++++++++---------- src/core/handler.ts | 8 ++++- src/core/index.ts | 3 +- src/core/libd.ts | 27 ++++++++++++--- src/core/loader.ts | 57 ++++++++++++++++++++++++++++++-- src/lib.ts | 9 +++-- src/modules/messageEmbed.ts | 2 +- src/structures.ts | 10 +++--- 12 files changed, 146 insertions(+), 86 deletions(-) diff --git a/src/commands/system/help.ts b/src/commands/system/help.ts index d205a10..53a701c 100644 --- a/src/commands/system/help.ts +++ b/src/commands/system/help.ts @@ -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: \`\`, \`[list/of/stuff]\`, \`(optional)\`, \`()\`, \`([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[] = []; diff --git a/src/commands/utility/info.ts b/src/commands/utility/info.ts index 7006a0e..6b7ca82 100644 --- a/src/commands/utility/info.ts +++ b/src/commands/utility/info.ts @@ -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); diff --git a/src/commands/utility/lsemotes.ts b/src/commands/utility/lsemotes.ts index a04f044..fef7813 100644 --- a/src/commands/utility/lsemotes.ts +++ b/src/commands/utility/lsemotes.ts @@ -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( diff --git a/src/commands/utility/react.ts b/src/commands/utility/react.ts index 5bb3eb0..42e8227 100644 --- a/src/commands/utility/react.ts +++ b/src/commands/utility/react.ts @@ -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/// ("Copy Message Link" Button) if (URLPattern.test(last)) { @@ -70,7 +70,7 @@ export default new NamedCommand({ args.pop(); } // - else if (/^\d{17,19}$/.test(last)) { + else if (/^\d{17,}$/.test(last)) { try { target = await channel.messages.fetch(last); } catch { diff --git a/src/core/command.ts b/src/core/command.ts index 0835da9..a89449b 100644 --- a/src/core/command.ts +++ b/src/core/command.ts @@ -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: /^$/, - 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: /^$/, + // The message type won't include #. 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. instead of <#...> } -interface CommandInfo { +export interface CommandInfo { readonly type: "info"; readonly command: Command; readonly subcommandInfo: Collection; @@ -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(""); 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(""); 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(""); 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(""); 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(""); 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(""); 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(""); menu.args.push(Number(param)); return this.number.execute(args, menu, metadata); } else if (this.any) { + metadata.symbolicArgs.push(""); 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 { - return this.resolveInfoInternal(args, {...defaultMetadata, args: [], usage: "", originalArgs: [...args]}); + public async resolveInfo(args: string[], header: string): Promise { + return this.resolveInfoInternal(args, { + permission: 0, + nsfw: false, + channelType: CHANNEL_TYPE.ANY, + header, + args: [], + usage: "", + originalArgs: [...args] + }); } private async resolveInfoInternal( diff --git a/src/core/handler.ts b/src/core/handler.ts index 54f8f1b..9503b3b 100644 --- a/src/core/handler.ts +++ b/src/core/handler.ts @@ -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) { diff --git a/src/core/index.ts b/src/core/index.ts index 0b1d56f..5d75c14 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -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"; diff --git a/src/core/libd.ts b/src/core/libd.ts index 84d6ea2..7f52c75 100644 --- a/src/core/libd.ts +++ b/src/core/libd.ts @@ -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); }); } } diff --git a/src/core/loader.ts b/src/core/loader.ts index d5ba5c1..8b20ad7 100644 --- a/src/core/loader.ts +++ b/src/core/loader.ts @@ -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(); +const categories = new Collection(); /** Returns the cache of the commands if it exists and searches the directory if not. */ export const loadableCommands = (async () => { - const commands = new Collection(); + const commands = new Collection(); // 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> { + const list = new Collection(); + 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]; +} diff --git a/src/lib.ts b/src/lib.ts index 8036a96..490dd13 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -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); } } diff --git a/src/modules/messageEmbed.ts b/src/modules/messageEmbed.ts index 5018ef2..7ad000c 100644 --- a/src/modules/messageEmbed.ts +++ b/src/modules/messageEmbed.ts @@ -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; diff --git a/src/structures.ts b/src/structures.ts index 351bbb6..f9065b9 100644 --- a/src/structures.ts +++ b/src/structures.ts @@ -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];