Removed lenient command handling

This commit is contained in:
WatDuhHekBro 2021-04-10 14:08:36 -05:00
parent 15012c7d17
commit 3798c27df9
14 changed files with 228 additions and 201 deletions

View File

@ -11,7 +11,8 @@
- Various changes to core
- 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
- Added a `RestCommand` type, declaratively states that the following command will do `args.join(" ")`, preventing any other subcommands from being added
- Is no longer lenient to arguments when no proper subcommand fits (now it doesn't silently fail anymore), you now have to explicitly declare a `RestCommand` to get an arbitrary number of arguments
# 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

@ -26,7 +26,6 @@ const responses = [
export default new NamedCommand({
description: "Answers your question in an 8-ball manner.",
endpoint: false,
usage: "<question>",
run: "Please provide a question.",
any: new Command({

View File

@ -62,7 +62,7 @@ export const BetCommand = new NamedCommand({
// handle invalid target
if (target.id == author.id) return send("You can't bet Mons with yourself!");
else if (target.bot && process.argv[2] !== "dev") return send("You can't bet Mons with a bot!");
else if (target.bot && !IS_DEV_MODE) return send("You can't bet Mons with a bot!");
// handle invalid amount
if (amount <= 0) return send("You must bet at least one Mon!");

View File

@ -128,7 +128,7 @@ export const PayCommand = new NamedCommand({
else if (sender.money < amount)
return send("You don't have enough Mons for that.", getMoneyEmbed(author));
else if (target.id === author.id) return send("You can't send Mons to yourself!");
else if (target.bot && process.argv[2] !== "dev") return send("You can't send Mons to a bot!");
else if (target.bot && !IS_DEV_MODE) return send("You can't send Mons to a bot!");
sender.money -= amount;
receiver.money += amount;

View File

@ -1,4 +1,4 @@
import {Command, NamedCommand} from "../../core";
import {Command, NamedCommand, RestCommand} from "../../core";
const letters: {[letter: string]: string[]} = {
a: "aáàảãạâấầẩẫậăắằẳẵặ".split(""),
@ -35,7 +35,6 @@ export default new NamedCommand({
description: "Transforms your text into .",
usage: "thonk ([text])",
async run({send, message, channel, guild, author, member, client, args}) {
if (args.length > 0) phrase = args.join(" ");
const msg = await send(transform(phrase));
msg.createReactionCollector(
(reaction, user) => {
@ -44,5 +43,17 @@ export default new NamedCommand({
},
{time: 60000}
);
}
},
any: new RestCommand({
async run({send, message, channel, guild, author, member, client, args, combined}) {
const msg = await send(transform(combined));
msg.createReactionCollector(
(reaction, user) => {
if (user.id === author.id && reaction.emoji.name === "❌") msg.delete();
return false;
},
{time: 60000}
);
}
})
});

View File

@ -293,7 +293,7 @@ export default new NamedCommand({
});
send("Activity set to default.");
},
any: new Command({
any: new RestCommand({
description: `Select an activity type to set. Available levels: \`[${activities.join(", ")}]\``,
async run({send, message, channel, guild, author, member, client, args}) {
const type = args[0];

View File

@ -1,4 +1,4 @@
import {Command, NamedCommand} from "../core";
import {Command, NamedCommand, RestCommand} from "../core";
export default new NamedCommand({
async run({send, message, channel, guild, author, member, client, args}) {

View File

@ -1,11 +1,11 @@
import {Command, NamedCommand} from "../../core";
import {Command, NamedCommand, RestCommand} from "../../core";
import {processEmoteQueryFormatted} from "./modules/emote-utils";
export default new NamedCommand({
description:
"Send the specified emote list. Enter + to move an emote list to the next line, - to add a space, and _ to add a zero-width space.",
run: "Please provide a list of emotes.",
any: new Command({
any: new RestCommand({
description: "The emote(s) to send.",
usage: "<emotes...>",
async run({send, guild, channel, message, args}) {

View File

@ -1,5 +1,5 @@
import {GuildEmoji, MessageEmbed, User} from "discord.js";
import {Command, NamedCommand, paginate, SendFunction} from "../../core";
import {Command, NamedCommand, RestCommand, paginate, SendFunction} from "../../core";
import {split} from "../../lib";
import vm from "vm";
@ -11,7 +11,7 @@ export default new NamedCommand({
async run({send, message, channel, guild, author, member, client, args}) {
displayEmoteList(client.emojis.cache.array(), send, author);
},
any: new Command({
any: new RestCommand({
description:
"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({send, message, channel, guild, author, member, client, args}) {

View File

@ -1,4 +1,4 @@
import {Command, NamedCommand} from "../../core";
import {Command, NamedCommand, RestCommand} from "../../core";
import {Message, Channel, TextChannel} from "discord.js";
import {processEmoteQueryArray} from "./modules/emote-utils";
@ -6,109 +6,112 @@ export default new NamedCommand({
description:
"Reacts to the a previous message in your place. You have to react with the same emote before the bot removes that reaction.",
usage: 'react <emotes...> (<distance / message ID / "Copy ID" / "Copy Message Link">)',
async run({send, message, channel, guild, author, member, client, args}) {
let target: Message | undefined;
let distance = 1;
run: "You need to enter some emotes first.",
any: new RestCommand({
async run({send, message, channel, guild, author, member, client, args}) {
let target: Message | undefined;
let distance = 1;
if (message.reference) {
// If the command message is a reply to another message, use that as the react target.
target = await channel.messages.fetch(message.reference.messageID!);
}
// 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,})\/(\d{17,})\/(\d{17,}))$/;
const copyIDPattern = /^(?:(\d{17,})-(\d{17,}))$/;
if (message.reference) {
// If the command message is a reply to another message, use that as the react target.
target = await channel.messages.fetch(message.reference.messageID!);
}
// 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,})\/(\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)) {
const match = URLPattern.exec(last)!;
const guildID = match[1];
const channelID = match[2];
const messageID = match[3];
let tmpChannel: Channel | undefined = channel;
// https://discord.com/channels/<Guild ID>/<Channel ID>/<Message ID> ("Copy Message Link" Button)
if (URLPattern.test(last)) {
const match = URLPattern.exec(last)!;
const guildID = match[1];
const channelID = match[2];
const messageID = match[3];
let tmpChannel: Channel | undefined = channel;
if (guild?.id !== guildID) {
try {
guild = await client.guilds.fetch(guildID);
} catch {
return send(`\`${guildID}\` is an invalid guild ID!`);
if (guild?.id !== guildID) {
try {
guild = await client.guilds.fetch(guildID);
} catch {
return send(`\`${guildID}\` is an invalid guild ID!`);
}
}
}
if (tmpChannel.id !== channelID) tmpChannel = guild.channels.cache.get(channelID);
if (!tmpChannel) return send(`\`${channelID}\` is an invalid channel ID!`);
if (tmpChannel.id !== channelID) tmpChannel = guild.channels.cache.get(channelID);
if (!tmpChannel) return send(`\`${channelID}\` is an invalid channel ID!`);
if (message.id !== messageID) {
try {
target = await (tmpChannel as TextChannel).messages.fetch(messageID);
} catch {
return send(`\`${messageID}\` is an invalid message ID!`);
if (message.id !== messageID) {
try {
target = await (tmpChannel as TextChannel).messages.fetch(messageID);
} catch {
return send(`\`${messageID}\` is an invalid message ID!`);
}
}
args.pop();
}
// <Channel ID>-<Message ID> ("Copy ID" Button)
else if (copyIDPattern.test(last)) {
const match = copyIDPattern.exec(last)!;
const channelID = match[1];
const messageID = match[2];
let tmpChannel: Channel | undefined = channel;
args.pop();
}
// <Channel ID>-<Message ID> ("Copy ID" Button)
else if (copyIDPattern.test(last)) {
const match = copyIDPattern.exec(last)!;
const channelID = match[1];
const messageID = match[2];
let tmpChannel: Channel | undefined = channel;
if (tmpChannel.id !== channelID) tmpChannel = guild?.channels.cache.get(channelID);
if (!tmpChannel) return send(`\`${channelID}\` is an invalid channel ID!`);
if (tmpChannel.id !== channelID) tmpChannel = guild?.channels.cache.get(channelID);
if (!tmpChannel) return send(`\`${channelID}\` is an invalid channel ID!`);
if (message.id !== messageID) {
try {
target = await (tmpChannel as TextChannel).messages.fetch(messageID);
} catch {
return send(`\`${messageID}\` is an invalid message ID!`);
if (message.id !== messageID) {
try {
target = await (tmpChannel as TextChannel).messages.fetch(messageID);
} catch {
return send(`\`${messageID}\` is an invalid message ID!`);
}
}
args.pop();
}
// <Message ID>
else if (/^\d{17,}$/.test(last)) {
try {
target = await channel.messages.fetch(last);
} catch {
return send(`No valid message found by the ID \`${last}\`!`);
}
args.pop();
}
// <Message ID>
else if (/^\d{17,}$/.test(last)) {
try {
target = await channel.messages.fetch(last);
} catch {
return send(`No valid message found by the ID \`${last}\`!`);
args.pop();
}
// The entire string has to be a number for this to match. Prevents leaCheeseAmerican1 from triggering this.
else if (/^\d+$/.test(last)) {
distance = parseInt(last);
args.pop();
if (distance >= 0 && distance <= 99) args.pop();
else return send("Your distance must be between 0 and 99!");
}
}
// The entire string has to be a number for this to match. Prevents leaCheeseAmerican1 from triggering this.
else if (/^\d+$/.test(last)) {
distance = parseInt(last);
if (distance >= 0 && distance <= 99) args.pop();
else return send("Your distance must be between 0 and 99!");
if (!target) {
// Messages are ordered from latest to earliest.
// You also have to add 1 as well because fetchMessages includes your own message.
target = (
await message.channel.messages.fetch({
limit: distance + 1
})
).last();
}
for (const emote of processEmoteQueryArray(args)) {
// Even though the bot will always grab *some* emote, the user can choose not to keep that emote there if it isn't what they want
const reaction = await target!.react(emote);
// This part is called with a promise because you don't want to wait 5 seconds between each reaction.
setTimeout(() => {
// This reason for this null assertion is that by the time you use this command, the client is going to be loaded.
reaction.users.remove(client.user!);
}, 5000);
}
return;
}
if (!target) {
// Messages are ordered from latest to earliest.
// You also have to add 1 as well because fetchMessages includes your own message.
target = (
await message.channel.messages.fetch({
limit: distance + 1
})
).last();
}
for (const emote of processEmoteQueryArray(args)) {
// Even though the bot will always grab *some* emote, the user can choose not to keep that emote there if it isn't what they want
const reaction = await target!.react(emote);
// This part is called with a promise because you don't want to wait 5 seconds between each reaction.
setTimeout(() => {
// This reason for this null assertion is that by the time you use this command, the client is going to be loaded.
reaction.users.remove(client.user!);
}, 5000);
}
return;
}
})
});

View File

@ -1,4 +1,4 @@
import {Command, NamedCommand} from "../../core";
import {Command, NamedCommand, RestCommand} from "../../core";
import {streamList} from "../../modules/streamNotifications";
export default new NamedCommand({
@ -8,12 +8,11 @@ export default new NamedCommand({
if (streamList.has(userID)) {
const stream = streamList.get(userID)!;
const description = args.join(" ") || "No description set.";
stream.description = description;
stream.description = "No description set.";
stream.update();
send(`Successfully set the stream description to:`, {
embed: {
description,
description: "No description set.",
color: member!.displayColor
}
});
@ -21,5 +20,25 @@ export default new NamedCommand({
// Alternatively, I could make descriptions last outside of just one stream.
send("You can only use this command when streaming.");
}
}
},
any: new RestCommand({
async run({send, message, channel, guild, author, member, client, args, combined}) {
const userID = author.id;
if (streamList.has(userID)) {
const stream = streamList.get(userID)!;
stream.description = combined;
stream.update();
send(`Successfully set the stream description to:`, {
embed: {
description: stream.description,
color: member!.displayColor
}
});
} else {
// Alternatively, I could make descriptions last outside of just one stream.
send("You can only use this command when streaming.");
}
}
})
});

View File

@ -1,35 +1,43 @@
import {Command, NamedCommand} from "../../core";
import {Command, NamedCommand, RestCommand} from "../../core";
import translate from "translate-google";
export default new NamedCommand({
description: "Translates your input.",
usage: "<lang ID> <input>",
async run({send, message, channel, guild, author, member, client, args}) {
const lang = args[0];
const input = args.slice(1).join(" ");
translate(input, {
to: lang
})
.then((res) => {
send({
embed: {
title: "Translation",
fields: [
{
name: "Input",
value: `\`\`\`${input}\`\`\``
},
{
name: "Output",
value: `\`\`\`${res}\`\`\``
run: "You need to specify a language to translate to.",
any: new Command({
run: "You need to enter some text to translate.",
any: new RestCommand({
async run({send, message, channel, guild, author, member, client, args}) {
const lang = args[0];
const input = args.slice(1).join(" ");
translate(input, {
to: lang
})
.then((res) => {
send({
embed: {
title: "Translation",
fields: [
{
name: "Input",
value: `\`\`\`${input}\`\`\``
},
{
name: "Output",
value: `\`\`\`${res}\`\`\``
}
]
}
]
}
});
})
.catch((error) => {
console.error(error);
send(`${error}\nPlease use the following list: https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes`);
});
}
});
})
.catch((error) => {
console.error(error);
send(
`${error}\nPlease use the following list: https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes`
);
});
}
})
})
});

View File

@ -73,23 +73,15 @@ interface CommandMenu {
interface CommandOptionsBase {
readonly description?: string;
readonly endpoint?: boolean;
readonly usage?: string;
readonly permission?: number;
readonly nsfw?: boolean;
readonly channelType?: CHANNEL_TYPE;
}
interface CommandOptionsEndpoint {
readonly endpoint: true;
readonly run?: (($: CommandMenu) => Promise<any>) | string;
}
// Prevents subcommands from being added by compile-time.
// Also, contrary to what you might think, channel pings do still work in DM channels.
// Role pings, maybe not, but it's not a big deal.
interface CommandOptionsNonEndpoint {
readonly endpoint?: false;
interface CommandOptions extends CommandOptionsBase {
readonly run?: (($: CommandMenu) => Promise<any>) | string;
readonly subcommands?: {[key: string]: NamedCommand};
readonly channel?: Command;
@ -103,11 +95,14 @@ interface CommandOptionsNonEndpoint {
readonly any?: Command | RestCommand;
}
type CommandOptions = CommandOptionsBase & (CommandOptionsEndpoint | CommandOptionsNonEndpoint);
type NamedCommandOptions = CommandOptions & {aliases?: string[]; nameOverride?: string};
type RestCommandOptions = CommandOptionsBase & {
run?: (($: CommandMenu & {readonly combined: string}) => Promise<any>) | string;
};
interface NamedCommandOptions extends CommandOptions {
readonly aliases?: string[];
readonly nameOverride?: string;
}
interface RestCommandOptions extends CommandOptionsBase {
readonly run?: (($: CommandMenu & {readonly combined: string}) => Promise<any>) | string;
}
interface ExecuteCommandMetadata {
readonly header: string;
@ -164,7 +159,6 @@ abstract class BaseCommand {
// 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;
@ -182,31 +176,20 @@ export class Command extends BaseCommand {
constructor(options?: CommandOptions) {
super(options);
this.endpoint = !!options?.endpoint;
this.run = options?.run || "No action was set on this command!";
this.subcommands = new Collection(); // Populate this collection after setting subcommands.
this.channel = null;
this.role = null;
this.emote = null;
this.message = null;
this.user = null;
this.guild = null;
this.channel = options?.channel || null;
this.role = options?.role || null;
this.emote = options?.emote || null;
this.message = options?.message || null;
this.user = options?.user || null;
this.guild = options?.guild || null;
this.id = null;
this.idType = null;
this.number = null;
this.any = 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;
this.idType = options?.id || null;
this.number = options?.number || null;
this.any = options?.any || null;
if (options)
switch (options.id) {
case "channel":
this.id = this.channel;
@ -232,30 +215,29 @@ export class Command extends BaseCommand {
requireAllCasesHandledFor(options.id);
}
if (options.subcommands) {
const baseSubcommands = Object.keys(options.subcommands);
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]);
// 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.name = name;
const aliases = subcmd.aliases;
// 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.name = name;
const aliases = subcmd.aliases;
for (const alias of aliases) {
if (baseSubcommands.includes(alias))
console.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))
console.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);
}
for (const alias of aliases) {
if (baseSubcommands.includes(alias))
console.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))
console.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);
}
}
}
@ -319,9 +301,6 @@ export class Command extends BaseCommand {
return null;
}
// 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) 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).
const isMessageLink = patterns.messageLink.test(param);
@ -506,11 +485,15 @@ export class Command extends BaseCommand {
} else if (this.any instanceof RestCommand) {
metadata.symbolicArgs.push("<...>");
args.unshift(param);
menu.args.push(...args);
return this.any.execute(args.join(" "), 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);
metadata.symbolicArgs.push(`"${param}"`);
return {
content: `No valid command sequence matching \`${metadata.header} ${metadata.symbolicArgs.join(
" "
)}\` found.`
};
}
// Note: Do NOT add a return statement here. In case one of the other sections is missing
@ -726,7 +709,9 @@ export class RestCommand extends BaseCommand {
} else {
// Then capture any potential errors.
try {
await this.run({...menu, combined});
// Args will still be kept intact. A common pattern is popping some parameters off the end then doing some branching.
// That way, you can still declaratively mark an argument list as continuing while also handling the individual args.
await this.run({...menu, args: menu.args, combined});
} catch (error) {
const errorMessage = error.stack ?? error;
console.error(`Command Error: ${metadata.header} (${metadata.args.join(", ")})\n${errorMessage}`);

View File

@ -21,8 +21,7 @@ const lastCommandInfo: {
const defaultMetadata = {
permission: 0,
nsfw: false,
channelType: 0, // CHANNEL_TYPE.ANY, apparently isn't initialized at this point yet
symbolicArgs: []
channelType: 0 // CHANNEL_TYPE.ANY, apparently isn't initialized at this point yet
};
// Note: client.user is only undefined before the bot logs in, so by this point, client.user cannot be undefined.
@ -67,7 +66,8 @@ export function attachMessageHandlerToClient(client: Client) {
const result = await command.execute(args, menu, {
header,
args: [...args],
...defaultMetadata
...defaultMetadata,
symbolicArgs: []
});
// If something went wrong, let the user know (like if they don't have permission to use a command).
@ -104,7 +104,8 @@ export function attachMessageHandlerToClient(client: Client) {
const result = await command.execute(args, menu, {
header,
args: [...args],
...defaultMetadata
...defaultMetadata,
symbolicArgs: []
});
// If something went wrong, let the user know (like if they don't have permission to use a command).