n1cat-discord-script-manager
Version:
A Discord.js plugin for dynamic script management and execution
434 lines (371 loc) • 12.7 kB
JavaScript
/**
* 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;