diff --git a/src/structures/BaseContext.js b/src/structures/BaseContext.js new file mode 100644 index 0000000..ac9fd49 --- /dev/null +++ b/src/structures/BaseContext.js @@ -0,0 +1,26 @@ +/** + * @typedef {Object} ContextData + * @property {string} name - The name of the command (must be lowercase) + * @property {string} description - A short description of the command + * @property {import('discord.js').ApplicationCommandType} type - The type of application command + * @property {boolean} [enabled] - Whether the slash command is enabled or not + * @property {boolean} [ephemeral] - Whether the reply should be ephemeral + * @property {boolean} [defaultPermission] - Whether default permission must be enabled + * @property {import('discord.js').PermissionResolvable[]} [userPermissions] - Permissions required by the user to use the command. + * @property {number} [cooldown] - Command cooldown in seconds + * @property {function(import('discord.js').ContextMenuCommandInteraction)} run - The callback to be executed when the context is invoked + */ + +/** + * @type {ContextData} data - The context information + */ +module.exports = { + name: "", + description: "", + type: "", + enabled: "", + ephemeral: "", + options: true, + userPermissions: [], + cooldown: 0, +} \ No newline at end of file diff --git a/src/structures/BotClient.js b/src/structures/BotClient.js new file mode 100644 index 0000000..b752cfd --- /dev/null +++ b/src/structures/BotClient.js @@ -0,0 +1,345 @@ +const { + Client, + Collection, + GatewayIntentBits, + Partials, + WebhookClient, + ApplicationCommandType, +} = require("discord.js"); +const path = require("path"); +const { table } = require("table"); +const Logger = require("../helpers/Logger"); +const { recursiveReadDirSync } = require("../helpers/Utils"); +const { validateCommand, validateContext } = require("../helpers/Validator"); +const { schemas } = require("@src/database/mongoose"); +const CommandCategory = require("./CommandCategory"); +const lavaclient = require("../handlers/lavaclient"); +const giveawaysHandler = require("../handlers/giveaway"); +const { DiscordTogether } = require("discord-together"); + +module.exports = class BotClient extends Client { + constructor() { + super({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.MessageContent, + GatewayIntentBits.GuildInvites, + GatewayIntentBits.GuildMembers, + GatewayIntentBits.GuildPresences, + GatewayIntentBits.GuildMessageReactions, + GatewayIntentBits.GuildVoiceStates, + ], + partials: [Partials.User, Partials.Message, Partials.Reaction], + allowedMentions: { + repliedUser: false, + }, + restRequestTimeout: 20000, + }); + + this.wait = require("util").promisify(setTimeout); // await client.wait(1000) - Wait 1 second + this.config = require("@root/config"); // load the config file + + /** + * @type {import('@structures/Command')[]} + */ + this.commands = []; // store actual command + this.commandIndex = new Collection(); // store (alias, arrayIndex) pair + + /** + * @type {Collection} + */ + this.slashCommands = new Collection(); // store slash commands + + /** + * @type {Collection} + */ + this.contextMenus = new Collection(); // store contextMenus + this.counterUpdateQueue = []; // store guildId's that needs counter update + + // initialize webhook for sending guild join/leave details + this.joinLeaveWebhook = process.env.JOIN_LEAVE_LOGS + ? new WebhookClient({ url: process.env.JOIN_LEAVE_LOGS }) + : undefined; + + // Music Player + if (this.config.MUSIC.ENABLED) this.musicManager = lavaclient(this); + + // Giveaways + if (this.config.GIVEAWAYS.ENABLED) this.giveawaysManager = giveawaysHandler(this); + + // Logger + this.logger = Logger; + + // Database + this.database = schemas; + + // Discord Together + this.discordTogether = new DiscordTogether(this); + } + + /** + * Load all events from the specified directory + * @param {string} directory directory containing the event files + */ + loadEvents(directory) { + this.logger.log(`Loading events...`); + let success = 0; + let failed = 0; + const clientEvents = []; + + recursiveReadDirSync(directory).forEach((filePath) => { + const file = path.basename(filePath); + try { + const eventName = path.basename(file, ".js"); + const event = require(filePath); + + this.on(eventName, event.bind(null, this)); + clientEvents.push([file, "✓"]); + + delete require.cache[require.resolve(filePath)]; + success += 1; + } catch (ex) { + failed += 1; + this.logger.error(`loadEvent - ${file}`, ex); + } + }); + + console.log( + table(clientEvents, { + header: { + alignment: "center", + content: "Client Events", + }, + singleLine: true, + columns: [{ width: 25 }, { width: 5, alignment: "center" }], + }) + ); + + this.logger.log(`Loaded ${success + failed} events. Success (${success}) Failed (${failed})`); + } + + /** + * Find command matching the invoke + * @param {string} invoke + * @returns {import('@structures/Command')|undefined} + */ + getCommand(invoke) { + const index = this.commandIndex.get(invoke.toLowerCase()); + return index !== undefined ? this.commands[index] : undefined; + } + + /** + * Register command file in the client + * @param {import("@structures/Command")} cmd + */ + loadCommand(cmd) { + // Check if category is disabled + if (cmd.category && CommandCategory[cmd.category]?.enabled === false) { + this.logger.debug(`Skipping Command ${cmd.name}. Category ${cmd.category} is disabled`); + return; + } + // Prefix Command + if (cmd.command?.enabled) { + const index = this.commands.length; + if (this.commandIndex.has(cmd.name)) { + throw new Error(`Command ${cmd.name} already registered`); + } + if (Array.isArray(cmd.command.aliases)) { + cmd.command.aliases.forEach((alias) => { + if (this.commandIndex.has(alias)) throw new Error(`Alias ${alias} already registered`); + this.commandIndex.set(alias.toLowerCase(), index); + }); + } + this.commandIndex.set(cmd.name.toLowerCase(), index); + this.commands.push(cmd); + } else { + this.logger.debug(`Skipping command ${cmd.name}. Disabled!`); + } + + // Slash Command + if (cmd.slashCommand?.enabled) { + if (this.slashCommands.has(cmd.name)) throw new Error(`Slash Command ${cmd.name} already registered`); + this.slashCommands.set(cmd.name, cmd); + } else { + this.logger.debug(`Skipping slash command ${cmd.name}. Disabled!`); + } + } + + /** + * Load all commands from the specified directory + * @param {string} directory + */ + loadCommands(directory) { + this.logger.log(`Loading commands...`); + const files = recursiveReadDirSync(directory); + for (const file of files) { + try { + const cmd = require(file); + if (typeof cmd !== "object") continue; + validateCommand(cmd); + this.loadCommand(cmd); + } catch (ex) { + this.logger.error(`Failed to load ${file} Reason: ${ex.message}`); + } + } + + this.logger.success(`Loaded ${this.commands.length} commands`); + this.logger.success(`Loaded ${this.slashCommands.size} slash commands`); + if (this.slashCommands.size > 100) throw new Error("A maximum of 100 slash commands can be enabled"); + } + + /** + * Load all contexts from the specified directory + * @param {string} directory + */ + loadContexts(directory) { + this.logger.log(`Loading contexts...`); + const files = recursiveReadDirSync(directory); + for (const file of files) { + try { + const ctx = require(file); + if (typeof ctx !== "object") continue; + validateContext(ctx); + if (!ctx.enabled) return this.logger.debug(`Skipping context ${ctx.name}. Disabled!`); + if (this.contextMenus.has(ctx.name)) throw new Error(`Context already exists with that name`); + this.contextMenus.set(ctx.name, ctx); + } catch (ex) { + this.logger.error(`Failed to load ${file} Reason: ${ex.message}`); + } + } + + const userContexts = this.contextMenus.filter((ctx) => ctx.type === ApplicationCommandType.User).size; + const messageContexts = this.contextMenus.filter((ctx) => ctx.type === ApplicationCommandType.Message).size; + + if (userContexts > 3) throw new Error("A maximum of 3 USER contexts can be enabled"); + if (messageContexts > 3) throw new Error("A maximum of 3 MESSAGE contexts can be enabled"); + + this.logger.success(`Loaded ${userContexts} USER contexts`); + this.logger.success(`Loaded ${messageContexts} MESSAGE contexts`); + } + + /** + * Register slash command on startup + * @param {string} [guildId] + */ + async registerInteractions(guildId) { + const toRegister = []; + + // filter slash commands + if (this.config.INTERACTIONS.SLASH) { + this.slashCommands + .map((cmd) => ({ + name: cmd.name, + description: cmd.description, + type: ApplicationCommandType.ChatInput, + options: cmd.slashCommand.options, + })) + .forEach((s) => toRegister.push(s)); + } + + // filter contexts + if (this.config.INTERACTIONS.CONTEXT) { + this.contextMenus + .map((ctx) => ({ + name: ctx.name, + type: ctx.type, + })) + .forEach((c) => toRegister.push(c)); + } + + // Register GLobally + if (!guildId) { + await this.application.commands.set(toRegister); + } + + // Register for a specific guild + else if (guildId && typeof guildId === "string") { + const guild = this.guilds.cache.get(guildId); + if (!guild) { + this.logger.error(`Failed to register interactions in guild ${guildId}`, new Error("No matching guild")); + return; + } + await guild.commands.set(toRegister); + } + + // Throw an error + else { + throw new Error("Did you provide a valid guildId to register interactions"); + } + + this.logger.success("Successfully registered interactions"); + } + + /** + * @param {string} search + * @param {Boolean} exact + */ + async resolveUsers(search, exact = false) { + if (!search || typeof search !== "string") return []; + const users = []; + + // check if userId is passed + const patternMatch = search.match(/(\d{17,20})/); + if (patternMatch) { + const id = patternMatch[1]; + const fetched = await this.users.fetch(id, { cache: true }).catch(() => {}); // check if mentions contains the ID + if (fetched) { + users.push(fetched); + return users; + } + } + + // check if exact tag is matched in cache + const matchingTags = this.users.cache.filter((user) => user.tag === search); + if (exact && matchingTags.size === 1) users.push(matchingTags.first()); + else matchingTags.forEach((match) => users.push(match)); + + // check matching username + if (!exact) { + this.users.cache + .filter( + (x) => + x.username === search || + x.username.toLowerCase().includes(search.toLowerCase()) || + x.tag.toLowerCase().includes(search.toLowerCase()) + ) + .forEach((user) => users.push(user)); + } + + return users; + } + + /** + * Get bot's invite + */ + getInvite() { + return this.generateInvite({ + scopes: ["bot", "applications.commands"], + permissions: [ + "AddReactions", + "AttachFiles", + "BanMembers", + "ChangeNickname", + "Connect", + "DeafenMembers", + "EmbedLinks", + "KickMembers", + "ManageChannels", + "ManageGuild", + "ManageMessages", + "ManageNicknames", + "ManageRoles", + "ModerateMembers", + "MoveMembers", + "MuteMembers", + "PrioritySpeaker", + "ReadMessageHistory", + "SendMessages", + "SendMessagesInThreads", + "Speak", + "ViewChannel", + ], + }); + } +}; \ No newline at end of file diff --git a/src/structures/Command.js b/src/structures/Command.js new file mode 100644 index 0000000..72cc832 --- /dev/null +++ b/src/structures/Command.js @@ -0,0 +1,75 @@ +/** + * @typedef {Object} Validation + * @property {function} callback - The condition to validate + * @property {string} message - The message to be displayed if callback condition is not met + */ + +/** + * @typedef {Object} SubCommand + * @property {string} trigger - subcommand invoke + * @property {string} description - subcommand description + */ + +/** + * @typedef {"ADMIN"|"ANIME"|"AUTOMOD"|"ECONOMY"|"FUN"|"IMAGE"|"INFORMATION"|"INVITE"|"MODERATION"|"ERELA_JS"|"NONE"|"OWNER"|"SOCIAL"|"SUGGESTION"|"TICKET"|"UTILITY"} CommandCategory + */ + +/** + * @typedef {Object} InteractionInfo + * @property {boolean} enabled - Whether the slash command is enabled or not + * @property {boolean} ephemeral - Whether the reply should be ephemeral + * @property {import('discord.js').ApplicationCommandOptionData[]} options - command options + */ + +/** + * @typedef {Object} CommandInfo + * @property {boolean} enabled - Whether the command is enabled or not + * @property {string[]} [aliases] - Alternative names for the command (all must be lowercase) + * @property {string} [usage=""] - The command usage format string + * @property {number} [minArgsCount=0] - Minimum number of arguments the command takes (default is 0) + * @property {SubCommand[]} [subcommands=[]] - List of subcommands + */ + +/** + * @typedef {Object} CommandData + * @property {string} name - The name of the command (must be lowercase) + * @property {string} description - A short description of the command + * @property {number} cooldown - The command cooldown in seconds + * @property {CommandCategory} category - The category this command belongs to + * @property {import('discord.js').PermissionResolvable[]} [botPermissions] - Permissions required by the client to use the command. + * @property {import('discord.js').PermissionResolvable[]} [userPermissions] - Permissions required by the user to use the command + * @property {Validation[]} [validations] - List of validations to be run before the command is executed + * @property {CommandInfo} command - A short description of the command + * @property {InteractionInfo} slashCommand - A short description of the command + * @property {function(import('discord.js').Message, string[], object)} messageRun - The callback to be executed when the command is invoked + * @property {function(import('discord.js').ChatInputCommandInteraction, object)} interactionRun - The callback to be executed when the interaction is invoked + */ + +/** + * Placeholder for command data + * @type {CommandData} + */ +module.exports = { + name: "", + description: "", + cooldown: 0, + isPremium: false, + category: "NONE", + botPermissions: [], + userPermissions: [], + validations: [], + command: { + enabled: true, + aliases: [], + usage: "", + minArgsCount: 0, + subcommands: [], + }, + slashCommand: { + enabled: true, + ephemeral: false, + options: [], + }, + messageRun: (message, args, data) => {}, + interactionRun: (interaction, data) => {}, +}; \ No newline at end of file diff --git a/src/structures/CommandCategory.js b/src/structures/CommandCategory.js new file mode 100644 index 0000000..c244dac --- /dev/null +++ b/src/structures/CommandCategory.js @@ -0,0 +1,99 @@ +const config = require("@root/config"); + +module.exports = { + ADMIN: { + name: "Admin", + image: "https://icons.iconarchive.com/icons/dakirby309/simply-styled/256/Settings-icon.png", + emoji: "⚙️", + }, + AUTOMOD: { + name: "Automod", + enabled: config.AUTOMOD.ENABLED, + image: "https://icons.iconarchive.com/icons/dakirby309/simply-styled/256/Settings-icon.png", + emoji: "🤖", + }, + ANIME: { + name: "Anime", + image: "https://wallpaperaccess.com/full/5680679.jpg", + emoji: "🎨", + }, + ECONOMY: { + name: "Economy", + enabled: config.ECONOMY.ENABLED, + image: "https://icons.iconarchive.com/icons/custom-icon-design/pretty-office-11/128/coins-icon.png", + emoji: "🪙", + }, + FUN: { + name: "Fun", + image: "https://icons.iconarchive.com/icons/flameia/aqua-smiles/128/make-fun-icon.png", + emoji: "😂", + }, + GIVEAWAY: { + name: "Giveaway", + enabled: config.GIVEAWAYS.ENABLED, + image: "https://cdn-icons-png.flaticon.com/512/4470/4470928.png", + emoji: "🎉", + }, + IMAGE: { + name: "Image", + enabled: config.IMAGE.ENABLED, + image: "https://icons.iconarchive.com/icons/dapino/summer-holiday/128/photo-icon.png", + emoji: "🖼️", + }, + INVITE: { + name: "Invite", + enabled: config.INVITE.ENABLED, + image: "https://cdn4.iconfinder.com/data/icons/general-business/150/Invite-512.png", + emoji: "📨", + }, + INFORMATION: { + name: "Information", + image: "https://icons.iconarchive.com/icons/graphicloads/100-flat/128/information-icon.png", + emoji: "🪧", + }, + MODERATION: { + name: "Moderation", + enabled: config.MODERATION.ENABLED, + image: "https://icons.iconarchive.com/icons/lawyerwordpress/law/128/Gavel-Law-icon.png", + emoji: "🔨", + }, + MUSIC: { + name: "Music", + enabled: config.MUSIC.ENABLED, + image: "https://icons.iconarchive.com/icons/wwalczyszyn/iwindows/256/Music-Library-icon.png", + emoji: "🎵", + }, + OWNER: { + name: "Owner", + image: "https://www.pinclipart.com/picdir/middle/531-5318253_web-designing-icon-png-clipart.png", + emoji: "🤴", + }, + SOCIAL: { + name: "Social", + image: "https://icons.iconarchive.com/icons/dryicons/aesthetica-2/128/community-users-icon.png", + emoji: "🫂", + }, + STATS: { + name: "Statistics", + enabled: config.STATS.ENABLED, + image: "https://icons.iconarchive.com/icons/graphicloads/flat-finance/256/dollar-stats-icon.png", + emoji: "📈", + }, + SUGGESTION: { + name: "Suggestion", + enabled: config.SUGGESTIONS.ENABLED, + image: "https://cdn-icons-png.flaticon.com/512/1484/1484815.png", + emoji: "📝", + }, + TICKET: { + name: "Ticket", + enabled: config.TICKET.ENABLED, + image: "https://icons.iconarchive.com/icons/custom-icon-design/flatastic-2/512/ticket-icon.png", + emoji: "🎫", + }, + UTILITY: { + name: "Utility", + image: "https://icons.iconarchive.com/icons/blackvariant/button-ui-system-folders-alt/128/Utilities-icon.png", + emoji: "🛠", + }, +}; \ No newline at end of file diff --git a/src/structures/index.js b/src/structures/index.js new file mode 100644 index 0000000..eaa31e1 --- /dev/null +++ b/src/structures/index.js @@ -0,0 +1,11 @@ +const BotClient = require("./BotClient"); +const Command = require("./Command"); +const CommandCategory = require("./CommandCategory"); +const BaseContext = require("./BaseContext"); + +module.exports = { + BaseContext, + BotClient, + Command, + CommandCategory, +}; \ No newline at end of file