From fbd78dd7adb2c8b55b64835c881d096ac1d9cd92 Mon Sep 17 00:00:00 2001 From: Jerryplusy Date: Wed, 27 Aug 2025 18:16:04 +0800 Subject: [PATCH] init --- .gitignore | 1 + .idea/.gitignore | 8 ++ .idea/crystelf-admin.iml | 12 ++ .idea/modules.xml | 8 ++ .idea/vcs.xml | 6 + .prettierrc | 7 + README.md | 4 +- apps/coreRestart.js | 38 ++++++ apps/reportBots.js | 69 ++++++++++ components/date.js | 44 +++++++ components/json.js | 247 ++++++++++++++++++++++++++++++++++++ components/module.js | 57 +++++++++ components/tool.js | 125 ++++++++++++++++++ config/config.md | 61 +++++++++ config/default.json | 11 ++ constants/path.js | 29 +++++ constants/relativelyPath.js | 6 + index.js | 37 ++++++ lib/config/configControl.js | 74 +++++++++++ lib/core/botControl.js | 98 ++++++++++++++ lib/core/systemControl.js | 20 +++ lib/system/init.js | 12 ++ lib/system/updater.js | 79 ++++++++++++ lib/system/version.js | 26 ++++ modules/ws/handler.js | 95 ++++++++++++++ modules/ws/wsClient.js | 95 ++++++++++++++ package.json | 30 +++++ 27 files changed, 1298 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 .idea/.gitignore create mode 100644 .idea/crystelf-admin.iml create mode 100644 .idea/modules.xml create mode 100644 .idea/vcs.xml create mode 100644 .prettierrc create mode 100644 apps/coreRestart.js create mode 100644 apps/reportBots.js create mode 100644 components/date.js create mode 100644 components/json.js create mode 100644 components/module.js create mode 100644 components/tool.js create mode 100644 config/config.md create mode 100644 config/default.json create mode 100644 constants/path.js create mode 100644 constants/relativelyPath.js create mode 100644 index.js create mode 100644 lib/config/configControl.js create mode 100644 lib/core/botControl.js create mode 100644 lib/core/systemControl.js create mode 100644 lib/system/init.js create mode 100644 lib/system/updater.js create mode 100644 lib/system/version.js create mode 100644 modules/ws/handler.js create mode 100644 modules/ws/wsClient.js create mode 100644 package.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..096746c --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/node_modules/ \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..35410ca --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# 默认忽略的文件 +/shelf/ +/workspace.xml +# 基于编辑器的 HTTP 客户端请求 +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/crystelf-admin.iml b/.idea/crystelf-admin.iml new file mode 100644 index 0000000..24643cc --- /dev/null +++ b/.idea/crystelf-admin.iml @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..14333c3 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..128a9ab --- /dev/null +++ b/.prettierrc @@ -0,0 +1,7 @@ +{ + "singleQuote": true, + "semi": true, + "printWidth": 100, + "tabWidth": 2, + "trailingComma": "es5" +} diff --git a/README.md b/README.md index 809398b..2f22e38 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,4 @@ # crystelf-admin -TRSS-Yunzai的后🚪插件 +TRSS-Yunzai的管理插件 + +用于晶灵管理群聊等,含有大量后门逻辑,请千万~~不~~要安装 diff --git a/apps/coreRestart.js b/apps/coreRestart.js new file mode 100644 index 0000000..26679ca --- /dev/null +++ b/apps/coreRestart.js @@ -0,0 +1,38 @@ +import systemControl from '../lib/core/systemControl.js'; +import tools from '../components/tool.js'; +import configControl from '../lib/config/configControl.js'; + +export default class CoreRestart extends plugin { + constructor() { + super({ + name: 'crystelf重启核心', + dsc: '实现核心的重启功能', + rule: [ + { + reg: '^#core重启$', + fnc: 'restart', + permission: 'master', + }, + ], + }); + } + + async restart(e) { + if (!configControl.get('core')) { + return e.reply(`晶灵核心未启用..`, true); + } + const returnData = await systemControl.systemRestart(); + if (returnData?.data?.success) { + await e.reply(`操作成功:${returnData?.data?.data}..`, true); + } else { + await e.reply(`操作失败:${returnData?.data?.data}..`, true); + } + await tools.sleep(8000); + const restartTime = await systemControl.getRestartTime(); + if (restartTime) { + await e.reply(`晶灵核心重启成功!耗时${restartTime?.data?.data}秒..`, true); + } else { + await e.reply(`核心重启花的时间有点久了呢..${restartTime?.data?.data}`, true); + } + } +} diff --git a/apps/reportBots.js b/apps/reportBots.js new file mode 100644 index 0000000..eb1b4ee --- /dev/null +++ b/apps/reportBots.js @@ -0,0 +1,69 @@ +import botControl from '../lib/core/botControl.js'; +import configControl from '../lib/config/configControl.js'; +import schedule from 'node-schedule'; +import axios from 'axios'; + +export default class ReportBots extends plugin { + constructor() { + super({ + name: 'crystelf Bot状态上报', + dsc: '一些操作bot的功能', + rule: [ + { + reg: '^#crystelf同步$', + fnc: 'manualReport', + permission: 'master', + }, + { + reg: '^#crystelf广播(.+)$', + fnc: 'broadcast', + permission: 'master', + }, + ], + }); + schedule.scheduleJob('*/30 * * * *', () => this.autoReport()); + } + + async autoReport() { + logger.mark(`正在自动同步bot数据到晶灵核心..`); + if (configControl.get('core')) { + await botControl.reportBots(); + } + } + + async manualReport(e) { + if (!configControl.get('core')) { + return e.reply(`晶灵核心未启用..`, true); + } + let success = await botControl.reportBots(); + if (success) { + await e.reply('crystelf Bot信息已同步到核心..', true); + } else { + await e.reply('crystelf Bot同步失败:核心未连接..', true); + } + } + + async broadcast(e) { + const msg = e?.msg?.match(/^#crystelf广播(.+)$/)?.[1]?.trim(); + if (!msg) { + return e.reply('广播内容不能为空'); + } + await e.reply(`开始广播消息到所有群..`); + try { + const sendData = { + token: configControl.get('coreConfig')?.token, + message: msg.toString(), + }; + const url = configControl.get('coreConfig')?.coreUrl; + const returnData = await axios.post(`${url}/api/bot/broadcast`, sendData); + if (returnData?.data?.success) { + return await e.reply(`操作成功:${returnData?.data.data?.toString()}`); + } else { + return await e.reply(`广播出现错误,请检查日志..`); + } + } catch (err) { + logger.error(`广播执行异常: ${err.message}`); + return await e.reply('广播过程中发生错误,请检查日志..'); + } + } +} diff --git a/components/date.js b/components/date.js new file mode 100644 index 0000000..53ee308 --- /dev/null +++ b/components/date.js @@ -0,0 +1,44 @@ +let date = { + /** + * 格式化日期时间 + * @param {Date|number|string} [date=new Date()] - 可接收Date对象、时间戳或日期字符串 + * @param {string} [format='YYYY-MM-DD HH:mm:ss'] - 格式模板,支持: + * YYYY-年, MM-月, DD-日, + * HH-时, mm-分, ss-秒 + * @returns {string} 格式化后的日期字符串 + * @example + * fc.formatDate(new Date(), 'YYYY年MM月DD日') // "2023年08月15日" + */ + formatDate(date = new Date(), format = 'YYYY-MM-DD HH:mm:ss') { + const d = new Date(date); + const pad = (n) => n.toString().padStart(2, '0'); + + return format + .replace(/YYYY/g, pad(d.getFullYear())) + .replace(/MM/g, pad(d.getMonth() + 1)) + .replace(/DD/g, pad(d.getDate())) + .replace(/HH/g, pad(d.getHours())) + .replace(/mm/g, pad(d.getMinutes())) + .replace(/ss/g, pad(d.getSeconds())); + }, + + formatDuration(seconds) { + const days = Math.floor(seconds / 86400); + const hours = Math.floor((seconds % 86400) / 3600); + const mins = Math.floor((seconds % 3600) / 60); + const secs = seconds % 60; + + return ( + [ + days > 0 ? `${days}天` : '', + hours > 0 ? `${hours}小时` : '', + mins > 0 ? `${mins}分钟` : '', + secs > 0 ? `${secs}秒` : '', + ] + .filter(Boolean) + .join(' ') || '0秒' + ); + }, +}; + +export default date; diff --git a/components/json.js b/components/json.js new file mode 100644 index 0000000..89a185a --- /dev/null +++ b/components/json.js @@ -0,0 +1,247 @@ +import fs from 'fs'; +import path from 'path'; +import Version from '../lib/system/version.js'; + +const Plugin_Name = Version.name; + +const _path = process.cwd(); +const getRoot = (root = '') => { + if (root === 'root' || root === 'yunzai') { + root = `${_path}/`; + } else if (!root) { + root = `${_path}/plugins/${Plugin_Name}/`; + } + return root; +}; + +let fc = { + /** + * 递归创建目录结构 + * @param {string} [path=""] - 要创建的相对路径,支持多级目录(如 "dir1/dir2") + * @param {string} [root=""] - 基础根目录,可选值: + * - "root" 或 "yunzai": 使用 Yunzai 根目录 + * - 空值: 使用插件目录 + * @param {boolean} [includeFile=false] - 是否包含最后一级作为文件名 + * @example + * fc.createDir("config/deepseek", "root") // 在 Yunzai 根目录创建 config/deepseek 目录 + */ + createDir(path = '', root = '', includeFile = false) { + root = getRoot(root); + let pathList = path.split('/'); + let nowPath = root; + pathList.forEach((name, idx) => { + name = name.trim(); + if (!includeFile && idx <= pathList.length - 1) { + nowPath += name + '/'; + if (name) { + if (!fs.existsSync(nowPath)) { + fs.mkdirSync(nowPath); + } + } + } + }); + }, + + /** + * 读取JSON文件 + * @param {string} [file=""] - JSON文件路径(相对路径) + * @param {string} [root=""] - 基础根目录(同 createDir) + * @returns {object} 解析后的JSON对象,如文件不存在或解析失败返回空对象 + * @example + * const config = fc.readJSON("config.json", "root") + */ + readJSON(file = '', root = '') { + root = getRoot(root); + if (fs.existsSync(`${root}/${file}`)) { + try { + return JSON.parse(fs.readFileSync(`${root}/${file}`, 'utf8')); + } catch (e) { + console.log(e); + } + } + return {}; + }, + + statSync(file = '', root = '') { + root = getRoot(root); + try { + return fs.statSync(`${root}/${file}`); + } catch (e) { + console.log(e); + } + }, + + /** + * 写入JSON文件(完全覆盖) + * @param {string} file - 目标文件路径 + * @param {object} data - 要写入的JSON数据 + * @param {string} [root=""] - 基础根目录(同 createDir) + * @param {number} [space=4] - JSON格式化缩进空格数 + * @returns {boolean} 是否写入成功 + * @warning 此方法会完全覆盖目标文件原有内容 + * @example + * fc.writeJSON("config.json", {key: "value"}, "root", 4) + */ + writeJSON(file, data, root = '', space = 4) { + fc.createDir(file, root, true); + root = getRoot(root); + try { + fs.writeFileSync(`${root}/${file}`, JSON.stringify(data, null, space)); + return true; + } catch (err) { + logger.error(err); + return false; + } + }, + + /** + * 安全写入JSON文件(合并模式) + * @param {string} file - 目标文件路径 + * @param {object} data - 要合并的数据 + * @param {string} [root=""] - 基础根目录(同 createDir) + * @param {number} [space=4] - JSON格式化缩进空格数 + * @returns {boolean} 是否写入成功 + * @description + * - 如果目标文件不存在,创建新文件 + * - 如果目标文件存在,深度合并新旧数据 + * - 如果目标文件损坏,会创建新文件并记录警告 + * @example + * fc.safewriteJSON("config.json", {newKey: "value"}) + */ + safeWriteJSON(file, data, root = '', space = 4) { + fc.createDir(file, root, true); + root = getRoot(root); + const filePath = `${root}/${file}`; + + try { + let existingData = {}; + if (fs.existsSync(filePath)) { + try { + existingData = JSON.parse(fs.readFileSync(filePath, 'utf8')) || {}; + } catch (e) { + logger.warn(`无法解析现有JSON文件 ${filePath},将创建新文件`); + } + } + + const mergedData = this.deepMerge(existingData, data); + + fs.writeFileSync(filePath, JSON.stringify(mergedData, null, space)); + return true; + } catch (err) { + logger.error(`写入JSON文件失败 ${filePath}:`, err); + return false; + } + }, + + /** + * 深度合并两个对象 + * @param {object} target - 目标对象(将被修改) + * @param {object} source - 源对象 + * @returns {object} 合并后的目标对象 + * @description + * - 递归合并嵌套对象 + * - 对于非对象属性直接覆盖 + * - 不会合并数组(数组会被直接覆盖) + * @example + * const merged = fc.deepMerge({a: 1}, {b: {c: 2}}) + * // 返回 {a: 1, b: {c: 2}} + */ + deepMerge(target, source) { + for (const key in source) { + if (source.hasOwnProperty(key)) { + if ( + source[key] && + typeof source[key] === 'object' && + target[key] && + typeof target[key] === 'object' + ) { + this.deepMerge(target[key], source[key]); + } else { + target[key] = source[key]; + } + } + } + return target; + }, + + /** + * 递归读取目录中的特定扩展名文件 + * @param {string} directory - 要搜索的目录路径 + * @param {string} extension - 文件扩展名(不带点) + * @param {string} [excludeDir] - 要排除的目录名 + * @returns {string[]} 匹配的文件相对路径数组 + * @description + * - 自动跳过以下划线开头的文件 + * - 结果包含子目录中的文件 + * @example + * const jsFiles = fc.readDirRecursive("./plugins", "js", "node_modules") + */ + readDirRecursive(directory, extension, excludeDir) { + let files = fs.readdirSync(directory); + + let jsFiles = files.filter( + (file) => path.extname(file) === `.${extension}` && !file.startsWith('_') + ); + + files + .filter((file) => fs.statSync(path.join(directory, file)).isDirectory()) + .forEach((subdirectory) => { + if (subdirectory === excludeDir) { + return; + } + + const subdirectoryPath = path.join(directory, subdirectory); + jsFiles.push( + ...fc + .readDirRecursive(subdirectoryPath, extension, excludeDir) + .map((fileName) => path.join(subdirectory, fileName)) + ); + }); + + return jsFiles; + }, + + /** + * 深度克隆对象(支持基本类型/数组/对象/Date/RegExp) + * @param {*} source - 要克隆的数据 + * @returns {*} 深度克隆后的副本 + * @description + * - 处理循环引用 + * - 保持原型链 + * - 支持特殊对象类型(Date/RegExp等) + * @example + * const obj = { a: 1, b: [2, 3] }; + * const cloned = fc.deepClone(obj); + */ + deepClone(source) { + const cache = new WeakMap(); + + const clone = (value) => { + if (value === null || typeof value !== 'object') { + return value; + } + + if (cache.has(value)) { + return cache.get(value); + } + + if (value instanceof Date) return new Date(value); + if (value instanceof RegExp) return new RegExp(value); + + const target = new value.constructor(); + cache.set(value, target); + + for (const key in value) { + if (value.hasOwnProperty(key)) { + target[key] = clone(value[key]); + } + } + + return target; + }; + + return clone(source); + }, +}; + +export default fc; diff --git a/components/module.js b/components/module.js new file mode 100644 index 0000000..4966149 --- /dev/null +++ b/components/module.js @@ -0,0 +1,57 @@ +import Version from '../lib/system/version.js'; + +const Plugin_Name = Version.name; + +const _path = process.cwd(); +const getRoot = (root = '') => { + if (root === 'root' || root === 'yunzai') { + root = `${_path}/`; + } else if (!root) { + root = `${_path}/plugins/${Plugin_Name}/`; + } + return root; +}; + +let mc = { + /** + * 动态导入JS模块 + * @param {string} file - 模块文件路径(可省略.js后缀) + * @param {string} [root=""] - 基础根目录(同 createDir) + * @returns {Promise} 模块导出对象,如导入失败返回空对象 + * @description + * - 自动添加时间戳参数防止缓存 + * - 自动补全.js后缀 + * @example + * const module = await fc.importModule("utils/helper") + */ + async importModule(file, root = '') { + root = getRoot(root); + if (!/\.js$/.test(file)) { + file = file + '.js'; + } + if (fs.existsSync(`${root}/${file}`)) { + try { + let data = await import(`file://${root}/${file}?t=${new Date() * 1}`); + return data || {}; + } catch (e) { + console.log(e); + } + } + return {}; + }, + + /** + * 动态导入JS模块的默认导出 + * @param {string} file - 模块文件路径 + * @param {string} [root=""] - 基础根目录(同 createDir) + * @returns {Promise} 模块的默认导出,如失败返回空对象 + * @example + * const defaultExport = await fc.importDefault("components/Header") + */ + async importDefault(file, root) { + let ret = await fc.importModule(file, root); + return ret.default || {}; + }, +}; + +export default mc; diff --git a/components/tool.js b/components/tool.js new file mode 100644 index 0000000..894b32f --- /dev/null +++ b/components/tool.js @@ -0,0 +1,125 @@ +let tools = { + /** + * 异步延时函数 + * @param {number} ms - 等待的毫秒数 + * @returns {Promise} + * @example + * await fc.sleep(1000) // 等待1秒 + */ + sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); + }, + + /** + * 生成指定范围内的随机整数 + * @param {number} min - 最小值(包含) + * @param {number} max - 最大值(包含) + * @returns {number} 范围内的随机整数 + * @example + * const randomNum = fc.randomInt(1, 10) // 可能返回 5 + */ + randomInt(min, max) { + return Math.floor(Math.random() * (max - min + 1)) + min; + }, + + /** + * 防抖函数 + * @param {Function} fn - 要执行的函数 + * @param {number} [delay=300] - 延迟时间(毫秒) + * @param {boolean} [immediate=false] - 是否立即执行 + * @returns {Function} 防抖处理后的函数 + * @description + * 1. immediate=true时:先立即执行,后续调用在delay时间内被忽略 + * 2. immediate=false时:延迟执行,重复调用会重置计时器 + * @example + * window.addEventListener('resize', fc.debounce(() => { + * console.log('resize end'); + * }, 500)); + */ + debounce(fn, delay = 300, immediate = false) { + let timer = null; + return function (...args) { + if (immediate && !timer) { + fn.apply(this, args); + } + + clearTimeout(timer); + timer = setTimeout(() => { + if (!immediate) { + fn.apply(this, args); + } + timer = null; + }, delay); + }; + }, + + /** + * 异步重试机制 + * @param {Function} asyncFn - 返回Promise的异步函数 + * @param {number} [maxRetries=3] - 最大重试次数 + * @param {number} [delay=1000] - 重试间隔(毫秒) + * @param {Function} [retryCondition] - 重试条件函数(err => boolean) + * @returns {Promise} 最终成功或失败的结果 + * @example + * await fc.retry(fetchData, 5, 2000, err => err.status !== 404); + */ + async retry(asyncFn, maxRetries = 3, delay = 1000, retryCondition = () => true) { + let attempt = 0; + let lastError; + + while (attempt <= maxRetries) { + try { + return await asyncFn(); + } catch (err) { + lastError = err; + if (attempt === maxRetries || !retryCondition(err)) { + break; + } + attempt++; + await this.sleep(delay); + } + } + + throw lastError; + }, + + /** + * 将对象转换为URL查询字符串 + * @param {object} params - 参数对象 + * @param {boolean} [encode=true] - 是否进行URL编码 + * @returns {string} 查询字符串(不带问号) + * @example + * fc.objectToQuery({a: 1, b: 'test'}) // "a=1&b=test" + */ + objectToQuery(params, encode = true) { + return Object.entries(params) + .map(([key, val]) => { + const value = val === null || val === undefined ? '' : val; + return `${key}=${encode ? encodeURIComponent(value) : value}`; + }) + .join('&'); + }, + + /** + * 从错误堆栈中提取简洁的错误信息 + * @param {Error} error - 错误对象 + * @param {number} [depth=3] - 保留的堆栈深度 + * @returns {string} 格式化后的错误信息 + * @example + * try { ... } catch(err) { + * logger.error(fc.formatError(err)); + * } + */ + formatError(error, depth = 3) { + if (!(error instanceof Error)) return String(error); + + const stack = error.stack?.split('\n') || []; + const message = `${error.name}: ${error.message}`; + + if (stack.length <= 1) return message; + + return [message, ...stack.slice(1, depth + 1).map((line) => line.trim())].join('\n at '); + }, +}; + +export default tools; diff --git a/config/config.md b/config/config.md new file mode 100644 index 0000000..ae3813b --- /dev/null +++ b/config/config.md @@ -0,0 +1,61 @@ +### 参考以下注释进行配置,不要带注释粘贴,也不要改动`default.json` + +配置文件位于`yunzai`根目录`/data/crystelf下` + +参考注释: + +``` yaml +{ + "debug": true,\\是否启用调试模式 + "core": true,\\是否启用晶灵核心相关功能 + "coreConfig": { //晶灵核心配置 + "coreUrl": "", //核心网址,需要加https://前缀 + "wsUrl": "", //ws连接地址如ws:// + "wsClientId": "",//端id + "wsSecret": "", wsmiy + "wsReConnectInterval": "5000", + "token": ""//postAPI调用密钥 + }, + "maxFeed": 10,//最大缓存rss流 + "feeds": [//rss相关配置,无需手动更改 + { + "url": "", + "targetGroups": [114,154], + "screenshot": true + } + ], + "fanqieConfig": {//番茄小说功能 + "url": "http://127.0.0.1:6868", + "outDir": "/home/user/debian/cache/Downloads" + }, + "poke": {//戳一戳概率,加起来不超过1,余下的概率为反击概率 + "replyText": 0.4, + "replyVoice": 0.2, + "mutePick": 0.1, + ""muteTime": 2" + }, + "mode": "deepseek",//deepseekORopenai + "modelType": "deepseek-ai/DeepSeek-V3",//无需更改 + "historyLength": 3, + "maxLength": 3, + "chatTemperature": 1, + "pluginTemperature": 0.5, + "nickName": "寄气人",//昵称 + "checkChat": { + "rdNum": 2,//随机数,0-100 + "masterReply": true,//主人回复 + "userId": [ //一定回复的人 + 114514 + ], + "blackGroups": [//不许使用的群聊 + 114, + 514 + ], + "enableGroups": [//一定回复的群聊 + 11115 + ] + }, + "maxMessageLength": 100//最大上下文 +} + +``` diff --git a/config/default.json b/config/default.json new file mode 100644 index 0000000..b8a2b94 --- /dev/null +++ b/config/default.json @@ -0,0 +1,11 @@ +{ + "debug": true, + "core": true, + "coreConfig": { + "coreUrl": "", + "wsClientId": "", + "wsSecret": "", + "wsReConnectInterval": "5000", + "token": "" + } +} diff --git a/constants/path.js b/constants/path.js new file mode 100644 index 0000000..3a67434 --- /dev/null +++ b/constants/path.js @@ -0,0 +1,29 @@ +import url from 'url'; +import fs from 'fs'; +import path from 'path'; + +const __filename = url.fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const rootDir = path.join(__dirname, '..'); + +const Path = { + root: rootDir, + apps: path.join(rootDir, 'apps'), + components: path.join(rootDir, 'components'), + defaultConfig: path.join(rootDir, 'config/default.json'), + config: path.resolve(rootDir, '../../data/crystelf-admin'), + constants: path.join(rootDir, 'constants'), + lib: path.join(rootDir, 'lib'), + models: path.join(rootDir, 'models'), + index: path.join(rootDir, 'index.js'), + pkg: path.join(rootDir, 'package.json'), + yunzai: path.join(rootDir, '../../'), + data: path.join(rootDir, '../../data/crystelf-admin/data'), + rssHTML: path.join(rootDir, 'constants/rss/rss_template.html'), + rssCache: path.join(rootDir, '../../data/crystelf-admin'), +}; + +const configFile = fs.readFileSync(Path.defaultConfig, 'utf8'); +export const defaultConfig = JSON.parse(configFile); + +export default Path; diff --git a/constants/relativelyPath.js b/constants/relativelyPath.js new file mode 100644 index 0000000..b3215cd --- /dev/null +++ b/constants/relativelyPath.js @@ -0,0 +1,6 @@ +const relativelyPath = { + config: '/data/crystelf/config.json', + data: '/data/crystelf/data/', +}; + +export default relativelyPath; diff --git a/index.js b/index.js new file mode 100644 index 0000000..bfde29e --- /dev/null +++ b/index.js @@ -0,0 +1,37 @@ +import chalk from 'chalk'; +import Version from './lib/system/version.js'; +import fc from './components/json.js'; +import Path from './constants/path.js'; +import { crystelfInit } from './lib/system/init.js'; +import updater from './lib/system/updater.js'; + +logger.info( + chalk.rgb(134, 142, 204)(`crystelf-admin ${Version.ver} 初始化 ~ by ${Version.author}`) +); + +updater.checkAndUpdate().catch((err) => { + logger.err(err); +}); +await crystelfInit.CSH(); + +const appPath = Path.apps; +const jsFiles = fc.readDirRecursive(appPath, 'js'); + +let ret = jsFiles.map((file) => { + return import(`./apps/${file}`); +}); + +ret = await Promise.allSettled(ret); + +let apps = {}; +for (let i in jsFiles) { + let name = jsFiles[i].replace('.js', ''); + + if (ret[i].status !== 'fulfilled') { + logger.error(name, ret[i].reason); + continue; + } + apps[name] = ret[i].value[Object.keys(ret[i].value)[0]]; +} + +export { apps }; diff --git a/lib/config/configControl.js b/lib/config/configControl.js new file mode 100644 index 0000000..2448fbe --- /dev/null +++ b/lib/config/configControl.js @@ -0,0 +1,74 @@ +import Path, { defaultConfig } from '../../constants/path.js'; +import fc from '../../components/json.js'; +import path from 'path'; +import fs from 'fs'; +import relativelyPath from '../../constants/relativelyPath.js'; + +const configPath = Path.config; +const dataPath = Path.data; +const configFile = path.join(configPath, 'config.json'); +const configDir = relativelyPath.config; + +let configCache = {}; +let lastModified = 0; + +function init() { + try { + if (!fs.existsSync(configPath)) { + fs.mkdirSync(configPath, { recursive: true }); + fs.mkdirSync(dataPath, { recursive: true }); + logger.mark(`crystelf 配置文件夹创建成功,位于 ${configPath}..`); + } + + if (!fs.existsSync(configFile)) { + fs.writeFileSync(configFile, JSON.stringify(defaultConfig, null, 4), 'utf8'); + logger.mark('crystelf 配置文件创建成功..'); + } else { + const cfgFile = fs.readFileSync(configFile, 'utf8'); + const loadedConfig = JSON.parse(cfgFile); + const cfg = { ...defaultConfig, ...loadedConfig }; + + if (JSON.stringify(cfg) !== JSON.stringify(loadedConfig)) { + fs.writeFileSync(configFile, JSON.stringify(cfg, null, 4), 'utf8'); + logger.mark('crystelf 配置文件已更新,补充配置项..'); + } + } + + const stats = fc.statSync(configDir, 'root'); + configCache = fc.readJSON(configDir, 'root'); + lastModified = stats.mtimeMs; + + if (configCache.debug) { + logger.info('crystelf-plugin 配置模块初始化成功..'); + } + } catch (err) { + logger.warn('crystelf-plugin 初始化配置失败,使用空配置..', err); + configCache = {}; + } +} + +const configControl = { + async init() { + init(); + }, + + get(key) { + return key ? configCache[key] : configCache; + }, + + async set(key, value) { + configCache[key] = value; + return fc.safeWriteJSON(configDir, configCache, 'root', 4); + }, + + async save() { + return fc.safeWriteJSON(configDir, configCache, 'root', 4); + }, + + async reload() { + await init(); + return true; + }, +}; + +export default configControl; diff --git a/lib/core/botControl.js b/lib/core/botControl.js new file mode 100644 index 0000000..e963432 --- /dev/null +++ b/lib/core/botControl.js @@ -0,0 +1,98 @@ +import wsClient from '../../modules/ws/wsClient.js'; +import configControl from '../config/configControl.js'; + +const botControl = { + /** + * 获取全部bot信息并同步到core + * @returns {Promise} + */ + async reportBots() { + const bots = [{ client: configControl.get('coreConfig').wsClientId }]; + + for (const bot of Object.values(Bot)) { + if (!bot || !bot.uin) continue; + + const botInfo = { + uin: bot.uin, + nickName: bot.nickname.replace(/[\u200E-\u200F\u202A-\u202E\u2066-\u2069]/g, ''), + groups: [], + }; + + let groupsMap = bot.gl; + if (groupsMap) { + for (const [groupId, groupInfo] of groupsMap) { + botInfo.groups.push({ + group_id: groupId, + group_name: groupInfo.group_name || '未知', + }); + } + } + + bots.push(botInfo); + } + + const message = { + type: 'reportBots', + data: bots, + }; + + return await wsClient.sendMessage(message); + }, + + /** + * 获取群聊信息 + * @param botId + * @param groupId + * @returns {Promise<*|null>} + */ + async getGroupInfo(botId, groupId) { + const bot = Bot[botId]; + if (!bot) { + logger.warn(`未找到bot: ${botId}`); + return null; + } + + const group = bot.pickGroup(groupId); + if (!group) { + logger.warn(`Bot ${botId}中未找到群${groupId}`); + return null; + } + + try { + return await group.getInfo(); + } catch (e) { + logger.error(`获取群聊信息失败:${groupId}..`); + return null; + } + }, + + /** + * 发送信息到群 + * @param botId bot账号 + * @param message 发送的信息 + * @param groupId 群号 + * @returns {Promise} + */ + async sendMessage(botId, message, groupId) { + const bot = Bot[botId]; + if (!bot) { + logger.warn(`未找到bot: ${botId}`); + return false; + } + + const group = bot.pickGroup(groupId); + if (!group) { + logger.warn(`Bot ${botId}中未找到群${groupId}`); + return false; + } + + try { + return !!(await group.send(message)); + } catch (e) { + logger.error(`发送群信息失败:${groupId}..`); + return false; + } + }, +}; + +export default botControl; diff --git a/lib/core/systemControl.js b/lib/core/systemControl.js new file mode 100644 index 0000000..e8912bd --- /dev/null +++ b/lib/core/systemControl.js @@ -0,0 +1,20 @@ +import configControl from '../config/configControl.js'; +import axios from 'axios'; + +let systemControl = { + async systemRestart() { + const token = configControl.get('coreConfig')?.token; + const coreUrl = configControl.get('coreConfig')?.coreUrl; + const postUrl = coreUrl + '/api/system/restart'; + //logger.info(returnData); + return await axios.post(postUrl, { token: token }); + }, + + async getRestartTime() { + const token = configControl.get('coreConfig')?.token; + const coreUrl = configControl.get('coreConfig')?.coreUrl; + const postUrl = coreUrl + '/api/system/getRestartTime'; + return axios.post(postUrl, { token: token }); + }, +}; +export default systemControl; diff --git a/lib/system/init.js b/lib/system/init.js new file mode 100644 index 0000000..eaf5afd --- /dev/null +++ b/lib/system/init.js @@ -0,0 +1,12 @@ +import configControl from '../config/configControl.js'; +import wsClient from '../../modules/ws/wsClient.js'; + +export const crystelfInit = { + async CSH() { + await configControl.init(); + if (configControl.get('core')) { + await wsClient.initialize(); + } + logger.mark('crystelf-admin 完成初始化'); + }, +}; diff --git a/lib/system/updater.js b/lib/system/updater.js new file mode 100644 index 0000000..60cce9a --- /dev/null +++ b/lib/system/updater.js @@ -0,0 +1,79 @@ +import child_process from 'child_process'; +import fs from 'fs'; +import path from 'path'; +import chalk from 'chalk'; +import Path from '../../constants/path.js'; + +const GIT_DIR = path.join(Path.root, '.git'); + +const execStr = (cmd) => child_process.execSync(cmd, { cwd: Path.root }).toString().trim(); + +const Updater = { + isGitRepo() { + return fs.existsSync(GIT_DIR); + }, + + getBranch() { + return execStr('git symbolic-ref --short HEAD'); + }, + + getLocalHash() { + return execStr('git rev-parse HEAD'); + }, + + getRemoteHash(branch = 'main') { + return execStr(`git rev-parse origin/${branch}`); + }, + + async hasUpdate() { + try { + const branch = this.getBranch(); + + await new Promise((resolve, reject) => { + child_process.exec('git fetch', { cwd: Path.root }, (err) => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + }); + + const local = this.getLocalHash(); + const remote = this.getRemoteHash(branch); + + return local !== remote; + } catch (err) { + logger.error('[crystelf-plugin] 检查更新失败:', err); + return false; + } + }, + + async update() { + logger.mark(chalk.cyan('[crystelf-plugin] 检测到插件有更新,自动执行 git pull')); + child_process.execSync('git pull', { + cwd: Path.root, + stdio: 'inherit', + }); + logger.mark(chalk.green('[crystelf-plugin] 插件已自动更新完成')); + }, + + async checkAndUpdate() { + if (!this.isGitRepo()) { + logger.warn('[crystelf-plugin] 当前目录不是 Git 仓库,自动更新功能已禁用'); + return; + } + + try { + if (await this.hasUpdate()) { + await this.update(); + } else { + logger.info('[crystelf-plugin] 当前已是最新版本,无需更新'); + } + } catch (err) { + logger.error('[crystelf-plugin] 自动更新失败:', err); + } + }, +}; + +export default Updater; diff --git a/lib/system/version.js b/lib/system/version.js new file mode 100644 index 0000000..b255271 --- /dev/null +++ b/lib/system/version.js @@ -0,0 +1,26 @@ +import fs from 'fs'; +import url from 'url'; +import path from 'path'; + +const __filename = url.fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const pkgPath = path.join(__dirname, '../..', 'package.json'); +const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + +const Version = { + get ver() { + return pkg.version; + }, + get author() { + return pkg.author; + }, + get name() { + return pkg.name; + }, + get description() { + return pkg.description; + }, +}; + +export default Version; diff --git a/modules/ws/handler.js b/modules/ws/handler.js new file mode 100644 index 0000000..89a02a7 --- /dev/null +++ b/modules/ws/handler.js @@ -0,0 +1,95 @@ +import botControl from '../../lib/core/botControl.js'; +import wsClient from './wsClient.js'; + +class Handler { + constructor() { + this.handlers = new Map([ + ['auth', this.handleAuth.bind(this)], + ['ping', this.handlePing.bind(this)], + ['message', this.handleMessageFromServer.bind(this)], + ['error', this.handleError.bind(this)], + ['getGroupInfo', this.handleGetGroupInfo.bind(this)], + ['sendMessage', this.handleSendMessage.bind(this)], + ['reportBots', this.reportBots.bind(this)], + ]); + } + + async handle(client, msg) { + const handler = this.handlers.get(msg.type); + if (handler) { + await handler(client, msg); + } else { + logger.warn(`未知消息类型: ${msg.type}`); + } + } + + async handleAuth(client, msg) { + if (msg.success) { + logger.mark('crystelf WS 认证成功..'); + } else { + logger.error('crystelf WS 认证失败,关闭连接..'); + client.ws.close(4001, '认证失败'); + } + } + + async handlePing(client, msg) { + await client.sendMessage({ type: 'pong' }); + } + + async handleMessageFromServer(client, msg) { + logger.mark(`crystelf 服务端消息: ${msg.data}`); + } + + async handleError(client, msg) { + logger.warn(`crystelf WS 错误:${msg.data}`); + } + + /** + 获取群聊信息,自动回调 + @examples 请求示例 + ```json + { + requestId: 114514, + type: 'getGroupInfo', + data: { + botId: 114514, + groupId: 114514, + }, + } + ``` + **/ + async handleGetGroupInfo(client, msg) { + const requestId = msg?.requestId; + const botId = msg.data?.botId; + const groupId = msg.data?.groupId; + const type = msg.type + 'Return'; + const groupData = await botControl.getGroupInfo(botId, groupId); + const returnData = { + type: type, + requestId: requestId, + data: groupData, + }; + await wsClient.sendMessage(returnData); + } + + /** + * 发送信息到群聊 + * @param client + * @param msg + * @returns {Promise} + */ + // TODO 测试可用性 + async handleSendMessage(client, msg) { + const botId = Number(msg.data?.botId); + const groupId = Number(msg.data?.groupId); + const message = msg.data?.message?.toString(); + await botControl.sendMessage(botId, message, groupId); + } + + async reportBots(client, msg) { + await botControl.reportBots(); + } +} + +const handler = new Handler(); +export default handler; diff --git a/modules/ws/wsClient.js b/modules/ws/wsClient.js new file mode 100644 index 0000000..7fd77c5 --- /dev/null +++ b/modules/ws/wsClient.js @@ -0,0 +1,95 @@ +import WebSocket from 'ws'; +import configControl from '../../lib/config/configControl.js'; +import handler from './handler.js'; + +class WsClient { + constructor() { + this.ws = null; + this.wsURL = null; + this.secret = null; + this.clientId = null; + this.reconnectInterval = null; + this.isReconnecting = false; + } + + async initialize() { + try { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + logger.mark('crystelf WS 客户端已连接..'); + return; + } + + this.wsURL = configControl.get('coreConfig')?.wsUrl; + this.secret = configControl.get('coreConfig')?.wsSecret; + this.clientId = configControl.get('coreConfig')?.wsClientId; + this.reconnectInterval = configControl.get('coreConfig')?.wsReConnectInterval; + + //logger.info(this.wsURL); + this.ws = new WebSocket(this.wsURL); + + this.ws.on('open', () => { + logger.mark('crystelf WS 客户端连接成功..'); + this.authenticate(); + }); + + this.ws.on('message', (raw) => { + try { + const data = JSON.parse(raw); + handler.handle(this, data); + } catch (err) { + logger.err(err); + } + }); + + this.ws.on('error', (err) => { + logger.error('WS 连接错误:', err); + }); + + this.ws.on('close', (code, reason) => { + logger.warn(`crystelf WS 客户端连接断开:${code} - ${reason}`); + this.reconnect(); + }); + } catch (err) { + logger.error(err); + } + } + + async authenticate() { + const authMsg = { + type: 'auth', + secret: this.secret, + clientId: this.clientId, + }; + await this.sendMessage(authMsg); + } + + /** + * 发送信息到ws服务端,自动格式化 + * @param msg + * @returns {Promise} + */ + async sendMessage(msg) { + if (this.ws?.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify(msg)); + return true; + } else { + logger.warn('crystelf WS 服务器未连接,无法发送消息..'); + return false; + } + } + + async reconnect() { + if (this.isReconnecting) return; + this.isReconnecting = true; + + logger.mark('crystelf WS 客户端尝试重连..'); + setTimeout(() => { + this.isReconnecting = false; + this.initialize(); + }, this.reconnectInterval); + } +} + +const wsClient = new WsClient(); + +export default wsClient; diff --git a/package.json b/package.json new file mode 100644 index 0000000..752a345 --- /dev/null +++ b/package.json @@ -0,0 +1,30 @@ +{ + "name": "crystelf-admin", + "version": "1.0.0", + "description": "晶灵管理插件", + "main": "index.js", + "type": "module", + "scripts": {}, + "repository": { + "type": "git", + "url": "" + }, + "keywords": [ + "TRSS-Yunzai", + "crystelf-admin" + ], + "author": "Jerry", + "License": "MIT", + "dependencies": { + "axios": "^1.8.4", + "chalk": "^5.4.1", + "ws": "^8.18.1" + }, + "imports": {}, + "devDependencies": { + "@eslint/js": "^9.23.0", + "eslint": "^8.57.1", + "globals": "^16.0.0", + "prettier": "^3.5.3" + } +}