mirror of
https://github.com/keanuplayz/TravBot-v3.git
synced 2024-08-15 02:33:12 +00:00
Reworked Command.execute and subcommand recursion
This commit is contained in:
parent
63441b4aca
commit
6eea068909
3 changed files with 192 additions and 176 deletions
|
@ -1,9 +1,10 @@
|
|||
import {parseVars, requireAllCasesHandledFor} from "./lib";
|
||||
import {Collection} from "discord.js";
|
||||
import {Client, Message, TextChannel, DMChannel, NewsChannel, Guild, User, GuildMember} from "discord.js";
|
||||
import {Client, Message, TextChannel, DMChannel, NewsChannel, Guild, User, GuildMember, GuildChannel} from "discord.js";
|
||||
import {getPrefix} from "../core/structures";
|
||||
import {SingleMessageOptions} from "./libd";
|
||||
import {hasPermission, getPermissionLevel, getPermissionName} from "./permissions";
|
||||
import {client} from "../index";
|
||||
|
||||
export enum TYPES {
|
||||
SUBCOMMAND,
|
||||
|
@ -13,6 +14,16 @@ export enum TYPES {
|
|||
NONE
|
||||
}
|
||||
|
||||
// RegEx patterns used for identifying/extracting each type from a string argument.
|
||||
const patterns = {
|
||||
channel: /^<#(\d{17,19})>$/,
|
||||
role: /^<@&(\d{17,19})>$/,
|
||||
emote: /^<a?:.*?:(\d{17,19})>$/,
|
||||
message: /(?:\d{17,19}\/(\d{17,19})\/(\d{17,19})$)|(?:^(\d{17,19})-(\d{17,19})$)/,
|
||||
user: /^<@!?(\d{17,19})>$/,
|
||||
id: /^(\d{17,19})$/
|
||||
};
|
||||
|
||||
// Callbacks don't work with discriminated unions:
|
||||
// - https://github.com/microsoft/TypeScript/issues/41759
|
||||
// - https://github.com/microsoft/TypeScript/issues/35769
|
||||
|
@ -26,55 +37,58 @@ export enum CHANNEL_TYPE {
|
|||
}
|
||||
|
||||
interface CommandMenu {
|
||||
args: any[];
|
||||
client: Client;
|
||||
message: Message;
|
||||
channel: TextChannel | DMChannel | NewsChannel;
|
||||
guild: Guild | null;
|
||||
author: User;
|
||||
readonly args: any[];
|
||||
readonly client: Client;
|
||||
readonly message: Message;
|
||||
readonly channel: TextChannel | DMChannel | NewsChannel;
|
||||
readonly guild: Guild | null;
|
||||
readonly author: User;
|
||||
// According to the documentation, a message can be part of a guild while also not having a
|
||||
// member object for the author. This will happen if the author of a message left the guild.
|
||||
member: GuildMember | null;
|
||||
readonly member: GuildMember | null;
|
||||
}
|
||||
|
||||
interface CommandOptionsBase {
|
||||
description?: string;
|
||||
endpoint?: boolean;
|
||||
usage?: string;
|
||||
permission?: number;
|
||||
nsfw?: boolean;
|
||||
channelType?: CHANNEL_TYPE;
|
||||
run?: (($: CommandMenu) => Promise<any>) | string;
|
||||
readonly description?: string;
|
||||
readonly endpoint?: boolean;
|
||||
readonly usage?: string;
|
||||
readonly permission?: number;
|
||||
readonly nsfw?: boolean;
|
||||
readonly channelType?: CHANNEL_TYPE;
|
||||
readonly run?: (($: CommandMenu) => Promise<any>) | string;
|
||||
}
|
||||
|
||||
interface CommandOptionsEndpoint {
|
||||
endpoint: true;
|
||||
readonly endpoint: true;
|
||||
}
|
||||
|
||||
// Prevents subcommands from being added by compile-time.
|
||||
interface CommandOptionsNonEndpoint {
|
||||
endpoint?: false;
|
||||
subcommands?: {[key: string]: NamedCommand};
|
||||
user?: Command;
|
||||
number?: Command;
|
||||
any?: Command;
|
||||
readonly endpoint?: false;
|
||||
readonly subcommands?: {[key: string]: NamedCommand};
|
||||
readonly user?: Command;
|
||||
readonly number?: Command;
|
||||
readonly any?: Command;
|
||||
}
|
||||
|
||||
type CommandOptions = CommandOptionsBase & (CommandOptionsEndpoint | CommandOptionsNonEndpoint);
|
||||
type NamedCommandOptions = CommandOptions & {aliases?: string[]};
|
||||
|
||||
// RegEx patterns used for identifying/extracting each type from a string argument.
|
||||
const patterns = {
|
||||
//
|
||||
};
|
||||
interface ExecuteCommandMetadata {
|
||||
readonly header: string;
|
||||
readonly args: string[];
|
||||
permission: number;
|
||||
nsfw: boolean;
|
||||
channelType: CHANNEL_TYPE;
|
||||
}
|
||||
|
||||
export class Command {
|
||||
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;
|
||||
public readonly channelType: CHANNEL_TYPE;
|
||||
public readonly nsfw: boolean | null; // null (default) indicates to inherit
|
||||
public readonly channelType: CHANNEL_TYPE | null; // null (default) indicates to inherit
|
||||
protected run: (($: CommandMenu) => Promise<any>) | string;
|
||||
protected readonly subcommands: Collection<string, Command>; // This is the final data structure you'll actually use to work with the commands the aliases point to.
|
||||
protected user: Command | null;
|
||||
|
@ -87,8 +101,8 @@ export class Command {
|
|||
this.endpoint = !!options?.endpoint;
|
||||
this.usage = options?.usage ?? "";
|
||||
this.permission = options?.permission ?? -1;
|
||||
this.nsfw = !!options?.nsfw;
|
||||
this.channelType = options?.channelType ?? CHANNEL_TYPE.ANY;
|
||||
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.user = null;
|
||||
|
@ -129,116 +143,135 @@ export class Command {
|
|||
}
|
||||
}
|
||||
|
||||
public execute($: CommandMenu) {
|
||||
if (typeof this.run === "string") {
|
||||
$.channel.send(
|
||||
parseVars(
|
||||
this.run,
|
||||
{
|
||||
author: $.author.toString(),
|
||||
prefix: getPrefix($.guild)
|
||||
},
|
||||
"???"
|
||||
)
|
||||
);
|
||||
} else this.run($).catch(handler.bind($));
|
||||
}
|
||||
|
||||
// Go through the arguments provided and find the right subcommand, then execute with the given arguments.
|
||||
// Will return null if it successfully executes, SingleMessageOptions if there's an error (to let the user know what it is).
|
||||
public async actualExecute(args: string[], tmp: any): Promise<SingleMessageOptions | null> {
|
||||
// For debug info, use this.originalCommandName?
|
||||
// Subcommand Recursion //
|
||||
let command: Command = new Command(); // = commands.get(header)!;
|
||||
//resolveSubcommand()
|
||||
const params: any[] = [];
|
||||
let isEndpoint = false;
|
||||
let permLevel = command.permission ?? 0;
|
||||
public async execute(
|
||||
args: string[],
|
||||
menu: CommandMenu,
|
||||
metadata: ExecuteCommandMetadata
|
||||
): Promise<SingleMessageOptions | null> {
|
||||
const param = args.shift();
|
||||
|
||||
for (const param of args) {
|
||||
const type = command.resolve(param);
|
||||
command = command.get(param);
|
||||
permLevel = command.permission ?? permLevel;
|
||||
// If there are no arguments left, execute the current command. Otherwise, continue on.
|
||||
if (!param) {
|
||||
// See if there is anything that'll prevent the user from executing the command.
|
||||
|
||||
if (type === TYPES.USER) {
|
||||
const id = param.match(/\d+/g)![0];
|
||||
try {
|
||||
params.push(await message.client.users.fetch(id));
|
||||
} catch (error) {
|
||||
return message.channel.send(`No user found by the ID \`${id}\`!`);
|
||||
// 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)
|
||||
) {
|
||||
return {content: "This command must be executed in a server."};
|
||||
} else if (
|
||||
metadata.channelType === CHANNEL_TYPE.DM &&
|
||||
(menu.channel.type !== "dm" || menu.guild !== 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") {
|
||||
await menu.channel.send(
|
||||
parseVars(
|
||||
this.run,
|
||||
{
|
||||
author: menu.author.toString(),
|
||||
prefix: getPrefix(menu.guild)
|
||||
},
|
||||
"???"
|
||||
)
|
||||
);
|
||||
} else {
|
||||
await this.run(menu);
|
||||
}
|
||||
} else if (type === TYPES.NUMBER) params.push(Number(param));
|
||||
else if (type !== TYPES.SUBCOMMAND) params.push(param);
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
const errorMessage = error.stack ?? error;
|
||||
console.error(`Command Error: ${metadata.header} (${metadata.args})\n${errorMessage}`);
|
||||
|
||||
return {
|
||||
content: `There was an error while trying to execute that command!\`\`\`${errorMessage}\`\`\``
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!message.member)
|
||||
return console.warn("This command was likely called from a DM channel meaning the member object is 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 (!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 \`${getPermissionName(
|
||||
userPermLevel
|
||||
)}\` (${userPermLevel}), but this command requires a permission level of \`${getPermissionName(
|
||||
permLevel
|
||||
)}\` (${permLevel}).`
|
||||
);
|
||||
// If the current command's permission level isn't -1 (inherit), then set the permission metadata equal to that.
|
||||
if (this.permission !== -1) metadata.permission = this.permission;
|
||||
|
||||
// If the current command has an NSFW setting specified, set it.
|
||||
if (this.nsfw !== null) metadata.nsfw = this.nsfw;
|
||||
|
||||
// If the current command doesn't inherit its channel type, set it.
|
||||
if (this.channelType !== null) metadata.channelType = this.channelType;
|
||||
|
||||
// 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).
|
||||
if (this.subcommands.has(param)) {
|
||||
return this.subcommands.get(param)!.execute(args, menu, metadata);
|
||||
} else if (this.user && patterns.user.test(param)) {
|
||||
const id = patterns.user.exec(param)![1];
|
||||
|
||||
try {
|
||||
menu.args.push(await client.users.fetch(id));
|
||||
return this.user.execute(args, menu, metadata);
|
||||
} catch {
|
||||
return {
|
||||
content: `No user found by the ID \`${id}\`!`
|
||||
};
|
||||
}
|
||||
} else if (this.number && !Number.isNaN(Number(param)) && param !== "Infinity" && param !== "-Infinity") {
|
||||
menu.args.push(Number(param));
|
||||
return this.number.execute(args, menu, metadata);
|
||||
} else if (this.any) {
|
||||
menu.args.push(param);
|
||||
return this.any.execute(args, menu, metadata);
|
||||
} else {
|
||||
// Continue adding on the rest of the arguments if there's no valid subcommand.
|
||||
menu.args.push(param);
|
||||
return this.execute(args, menu, metadata);
|
||||
}
|
||||
|
||||
if (isEndpoint) return message.channel.send("Too many arguments!");
|
||||
|
||||
command.execute({
|
||||
args: params,
|
||||
author: message.author,
|
||||
channel: message.channel,
|
||||
client: message.client,
|
||||
guild: message.guild,
|
||||
member: message.member,
|
||||
message: message
|
||||
});
|
||||
|
||||
return null;
|
||||
// Note: Do NOT add a return statement here. In case one of the other sections is missing
|
||||
// a return statement, there'll be a compile error to catch that.
|
||||
}
|
||||
}
|
||||
|
||||
private resolve(param: string): TYPES {
|
||||
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)) return TYPES.USER;
|
||||
// Disallow infinity and allow for 0.
|
||||
else if (this.number && (Number(param) || param === "0") && !param.includes("Infinity")) return TYPES.NUMBER;
|
||||
else if (this.any) return TYPES.ANY;
|
||||
else return TYPES.NONE;
|
||||
}
|
||||
export class NamedCommand extends Command {
|
||||
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 get(param: string): Command {
|
||||
const type = this.resolve(param);
|
||||
let command: Command;
|
||||
|
||||
switch (type) {
|
||||
case TYPES.SUBCOMMAND:
|
||||
command = this.subcommands.get(param)!;
|
||||
break;
|
||||
case TYPES.USER:
|
||||
command = this.user!;
|
||||
break;
|
||||
case TYPES.NUMBER:
|
||||
command = this.number!;
|
||||
break;
|
||||
case TYPES.ANY:
|
||||
command = this.any!;
|
||||
break;
|
||||
case TYPES.NONE:
|
||||
command = this;
|
||||
break;
|
||||
default:
|
||||
requireAllCasesHandledFor(type);
|
||||
}
|
||||
|
||||
return command;
|
||||
constructor(options?: NamedCommandOptions) {
|
||||
super(options);
|
||||
this.aliases = options?.aliases || [];
|
||||
this.originalCommandName = null;
|
||||
}
|
||||
|
||||
// Returns: [category, command name, command, available subcommands: [type, subcommand]]
|
||||
public async resolveInfo(args: string[]): [string, string, Command, Collection<string, Command>] | null {
|
||||
// For debug info, use this.originalCommandName? (if it exists?)
|
||||
const commands = await loadableCommands;
|
||||
let header = args.shift();
|
||||
let command = commands.get(header);
|
||||
|
@ -307,31 +340,3 @@ export class Command {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class NamedCommand extends Command {
|
||||
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?
|
||||
|
||||
constructor(options?: NamedCommandOptions) {
|
||||
super(options);
|
||||
this.aliases = options?.aliases || [];
|
||||
this.originalCommandName = null;
|
||||
}
|
||||
}
|
||||
|
||||
// If you use promises, use this function to display the error in chat.
|
||||
// Case #1: await $.channel.send(""); --> Automatically caught by Command.execute().
|
||||
// Case #2: $.channel.send("").catch(handler.bind($)); --> Manually caught by the user.
|
||||
// TODO: Find a way to catch unhandled rejections automatically, forgoing the need for this.
|
||||
export function handler(this: CommandMenu, error: Error) {
|
||||
if (this)
|
||||
this.channel.send(
|
||||
`There was an error while trying to execute that command!\`\`\`${error.stack ?? error}\`\`\``
|
||||
);
|
||||
else
|
||||
console.warn(
|
||||
"No context was attached to $.handler! Make sure to use .catch($.handler.bind($)) or .catch(error => $.handler(error)) instead!"
|
||||
);
|
||||
|
||||
console.error(error);
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ import {loadableCommands} from "./loader";
|
|||
import {Permissions, Message} from "discord.js";
|
||||
import {getPrefix} from "./structures";
|
||||
import {Config} from "./structures";
|
||||
import {CHANNEL_TYPE} from "./command";
|
||||
|
||||
// For custom message events that want to cancel the command handler on certain conditions.
|
||||
const interceptRules: ((message: Message) => boolean)[] = [(message) => message.author.bot];
|
||||
|
@ -19,17 +20,16 @@ client.on("message", async (message) => {
|
|||
}
|
||||
}
|
||||
|
||||
const {author, channel, content, guild, member} = message;
|
||||
|
||||
// Continue if the bot has permission to send messages in this channel.
|
||||
if (
|
||||
message.channel.type === "dm" ||
|
||||
message.channel.permissionsFor(client.user!)!.has(Permissions.FLAGS.SEND_MESSAGES)
|
||||
) {
|
||||
const text = message.content;
|
||||
const prefix = getPrefix(message.guild);
|
||||
if (channel.type === "dm" || channel.permissionsFor(client.user!)!.has(Permissions.FLAGS.SEND_MESSAGES)) {
|
||||
const text = content;
|
||||
const prefix = getPrefix(guild);
|
||||
|
||||
// First, test if the message is just a ping to the bot.
|
||||
if (new RegExp(`^<@!?${client.user!.id}>$`).test(text)) {
|
||||
message.channel.send(`${message.author}, my prefix on this guild is \`${prefix}\`.`);
|
||||
channel.send(`${author}, my prefix on this guild is \`${prefix}\`.`);
|
||||
}
|
||||
// Then check if it's a normal command.
|
||||
else if (text.startsWith(prefix)) {
|
||||
|
@ -41,25 +41,36 @@ client.on("message", async (message) => {
|
|||
|
||||
// Send the arguments to the command to resolve and execute.
|
||||
// TMP[MAKE SURE TO REPLACE WITH command.execute WHEN FINISHED]
|
||||
const result = await command.actualExecute(args, {
|
||||
author: message.author,
|
||||
channel: message.channel,
|
||||
client: message.client,
|
||||
guild: message.guild,
|
||||
member: message.member,
|
||||
message: message
|
||||
});
|
||||
const result = await command.execute(
|
||||
args,
|
||||
{
|
||||
author,
|
||||
channel,
|
||||
client,
|
||||
guild,
|
||||
member,
|
||||
message,
|
||||
args: []
|
||||
},
|
||||
{
|
||||
header,
|
||||
args,
|
||||
permission: 0,
|
||||
nsfw: false,
|
||||
channelType: CHANNEL_TYPE.ANY
|
||||
}
|
||||
);
|
||||
|
||||
// If something went wrong, let the user know (like if they don't have permission to use a command).
|
||||
if (result) {
|
||||
message.channel.send(result);
|
||||
channel.send(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
message.author.send(
|
||||
`I don't have permission to send messages in ${message.channel}. ${
|
||||
message.member!.hasPermission(Permissions.FLAGS.ADMINISTRATOR)
|
||||
author.send(
|
||||
`I don't have permission to send messages in ${channel}. ${
|
||||
member!.hasPermission(Permissions.FLAGS.ADMINISTRATOR)
|
||||
? "Because you're a server admin, you have the ability to change that channel's permissions to match if that's what you intended."
|
||||
: "Try using a different channel or contacting a server admin to change permissions of that channel if you think something's wrong."
|
||||
}`
|
||||
|
|
|
@ -15,7 +15,7 @@ export const PermissionLevels: PermissionLevel[] = [
|
|||
{
|
||||
// MOD //
|
||||
name: "Moderator",
|
||||
check: (_, member) =>
|
||||
check: (_user, member) =>
|
||||
!!member &&
|
||||
(member.hasPermission(Permissions.FLAGS.MANAGE_ROLES) ||
|
||||
member.hasPermission(Permissions.FLAGS.MANAGE_MESSAGES) ||
|
||||
|
@ -25,12 +25,12 @@ export const PermissionLevels: PermissionLevel[] = [
|
|||
{
|
||||
// ADMIN //
|
||||
name: "Administrator",
|
||||
check: (_, member) => !!member && member.hasPermission(Permissions.FLAGS.ADMINISTRATOR)
|
||||
check: (_user, member) => !!member && member.hasPermission(Permissions.FLAGS.ADMINISTRATOR)
|
||||
},
|
||||
{
|
||||
// OWNER //
|
||||
name: "Server Owner",
|
||||
check: (_, member) => !!member && member.guild.ownerID === member.id
|
||||
check: (_user, member) => !!member && member.guild.ownerID === member.id
|
||||
},
|
||||
{
|
||||
// BOT_SUPPORT //
|
||||
|
@ -52,13 +52,13 @@ export const PermissionLevels: PermissionLevel[] = [
|
|||
// After checking the lengths of these three objects, use this as the length for consistency.
|
||||
const length = PermissionLevels.length;
|
||||
|
||||
export function hasPermission(member: GuildMember, permission: number): boolean {
|
||||
for (let i = length - 1; i >= permission; i--) if (PermissionLevels[i].check(member.user, member)) return true;
|
||||
export function hasPermission(user: User, member: GuildMember | null, permission: number): boolean {
|
||||
for (let i = length - 1; i >= permission; i--) if (PermissionLevels[i].check(user, member)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getPermissionLevel(member: GuildMember): number {
|
||||
for (let i = length - 1; i >= 0; i--) if (PermissionLevels[i].check(member.user, member)) return i;
|
||||
export function getPermissionLevel(user: User, member: GuildMember | null): number {
|
||||
for (let i = length - 1; i >= 0; i--) if (PermissionLevels[i].check(user, member)) return i;
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue