UNPKG

n1cat-discord-script-manager

Version:

A Discord.js plugin for dynamic script management and execution

434 lines (371 loc) 12.7 kB
/** * ScriptManager - 腳本管理系統 * 負責加載、重新加載和執行腳本,以及處理腳本錯誤 */ const fs = require("fs"); const path = require("path"); const { Collection } = require("discord.js"); const MessageHandler = require("./MessageHandler"); const Logger = require("../utils/logger"); const { loadModule } = require("../utils/moduleLoader"); class ScriptManager { /** * 創建一個新的腳本管理器 * @param {Object} options - 配置選項 * @param {string} options.scriptFolder - 腳本資料夾路徑 * @param {Object} options.client - Discord.js 客戶端 * @param {function} [options.onError] - 錯誤處理函數 * @param {number} [options.maxExecutionTime=5000] - 腳本最大執行時間(毫秒) * @param {string[]} [options.allowedUsers=[]] - 允許使用腳本的用戶ID列表 * @param {number} [options.maxScriptSize=1024*1024] - 最大腳本大小(字節) * @param {boolean} [options.debug=false] - 是否開啟調試模式 * @param {string} [options.guildId] - 用於註冊命令的伺服器ID */ constructor(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"); } // 初始化屬性 this.client = options.client; this.scripts = new Collection(); this.commands = new Collection(); this._isInitialized = false; // 創建上下文日誌器 this.logger = Logger.createContextLogger({ module: "ScriptManager", debug: options.debug ?? false, }); // 設置選項 this.options = { scriptFolder: path.isAbsolute(options.scriptFolder) ? options.scriptFolder : path.resolve(process.cwd(), options.scriptFolder), allowedUsers: options.allowedUsers || [], maxScriptSize: options.maxScriptSize || 1024 * 1024, debug: options.debug ?? false, maxExecutionTime: options.maxExecutionTime || 5000, onError: options.onError || this._defaultErrorHandler, guildId: options.guildId || null, }; // 確保腳本目錄存在 this._ensureScriptFolderExists(); // 初始化命令和事件處理器 this._initCommands(); this._setupEventHandlers(); // 初始化執行日誌 this.executionLogs = new Map(); } /** * 確保腳本目錄存在 * @private */ _ensureScriptFolderExists() { try { if (!fs.existsSync(this.options.scriptFolder)) { this.logger.log(`創建腳本目錄: ${this.options.scriptFolder}`); fs.mkdirSync(this.options.scriptFolder, { recursive: true }); } } catch (error) { this.logger.error(`創建腳本目錄失敗: ${error.message}`, { showStack: true, }); throw error; } } /** * 加載所有腳本 * @param {Object} [options] - 加載選項 * @param {Object} [options.channel] - 用於發送狀態的頻道 * @returns {Promise<{total: number, loaded: number}>} 加載結果 */ async loadScripts(options = {}) { this.logger.log( `Loading scripts from folder: ${this.options.scriptFolder}` ); const scriptFiles = fs .readdirSync(this.options.scriptFolder) .filter((file) => file.endsWith(".js")); if (scriptFiles.length === 0) { this.logger.log("No scripts found in folder"); return { total: 0, loaded: 0 }; } // 清空現有腳本集合 this.scripts.clear(); let loadedCount = 0; for (const file of scriptFiles) { const filePath = path.join(this.options.scriptFolder, file); this.logger.log(`Loading script: ${file}`); try { // 清除快取 delete require.cache[require.resolve(filePath)]; // 載入腳本 const script = await loadModule(filePath); // 檢查腳本是否為函數 if (typeof script === "function") { // 獲取腳本版本 const version = this._getScriptVersion(filePath); this.scripts.set(file, { module: script, version: version, lastExecuted: null, executionCount: 0, averageExecutionTime: 0, }); this.logger.log(`✅ ${file} (v${version})`); loadedCount++; } else { throw new Error("Script must export a function"); } } catch (error) { this.logger.log( `Failed to load script ${file}: ${error.message}`, true ); this._handleLoadError(file, error, options.channel); } } this._logLoadingSummary(scriptFiles.length, loadedCount); return { total: scriptFiles.length, loaded: loadedCount, }; } /** * 重新加載特定腳本 * @param {string} fileName - 腳本檔案名稱 * @param {Object} [options] - 加載選項 * @param {Object} [options.channel] - 用於發送狀態的頻道 * @returns {Promise<boolean>} 是否成功重新加載 */ async reloadScript(fileName, options = {}) { const filePath = path.join(this.options.scriptFolder, fileName); if (!fs.existsSync(filePath)) { const errorMessage = `找不到腳本: ${fileName}`; this.logger.error(errorMessage); if (options.channel) { await options.channel.send(errorMessage); } return false; } try { // 清除快取 delete require.cache[require.resolve(filePath)]; // 載入腳本 const script = await loadModule(filePath); // 檢查腳本是否為函數 if (typeof script === "function") { // 獲取腳本版本 const version = this._getScriptVersion(filePath); // 保存舊的執行統計 const oldStats = this.scripts.get(fileName); this.scripts.set(fileName, { module: script, version: version, lastExecuted: oldStats?.lastExecuted || null, executionCount: oldStats?.executionCount || 0, averageExecutionTime: oldStats?.averageExecutionTime || 0, }); const successMessage = `✅ 已重新載入腳本: ${fileName} (v${version})`; this.logger.log(successMessage); if (options.channel) { await options.channel.send(successMessage); } return true; } else { throw new Error("Script must export a function"); } } catch (error) { await this._handleLoadError(fileName, error, options.channel); return false; } } /** * 添加新腳本 * @param {string} fileName - 腳本檔案名稱 * @param {string} scriptContent - 腳本內容 * @param {Object} metadata - 腳本元數據 * @param {Object} [options] - 選項 * @param {Object} [options.channel] - 用於發送狀態的頻道 * @returns {Promise<boolean>} 是否成功添加 */ async addScript(fileName, scriptContent, metadata, options = {}) { const filePath = path.join(this.options.scriptFolder, fileName); // 添加元數據頭 const headerContent = `/* 此檔案為使用者腳本 由 ${metadata.author} 在 ${metadata.createdAt} 上傳 版本: 1.0.0 */ `; const fullContent = headerContent + scriptContent; try { // 寫入檔案 fs.writeFileSync(filePath, fullContent); // 載入腳本 return await this.reloadScript(fileName, options); } catch (error) { const errorMessage = `無法保存或載入腳本 ${fileName}: ${error.message}`; this.logger.error(errorMessage, { showStack: true }); if (options.channel) { await options.channel.send(errorMessage); } return false; } } /** * 處理消息事件,執行所有腳本 * @param {Message} message - Discord.js 消息對象 * @returns {Promise<void>} */ async processMessage(message) { if (message.author.bot) return; // 創建消息處理器 const handler = new MessageHandler(message); // 執行每個腳本 for (const [fileName, scriptInfo] of this.scripts) { try { const startTime = Date.now(); // 使用 Promise.race 來實現超時控制 await Promise.race([ Promise.resolve(scriptInfo.module(handler)), new Promise((_, reject) => setTimeout( () => reject( new Error( `Script execution timeout after ${this.options.maxExecutionTime}ms` ) ), this.options.maxExecutionTime ) ), ]).catch((error) => { this.options.onError(error, fileName, message); }); // 更新執行統計 const executionTime = Date.now() - startTime; scriptInfo.lastExecuted = new Date(); scriptInfo.executionCount++; scriptInfo.averageExecutionTime = (scriptInfo.averageExecutionTime * (scriptInfo.executionCount - 1) + executionTime) / scriptInfo.executionCount; // 記錄執行日誌 this._logExecution(fileName, executionTime); } catch (error) { this.options.onError(error, fileName, message); } } } /** * 獲取腳本執行統計 * @param {string} fileName - 腳本檔案名稱 * @returns {Object|null} 執行統計信息 */ getScriptStats(fileName) { const scriptInfo = this.scripts.get(fileName); if (!scriptInfo) return null; return { version: scriptInfo.version, lastExecuted: scriptInfo.lastExecuted, executionCount: scriptInfo.executionCount, averageExecutionTime: scriptInfo.averageExecutionTime, }; } /** * @private * 獲取腳本版本 * @param {string} filePath - 腳本文件路徑 * @returns {string} 腳本版本 */ _getScriptVersion(filePath) { try { const content = fs.readFileSync(filePath, "utf8"); const versionMatch = content.match(/版本:\s*([\d.]+)/); return versionMatch ? versionMatch[1] : "1.0.0"; } catch (error) { return "1.0.0"; } } /** * @private * 記錄腳本執行 * @param {string} fileName - 腳本檔案名稱 * @param {number} executionTime - 執行時間(毫秒) */ _logExecution(fileName, executionTime) { const now = new Date(); const logKey = `${now.toISOString().split("T")[0]}`; if (!this.executionLogs.has(logKey)) { this.executionLogs.set(logKey, new Map()); } const dayLogs = this.executionLogs.get(logKey); if (!dayLogs.has(fileName)) { dayLogs.set(fileName, { count: 0, totalTime: 0, minTime: Infinity, maxTime: 0, }); } const scriptLog = dayLogs.get(fileName); scriptLog.count++; scriptLog.totalTime += executionTime; scriptLog.minTime = Math.min(scriptLog.minTime, executionTime); scriptLog.maxTime = Math.max(scriptLog.maxTime, executionTime); // 只保留最近 30 天的日誌 const thirtyDaysAgo = new Date(); thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); for (const [date] of this.executionLogs) { if (new Date(date) < thirtyDaysAgo) { this.executionLogs.delete(date); } } } /** * @private * 預設錯誤處理器 * @param {Error} error - 錯誤對象 * @param {string} fileName - 腳本檔案名稱 * @param {Message} message - 原始消息對象 */ _defaultErrorHandler(error, fileName, message) { this.logger.error(`Error in ${fileName}`, { showStack: true }); } /** * @private * 處理腳本載入錯誤 * @param {string} fileName - 腳本檔案名稱 * @param {Error} error - 錯誤對象 * @param {Object} [channel] - 用於發送狀態的頻道 */ async _handleLoadError(fileName, error, channel) { const errorMessage = error.stack || error.message; this.logger.error(`❌ 載入腳本失敗 ${fileName}`, { showStack: true }); if (channel) { await channel.send( `❌ 載入腳本 \`${fileName}\` 失敗:\`\`\`\n${errorMessage}\n\`\`\`` ); } } /** * @private * 記錄載入摘要 * @param {number} total - 總腳本數 * @param {number} loaded - 成功載入數 */ _logLoadingSummary(total, loaded) { this.logger.log(`\n=== Script Loading Summary ===`); this.logger.log(`Total scripts found: ${total}`); this.logger.log(`Successfully loaded: ${loaded}`); this.logger.log(`========================\n`); } } module.exports = ScriptManager;