UNPKG

n1cat-discord-script-manager

Version:

A Discord.js plugin for dynamic script management and execution

441 lines (388 loc) 14.3 kB
const { SlashCommandBuilder } = require("@discordjs/builders"); const { ActionRowBuilder, ButtonBuilder, ButtonStyle, MessageFlags, } = require("discord.js"); const moment = require("moment"); const fs = require("fs"); const path = require("path"); const Logger = require("../utils/logger"); // 用於追蹤函式調用的輔助函數 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 scriptManager = global.client?.scriptManager; const debugMode = scriptManager?.options?.debug ?? false; // 如果是錯誤或 debug 模式開啟,則輸出詳細日誌 if (isError || debugMode) { console.log(`[${caller.file}:${caller.line}] ${message}`); } } // 新增一個清理緩存的函數 function cleanupTempScriptFiles(scriptFolder, olderThanDays = 7) { const logger = Logger.createContextLogger({ module: "ScriptCleanup", debug: global.client?.scriptManager?.options?.debug || false, }); try { const currentTime = Date.now(); const msPerDay = 24 * 60 * 60 * 1000; const cutoffTime = currentTime - olderThanDays * msPerDay; // 遍歷所有用戶的腳本目錄 const userFolders = fs.readdirSync(scriptFolder); userFolders.forEach((userId) => { const userScriptFolder = path.join(scriptFolder, userId); // 確保是目錄 if (fs.statSync(userScriptFolder).isDirectory()) { const files = fs.readdirSync(userScriptFolder); files.forEach((file) => { const filePath = path.join(userScriptFolder, file); const stats = fs.statSync(filePath); // 檢查是否為臨時文件或超過指定天數的文件 if ( (file.startsWith("temp_") || stats.mtime.getTime() < cutoffTime) && file.endsWith(".js") ) { fs.unlinkSync(filePath); logger.log(`刪除過期腳本文件: ${filePath}`); } }); } }); logger.log(`清理 ${olderThanDays} 天前的臨時腳本文件`); } catch (error) { logger.error(`清理臨時腳本文件時出錯: ${error.message}`, { showStack: true, }); } } module.exports = { data: new SlashCommandBuilder() .setName("uploadscript") .setDescription("上傳腳本並等待審核") .addAttachmentOption((option) => option .setName("script") .setDescription("上傳你的 JS 檔案") .setRequired(true) ), async execute(interaction) { const logger = Logger.createContextLogger({ module: "UploadScript", debug: interaction.client.config?.debug || false, }); logger.log("開始執行 uploadscript 命令"); try { // 獲取 ScriptManager 實例 const scriptManager = interaction.client.scriptManager; if (!scriptManager) { logger.error("找不到腳本管理器"); return await interaction.reply({ content: "❌ 找不到腳本管理器", ephemeral: true, }); } // 獲取第一個允許的用戶作為所有者 const OWNER_USER_ID = scriptManager.options.allowedUsers[0]; if (!OWNER_USER_ID) { logger.error("未設置允許的用戶"); return await interaction.reply({ content: "❌ 系統未正確配置", ephemeral: true, }); } const uploadedScript = interaction.options.getAttachment("script"); logger.log(`收到上傳的腳本: ${uploadedScript.name}`); // 檢查文件擴展名 const fileExtension = path.extname(uploadedScript.name); if (fileExtension !== ".js") { logger.error(`無效的文件擴展名: ${fileExtension}`); return interaction.reply({ content: "請上傳有效的 JavaScript 檔案!", flags: MessageFlags.Ephemeral, }); } // 動態載入 node-fetch let fetch; try { fetch = (await import("node-fetch")).default; logger.log("成功載入 node-fetch"); } catch (error) { logger.error(`載入 node-fetch 失敗: ${error.message}`); return interaction.reply({ content: "無法載入 node-fetch,請稍後再試。", flags: MessageFlags.Ephemeral, }); } // 取得腳本內容 let scriptContent; try { scriptContent = await fetch(uploadedScript.url).then((res) => res.text() ); logger.log("成功獲取腳本內容"); } catch (error) { logger.error(`獲取腳本內容失敗: ${error.message}`); return interaction.reply({ content: "無法獲取腳本內容,請稍後再試。", flags: MessageFlags.Ephemeral, }); } // 使用 Unix Timestamp 作為檔名 const unixTimestamp = Date.now(); const fileName = `${unixTimestamp}.js`; logger.log(`生成檔案名稱: ${fileName}`); // 保存臨時腳本文件 const userScriptFolder = path.join( scriptManager.options.scriptFolder, interaction.user.id ); // 確保用戶腳本目錄存在 if (!fs.existsSync(userScriptFolder)) { logger.log(`創建腳本目錄: ${userScriptFolder}`); fs.mkdirSync(userScriptFolder, { recursive: true }); } const tempScriptPath = path.join(userScriptFolder, `temp_${fileName}`); logger.log(`保存臨時腳本文件: ${tempScriptPath}`); fs.writeFileSync(tempScriptPath, scriptContent); // 審核顯示格式 const MAX_FIELD_LENGTH = 1000; const MAX_FIELDS = 3; // 將腳本內容分割成多個 Fields const scriptContentFields = []; let remainingContent = scriptContent; while ( remainingContent.length > 0 && scriptContentFields.length < MAX_FIELDS ) { const fieldContent = remainingContent.slice(0, MAX_FIELD_LENGTH); scriptContentFields.push({ name: `腳本內容 ${scriptContentFields.length + 1}`, value: `\`\`\`js\n${fieldContent}\n\`\`\``, inline: false, }); remainingContent = remainingContent.slice(MAX_FIELD_LENGTH); } // 如果還有剩餘內容,添加一個提示 if (remainingContent.length > 0) { scriptContentFields.push({ name: "⚠️ 注意", value: "腳本內容過長,已顯示部分內容。完整內容請查看附件。", inline: false, }); } const approvalEmbed = { title: `已上傳 \`${fileName}\` (原始檔案: \`${uploadedScript.name}\`)`, color: 0x00ff00, fields: scriptContentFields, }; // 創建確認按鈕 const approveButton = new ButtonBuilder() .setCustomId("approve") .setLabel("通過") .setStyle(ButtonStyle.Success); const rejectButton = new ButtonBuilder() .setCustomId("reject") .setLabel("拒絕") .setStyle(ButtonStyle.Danger); const row = new ActionRowBuilder().addComponents( approveButton, rejectButton ); // 創建臨時文件作為附件 const tempAttachmentPath = path.join( scriptManager.options.scriptFolder, interaction.user.id, `${fileName}` ); logger.log(`創建臨時附件: ${tempAttachmentPath}`); // 確保目錄存在 const tempAttachmentDir = path.dirname(tempAttachmentPath); if (!fs.existsSync(tempAttachmentDir)) { fs.mkdirSync(tempAttachmentDir, { recursive: true }); } fs.writeFileSync(tempAttachmentPath, scriptContent); logger.log("準備發送審核消息"); // 直接在 interaction.reply 裡發送訊息給 Owner const reply = await interaction.reply({ content: `腳本已上傳並等待審核。\n<@${OWNER_USER_ID}> 請審核以下上傳的腳本:`, embeds: [approvalEmbed], components: [row], files: [ { attachment: tempAttachmentPath, name: fileName, }, ], }); // 詳細記錄附件信息 logger.log(`Reply object keys: ${Object.keys(reply)}`); // 安全地記錄對象,避免 BigInt 序列化問題 const safeLogObject = (obj) => { if (obj === null || obj === undefined) return "null/undefined"; try { // 處理特殊類型 if (typeof obj === "bigint") return obj.toString(); // 如果是對象或數組,遞歸處理 if (typeof obj === "object") { const safeObj = {}; for (const [key, value] of Object.entries(obj)) { try { safeObj[key] = safeLogObject(value); } catch { safeObj[key] = "Unable to serialize"; } } return safeObj; } return obj; } catch { return "Unable to serialize"; } }; // 獲取上傳文件的 CDN 鏈接 let attachmentUrl = null; try { // 方法1:直接從 reply 對象獲取 logger.log("開始檢查 reply 的附件"); // 安全地檢查 attachments const replyAttachments = reply.attachments; logger.log( `Reply attachments 詳情: ${JSON.stringify( replyAttachments ? Array.from(replyAttachments.values()) : "No attachments" )}` ); if (replyAttachments && replyAttachments.size > 0) { const attachment = replyAttachments.first(); attachmentUrl = attachment.url; logger.log(`方法1 - 成功獲取附件鏈接: ${attachmentUrl}`); logger.log( `附件詳情: ${JSON.stringify({ id: attachment.id, name: attachment.name, size: attachment.size, url: attachment.url, proxyURL: attachment.proxyURL, })}` ); } // 方法2:從最近的消息獲取 if (!attachmentUrl) { logger.log("方法1 失敗,嘗試從最近的消息獲取附件"); const channel = interaction.channel; const lastMessage = await channel.messages.fetch({ limit: 1 }); const message = lastMessage.first(); logger.log(`最近消息的附件數量: ${message?.attachments?.size || 0}`); if (message && message.attachments && message.attachments.size > 0) { const attachment = message.attachments.first(); attachmentUrl = attachment.url; logger.log(`方法2 - 成功獲取附件鏈接: ${attachmentUrl}`); logger.log( `附件詳情: ${JSON.stringify({ id: attachment.id, name: attachment.name, size: attachment.size, url: attachment.url, proxyURL: attachment.proxyURL, })}` ); } } // 方法3:使用臨時文件路徑 if (!attachmentUrl) { attachmentUrl = `file://${tempAttachmentPath}`; logger.log(`方法3 - 使用本地文件路徑: ${attachmentUrl}`); } // 如果成功獲取到鏈接,則編輯回覆 if (attachmentUrl) { logger.log(`準備編輯回覆,附件鏈接為: ${attachmentUrl}`); await interaction.editReply({ content: `腳本已上傳並等待審核。\n<@${OWNER_USER_ID}> 請審核以下上傳的腳本:\n\n${attachmentUrl}`, embeds: [approvalEmbed], components: [row], }); logger.log("回覆編輯成功"); } else { logger.log("未能獲取附件鏈接"); } const metadata = { author: interaction.user.username, createdAt: moment().utc().format("YYYY/MM/DD HH:mm:ss"), uploaderId: interaction.user.id, attachmentUrl: attachmentUrl, originalFileName: uploadedScript.name, scriptFileName: fileName, }; } catch (error) { logger.error(`獲取附件鏈接時出錯: ${error.message}`, { showStack: true, replyObject: safeLogObject(reply), interactionObject: safeLogObject(interaction), }); } logger.log("審核消息已發送"); } catch (error) { logger.error(`上傳腳本時出錯: ${error.message}`, { showStack: true }); console.error("詳細錯誤信息:", { error: error.message, code: error.code, interactionId: interaction.id, interactionState: { replied: interaction.replied, deferred: interaction.deferred, }, timestamp: new Date().toISOString(), }); if (!interaction.replied && !interaction.deferred) { try { await interaction.reply({ content: `❌ 執行命令時發生錯誤:${error.message}`, ephemeral: true, }); logger.log("已發送錯誤回應"); } catch (replyError) { logger.error(`發送錯誤回應時出錯: ${replyError.message}`, { showStack: true, }); console.error("發送錯誤回應時的詳細錯誤:", { error: replyError.message, code: replyError.code, interactionId: interaction.id, interactionState: { replied: interaction.replied, deferred: interaction.deferred, }, timestamp: new Date().toISOString(), }); } } else { logger.log("互動已經被回應,跳過錯誤回應"); } } }, cleanupTempScriptFiles, // 導出以便其他地方可以調用 };