UNPKG

n1cat-discord-script-manager

Version:

A Discord.js plugin for dynamic script management and execution

1,027 lines (915 loc) 32.7 kB
const { Collection } = require("discord.js"); const path = require("path"); const fs = require("fs"); // Import Logger from utils const Logger = require("./utils/logger"); const CoreScriptManager = require("./core/ScriptManager"); const MessageHandler = require("./core/MessageHandler"); const EventHandler = require("./core/EventHandler"); const { installDependency, loadModule, checkDependencyVersion, checkDependencyConflicts, checkDependencySecurity, getDependencyInfo, } = require("./utils/moduleLoader"); // 用於追蹤函式調用的輔助函數 function getCallerInfo() { const stack = new Error().stack; const callerLine = stack.split("\n")[3]; // 跳過 Error 和 getCallerInfo 的堆疊 const match = callerLine.match(/at\s+(.+?)\s+\((.+?):(\d+):(\d+)\)/); if (match) { const [, functionName, file, line, column] = match; return { function: functionName, file: file.split("/").pop(), // 只取檔案名稱 line, column, }; } return { function: "unknown", file: "unknown", line: "unknown", column: "unknown", }; } // 日誌輸出函數 function log(message, isError = false) { const caller = getCallerInfo(); const debugMode = this.options?.debug ?? false; // 如果是錯誤或 debug 模式開啟,則輸出詳細日誌 if (isError || debugMode) { console.log(`[${caller.file}:${caller.line}] ${message}`); } } class ScriptManager { constructor(client, options = {}) { // Validate client object if (!client) { throw new Error("Discord client is required"); } // Check for essential Discord.js client methods const requiredMethods = ["on", "once"]; const missingMethods = requiredMethods.filter( (method) => typeof client[method] !== "function" ); if (missingMethods.length > 0) { throw new TypeError( `Invalid Discord client. Missing methods: ${missingMethods.join(", ")}` ); } this.client = client; this.scripts = new Collection(); this.commands = new Collection(); // New flags for tracking initialization and readiness this._isInitialized = false; this._isReady = false; // Store ScriptManager instance on client client.scriptManager = this; // Ensure script folder path is absolute const scriptFolder = options.scriptFolder || "scripts"; this.options = { scriptFolder: path.isAbsolute(scriptFolder) ? scriptFolder : path.resolve(process.cwd(), scriptFolder), allowedUsers: options.allowedUsers || [], maxScriptSize: options.maxScriptSize || 1024 * 1024, // 1MB debug: options.debug ?? false, ...options, }; // Ensure script directory exists if (!fs.existsSync(this.options.scriptFolder)) { fs.mkdirSync(this.options.scriptFolder, { recursive: true }); } // Add ready event listener for application initialization this._setupReadyHandler(); } /** * Check if ScriptManager is ready * @returns {boolean} Whether ScriptManager is fully initialized */ get isReady() { return this._isReady; } /** * Set up a ready handler to ensure application is properly initialized * @private */ _setupReadyHandler() { const logger = Logger.createContextLogger({ module: "ScriptManager", debug: this.options.debug, }); this.client.once("ready", async () => { try { // Attempt to fetch application if not already available if (!this.client.application) { logger.log("Fetching client application..."); await this.client.application?.fetch(); } // Initialize commands and event handlers await this._initCommands(); this._setupEventHandlers(); // Mark as initialized and ready this._isInitialized = true; this._isReady = true; logger.log("ScriptManager initialized successfully"); // Emit a ready event for users to listen this.client.emit("scriptManagerReady", this); } catch (error) { // Mark as not ready due to initialization failure this._isReady = false; logger.error("Failed to initialize ScriptManager", { showStack: true, additionalInfo: error.message, }); // Emit an error event that users can listen to this.client.emit("scriptManagerInitError", error); } }); } /** * Manually initialize ScriptManager if not using the ready event * @param {boolean} [forceFetchApplication=true] - Whether to automatically fetch application * @returns {Promise<void>} */ async init(forceFetchApplication = true) { const logger = Logger.createContextLogger({ module: "ScriptManager", debug: this.options.debug, }); try { // Ensure client is ready if (this.client.status !== 0) { throw new Error("Client is not connected. Call client.login() first."); } // Optionally fetch application if (forceFetchApplication && !this.client.application) { logger.log("Manually fetching client application..."); await this.client.application?.fetch(); } // Initialize commands and event handlers await this._initCommands(); this._setupEventHandlers(); // Mark as initialized and ready this._isInitialized = true; this._isReady = true; logger.log("ScriptManager manually initialized successfully"); // Emit a ready event this.client.emit("scriptManagerReady", this); } catch (error) { // Mark as not ready due to initialization failure this._isReady = false; logger.error("Failed to manually initialize ScriptManager", { showStack: true, additionalInfo: error.message, }); // Emit an error event this.client.emit("scriptManagerInitError", error); throw error; } } _setupEventHandlers() { // 如果已經初始化過,先移除舊的事件監聽器 if (this._isInitialized) { this.client.removeAllListeners("interactionCreate"); this.client.removeAllListeners("messageCreate"); } // 用於追蹤正在處理的互動 const processingInteractions = new Set(); // 設置互動處理器 this.client.on("interactionCreate", async (interaction) => { try { // 檢查是否正在處理此互動 if (processingInteractions.has(interaction.id)) { log.call(this, `跳過重複的互動處理: ${interaction.id}`); return; } // 標記此互動為處理中 processingInteractions.add(interaction.id); if (interaction.isButton()) { log.call(this, `收到按鈕互動: ${interaction.customId}`); const command = this.commands.get("interaction"); if (command) { try { log.call(this, "開始執行互動命令"); await command.execute(interaction); log.call(this, "互動命令執行完成"); } catch (error) { log.call(this, `處理按鈕互動時出錯: ${error.message}`, true); console.error("詳細錯誤信息:", { error: error.message, code: error.code, stack: error.stack, interactionId: interaction.id, interactionState: { replied: interaction.replied, deferred: interaction.deferred, }, timestamp: new Date().toISOString(), }); try { if (!interaction.replied && !interaction.deferred) { log.call(this, "嘗試延遲回應互動"); await interaction.deferUpdate(); log.call(this, "成功延遲回應互動"); } // 嘗試更新消息以顯示錯誤 await interaction.message .edit({ content: `${interaction.message.content}\n❌ 處理請求時發生錯誤:${error.message}`, embeds: interaction.message.embeds, components: [], }) .catch((editError) => { log.call( this, `更新消息時出錯: ${editError.message}`, true ); }); } catch (responseError) { log.call( this, `回應互動時出錯: ${responseError.message}`, true ); } } } else { log.call(this, "找不到互動命令處理器", true); try { await interaction.reply({ content: "❌ 找不到命令處理器", ephemeral: true, }); } catch (replyError) { log.call(this, `發送錯誤消息時出錯: ${replyError.message}`, true); } } } else if (interaction.isAutocomplete()) { log.call(this, `收到自動完成請求: ${interaction.commandName}`); const command = this.commands.get(interaction.commandName); if (command && command.autocomplete) { try { await command.autocomplete(interaction); } catch (error) { log.call(this, `處理自動完成時出錯: ${error.message}`, true); await interaction.respond([]).catch(console.error); } } } else if (interaction.isCommand()) { log.call(this, `收到命令: ${interaction.commandName}`); log.call( this, `命令來源: ${JSON.stringify({ id: interaction.id, token: interaction.token, applicationId: interaction.applicationId, guildId: interaction.guildId, channelId: interaction.channelId, userId: interaction.user.id, timestamp: new Date().toISOString(), stack: new Error().stack, })}` ); const command = this.commands.get(interaction.commandName); if (command) { try { log.call(this, `開始執行命令: ${interaction.commandName}`); log.call( this, `互動狀態: ${JSON.stringify({ id: interaction.id, replied: interaction.replied, deferred: interaction.deferred, timestamp: new Date().toISOString(), })}` ); // 執行命令 await command.execute(interaction); log.call(this, `命令執行完成: ${interaction.commandName}`); log.call( this, `執行後互動狀態: ${JSON.stringify({ id: interaction.id, replied: interaction.replied, deferred: interaction.deferred, timestamp: new Date().toISOString(), })}` ); } catch (error) { log.call(this, `執行命令時出錯: ${error.message}`, true); console.error("詳細錯誤信息:", { command: interaction.commandName, error: error.message, code: error.code, interactionId: interaction.id, interactionState: { replied: interaction.replied, deferred: interaction.deferred, }, timestamp: new Date().toISOString(), stack: error.stack, }); // 只在互動還沒有被回應時才發送錯誤消息 if (!interaction.replied && !interaction.deferred) { try { await interaction.reply({ content: `❌ 執行命令時發生錯誤:${error.message}`, ephemeral: true, }); log.call(this, "已發送錯誤回應"); } catch (replyError) { log.call( this, `發送錯誤回應時出錯: ${replyError.message}`, true ); console.error("發送錯誤回應時的詳細錯誤:", { error: replyError.message, code: replyError.code, interactionId: interaction.id, interactionState: { replied: interaction.replied, deferred: interaction.deferred, }, timestamp: new Date().toISOString(), stack: replyError.stack, }); } } else { log.call(this, "互動已經被回應,跳過錯誤回應"); } } } } else if (interaction.isModalSubmit()) { log.call(this, `收到 Modal Submit 互動: ${interaction.customId}`); const command = this.commands.get("interaction"); if (command) { try { log.call(this, "開始執行互動命令"); await command.execute(interaction); log.call(this, "互動命令執行完成"); } catch (error) { log.call( this, `處理 Modal Submit 互動時出錯: ${error.message}`, true ); console.error("詳細錯誤信息:", { error: error.message, code: error.code, stack: error.stack, interactionId: interaction.id, interactionState: { replied: interaction.replied, deferred: interaction.deferred, }, timestamp: new Date().toISOString(), }); try { if (!interaction.replied && !interaction.deferred) { log.call(this, "嘗試回應互動"); await interaction.reply({ content: `❌ 處理請求時發生錯誤:${error.message}`, ephemeral: true, }); } } catch (responseError) { log.call( this, `回應互動時出錯: ${responseError.message}`, true ); } } } else { log.call(this, "找不到互動命令處理器", true); try { await interaction.reply({ content: "❌ 找不到命令處理器", ephemeral: true, }); } catch (replyError) { log.call(this, `發送錯誤消息時出錯: ${replyError.message}`, true); } } } } catch (error) { log.call(this, `處理互動時出錯: ${error.message}`, true); } finally { // 移除處理中的標記 processingInteractions.delete(interaction.id); } }); // 設置消息處理器 this.client.on("messageCreate", async (message) => { if (message.author.bot) return; for (const [name, script] of this.scripts) { // 檢查腳本是否啟用 if (script.enabled === false) continue; try { const scriptModule = script.module; if (typeof scriptModule === "function") { // 如果是函數,直接調用 await scriptModule(message); } else if (typeof scriptModule.execute === "function") { // 如果是對象,調用 execute 方法 await scriptModule.execute({ client: this.client, message }); } } catch (error) { log.call(this, `執行腳本 ${name} 時出錯: ${error.message}`, true); // 添加更詳細的錯誤日誌 this._logScriptError(name, error, message); } } }); this._isInitialized = true; // 標記為已初始化 } async _initCommands() { const commandsPath = path.join(__dirname, "commands"); const commandFiles = fs .readdirSync(commandsPath) .filter((file) => file.endsWith(".js")); log.call(this, "開始初始化命令..."); log.call(this, `找到的命令文件: ${JSON.stringify(commandFiles)}`); // 清空現有命令集合 this.commands.clear(); for (const file of commandFiles) { try { const command = require(path.join(commandsPath, file)); // 載入所有命令 if ("data" in command && "execute" in command) { if (this.commands.has(command.data.name)) { log.call( this, `警告: 命令 ${command.data.name} 已存在,將被覆蓋`, true ); } this.commands.set(command.data.name, command); log.call(this, `已載入命令: ${command.data.name}`); } // 載入 interaction 處理器(不需要 data 屬性) else if (file === "interaction.js" && "execute" in command) { if (this.commands.has("interaction")) { log.call(this, "警告: interaction 處理器已存在,將被覆蓋", true); } this.commands.set("interaction", command); log.call(this, "已載入互動處理器"); } else { log.call( this, `警告: ${file} 缺少必要的屬性 (data 或 execute)`, true ); } } catch (error) { log.call(this, `載入命令 ${file} 時出錯: ${error.message}`, true); console.error("詳細錯誤信息:", { error: error.message, code: error.code, file, timestamp: new Date().toISOString(), }); } } log.call( this, `命令初始化完成,已載入命令: ${JSON.stringify( Array.from(this.commands.keys()) )}` ); } async loadScripts() { log.call(this, `開始載入腳本,腳本目錄: ${this.options.scriptFolder}`); // 確保腳本目錄存在 if (!fs.existsSync(this.options.scriptFolder)) { log.call(this, `創建腳本目錄: ${this.options.scriptFolder}`); fs.mkdirSync(this.options.scriptFolder, { recursive: true }); } const scriptFiles = fs .readdirSync(this.options.scriptFolder) .filter((file) => file.endsWith(".js")); log.call(this, `找到腳本文件: ${JSON.stringify(scriptFiles)}`); let loadedCount = 0; let errorCount = 0; for (const file of scriptFiles) { try { // 使用絕對路徑 const scriptPath = path.resolve(this.options.scriptFolder, file); log.call(this, `載入腳本: ${scriptPath}`); // 檢查文件大小 const stats = fs.statSync(scriptPath); if (stats.size > this.options.maxScriptSize) { throw new Error( `腳本大小超過限制 (${stats.size} > ${this.options.maxScriptSize})` ); } // 清除快取 if (require.cache[scriptPath]) { delete require.cache[scriptPath]; } // 使用 require.resolve 來獲取規範化的路徑 const resolvedPath = require.resolve(scriptPath); const script = require(resolvedPath); // 檢查是否已存在此腳本,如果存在則保留其啟用狀態 const existingScript = this.scripts.get(file); const enabled = existingScript ? existingScript.enabled : true; // 添加腳本元數據 const metadata = { name: file, path: scriptPath, size: stats.size, lastModified: stats.mtime, enabled: enabled, }; this.scripts.set(file, { ...metadata, module: script, }); log.call(this, `成功載入腳本: ${file}`); loadedCount++; } catch (error) { log.call(this, `載入腳本 ${file} 時出錯: ${error.message}`, true); console.error("詳細錯誤信息:", { error: error.message, code: error.code, file, timestamp: new Date().toISOString(), }); errorCount++; this._logScriptError(file, error); } } log.call(this, `腳本載入完成: ${loadedCount} 成功, ${errorCount} 失敗`); return { loaded: loadedCount, errors: errorCount }; } async executeScript(scriptName, context = { client: this.client }) { const script = this.scripts.get(scriptName); if (!script) { throw new Error(`腳本 ${scriptName} 不存在`); } try { const scriptModule = script.module; if (typeof scriptModule === "function") { return await scriptModule(context); } else if (typeof scriptModule.execute === "function") { return await scriptModule.execute(context); } else { throw new Error("腳本必須導出函數或包含 execute 方法的對象"); } } catch (error) { console.error(`執行腳本 ${scriptName} 時出錯:`, error); throw error; } } isUserAllowed(userId) { return ( this.options.allowedUsers.length === 0 || this.options.allowedUsers.includes(userId) ); } async registerCommands(guildId) { try { if (!this.client.application) { throw new Error("Client application is not available"); } // 優先使用傳入的 guildId,如果沒有則使用 options 中的 guildId const targetGuildId = guildId || this.options.guildId; log.call(this, "開始註冊命令..."); log.call(this, `目標伺服器: ${targetGuildId || "全局"}`); // 獲取現有指令 const existingCommands = await this.client.application.commands.fetch({ guildId: targetGuildId, }); log.call(this, `現有指令數量: ${existingCommands.size}`); log.call( this, `現有指令: ${JSON.stringify( Array.from(existingCommands.values()).map((cmd) => cmd.name) )}` ); // 修改刪除指令的邏輯,只刪除專案特定的命令 const projectCommands = new Set([ "uploadscript", "managescript", "listscripts", "deletescript", "previewscript", ]); log.call(this, "開始清理現有指令..."); for (const [id, command] of existingCommands) { if (projectCommands.has(command.name)) { log.call(this, `正在刪除指令: ${command.name} (${id})`); await command.delete(); log.call(this, `已刪除指令: ${command.name}`); } } // 等待一小段時間確保刪除操作完成 await new Promise((resolve) => setTimeout(resolve, 2000)); // 驗證刪除結果 const afterDeleteCommands = await this.client.application.commands.fetch({ guildId: targetGuildId, }); if (afterDeleteCommands.size > 0) { log.call( this, `警告: 仍有指令未被刪除: ${JSON.stringify( Array.from(afterDeleteCommands.values()).map((cmd) => cmd.name) )}`, true ); } else { log.call(this, "所有現有指令已成功刪除"); } // 載入並註冊新的指令 log.call(this, "開始註冊新指令..."); const commandsPath = path.join(__dirname, "commands"); const commandsToRegister = [ "uploadscript.js", "managescript.js", "listscripts.js", "deletescript.js", "previewscript.js", ]; const registeredCommands = new Set(); for (const commandFile of commandsToRegister) { const commandPath = path.join(commandsPath, commandFile); if (fs.existsSync(commandPath)) { const command = require(commandPath); if ("data" in command) { try { if (registeredCommands.has(command.data.name)) { log.call( this, `警告: 命令 ${command.data.name} 已註冊,跳過重複註冊`, true ); continue; } if (targetGuildId) { await this.client.application.commands.create( command.data.toJSON(), targetGuildId ); log.call( this, `成功註冊 ${command.data.name} 指令到伺服器 ${targetGuildId}` ); } else { await this.client.application.commands.create( command.data.toJSON() ); log.call(this, `成功註冊 ${command.data.name} 指令到全局`); } registeredCommands.add(command.data.name); } catch (error) { log.call( this, `註冊 ${command.data.name} 時出錯: ${error.message}`, true ); console.error("詳細錯誤信息:", { error: error.message, code: error.code, command: command.data.name, timestamp: new Date().toISOString(), }); throw error; } } else { log.call(this, `${commandFile} 缺少必要的 data 屬性`, true); } } else { log.call(this, `找不到命令文件: ${commandFile}`, true); } } // 驗證註冊結果 const finalCommands = await this.client.application.commands.fetch({ guildId: targetGuildId, }); log.call(this, `最終指令數量: ${finalCommands.size}`); log.call( this, `已註冊的指令: ${JSON.stringify( Array.from(finalCommands.values()).map((cmd) => cmd.name) )}` ); // 驗證所有需要的指令都已註冊 const missingCommands = commandsToRegister .map((file) => file.replace(".js", "")) .filter( (name) => !Array.from(finalCommands.values()).some((cmd) => cmd.name === name) ); if (missingCommands.length > 0) { log.call( this, `警告: 以下指令未能成功註冊: ${JSON.stringify(missingCommands)}`, true ); } log.call(this, "命令註冊完成"); } catch (error) { log.call(this, `註冊命令時出錯: ${error.message}`, true); console.error("詳細錯誤信息:", { error: error.message, code: error.code, guildId: targetGuildId, timestamp: new Date().toISOString(), }); throw error; } } /** * 記錄腳本錯誤 * @private */ _logScriptError(scriptName, error, message) { const errorInfo = { script: scriptName, error: error.message, stack: error.stack, timestamp: new Date().toISOString(), message: { id: message.id, content: message.content, author: message.author.tag, channel: message.channel.name, }, }; console.error("腳本錯誤詳情:", JSON.stringify(errorInfo, null, 2)); // 如果配置了錯誤通知頻道,發送錯誤通知 if (this.options.errorChannel) { this.options.errorChannel .send({ content: `❌ 腳本 \`${scriptName}\` 執行出錯`, embeds: [ { title: "錯誤詳情", description: `\`\`\`js\n${error.message}\n\`\`\``, fields: [ { name: "腳本", value: scriptName }, { name: "頻道", value: message.channel.name }, { name: "用戶", value: message.author.tag }, ], color: 0xff0000, timestamp: new Date(), }, ], }) .catch(console.error); } } } /** * Discord Script Manager * @module discord-script-manager */ /** * Create a new ScriptManager instance * @param {Object} options - Configuration options * @param {Object} options.client - Discord.js client * @param {string} [options.scriptFolder] - Path to script folder * @returns {ScriptManager} ScriptManager instance */ function createScriptManager(options) { // Validate options if (!options) { throw new Error( "Options are required. Please provide a configuration object." ); } // Check client if (!options.client) { throw new Error(` Discord client is required. Example setup: const { Client, GatewayIntentBits } = require('discord.js'); const { createScriptManager } = require('n1cat-discord-script-manager'); const client = new Client({ intents: [ GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent ] }); const scriptManager = createScriptManager({ client: client, scriptFolder: './scripts' // Optional: specify script folder });`); } // Check script folder if (!options.scriptFolder) { options.scriptFolder = path.resolve(process.cwd(), "scripts"); console.warn( `No script folder specified. Using default: ${options.scriptFolder}` ); } try { // Ensure script folder exists if (!fs.existsSync(options.scriptFolder)) { fs.mkdirSync(options.scriptFolder, { recursive: true }); console.log(`Created script folder: ${options.scriptFolder}`); } // Validate Logger is available if (typeof Logger === "undefined") { throw new Error(` Logger is not properly imported. Troubleshooting steps: 1. Ensure 'n1cat-discord-script-manager' is installed correctly 2. Check that './utils/logger.js' exists in the package 3. Verify no conflicts in logger import Example import: const { Logger } = require('n1cat-discord-script-manager'); or const Logger = require('n1cat-discord-script-manager/src/utils/logger');`); } return new ScriptManager(options.client, options); } catch (error) { console.error("Failed to create ScriptManager:", error); throw new Error(` ScriptManager initialization failed: ${error.message} Troubleshooting tips: 1. Ensure you're using a recent version of discord.js 2. Verify client is properly initialized with required intents 3. Check script folder permissions 4. Verify logger is correctly imported`); } } /** * 創建一個新的消息處理器實例 * @param {Message} message - Discord.js 消息對象 * @param {Object} [options] - 配置選項 * @param {number} [options.timeout=5000] - 操作超時時間 * @param {number} [options.maxRetries=3] - 最大重試次數 * @param {number} [options.cacheSize=100] - 消息緩存大小 * @param {boolean} [options.debug=false] - 是否開啟調試模式 * @returns {MessageHandler} 消息處理器實例 */ function createMessageHandler(message, options = {}) { if (!message) { throw new Error("Message is required"); } const defaultOptions = { timeout: 5000, maxRetries: 3, cacheSize: 100, debug: false, }; try { return new MessageHandler(message, { ...defaultOptions, ...options }); } catch (error) { console.error("Failed to create MessageHandler:", error); throw error; } } /** * 創建一個新的事件處理器實例 * @param {Object} options - 配置選項 * @param {Object} options.client - Discord.js 客戶端 * @param {string} options.scriptFolder - 腳本資料夾路徑 * @returns {EventHandler} 事件處理器實例 */ function createEventHandler(options) { if (!options) { throw new Error("Options are required"); } if (!options.client) { throw new Error("Discord client is required"); } if (!options.scriptFolder) { throw new Error("Script folder path is required"); } try { return new EventHandler(options); } catch (error) { console.error("Failed to create EventHandler:", error); throw error; } } // 導出所有工廠函數和工具函數 module.exports = { // 工廠函數 createScriptManager, createMessageHandler, createEventHandler, // 工具函數 installDependency, loadModule, checkDependencyVersion, checkDependencyConflicts, checkDependencySecurity, getDependencyInfo, // 為了向後兼容,也導出類 CoreScriptManager, MessageHandler, EventHandler, // 導出 Logger Logger, };