Added dynamically loaded events

This commit is contained in:
WatDuhHekBro 2020-07-25 06:01:24 -05:00
parent 295995aba2
commit 113fc965a9
9 changed files with 214 additions and 128 deletions

View File

@ -4,6 +4,7 @@ The top-level directory is reserved for files that have to be there for it to wo
- `core`: This is where core structures and critical functions for the bot go. - `core`: This is where core structures and critical functions for the bot go.
- `modules`: This is where modules go that accomplish one specific purpose but isn't so necessary for the bot to function. The goal is to be able to safely remove these without too much trouble. - `modules`: This is where modules go that accomplish one specific purpose but isn't so necessary for the bot to function. The goal is to be able to safely remove these without too much trouble.
- `commands`: Here's the place to store commands. The file name determines the command name. - `commands`: Here's the place to store commands. The file name determines the command name.
- `events`: Here's the place to store events. The file name determines the event type.
- `dist`: This is where the runnable code in `src` compiles to. (The directory structure mirrors `src`.) - `dist`: This is where the runnable code in `src` compiles to. (The directory structure mirrors `src`.)
- `data`: Holds all the dynamic data used by the bot. This is what you modify if you want to change stuff for just your instance of the bot. - `data`: Holds all the dynamic data used by the bot. This is what you modify if you want to change stuff for just your instance of the bot.
- `standard`: Contains all the standard data to be used with the project itself. It's part of the code and will not be checked for inaccuracies because it's not meant to be easily modified. - `standard`: Contains all the standard data to be used with the project itself. It's part of the code and will not be checked for inaccuracies because it's not meant to be easily modified.
@ -16,6 +17,7 @@ This list starts from `src`/`dist`.
- `core/lib`: Exports a function object which lets you wrap values letting you call special functions as well as calling utility functions common to all commands. - `core/lib`: Exports a function object which lets you wrap values letting you call special functions as well as calling utility functions common to all commands.
- `core/structures`: Contains all the structures that the dynamic data read from JSON files should follow. This exports instances of these classes. - `core/structures`: Contains all the structures that the dynamic data read from JSON files should follow. This exports instances of these classes.
- `core/command`: Contains the class used to instantiate commands. - `core/command`: Contains the class used to instantiate commands.
- `core/event`: Contains the class used to instantiate events.
- `core/storage`: Exports an object which handles everything related to files. - `core/storage`: Exports an object which handles everything related to files.
- `core/wrappers`: Contains classes that wrap around values and provide extra functionality. - `core/wrappers`: Contains classes that wrap around values and provide extra functionality.

31
src/core/event.ts Normal file
View File

@ -0,0 +1,31 @@
import {Client} from "discord.js";
// Last Updated: Discord.js v12.2.0
export const EVENTS = ["channelCreate", "channelDelete", "channelPinsUpdate", "channelUpdate", "debug", "warn", "disconnect", "emojiCreate", "emojiDelete", "emojiUpdate", "error", "guildBanAdd", "guildBanRemove", "guildCreate", "guildDelete", "guildUnavailable", "guildIntegrationsUpdate", "guildMemberAdd", "guildMemberAvailable", "guildMemberRemove", "guildMembersChunk", "guildMemberSpeaking", "guildMemberUpdate", "guildUpdate", "inviteCreate", "inviteDelete", "message", "messageDelete", "messageReactionRemoveAll", "messageReactionRemoveEmoji", "messageDeleteBulk", "messageReactionAdd", "messageReactionRemove", "messageUpdate", "presenceUpdate", "rateLimit", "ready", "invalidated", "roleCreate", "roleDelete", "roleUpdate", "typingStart", "userUpdate", "voiceStateUpdate", "webhookUpdate", "shardDisconnect", "shardError", "shardReady", "shardReconnecting", "shardResume"];
interface EventOptions
{
readonly on?: Function;
readonly once?: Function;
}
export default class Event
{
private readonly on: Function|null;
private readonly once: Function|null;
constructor(options: EventOptions)
{
this.on = options.on || null;
this.once = options.once || null;
}
// For this function, I'm going to assume that the event is used with the correct arguments and that the event tag is checked in "storage.ts".
public attach(client: Client, event: string)
{
if(this.on)
client.on(event as any, this.on as any);
if(this.once)
client.once(event as any, this.once as any);
}
}

View File

@ -1,7 +1,8 @@
import {GenericWrapper, NumberWrapper, ArrayWrapper} from "./wrappers"; import {GenericWrapper, NumberWrapper, ArrayWrapper} from "./wrappers";
import {Client, Message, TextChannel, DMChannel, NewsChannel, Guild, User, GuildMember, MessageReaction, PartialUser} from "discord.js"; import {Client, Message, TextChannel, DMChannel, NewsChannel, Guild, User, GuildMember} from "discord.js";
import chalk from "chalk"; import chalk from "chalk";
import FileManager from "./storage"; import FileManager from "./storage";
import {eventListeners} from "../events/messageReactionRemove";
/** A type that describes what the library module does. */ /** A type that describes what the library module does. */
export interface CommonLibrary export interface CommonLibrary
@ -67,17 +68,22 @@ export const logs: {[type: string]: string} = {
verbose: "" verbose: ""
}; };
let enabled = true;
export function setConsoleActivated(activated: boolean) {enabled = activated};
// The custom console. In order of verbosity, error, warn, log, and debug. Ready is a variation of log. // The custom console. In order of verbosity, error, warn, log, and debug. Ready is a variation of log.
// General Purpose Logger // General Purpose Logger
$.log = (...args: any[]) => { $.log = (...args: any[]) => {
console.log(chalk.white.bgGray(formatTimestamp()), chalk.black.bgWhite("INFO"), ...args); if(enabled)
console.log(chalk.white.bgGray(formatTimestamp()), chalk.black.bgWhite("INFO"), ...args);
const text = `[${formatUTCTimestamp()}] [INFO] ${args.join(" ")}\n`; const text = `[${formatUTCTimestamp()}] [INFO] ${args.join(" ")}\n`;
logs.info += text; logs.info += text;
logs.verbose += text; logs.verbose += text;
}; };
// "It'll still work, but you should really check up on this." // "It'll still work, but you should really check up on this."
$.warn = (...args: any[]) => { $.warn = (...args: any[]) => {
console.warn(chalk.white.bgGray(formatTimestamp()), chalk.black.bgYellow("WARN"), ...args); if(enabled)
console.warn(chalk.white.bgGray(formatTimestamp()), chalk.black.bgYellow("WARN"), ...args);
const text = `[${formatUTCTimestamp()}] [WARN] ${args.join(" ")}\n`; const text = `[${formatUTCTimestamp()}] [WARN] ${args.join(" ")}\n`;
logs.warn += text; logs.warn += text;
logs.info += text; logs.info += text;
@ -85,7 +91,8 @@ $.warn = (...args: any[]) => {
}; };
// Used for anything which prevents the program from actually running. // Used for anything which prevents the program from actually running.
$.error = (...args: any[]) => { $.error = (...args: any[]) => {
console.error(chalk.white.bgGray(formatTimestamp()), chalk.white.bgRed("ERROR"), ...args); if(enabled)
console.error(chalk.white.bgGray(formatTimestamp()), chalk.white.bgRed("ERROR"), ...args);
const text = `[${formatUTCTimestamp()}] [ERROR] ${args.join(" ")}\n`; const text = `[${formatUTCTimestamp()}] [ERROR] ${args.join(" ")}\n`;
logs.error += text; logs.error += text;
logs.warn += text; logs.warn += text;
@ -94,14 +101,15 @@ $.error = (...args: any[]) => {
}; };
// Be as verbose as possible. If anything might help when debugging an error, then include it. This only shows in your console if you run this with "dev", but you can still get it from "logs.verbose". // Be as verbose as possible. If anything might help when debugging an error, then include it. This only shows in your console if you run this with "dev", but you can still get it from "logs.verbose".
$.debug = (...args: any[]) => { $.debug = (...args: any[]) => {
if(process.argv[2] === "dev") if(process.argv[2] === "dev" && enabled)
console.debug(chalk.white.bgGray(formatTimestamp()), chalk.white.bgBlue("DEBUG"), ...args); console.debug(chalk.white.bgGray(formatTimestamp()), chalk.white.bgBlue("DEBUG"), ...args);
const text = `[${formatUTCTimestamp()}] [DEBUG] ${args.join(" ")}\n`; const text = `[${formatUTCTimestamp()}] [DEBUG] ${args.join(" ")}\n`;
logs.verbose += text; logs.verbose += text;
}; };
// Used once at the start of the program when the bot loads. // Used once at the start of the program when the bot loads.
$.ready = (...args: any[]) => { $.ready = (...args: any[]) => {
console.log(chalk.white.bgGray(formatTimestamp()), chalk.black.bgGreen("READY"), ...args); if(enabled)
console.log(chalk.white.bgGray(formatTimestamp()), chalk.black.bgGreen("READY"), ...args);
const text = `[${formatUTCTimestamp()}] [READY] ${args.join(" ")}\n`; const text = `[${formatUTCTimestamp()}] [READY] ${args.join(" ")}\n`;
logs.info += text; logs.info += text;
logs.verbose += text; logs.verbose += text;
@ -129,9 +137,6 @@ export function formatUTCTimestamp(now = new Date())
return `${year}-${month}-${day} ${hour}:${minute}:${second}`; return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
} }
// A list of message ID and callback pairs. You get the emote name and ID of the user reacting.
const eventListeners: Map<string, (emote: string, id: string) => void> = new Map();
// Pagination function that allows for customization via a callback. // 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. // Define your own pages outside the function because this only manages the actual turning of pages.
$.paginate = async(message: Message, senderID: string, total: number, callback: (page: number) => void, duration = 60000) => { $.paginate = async(message: Message, senderID: string, total: number, callback: (page: number) => void, duration = 60000) => {
@ -173,13 +178,6 @@ $.paginate = async(message: Message, senderID: string, total: number, callback:
message.reactions.cache.get('➡️')?.users.remove(message.author); message.reactions.cache.get('➡️')?.users.remove(message.author);
}; };
// Attached to the client, there can be one event listener attached to a message ID which is executed if present.
export function unreact(reaction: MessageReaction, user: User|PartialUser)
{
const callback = eventListeners.get(reaction.message.id);
callback && callback(reaction.emoji.name, user.id);
}
// Waits for the sender to either confirm an action or let it pass (and delete the message). // Waits for the sender to either confirm an action or let it pass (and delete the message).
$.prompt = async(message: Message, senderID: string, onConfirm: () => void, duration = 10000) => { $.prompt = async(message: Message, senderID: string, onConfirm: () => void, duration = 10000) => {
let isDeleted = false; let isDeleted = false;

View File

@ -1,7 +1,8 @@
import fs from "fs"; import fs from "fs";
import $ from "./lib"; import $ from "./lib";
import {Collection} from "discord.js"; import {Collection, Client} from "discord.js";
import Command, {template} from "../core/command"; import Command, {template} from "../core/command";
import {EVENTS} from "./event";
let commands: Collection<string, Command>|null = null; let commands: Collection<string, Command>|null = null;
@ -89,10 +90,26 @@ const Storage = {
const header = file.substring(0, file.indexOf(".js")); const header = file.substring(0, file.indexOf(".js"));
const command = (await import(`../commands/${header}`)).default; const command = (await import(`../commands/${header}`)).default;
commands.set(header, command); commands.set(header, command);
$.log("Loading Command:", header); $.log(`Loading Command: ${header}`);
} }
return commands; return commands;
},
async loadEvents(client: Client)
{
for(const file of Storage.open("dist/events", (filename: string) => filename.endsWith(".js")))
{
const header = file.substring(0, file.indexOf(".js"));
const event = (await import(`../events/${header}`)).default;
if(EVENTS.includes(header))
{
event.attach(client, header);
$.log(`Loading Event: ${header}`);
}
else
$.warn(`"${header}" is not a valid event type! Did you misspell it? (Note: If you fixed the issue, delete "dist" because the compiler won't automatically delete any extra files.)`);
}
} }
}; };

103
src/events/message.ts Normal file
View File

@ -0,0 +1,103 @@
import Event from "../core/event";
import Command from "../core/command";
import $ from "../core/lib";
import {Message, Permissions, Collection} from "discord.js";
import FileManager from "../core/storage";
import {Config, Storage} from "../core/structures";
// It's a rather hacky solution, but since there's no top-level await, I just have to make the loading conditional.
let commands: Collection<string, Command>|null = null;
export default new Event({
async on(message: Message)
{
// Load commands if it hasn't already done so. Luckily, it's called once at most.
if(!commands)
commands = await FileManager.loadCommands();
// Message Setup //
if(message.author.bot)
return;
const prefix = Storage.getGuild(message.guild?.id || "N/A").prefix || Config.prefix;
if(!message.content.startsWith(prefix))
return;
const [header, ...args] = message.content.substring(prefix.length).split(/ +/);
if(!commands.has(header))
return;
if(message.channel.type === "text" && !message.channel.permissionsFor(message.client.user || "")?.has(Permissions.FLAGS.SEND_MESSAGES))
{
let status;
if(message.member?.hasPermission(Permissions.FLAGS.ADMINISTRATOR))
status = "Because you're a server admin, you have the ability to change that channel's permissions to match if that's what you intended.";
else
status = "Try using a different channel or contacting a server admin to change permissions of that channel if you think something's wrong.";
return message.author.send(`I don't have permission to send messages in ${message.channel.toString()}. ${status}`);
}
$.log(`${message.author.username}#${message.author.discriminator} executed the command "${header}" with arguments "${args}".`);
// Subcommand Recursion //
let command = commands.get(header);
if(!command) return $.warn(`Command "${header}" was called but for some reason it's still undefined!`);
const params: any[] = [];
let isEndpoint = false;
for(let param of args)
{
if(command.endpoint)
{
if(command.subcommands || command.user || command.number || command.any)
$.warn(`An endpoint cannot have subcommands! Check ${prefix}${header} again.`);
isEndpoint = true;
break;
}
if(command.subcommands?.[param])
command = command.subcommands[param];
// Any Discord ID format will automatically format to a user ID.
else if(command.user && (/\d{17,19}/.test(param)))
{
const id = param.match(/\d+/g)![0];
command = command.user;
try {params.push(await message.client.users.fetch(id))}
catch(error) {return message.channel.send(`No user found by the ID \`${id}\`!`)}
}
// Disallow infinity and allow for 0.
else if(command.number && (Number(param) || param === "0") && !param.includes("Infinity"))
{
command = command.number;
params.push(Number(param));
}
else if(command.any)
{
command = command.any;
params.push(param);
}
else
params.push(param);
}
if(isEndpoint)
return message.channel.send("Too many arguments!");
// Execute with dynamic library attached. //
// The purpose of using $.bind($) is to clone the function so as to not modify the original $.
// The cloned function doesn't copy the properties, so Object.assign() is used.
// Object.assign() modifies the first element and returns that, the second element applies its properties and the third element applies its own overriding the second one.
command.execute(Object.assign($.bind($), {
args: params,
author: message.author,
channel: message.channel,
client: message.client,
guild: message.guild,
member: message.member,
message: message
}, $));
}
});

View File

@ -0,0 +1,14 @@
import Event from "../core/event";
import {MessageReaction, User, PartialUser} from "discord.js";
// A list of message ID and callback pairs. You get the emote name and ID of the user reacting.
export const eventListeners: 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.
export default new Event({
on(reaction: MessageReaction, user: User|PartialUser)
{
const callback = eventListeners.get(reaction.message.id);
callback && callback(reaction.emoji.name, user.id);
}
});

18
src/events/ready.ts Normal file
View File

@ -0,0 +1,18 @@
import Event from "../core/event";
import {client} from "../index";
import $ from "../core/lib";
import {Config} from "../core/structures";
export default new Event({
once()
{
if(client.user)
{
$.ready(`Logged in as ${client.user.username}#${client.user.discriminator}.`);
client.user.setActivity({
type: "LISTENING",
name: `${Config.prefix}help`
});
}
}
});

View File

@ -1,113 +1,15 @@
import {Client, Permissions} from "discord.js"; import {Client} from "discord.js";
import $, {unreact} from "./core/lib";
import setup from "./setup"; import setup from "./setup";
import FileManager from "./core/storage"; import FileManager from "./core/storage";
import {Config, Storage} from "./core/structures"; import {Config} from "./core/structures";
(async() => { // This is here in order to make it much less of a headache to access the client from other files.
// Setup // // This of course won't actually do anything until the setup process is complete and it logs in.
await setup.init(); export const client = new Client();
const client = new Client();
const commands = await FileManager.loadCommands(); // Begin the command loading here rather than when it's needed like in the message event.
setup.init().then(() => {
FileManager.loadCommands();
FileManager.loadEvents(client);
client.login(Config.token).catch(setup.again); client.login(Config.token).catch(setup.again);
});
client.on("message", async message => {
// Message Setup //
if(message.author.bot)
return;
const prefix = Storage.getGuild(message.guild?.id || "N/A").prefix || Config.prefix;
if(!message.content.startsWith(prefix))
return;
const [header, ...args] = message.content.substring(prefix.length).split(/ +/);
if(!commands.has(header))
return;
if(message.channel.type === "text" && !message.channel.permissionsFor(client.user || "")?.has(Permissions.FLAGS.SEND_MESSAGES))
{
let status;
if(message.member?.hasPermission(Permissions.FLAGS.ADMINISTRATOR))
status = "Because you're a server admin, you have the ability to change that channel's permissions to match if that's what you intended.";
else
status = "Try using a different channel or contacting a server admin to change permissions of that channel if you think something's wrong.";
return message.author.send(`I don't have permission to send messages in ${message.channel.toString()}. ${status}`);
}
$.log(`${message.author.username}#${message.author.discriminator} executed the command "${header}" with arguments "${args}".`);
// Subcommand Recursion //
let command = commands.get(header);
if(!command) return $.warn(`Command "${header}" was called but for some reason it's still undefined!`);
const params: any[] = [];
let isEndpoint = false;
for(let param of args)
{
if(command.endpoint)
{
if(command.subcommands || command.user || command.number || command.any)
$.warn(`An endpoint cannot have subcommands! Check ${prefix}${header} again.`);
isEndpoint = true;
break;
}
if(command.subcommands?.[param])
command = command.subcommands[param];
// Any Discord ID format will automatically format to a user ID.
else if(command.user && (/\d{17,19}/.test(param)))
{
const id = param.match(/\d+/g)![0];
command = command.user;
try {params.push(await client.users.fetch(id))}
catch(error) {return message.channel.send(`No user found by the ID \`${id}\`!`)}
}
// Disallow infinity and allow for 0.
else if(command.number && (Number(param) || param === "0") && !param.includes("Infinity"))
{
command = command.number;
params.push(Number(param));
}
else if(command.any)
{
command = command.any;
params.push(param);
}
else
params.push(param);
}
if(isEndpoint)
return message.channel.send("Too many arguments!");
// Execute with dynamic library attached. //
// The purpose of using $.bind($) is to clone the function so as to not modify the original $.
// The cloned function doesn't copy the properties, so Object.assign() is used.
// Object.assign() modifies the first element and returns that, the second element applies its properties and the third element applies its own overriding the second one.
command.execute(Object.assign($.bind($), {
args: params,
author: message.author,
channel: message.channel,
client: client,
guild: message.guild,
member: message.member,
message: message
}, $));
});
client.once("ready", () => {
if(client.user)
{
$.ready(`Logged in as ${client.user.username}#${client.user.discriminator}.`);
client.user.setActivity({
type: "LISTENING",
name: `${Config.prefix}help`
});
}
});
client.on("messageReactionRemove", unreact);
})()

View File

@ -2,7 +2,7 @@ import {existsSync as exists} from "fs";
import inquirer from "inquirer"; import inquirer from "inquirer";
import Storage from "./core/storage"; import Storage from "./core/storage";
import {Config} from "./core/structures"; import {Config} from "./core/structures";
import $ from "./core/lib"; import $, {setConsoleActivated} from "./core/lib";
// This file is called (or at least should be called) automatically as long as a config file doesn't exist yet. // This file is called (or at least should be called) automatically as long as a config file doesn't exist yet.
// And that file won't be written until the data is successfully initialized. // And that file won't be written until the data is successfully initialized.
@ -40,6 +40,7 @@ export default {
async again() async again()
{ {
$.error("It seems that the token you provided is invalid."); $.error("It seems that the token you provided is invalid.");
setConsoleActivated(false);
const answers = await inquirer.prompt(prompts.slice(0, 1)); const answers = await inquirer.prompt(prompts.slice(0, 1));
Config.token = answers.token as string; Config.token = answers.token as string;
Config.save(false); Config.save(false);