n1cat-discord-script-manager
Version:
A Discord.js plugin for dynamic script management and execution
441 lines (388 loc) • 14.3 kB
JavaScript
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, // 導出以便其他地方可以調用
};