UNPKG

lixo.js

Version:
486 lines (419 loc) 22.3 kB
const { Client, Collection, GatewayIntentBits, Partials, REST, Routes, InteractionType, ChannelType, ApplicationCommandOptionType, Message } = require('discord.js'); const { QuickDB } = require("quick.db"); const EventEmitter = require('events'); const path = require('path'); const fs = require('fs'); const CommandHandler = require('../handlers/CommandHandler'); const FunctionParser = require('../handlers/FunctionParser'); const { mapIntents, mapPartials, mapOptionType, mapChannelTypes } = require('../utils/discordMappings'); const { DEFAULT_INTENTS, DEFAULT_PARTIALS, DEFAULT_PREFIX, COMMAND_TYPES, INTERACTION_COMMAND_TYPES, MESSAGE_COMMAND_TYPES } = require('../utils/constants'); const { loadFiles, requireUncached } = require('../utils/fileUtils'); class LixoClient extends Client { constructor(options = {}) { const { lixo = {}, intents: userIntents, partials: userPartials, ...djsOptions } = options; let finalIntents; if (Array.isArray(userIntents) && userIntents.length > 0) { finalIntents = mapIntents(userIntents); } else { console.warn("No intents provided. Defaulting to Guilds, GuildMessages, MessageContent, GuildVoiceStates."); finalIntents = [...DEFAULT_INTENTS]; } if (!finalIntents.includes(GatewayIntentBits.GuildVoiceStates)) { console.warn("Adding GuildVoiceStates intent implicitly for potential voice functionality."); finalIntents.push(GatewayIntentBits.GuildVoiceStates); } let finalPartials; if (Array.isArray(userPartials) && userPartials.length > 0) { finalPartials = mapPartials(userPartials); } else { console.log("No partials provided. Defaulting to Channel, Message, GuildMember, User, Reaction."); finalPartials = [...DEFAULT_PARTIALS]; } super({ intents: finalIntents, partials: finalPartials, ...djsOptions }); this.lixoOptions = { prefix: DEFAULT_PREFIX, token: process.env.DISCORD_TOKEN, dbPath: 'lixo_db.sqlite', loadBuiltinFunctions: true, ...lixo }; this.commands = new Collection(); this.events = new Collection(); this.functions = new Collection(); this.variables = new Collection(); this.slashCommandsData = []; this.commandHandler = new CommandHandler(this); this.functionParser = new FunctionParser(this); try { this.db = new QuickDB({ filePath: this.lixoOptions.dbPath }); console.log("Database initialized at", this.lixoOptions.dbPath); } catch (dbError) { console.error("Failed to initialize QuickDB. Database features will be unavailable.", dbError); this.db = null; } this.customEvents = new EventEmitter(); if (this.lixoOptions.loadBuiltinFunctions) { this._loadBuiltInFunctions(); } if (!this.lixoOptions.token) { console.warn("Bot token not found in options or DISCORD_TOKEN env var. Provide it via LixoClient options or the login() method."); } } register(commandData) { if (Array.isArray(commandData)) { let registered = false; commandData.forEach(data => { if (this._registerSingle(data)) registered = true; }); return registered; } else if (typeof commandData === 'object' && commandData !== null) { return this._registerSingle(commandData); } else { console.warn(`Invalid data passed to register. Expected object or array.`); return false; } } _registerSingle(data) { if (data.event && typeof data.event === 'string' && data.code) { return this._registerEventHandler(data); } else if (data.name && data.code) { return this._registerCommand(data); } else { console.warn(`Invalid command/event data. Requires 'name'/'event' and 'code'.`, data); return false; } } _registerEventHandler(handlerData) { const eventName = handlerData.event; if (!this.events.has(eventName)) { this.events.set(eventName, []); } this.events.get(eventName).push(handlerData); return true; } _registerCommand(commandData) { const nameLower = commandData.name.toLowerCase(); const type = commandData.type || COMMAND_TYPES.BOTH; const key = (type === COMMAND_TYPES.BUTTON || type === COMMAND_TYPES.SELECT_MENU || type === COMMAND_TYPES.MODAL_SUBMIT) ? commandData.name : nameLower; if (this.commands.has(key)) { console.warn(`Duplicate command/interaction key detected "${key}". Overwriting.`); } this.commands.set(key, { ...commandData, type }); if (MESSAGE_COMMAND_TYPES.includes(type)) { if (commandData.aliases && Array.isArray(commandData.aliases)) { commandData.aliases.forEach(alias => { const aliasLower = alias.toLowerCase(); if (aliasLower === nameLower) return; if (this.commands.has(aliasLower)) { const conflictingCmd = this.commands.get(aliasLower); if (conflictingCmd !== commandData) { console.warn(`Alias "${aliasLower}" conflicts with command/alias for "${conflictingCmd.name}". Overwriting.`); } } this.commands.set(aliasLower, { ...commandData, type, _isAlias: true, _aliasFor: nameLower }); }); } } if (INTERACTION_COMMAND_TYPES.includes(type) && commandData.description) { this._prepareSlashCommandData(commandData, nameLower); } else if (INTERACTION_COMMAND_TYPES.includes(type) && !commandData.description) { console.warn(`Command "${commandData.name}" needs a 'description' to be registered as a slash command.`); } return true; } _prepareSlashCommandData(commandData, nameLower) { const slashData = { name: nameLower, description: commandData.description, options: [], }; if (commandData.options && Array.isArray(commandData.options)) { slashData.options = commandData.options.map(opt => { if (!opt.name || !opt.description || !opt.type) { console.warn(`Slash Prep: Option for command "${nameLower}" is missing name, description, or type. Skipping.`, opt); return null; } const optionType = mapOptionType(opt.type); const processedOption = { name: opt.name.toLowerCase(), description: opt.description, type: optionType, required: opt.required === true, }; if (opt.choices && Array.isArray(opt.choices)) { processedOption.choices = opt.choices.map(choice => { if (typeof choice === 'object' && choice.name && choice.value !== undefined) return { name: choice.name, value: choice.value }; if (typeof choice === 'string' && optionType === ApplicationCommandOptionType.String) return { name: choice, value: choice }; if (typeof choice === 'number' && (optionType === ApplicationCommandOptionType.Number || optionType === ApplicationCommandOptionType.Integer)) return { name: String(choice), value: choice }; console.warn(`Slash Prep: Invalid choice format for option "${opt.name}" in command "${nameLower}". Skipping choice.`, choice); return null; }).filter(c => c !== null); if (processedOption.choices.length === 0) delete processedOption.choices; } if (opt.channel_types && Array.isArray(opt.channel_types) && optionType === ApplicationCommandOptionType.Channel) { processedOption.channel_types = mapChannelTypes(opt.channel_types); if (processedOption.channel_types.length === 0) delete processedOption.channel_types; } if (opt.min_value !== undefined && (optionType === ApplicationCommandOptionType.Integer || optionType === ApplicationCommandOptionType.Number)) processedOption.min_value = opt.min_value; if (opt.max_value !== undefined && (optionType === ApplicationCommandOptionType.Integer || optionType === ApplicationCommandOptionType.Number)) processedOption.max_value = opt.max_value; if (opt.min_length !== undefined && optionType === ApplicationCommandOptionType.String) processedOption.min_length = opt.min_length; if (opt.max_length !== undefined && optionType === ApplicationCommandOptionType.String) processedOption.max_length = opt.max_length; if (opt.autocomplete !== undefined && [ApplicationCommandOptionType.String, ApplicationCommandOptionType.Integer, ApplicationCommandOptionType.Number].includes(optionType)) { processedOption.autocomplete = !!opt.autocomplete; if (processedOption.autocomplete && processedOption.choices) { console.warn(`Slash Prep: Option "${opt.name}" in command "${nameLower}" has both autocomplete=true and choices defined. Choices will be ignored by Discord.`); delete processedOption.choices; } } return processedOption; }).filter(opt => opt !== null); } const existingIndex = this.slashCommandsData.findIndex(cmd => cmd.name === nameLower); if (existingIndex !== -1) { this.slashCommandsData[existingIndex] = slashData; } else { this.slashCommandsData.push(slashData); } } async registerSlashCommands() { const token = this.token || this.lixoOptions.token; const clientId = this.user?.id; if (!token) { console.warn("Cannot register slash commands - Bot token is missing."); return; } if (!clientId) { console.warn("Cannot register slash commands - Client ID is unavailable (Bot not ready?). Waiting for 'ready' event."); this.once('ready', () => this.registerSlashCommands()); return; } if (this.slashCommandsData.length === 0) { console.log("No slash commands prepared to register."); return; } const rest = new REST({ version: '10' }).setToken(token); try { console.log(`Refreshing ${this.slashCommandsData.length} application (/) commands.`); const data = await rest.put( Routes.applicationCommands(clientId), { body: this.slashCommandsData }, ); console.log(`Successfully reloaded ${Array.isArray(data) ? data.length : 'unknown number of'} application (/) commands.`); } catch (error) { console.error("Failed to register application commands:", error.response?.data || error.message || error); } } loadCommands(dirPath, clearExisting = true) { const absolutePath = path.resolve(dirPath); if (!fs.existsSync(absolutePath)) { console.error(`Commands directory not found: ${absolutePath}`); return; } if (clearExisting) { console.log("Clearing existing commands, events, and slash command data."); this.commands.clear(); this.events.clear(); this.slashCommandsData = []; } const commandFiles = loadFiles(absolutePath, '.js'); console.log(`Loading ${commandFiles.length} command/event files from ${absolutePath}...`); let loadedCount = 0; for (const filePath of commandFiles) { const commandData = requireUncached(filePath); if (commandData) { if (this.register(commandData)) { loadedCount++; } else { console.warn(`File ${path.basename(filePath)} loaded but contained invalid data or failed registration.`); } } } console.log(`Processed ${loadedCount} files containing commands/events. Prepared ${this.slashCommandsData.length} slash commands.`); } addFunction(funcData) { if (!funcData || typeof funcData.name !== 'string' || typeof funcData.execute !== 'function') { console.error(`Invalid function data provided. Requires 'name' (string) and 'execute' (function):`, funcData); return false; } let funcName = funcData.name; if (!funcName.startsWith('$')) { funcName = `$${funcName}`; console.warn(`Function name "${funcData.name}" did not start with $. Renaming to "${funcName}".`); } const nameLower = funcName.toLowerCase(); if (this.functions.has(nameLower)) { console.warn(`Overwriting existing function "${nameLower}"`); } this.functions.set(nameLower, { ...funcData, name: funcName }); return true; } loadFunctions(dirPath) { const absolutePath = path.resolve(dirPath); console.log(`Loading custom functions from ${absolutePath}...`); if (!fs.existsSync(absolutePath)) { console.warn(`Custom functions directory not found: ${absolutePath}`); return; } const functionFiles = loadFiles(absolutePath, '.js'); let loadedCount = 0; for (const filePath of functionFiles) { const funcData = requireUncached(filePath); if (funcData && this.addFunction(funcData)) { loadedCount++; } else if (funcData) { console.warn(`Custom function file ${path.basename(filePath)} loaded but contained invalid data or failed registration.`); } } console.log(`Loaded ${loadedCount} custom functions.`); } _loadBuiltInFunctions() { const funcDir = path.join(__dirname, '..', 'functions'); console.log(`Loading built-in functions from ${funcDir}...`); const functionFiles = loadFiles(funcDir, '.js'); let loadedCount = 0; for (const filePath of functionFiles) { try { const funcData = require(filePath); const nameLower = funcData.name?.toLowerCase(); if (nameLower && !this.functions.has(nameLower)) { if(this.addFunction(funcData)) { loadedCount++; } } else if (nameLower && this.functions.has(nameLower)) { } } catch (error) { console.error(`Error loading built-in function file ${path.basename(filePath)}:`, error); } } console.log(`Loaded ${loadedCount} built-in functions.`); } async login(token) { const botToken = token || this.lixoOptions.token; if (!botToken) { throw new Error("Bot token is missing. Provide it in constructor options, environment variable DISCORD_TOKEN, or login() method."); } this.lixoOptions.token = botToken; this._setupEventListeners(); console.log("Logging in..."); try { const loggedInToken = await super.login(botToken); return loggedInToken; } catch (error) { console.error("Login failed:", error); throw error; } } _setupEventListeners() { this.once('ready', async () => { console.log(`Logged in as ${this.user?.tag}`); if (this.db) console.log("Database is ready."); else console.warn("Database was not initialized successfully."); await this.registerSlashCommands(); console.log(`Attaching ${this.events.size} types of event handlers...`); let attachedCount = 0; for (const [eventName, handlers] of this.events) { this.on(eventName, async (...eventArgs) => { const context = eventArgs[0]; if (eventName === 'messageCreate' && context instanceof Message && context.author?.bot) { return; } for (const handlerData of handlers) { try { await this.commandHandler.executeCommand(handlerData, context, eventArgs.slice(1), eventName); } catch (error) { console.error(`Event Loop: Error executing handler for event "${eventName}" (Handler: ${handlerData.name || 'unnamed'}):`, error); } } }); attachedCount += handlers.length; } if (attachedCount > 0) console.log(`Attached ${attachedCount} listeners across ${this.events.size} event types.`); else console.log(`No user-defined event handlers found to attach.`); this.customEvents.emit('lixoReady', this); }); this.on('messageCreate', async (message) => { if (message.author?.bot || !message.guild) return; const prefix = typeof this.lixoOptions.prefix === 'function' ? await this.lixoOptions.prefix(message) : this.lixoOptions.prefix; if (!prefix || !message.content.toLowerCase().startsWith(prefix.toLowerCase())) return; const args = message.content.slice(prefix.length).trim().split(/ +/); const commandName = args.shift()?.toLowerCase(); if (!commandName) return; const command = this.commands.get(commandName); if (command && MESSAGE_COMMAND_TYPES.includes(command.type)) { try { await this.commandHandler.executeCommand(command, message, args); } catch (error) { console.error(`Message Handler: Error executing message command "${command.name}":`, error); } } }); this.on('interactionCreate', async (interaction) => { let commandIdentifier; let command; let expectedTypes = []; let isAutocomplete = false; if (interaction.isChatInputCommand()) { commandIdentifier = interaction.commandName.toLowerCase(); expectedTypes = INTERACTION_COMMAND_TYPES; } else if (interaction.isButton()) { commandIdentifier = interaction.customId; expectedTypes = [COMMAND_TYPES.BUTTON]; } else if (interaction.isAnySelectMenu()) { commandIdentifier = interaction.customId; expectedTypes = [COMMAND_TYPES.SELECT_MENU]; } else if (interaction.isModalSubmit()) { commandIdentifier = interaction.customId; expectedTypes = [COMMAND_TYPES.MODAL_SUBMIT]; } else if (interaction.isAutocomplete()) { commandIdentifier = interaction.commandName.toLowerCase(); expectedTypes = INTERACTION_COMMAND_TYPES; isAutocomplete = true; } else { return; } command = this.commands.get(commandIdentifier); if (isAutocomplete) { if (command && command.options?.some(opt => opt.name === interaction.options.getFocused(true).name && opt.autocomplete)) { try { await this.commandHandler.executeCommand(command, interaction, [], null, true); } catch (error) { console.error(`Autocomplete: Error handling autocomplete for "${commandIdentifier}":`, error); } } else { console.warn(`Autocomplete: Received autocomplete for "${commandIdentifier}" but no matching command or autocomplete option found.`); try { await interaction.respond([]); } catch {} } return; } if (command && expectedTypes.includes(command.type)) { try { await this.commandHandler.executeCommand(command, interaction, []); } catch (error) { console.error(`Interaction Handler: Error handling ${InteractionType[interaction.type]} interaction "${commandIdentifier}" for command "${command.name}":`, error); } } else if (commandIdentifier) { if (!command) { console.warn(`Interaction Handler: Received ${InteractionType[interaction.type]} interaction for ID/Name "${commandIdentifier}" but no command was found.`); } else { console.warn(`Interaction Handler: Received ${InteractionType[interaction.type]} interaction for "${commandIdentifier}" but command "${command.name}" type (${command.type}) doesn't match expected type(s): ${expectedTypes.join('/')}.`); } if (interaction.isRepliable() && !interaction.replied && !interaction.deferred) { try { await interaction.reply({ content: `⚠️ This interaction component (${commandIdentifier}) seems to be outdated or is not configured correctly.`, ephemeral: true }).catch(()=>{}); } catch {} } } }); this.on('error', (err) => console.error("Discord Client Error:", err)); this.on('warn', (info) => console.warn("Discord Client Warning:", info)); } } module.exports = LixoClient;