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.
- `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.
- `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`.)
- `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.
@ -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/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/event`: Contains the class used to instantiate events.
- `core/storage`: Exports an object which handles everything related to files.
- `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 {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 FileManager from "./storage";
import {eventListeners} from "../events/messageReactionRemove";
/** A type that describes what the library module does. */
export interface CommonLibrary
@ -67,17 +68,22 @@ export const logs: {[type: string]: string} = {
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.
// General Purpose Logger
$.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`;
logs.info += text;
logs.verbose += text;
};
// "It'll still work, but you should really check up on this."
$.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`;
logs.warn += text;
logs.info += text;
@ -85,7 +91,8 @@ $.warn = (...args: any[]) => {
};
// Used for anything which prevents the program from actually running.
$.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`;
logs.error += 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".
$.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);
const text = `[${formatUTCTimestamp()}] [DEBUG] ${args.join(" ")}\n`;
logs.verbose += text;
};
// Used once at the start of the program when the bot loads.
$.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`;
logs.info += text;
logs.verbose += text;
@ -129,9 +137,6 @@ export function formatUTCTimestamp(now = new Date())
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.
// 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) => {
@ -173,13 +178,6 @@ $.paginate = async(message: Message, senderID: string, total: number, callback:
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).
$.prompt = async(message: Message, senderID: string, onConfirm: () => void, duration = 10000) => {
let isDeleted = false;

View File

@ -1,7 +1,8 @@
import fs from "fs";
import $ from "./lib";
import {Collection} from "discord.js";
import {Collection, Client} from "discord.js";
import Command, {template} from "../core/command";
import {EVENTS} from "./event";
let commands: Collection<string, Command>|null = null;
@ -89,10 +90,26 @@ const Storage = {
const header = file.substring(0, file.indexOf(".js"));
const command = (await import(`../commands/${header}`)).default;
commands.set(header, command);
$.log("Loading Command:", header);
$.log(`Loading Command: ${header}`);
}
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 $, {unreact} from "./core/lib";
import {Client} from "discord.js";
import setup from "./setup";
import FileManager from "./core/storage";
import {Config, Storage} from "./core/structures";
import {Config} from "./core/structures";
(async() => {
// Setup //
await setup.init();
const client = new Client();
const commands = await FileManager.loadCommands();
// 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.
export const client = new Client();
// 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.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 Storage from "./core/storage";
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.
// And that file won't be written until the data is successfully initialized.
@ -40,6 +40,7 @@ export default {
async again()
{
$.error("It seems that the token you provided is invalid.");
setConsoleActivated(false);
const answers = await inquirer.prompt(prompts.slice(0, 1));
Config.token = answers.token as string;
Config.save(false);