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 {Storage} from "../../../structures";
import {isAuthorized, getMoneyEmbed} from "./eco-utils"; import {isAuthorized, getMoneyEmbed} from "./eco-utils";

View File

@ -1,4 +1,3 @@
import {parseVars} from "../lib";
import { import {
Collection, Collection,
Client, Client,
@ -11,10 +10,24 @@ import {
GuildMember, GuildMember,
GuildChannel GuildChannel
} from "discord.js"; } from "discord.js";
import {getPrefix} from "../structures";
import {SingleMessageOptions} from "./libd"; import {SingleMessageOptions} from "./libd";
import {hasPermission, getPermissionLevel, getPermissionName} from "./permissions"; 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. // RegEx patterns used for identifying/extracting each type from a string argument.
const patterns = { const patterns = {
@ -264,7 +277,7 @@ export class Command {
const id = patterns.user.exec(param)![1]; const id = patterns.user.exec(param)![1];
try { 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); return this.user.execute(args, menu, metadata);
} catch { } catch {
return { return {

View File

@ -1,28 +1,29 @@
import {client} from "../index"; import {Client, Permissions, Message} from "discord.js";
import {botHasPermission} from "./libd"; 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. // 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(); 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. // 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. // 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>(); export const replyEventListeners = new Map<string, (message: Message) => void>();
client.on("message", (message) => { export function attachEventListenersToClient(client: Client) {
// If there's an inline reply, fire off that event listener (if it exists). // Attached to the client, there can be one event listener attached to a message ID which is executed if present.
if (message.reference) { client.on("messageReactionRemove", (reaction, user) => {
const reference = message.reference; const canDeleteEmotes = botHasPermission(reaction.message.guild, Permissions.FLAGS.MANAGE_MESSAGES);
replyEventListeners.get(`${reference.channelID}-${reference.messageID}`)?.(message);
} 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 {loadableCommands} from "./loader";
import {Permissions, Message, TextChannel, DMChannel, NewsChannel} from "discord.js";
import {getPrefix} from "../structures";
import {defaultMetadata} from "./command"; import {defaultMetadata} from "./command";
import {getPrefix} from "./interface";
// For custom message events that want to cancel the command handler on certain conditions. // For custom message events that want to cancel the command handler on certain conditions.
const interceptRules: ((message: Message) => boolean)[] = [(message) => message.author.bot]; 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: 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. // 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) => { export function attachMessageHandlerToClient(client: Client) {
for (const shouldIntercept of interceptRules) { client.on("message", async (message) => {
if (shouldIntercept(message)) { for (const shouldIntercept of interceptRules) {
return; 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);
} }
} 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. const commands = await loadableCommands;
if (new RegExp(`^<@!?${client.user!.id}>$`).test(text)) { const {author, channel, content, guild, member} = message;
channel.send(`${author}, my prefix on this server is \`${prefix}\`.`); const text = content;
} const menu = {
// Then check if it's a normal command. author,
else if (text.startsWith(prefix)) { channel,
const [header, ...args] = text.substring(prefix.length).split(/ +/); 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)) { if (commands.has(header)) {
const command = commands.get(header)!; const command = commands.get(header)!;
@ -104,20 +67,58 @@ client.on("message", async (message) => {
if (result) { if (result) {
channel.send(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\``
);
} }
} }
} // Continue if the bot has permission to send messages in this channel.
// Otherwise, let the sender know that the bot doesn't have permission to send messages. else if (channel.permissionsFor(client.user!)!.has(Permissions.FLAGS.SEND_MESSAGES)) {
else { const prefix = getPrefix(guild);
author.send(
`I don't have permission to send messages in ${channel}. ${ // First, test if the message is just a ping to the bot.
member!.hasPermission(Permissions.FLAGS.ADMINISTRATOR) if (new RegExp(`^<@!?${client.user!.id}>$`).test(text)) {
? "Because you're a server admin, you have the ability to change that channel's permissions to match if that's what you intended." channel.send(`${author}, my prefix on this server is \`${prefix}\`.`);
: "Try using a different channel or contacting a server admin to change permissions of that channel if you think something's wrong." }
}` // 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) => { process.on("unhandledRejection", (reason: any) => {
if (reason?.name === "DiscordAPIError") { if (reason?.name === "DiscordAPIError") {

View File

@ -1,5 +1,6 @@
export {Command, NamedCommand, CHANNEL_TYPE} from "./command"; export {Command, NamedCommand, CHANNEL_TYPE} from "./command";
export {addInterceptRule} from "./handler"; export {addInterceptRule} from "./handler";
export {launch} from "./interface";
export { export {
SingleMessageOptions, SingleMessageOptions,
botHasPermission, 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 {User, GuildMember} from "discord.js";
import {Config} from "../structures"; import {permissionLevels} from "./interface";
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;
export function hasPermission(user: User, member: GuildMember | null, permission: number): boolean { 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; return false;
} }
export function getPermissionLevel(user: User, member: GuildMember | null): number { 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; return 0;
} }
export function getPermissionName(level: number) { export function getPermissionName(level: number) {
if (level > length || level < 0) return "N/A"; if (level > permissionLevels.length || level < 0) return "N/A";
else return PermissionLevels[level].name; else return permissionLevels[level].name;
} }

View File

@ -1,8 +1,9 @@
// Bootstrapping Section // // Bootstrapping Section //
import "./modules/globals"; import "./modules/globals";
import {Client} from "discord.js"; import {Client, Permissions} from "discord.js";
import {launch} from "./core";
import setup from "./modules/setup"; 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 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. // 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); 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 // // 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/ready";
import "./modules/presence"; import "./modules/presence";
import "./modules/lavalink"; import "./modules/lavalink";