Fully isolated command handler from rest of code

This commit is contained in:
WatDuhHekBro 2021-04-05 04:26:33 -05:00
parent 44cae5c0cb
commit 03f37680e7
9 changed files with 193 additions and 180 deletions

View File

@ -1,4 +1,4 @@
import {Command, NamedCommand} from "../../../core/command";
import {Command, NamedCommand} from "../../../core";
import {Storage} from "../../../structures";
import {isAuthorized, getMoneyEmbed} from "./eco-utils";

View File

@ -1,4 +1,3 @@
import {parseVars} from "../lib";
import {
Collection,
Client,
@ -11,10 +10,24 @@ import {
GuildMember,
GuildChannel
} from "discord.js";
import {getPrefix} from "../structures";
import {SingleMessageOptions} from "./libd";
import {hasPermission, getPermissionLevel, getPermissionName} from "./permissions";
import {client} from "../index";
import {getPrefix} from "./interface";
import {parseVars} from "../lib";
/**
* ===[ Command Types ]===
* SUBCOMMAND - Any specifically-defined keywords / string literals.
* CHANNEL - <#...>
* ROLE - <@&...>
* EMOTE - <::ID> (The previous two values, animated and emote name respectively, do not matter at all for finding the emote.)
* MESSAGE - Available by using the built-in "Copy Message Link" or "Copy ID" buttons. https://discordapp.com/channels/<Guild ID>/<Channel ID>/<Message ID> or <Channel ID>-<Message ID> (automatically searches all guilds for the channel ID).
* USER - <@...> and <@!...>
* ID - Any number with 17-19 digits. Only used as a redirect to another subcommand type.
* NUMBER - Any valid number via the Number() function, except for NaN and Infinity (because those can really mess with the program).
* ANY - Generic argument case.
* NONE - No subcommands exist.
*/
// RegEx patterns used for identifying/extracting each type from a string argument.
const patterns = {
@ -264,7 +277,7 @@ export class Command {
const id = patterns.user.exec(param)![1];
try {
menu.args.push(await client.users.fetch(id));
menu.args.push(await menu.client.users.fetch(id));
return this.user.execute(args, menu, metadata);
} catch {
return {

View File

@ -1,28 +1,29 @@
import {client} from "../index";
import {Client, Permissions, Message} from "discord.js";
import {botHasPermission} from "./libd";
import {Permissions, Message} from "discord.js";
// A list of message ID and callback pairs. You get the emote name and ID of the user reacting.
export const unreactEventListeners: Map<string, (emote: string, id: string) => void> = new Map();
// Attached to the client, there can be one event listener attached to a message ID which is executed if present.
client.on("messageReactionRemove", (reaction, user) => {
const canDeleteEmotes = botHasPermission(reaction.message.guild, Permissions.FLAGS.MANAGE_MESSAGES);
if (!canDeleteEmotes) {
const callback = unreactEventListeners.get(reaction.message.id);
callback && callback(reaction.emoji.name, user.id);
}
});
// A list of "channel-message" and callback pairs. Also, I imagine that the callback will be much more maintainable when discord.js v13 comes out with a dedicated message.referencedMessage property.
// Also, I'm defining it here instead of the message event because the load order screws up if you export it from there. Yeah... I'm starting to notice just how much technical debt has been built up. The command handler needs to be modularized and refactored sooner rather than later. Define all constants in one area then grab from there.
export const replyEventListeners = new Map<string, (message: Message) => void>();
client.on("message", (message) => {
// If there's an inline reply, fire off that event listener (if it exists).
if (message.reference) {
const reference = message.reference;
replyEventListeners.get(`${reference.channelID}-${reference.messageID}`)?.(message);
}
});
export function attachEventListenersToClient(client: Client) {
// Attached to the client, there can be one event listener attached to a message ID which is executed if present.
client.on("messageReactionRemove", (reaction, user) => {
const canDeleteEmotes = botHasPermission(reaction.message.guild, Permissions.FLAGS.MANAGE_MESSAGES);
if (!canDeleteEmotes) {
const callback = unreactEventListeners.get(reaction.message.id);
callback && callback(reaction.emoji.name, user.id);
}
});
client.on("message", (message) => {
// If there's an inline reply, fire off that event listener (if it exists).
if (message.reference) {
const reference = message.reference;
replyEventListeners.get(`${reference.channelID}-${reference.messageID}`)?.(message);
}
});
}

View File

@ -1,8 +1,7 @@
import {client} from "../index";
import {Client, Permissions, Message, TextChannel, DMChannel, NewsChannel} from "discord.js";
import {loadableCommands} from "./loader";
import {Permissions, Message, TextChannel, DMChannel, NewsChannel} from "discord.js";
import {getPrefix} from "../structures";
import {defaultMetadata} from "./command";
import {getPrefix} from "./interface";
// For custom message events that want to cancel the command handler on certain conditions.
const interceptRules: ((message: Message) => boolean)[] = [(message) => message.author.bot];
@ -23,67 +22,31 @@ const lastCommandInfo: {
// 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.
client.on("message", async (message) => {
for (const shouldIntercept of interceptRules) {
if (shouldIntercept(message)) {
return;
}
}
const commands = await loadableCommands;
const {author, channel, content, guild, member} = message;
const text = content;
const menu = {
author,
channel,
client,
guild,
member,
message,
args: []
};
// Execute a dedicated block for messages in DM channels.
if (channel.type === "dm") {
// In a DM channel, simply forget about the prefix and execute any message as a command.
const [header, ...args] = text.split(/ +/);
if (commands.has(header)) {
const command = commands.get(header)!;
// Set last command info in case of unhandled rejections.
lastCommandInfo.header = header;
lastCommandInfo.args = [...args];
lastCommandInfo.channel = channel;
// Send the arguments to the command to resolve and execute.
const result = await command.execute(args, menu, {
header,
args: [...args],
...defaultMetadata
});
// If something went wrong, let the user know (like if they don't have permission to use a command).
if (result) {
channel.send(result);
export function attachMessageHandlerToClient(client: Client) {
client.on("message", async (message) => {
for (const shouldIntercept of interceptRules) {
if (shouldIntercept(message)) {
return;
}
} else {
channel.send(
`I couldn't find the command or alias that starts with \`${header}\`. To see the list of commands, type \`help\``
);
}
}
// Continue if the bot has permission to send messages in this channel.
else if (channel.permissionsFor(client.user!)!.has(Permissions.FLAGS.SEND_MESSAGES)) {
const prefix = getPrefix(guild);
// First, test if the message is just a ping to the bot.
if (new RegExp(`^<@!?${client.user!.id}>$`).test(text)) {
channel.send(`${author}, my prefix on this server is \`${prefix}\`.`);
}
// Then check if it's a normal command.
else if (text.startsWith(prefix)) {
const [header, ...args] = text.substring(prefix.length).split(/ +/);
const commands = await loadableCommands;
const {author, channel, content, guild, member} = message;
const text = content;
const menu = {
author,
channel,
client,
guild,
member,
message,
args: []
};
// Execute a dedicated block for messages in DM channels.
if (channel.type === "dm") {
// In a DM channel, simply forget about the prefix and execute any message as a command.
const [header, ...args] = text.split(/ +/);
if (commands.has(header)) {
const command = commands.get(header)!;
@ -104,20 +67,58 @@ client.on("message", async (message) => {
if (result) {
channel.send(result);
}
} else {
channel.send(
`I couldn't find the command or alias that starts with \`${header}\`. To see the list of commands, type \`help\``
);
}
}
}
// Otherwise, let the sender know that the bot doesn't have permission to send messages.
else {
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."
}`
);
}
});
// Continue if the bot has permission to send messages in this channel.
else if (channel.permissionsFor(client.user!)!.has(Permissions.FLAGS.SEND_MESSAGES)) {
const prefix = getPrefix(guild);
// First, test if the message is just a ping to the bot.
if (new RegExp(`^<@!?${client.user!.id}>$`).test(text)) {
channel.send(`${author}, my prefix on this server is \`${prefix}\`.`);
}
// Then check if it's a normal command.
else if (text.startsWith(prefix)) {
const [header, ...args] = text.substring(prefix.length).split(/ +/);
if (commands.has(header)) {
const command = commands.get(header)!;
// Set last command info in case of unhandled rejections.
lastCommandInfo.header = header;
lastCommandInfo.args = [...args];
lastCommandInfo.channel = channel;
// Send the arguments to the command to resolve and execute.
const result = await command.execute(args, menu, {
header,
args: [...args],
...defaultMetadata
});
// If something went wrong, let the user know (like if they don't have permission to use a command).
if (result) {
channel.send(result);
}
}
}
}
// Otherwise, let the sender know that the bot doesn't have permission to send messages.
else {
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."
}`
);
}
});
}
process.on("unhandledRejection", (reason: any) => {
if (reason?.name === "DiscordAPIError") {

View File

@ -1,5 +1,6 @@
export {Command, NamedCommand, CHANNEL_TYPE} from "./command";
export {addInterceptRule} from "./handler";
export {launch} from "./interface";
export {
SingleMessageOptions,
botHasPermission,

23
src/core/interface.ts Normal file
View File

@ -0,0 +1,23 @@
import {Client, User, GuildMember, Guild} from "discord.js";
import {attachMessageHandlerToClient} from "./handler";
import {attachEventListenersToClient} from "./eventListeners";
interface LaunchSettings {
permissionLevels: PermissionLevel[];
getPrefix: (guild: Guild | null) => string;
}
export async function launch(client: Client, settings: LaunchSettings) {
attachMessageHandlerToClient(client);
attachEventListenersToClient(client);
permissionLevels = settings.permissionLevels;
getPrefix = settings.getPrefix;
}
interface PermissionLevel {
name: string;
check: (user: User, member: GuildMember | null) => boolean;
}
export let permissionLevels: PermissionLevel[] = [];
export let getPrefix: (guild: Guild | null) => string = () => ".";

View File

@ -79,25 +79,3 @@ function globP(path: string) {
});
});
}
// Gathers a list of categories and top-level commands.
// Returns: new Collection<string category, Command[] commandList>()
/*export async function getCommandList(): Promise<Collection<string, Command[]>> {
const categorizedCommands = new Collection<string, Command[]>();
const commands = await loadableCommands;
for (const [category, headers] of categories) {
const commandList: Command[] = [];
for (const header of headers) {
if (header !== "test") {
// If this is somehow undefined, it'll show up as an error when implementing a help command.
commandList.push(commands.get(header)!);
}
}
categorizedCommands.set(toTitleCase(category), commandList);
}
return categorizedCommands;
}*/

View File

@ -1,68 +1,18 @@
import {User, GuildMember, Permissions} from "discord.js";
import {Config} from "../structures";
interface PermissionLevel {
name: string;
check: (user: User, member: GuildMember | null) => boolean;
}
export const PermissionLevels: PermissionLevel[] = [
{
// NONE //
name: "User",
check: () => true
},
{
// MOD //
name: "Moderator",
check: (_user, member) =>
!!member &&
(member.hasPermission(Permissions.FLAGS.MANAGE_ROLES) ||
member.hasPermission(Permissions.FLAGS.MANAGE_MESSAGES) ||
member.hasPermission(Permissions.FLAGS.KICK_MEMBERS) ||
member.hasPermission(Permissions.FLAGS.BAN_MEMBERS))
},
{
// ADMIN //
name: "Administrator",
check: (_user, member) => !!member && member.hasPermission(Permissions.FLAGS.ADMINISTRATOR)
},
{
// OWNER //
name: "Server Owner",
check: (_user, member) => !!member && member.guild.ownerID === member.id
},
{
// BOT_SUPPORT //
name: "Bot Support",
check: (user) => Config.support.includes(user.id)
},
{
// BOT_ADMIN //
name: "Bot Admin",
check: (user) => Config.admins.includes(user.id)
},
{
// BOT_OWNER //
name: "Bot Owner",
check: (user) => Config.owner === user.id
}
];
// After checking the lengths of these three objects, use this as the length for consistency.
const length = PermissionLevels.length;
import {User, GuildMember} from "discord.js";
import {permissionLevels} from "./interface";
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;
for (let i = permissionLevels.length - 1; i >= permission; i--)
if (permissionLevels[i].check(user, member)) return true;
return false;
}
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;
for (let i = permissionLevels.length - 1; i >= 0; i--) if (permissionLevels[i].check(user, member)) return i;
return 0;
}
export function getPermissionName(level: number) {
if (level > length || level < 0) return "N/A";
else return PermissionLevels[level].name;
if (level > permissionLevels.length || level < 0) return "N/A";
else return permissionLevels[level].name;
}

View File

@ -1,8 +1,9 @@
// Bootstrapping Section //
import "./modules/globals";
import {Client} from "discord.js";
import {Client, Permissions} from "discord.js";
import {launch} from "./core";
import setup from "./modules/setup";
import {Config} from "./structures";
import {Config, getPrefix} from "./structures";
// This is here in order to make it much less of a headache to access the client from other files.
// This of course won't actually do anything until the setup process is complete and it logs in.
@ -13,9 +14,54 @@ setup.init().then(() => {
client.login(Config.token).catch(setup.again);
});
// Setup the command handler.
launch(client, {
permissionLevels: [
{
// NONE //
name: "User",
check: () => true
},
{
// MOD //
name: "Moderator",
check: (_user, member) =>
!!member &&
(member.hasPermission(Permissions.FLAGS.MANAGE_ROLES) ||
member.hasPermission(Permissions.FLAGS.MANAGE_MESSAGES) ||
member.hasPermission(Permissions.FLAGS.KICK_MEMBERS) ||
member.hasPermission(Permissions.FLAGS.BAN_MEMBERS))
},
{
// ADMIN //
name: "Administrator",
check: (_user, member) => !!member && member.hasPermission(Permissions.FLAGS.ADMINISTRATOR)
},
{
// OWNER //
name: "Server Owner",
check: (_user, member) => !!member && member.guild.ownerID === member.id
},
{
// BOT_SUPPORT //
name: "Bot Support",
check: (user) => Config.support.includes(user.id)
},
{
// BOT_ADMIN //
name: "Bot Admin",
check: (user) => Config.admins.includes(user.id)
},
{
// BOT_OWNER //
name: "Bot Owner",
check: (user) => Config.owner === user.id
}
],
getPrefix: getPrefix
});
// Initialize Modules //
import "./core/handler"; // Command loading will start as soon as an instance of "core/command" is loaded, which is loaded in "core/handler".
import "./core/eventListeners";
import "./modules/ready";
import "./modules/presence";
import "./modules/lavalink";