├── .gitignore ├── index.js ├── src ├── common │ ├── index.ts │ ├── dev.ts │ ├── utils.ts │ ├── version.ts │ ├── config.ts │ └── server.ts ├── api │ ├── utils │ │ └── index.ts │ ├── system │ │ ├── pastLog │ │ │ └── index.ts │ │ ├── realtimeLog │ │ │ └── index.ts │ │ ├── terminal │ │ │ └── index.ts │ │ └── files │ │ │ └── index.ts │ ├── plugins │ │ └── index.ts │ ├── Bot │ │ ├── plugins │ │ │ └── index.ts │ │ ├── sandbox │ │ │ └── index.ts │ │ └── stats │ │ │ └── index.ts │ ├── login │ │ └── index.ts │ ├── database │ │ ├── sqlite │ │ │ └── index.ts │ │ └── redis │ │ │ └── index.ts │ └── welcome │ │ └── index.ts ├── types │ └── global.d.ts └── index.ts ├── config └── default_config │ ├── server.yaml │ └── stats.yaml ├── tsconfig.json ├── LICENSE ├── .eslintrc.json ├── TODO.md ├── package.json ├── .github └── workflows │ └── build.yml ├── guoba.support.js ├── README.md └── CHANGELOG.md /.gitignore: -------------------------------------------------------------------------------- 1 | test 2 | **/test.js 3 | **/test.ts 4 | node_modules 5 | config/config 6 | lib -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | // import './src/index.ts' 2 | // @ts-ignore 3 | export * from './lib/index.js' 4 | -------------------------------------------------------------------------------- /src/common/index.ts: -------------------------------------------------------------------------------- 1 | import * as version from './version' 2 | import config from './config' 3 | import * as utils from './utils' 4 | import * as server from './server' 5 | 6 | export { 7 | version, 8 | config, 9 | server, 10 | utils 11 | } 12 | -------------------------------------------------------------------------------- /src/common/dev.ts: -------------------------------------------------------------------------------- 1 | import { version } from '@/common' 2 | 3 | if (version.isDev) { 4 | global.plugin = class {} 5 | global.redis = { 6 | // @ts-ignore 7 | get: () => {}, 8 | // @ts-ignore 9 | set: () => {}, 10 | // @ts-ignore 11 | mGet: () => ([]), 12 | } 13 | // @ts-ignore 14 | global.logger = new Proxy({}, { 15 | get (target, p) { 16 | return (...args: any[]) => console.log(p, ...args) 17 | } 18 | }) 19 | // @ts-ignore 20 | global.Bot = { 21 | uin: [], 22 | on: () => {}, 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /config/default_config/server.yaml: -------------------------------------------------------------------------------- 1 | # 端口 2 | port: 2877 3 | 4 | # 域名 5 | # 若为auto,则自动获取当前服务器的ip 6 | # 若为域名,则使用该域名(不会拼接端口号) + 自动获取 7 | host: auto 8 | 9 | # 自定义域名时是否拼接 /YePanel/#/login 10 | splicePath: true 11 | 12 | # # yp登录 存在本地web时是否显示公共面板地址 13 | # showPublic: true 14 | 15 | # api请求日志输出 可选request上的参数 或 false 关闭日志输出 16 | logs: 17 | - method 18 | - url 19 | - body 20 | 21 | # 密码 22 | # default对应的账号为Bot.uin, 此时会使用对应Bot的头像和名称 23 | # 也可以自定义账号 key是对应的账号,password是密码,nickname是昵称,avatarUrl是头像链接,enable是是否启用该账号 24 | password: 25 | default: 26 | password: "123456" 27 | admin: 28 | password: "123456" 29 | nickname: 管理员 30 | avatarUrl: https://q.qlogo.cn/g?b=qq&s=0&nk=3889000138 31 | enable: false -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "ESNext", 4 | "target": "ESNext", 5 | "moduleResolution": "node", 6 | "outDir": "./lib", 7 | "rootDir": "./src", 8 | "baseUrl": "./src", 9 | "strict": true, 10 | "esModuleInterop": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "skipLibCheck": true, 13 | "strictBindCallApply": true, 14 | "paths": { 15 | "@/*": ["*"], 16 | } 17 | }, 18 | "include": ["src/**/*", "globals.d.ts"], 19 | "exclude": ["node_modules"], 20 | "tsc-alias": { 21 | "resolveFullExtension": ".js", 22 | "resolveFullPaths": true 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/api/utils/index.ts: -------------------------------------------------------------------------------- 1 | import { RouteOptions } from 'fastify' 2 | 3 | export default [ 4 | { 5 | method: ['get', 'post'], 6 | url: '/api/transit', 7 | handler: async (request, reply) => { 8 | let { url } = (request.method === 'POST' ? request.body : request.query) as { url: string } 9 | url = decodeURIComponent(url) 10 | const response = await fetch(url, { 11 | method: request.method, 12 | body: request.body ? JSON.stringify(request.body) : undefined 13 | }) 14 | if (!response.ok) { 15 | return reply.status(response.status).send(response.statusText) 16 | } 17 | return Buffer.from(await response.arrayBuffer()) 18 | } 19 | } 20 | ] as RouteOptions[] 21 | -------------------------------------------------------------------------------- /config/default_config/stats.yaml: -------------------------------------------------------------------------------- 1 | # 以下数据均由YePanel统计, 设置为ture即开启统计功能 2 | # 若改动, 需重启生效 3 | 4 | # 是否额外单独Bot统计 5 | alone: false 6 | 7 | # 总计统计 8 | totalStats: 9 | # 总发送消息次数 10 | sent: false 11 | # 总接收消息次数 12 | recv: false 13 | # 总插件触发次数 14 | plugin: false 15 | 16 | # 数量统计图表, 在一个图表中 17 | countChart: 18 | # 发送消息次数 19 | sent: true 20 | # 接收消息次数 21 | recv: true 22 | # 插件触发次数 23 | plugin: false 24 | 25 | # 排行榜统计图表, 每一项为一个图表 26 | rankChart: 27 | # 发送消息类型排行 28 | sentType: false 29 | # 插件触发次数排行 30 | pluginUse: false 31 | # 插件发送消息排行 32 | pluginSent: false 33 | # 群聊接收消息排行 34 | groupRecv: false 35 | # 群聊发送消息排行 36 | groupSent: false 37 | # 用户接收消息排行 38 | userRecv: false 39 | # 用户发送消息排行 40 | userSent: false 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 XasYer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es2021": true, 4 | "node": true 5 | }, 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:@typescript-eslint/recommended" 9 | ], 10 | "parser": "@typescript-eslint/parser", 11 | "parserOptions": { 12 | "ecmaVersion": "latest", 13 | "sourceType": "module" 14 | }, 15 | "plugins": [ 16 | "@typescript-eslint", 17 | "@stylistic/ts" 18 | ], 19 | "globals": { 20 | "Bot": true, 21 | "redis": true, 22 | "logger": true, 23 | "plugin": true, 24 | "Renderer": true, 25 | "segment": true 26 | }, 27 | "rules": { 28 | "quotes": [ 29 | "error", 30 | "single" 31 | ], 32 | "semi": [ 33 | "error", 34 | "never" 35 | ], 36 | "space-infix-ops": "error", 37 | "@stylistic/ts/type-annotation-spacing": "error", 38 | "@typescript-eslint/ban-ts-comment": "off", 39 | "@typescript-eslint/no-explicit-any": "off", 40 | "comma-spacing": ["error", { "before": false, "after": true }] 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/api/system/pastLog/index.ts: -------------------------------------------------------------------------------- 1 | import { RouteOptions } from 'fastify' 2 | import fs from 'fs' 3 | import { version } from '@/common' 4 | import { join } from 'path' 5 | 6 | export default [ 7 | { 8 | url: '/get-log-list', 9 | method: 'get', 10 | handler: () => { 11 | try { 12 | const logList = fs.readdirSync(join(version.BotPath, 'logs')).filter(file => file.endsWith('.log')) 13 | return { 14 | success: true, 15 | data: logList 16 | } 17 | } catch (error) { 18 | return { 19 | success: false, 20 | message: (error as Error).message 21 | } 22 | } 23 | } 24 | }, 25 | { 26 | url: '/get-log-content', 27 | method: 'get', 28 | handler: ({ query }) => { 29 | const { name } = query as { name: string } 30 | try { 31 | const logContent = fs.readFileSync(join(version.BotPath, 'logs', name), 'utf-8') 32 | return { 33 | success: true, 34 | data: logContent 35 | } 36 | } catch (error) { 37 | return { 38 | success: false, 39 | message: (error as Error).message 40 | } 41 | } 42 | } 43 | } 44 | ] as RouteOptions[] 45 | -------------------------------------------------------------------------------- /src/common/utils.ts: -------------------------------------------------------------------------------- 1 | import moment, { type unitOfTime } from 'moment' 2 | import schedule from 'node-schedule' 3 | 4 | export function formatBytes (bytes: number) { 5 | if (bytes === 0) return '0 B' 6 | const k = 1024 7 | const sizes = ['B', 'K', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y'] 8 | const i = Math.floor(Math.log(bytes) / Math.log(k)) 9 | const size = parseFloat((bytes / Math.pow(k, i)).toFixed(2)) 10 | return `${size}${sizes[i]}` 11 | } 12 | 13 | export function formatDuration (inp: number, unit: unitOfTime.DurationConstructor = 'seconds') { 14 | const duration = moment.duration(inp, unit) 15 | 16 | const days = duration.days() 17 | const hours = duration.hours() 18 | const minutes = duration.minutes() 19 | const secs = duration.seconds() 20 | 21 | let formatted = '' 22 | if (days > 0)formatted += `${days}天` 23 | if (hours > 0) formatted += `${hours}时` 24 | if (minutes > 0) formatted += `${minutes}分` 25 | if (secs > 0 || formatted === '') formatted += `${secs}秒` 26 | 27 | return formatted.trim() 28 | } 29 | 30 | let time = moment().format('YYYY:MM:DD') 31 | export const getTime = () => time 32 | 33 | schedule.scheduleJob('0 0 0 * * ?', () => { 34 | time = moment().format('YYYY:MM:DD') 35 | }) 36 | -------------------------------------------------------------------------------- /src/common/version.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import { fileURLToPath } from 'url' 3 | import { join, dirname, basename } from 'path' 4 | 5 | /** 是否是dev环境 */ 6 | const isDev = process.env.npm_lifecycle_event === 'dev' 7 | 8 | const __filename = fileURLToPath(import.meta.url) 9 | 10 | const __dirname = dirname(__filename) 11 | 12 | /** 插件的路径 */ 13 | const pluginPath = join(__dirname, '../..').replace(/\\/g, '/') 14 | 15 | /** 自己的package.json */ 16 | const pluginPackage = JSON.parse(fs.readFileSync(join(pluginPath, 'package.json'), 'utf8')) 17 | 18 | const pluginName = basename(pluginPath) 19 | 20 | const pluginVersion = pluginPackage.version 21 | 22 | /** 运行环境的路径, 如果是dev环境, 则是自身的路径 */ 23 | const BotPath = isDev ? pluginPath : join(pluginPath, '../..') 24 | 25 | /** Bot的package.json */ 26 | const BotPackageJson = JSON.parse(fs.readFileSync(join(BotPath, 'package.json'), 'utf8')) 27 | 28 | const BotVersion = BotPackageJson.version 29 | 30 | const BotName = (() => { 31 | if (Array.isArray(global.Bot?.uin)) { 32 | return 'TRSS' 33 | } else { 34 | return 'Miao' 35 | } 36 | })() 37 | 38 | export { 39 | pluginVersion, 40 | pluginName, 41 | pluginPath, 42 | BotVersion, 43 | BotPath, 44 | BotName, 45 | isDev 46 | } 47 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | # TODO List 2 | 3 | - [x] 沙盒测试 4 | - [ ] 发送类型 5 | - [x] text 6 | - [ ] image 7 | - [ ] at 8 | - [ ] reply 9 | - [ ] button 10 | - [x] poke 11 | - [ ] node 12 | - [ ] markdown 13 | - [ ] video 14 | - [ ] audio 15 | - [ ] 接收类型 16 | - [x] text 17 | - [x] image 18 | - [x] at 19 | - [x] reply 20 | - [x] button 21 | - [x] poke 22 | - [ ] node 23 | - [ ] markdown 24 | - [ ] video 25 | - [ ] audio 26 | - [ ] +1 按钮 27 | - [x] 好像height有点大了 28 | - [x] 插件库预览 安装插件 卸载插件 29 | - [ ] 本体设置 30 | - [x] 远程终端手机端不好输入 31 | - [x] web增加build分支 可自行拉取到YePanel目录挂载到崽上 32 | - ~~[ ] 过往日志~~ 日志文件可能会很大 33 | - [x] 更新日志可点击查看更新详细 34 | - [ ] guoba GFromSub和EasyCron 35 | - [x] 访问127.0.0.1:2877时输出帮助信息或跳转至管理页面 36 | - [x] 跳转到公共面板时url携带地址,api默认为携带的地址 37 | - [x] Miao没有统计接收消息数量 添加配置文件,是否开启统计 38 | - [x] 数据统计页面 39 | - [x] 数据统计区分Bot 40 | - [ ] 发送消息统计可选去重 41 | - [ ] 增加好友数量,群数量,群成员数量 42 | - [x] redis 43 | - [x] 可查看多个db 44 | - [x] 连接自定义地址 45 | - [ ] 搜索直接在后端搜索 而不是前端 46 | - [x] 文件管理 47 | - [x] 移动端优化 48 | - [ ] 查看视频,音频等文件 49 | - [ ] 模拟qq页面实时接收消息并且可发送消息 50 | - [x] get-system-info容易超时,换成分开获取或增加超时时间 51 | - [x] 实时日志 52 | - [x] 增加暂停,清空 53 | - [ ] 增加console输出 54 | - [ ] runtime 55 | - [ ] 单独运行,不依赖于Yunzai -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "YePanel", 3 | "version": "1.10.9", 4 | "main": "index.js", 5 | "author": "XasYer", 6 | "type": "module", 7 | "scripts": { 8 | "start": "node lib/index.js", 9 | "dev": "tsx src/index.ts", 10 | "build": "tsc && tsc-alias", 11 | "clean": "rm -rf lib" 12 | }, 13 | "dependencies": { 14 | "@fastify/auth": "^5.0.1", 15 | "@fastify/cors": "^10.0.1", 16 | "@fastify/multipart": "^9.0.1", 17 | "@fastify/static": "^8.0.2", 18 | "@fastify/websocket": "^11.0.1", 19 | "chalk": "^5.3.0", 20 | "chokidar": "^4.0.1", 21 | "fastify": "^5.0.0", 22 | "iconv-lite": "^0.6.3", 23 | "lodash": "^4.17.21", 24 | "moment": "^2.30.1", 25 | "node-schedule": "^2.1.1", 26 | "redis": "^4.7.0", 27 | "sequelize": "^6.37.4", 28 | "systeminformation": "^5.23.5", 29 | "yaml": "^2.5.1" 30 | }, 31 | "devDependencies": { 32 | "@stylistic/eslint-plugin-ts": "^2.8.0", 33 | "@types/lodash": "^4.17.9", 34 | "@types/node": "^20.11.19", 35 | "@types/node-schedule": "^2.1.7", 36 | "@types/ws": "^8.5.10", 37 | "@typescript-eslint/eslint-plugin": "^8.8.0", 38 | "@typescript-eslint/parser": "^8.8.0", 39 | "eslint": "^8.57.0", 40 | "fastify-tsconfig": "^2.0.0", 41 | "tsc-alias": "1.8.13", 42 | "tsx": "^4.19.1", 43 | "typescript": "^5.6.2" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/api/system/realtimeLog/index.ts: -------------------------------------------------------------------------------- 1 | import moment from 'moment' 2 | import { WebSocket } from 'ws' 3 | import { RouteOptions } from 'fastify' 4 | import { version } from '@/common' 5 | // @ts-ignore 6 | import resetLog from '../../../../../../lib/config/log.js' 7 | // @ts-ignore 8 | import { Logger } from 'log4js' 9 | 10 | type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal' |'mark' 11 | 12 | const logLevels = ['trace', 'debug', 'info', 'warn', 'error', 'fatal', 'mark'] 13 | 14 | const sendWs = (ws: WebSocket, level: string, logs: string[]) => { 15 | ws.send(JSON.stringify({ type: 'logger', level, logs, timestamp: moment().format('HH:mm:ss.SSS') })) 16 | } 17 | 18 | const getProp = (target: Logger, p: string | symbol, ws: WebSocket) => { 19 | if (typeof p === 'string' && logLevels.includes(p)) { 20 | return (...logs: string[]) => { 21 | sendWs(ws, p, logs) 22 | return (target[p as LogLevel] as any)(...logs) 23 | } 24 | } 25 | return target[p as LogLevel] 26 | } 27 | 28 | const proxyLogger = (ws: WebSocket) => { 29 | if (version.BotName === 'TRSS') { 30 | global.logger.logger = new Proxy(global.logger.logger, { 31 | get (target, p) { 32 | return getProp(target, p, ws) 33 | } 34 | }) 35 | } else if (version.BotName === 'Miao') { 36 | global.logger = new Proxy(global.logger, { 37 | get (target, p) { 38 | return getProp(target, p, ws) 39 | } 40 | }) 41 | } 42 | } 43 | 44 | const unproxyLogger = () => resetLog() 45 | 46 | export default [ 47 | { 48 | url: '/realtimeLog', 49 | method: 'get', 50 | handler: () => 'Ciallo~(∠・ω< )⌒☆', 51 | wsHandler: (connection) => { 52 | proxyLogger(connection) 53 | connection.on('message', message => { 54 | let data 55 | try { 56 | data = JSON.parse(message.toString()) 57 | } catch { 58 | connection.send(JSON.stringify({ type: 'error', success: false, content: 'Invalid message format' })) 59 | return 60 | } 61 | const { action } = data 62 | switch (action) { 63 | // 心跳 64 | case 'ping': 65 | connection.send(JSON.stringify({ type: 'ping', content: 'pong' })) 66 | break 67 | default: 68 | break 69 | } 70 | }) 71 | connection.on('close', () => unproxyLogger()) 72 | connection.on('error', () => unproxyLogger()) 73 | } 74 | } 75 | ] as RouteOptions[] 76 | -------------------------------------------------------------------------------- /src/types/global.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | /* eslint-disable no-var */ 3 | import { RedisClientType } from 'redis' 4 | import { Logger } from 'log4js' 5 | 6 | interface logger extends Logger { 7 | error: (msg: string, ...args: any[]) => void 8 | info: (msg: string, ...args: any[]) => void 9 | debug: (msg: string, ...args: any[]) => void 10 | warn: (msg: string, ...args: any[]) => void 11 | trace: (msg: string, ...args: any[]) => void 12 | fatal: (msg: string, ...args: any[]) => void 13 | mark: (msg: string, ...args: any[]) => void 14 | logger: Logger 15 | blue: (name: string) => string 16 | } 17 | 18 | declare global { 19 | var Bot: { 20 | uin: number | string[] 21 | gl: Map; 22 | fl: Map; 23 | adapter: any[] | any 24 | em: (key: string, value: any) => void 25 | emit: (event: string, value: any) => void 26 | on: (event: string, listener: (value: any) => void) => void 27 | pickGroup: (group_id: string) => any 28 | pickUser: (user_id: string) => any 29 | [key: string]: { 30 | self_id: number | string 31 | nickname: string 32 | avatar: string 33 | uin: number | string 34 | adapter: { name: string, id: string, [key: string]: any } 35 | avatar: string 36 | nickname: string 37 | fl: Map 38 | gl: Map 39 | gml: Map 40 | version: { 41 | version: string 42 | name: string 43 | [key: string]: any 44 | } 45 | stat: { 46 | sent_msg_cnt?: number 47 | recv_msg_cnt?: number 48 | [key: string]: any 49 | } 50 | pickUser: (user_id: string) => any 51 | pickFriend: (user_id: string) => any 52 | pickGroup: (group_id: string) => any 53 | pickMember: (group_id: string, user_id: string) => any 54 | sendGroupMsg: (group_id: string, msg: any) => Promise 55 | sendPrivateMsg: (user_id: string, msg: any) => Promise 56 | getFriendList: () => Map 57 | getGroupList: () => Map 58 | getGroupMemberList: (group_id: string) => Map> 59 | dau?: { 60 | dauDB: 'redis'|'level'|false 61 | all_user: {[key: string]: number, total: number} 62 | all_group: {[key: string]: number, total: number} 63 | call_stats: {[key: string]: number, total: number} 64 | getStats: () => Promise<{user_count: number, group_count: number, receive_msg_count: number, send_msg_count: number, group_increase_count: number, group_decrease_count: number}> 65 | monthlyDau: (any) => Promise 66 | callStat: (any, boolean) => Promise 67 | } 68 | [key: string]: any 69 | } 70 | } 71 | var redis: RedisClientType 72 | var logger: logger 73 | var plugin: any 74 | } 75 | -------------------------------------------------------------------------------- /src/api/plugins/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import { version } from '@/common' 3 | import { RouteOptions } from 'fastify' 4 | import { join } from 'path' 5 | import fs from 'fs' 6 | 7 | /** 缓存每个插件的修改设置方法 */ 8 | const guobaSetConfigDataCache: { 9 | [key: string]: (data: any, cb: { Result: { ok: (data: any, msg: string) => void, error: (data: any, msg: string) => void }}) => any 10 | } = {} 11 | 12 | export const setConfigDataCache = (plugin: string, fnc: any) => { 13 | guobaSetConfigDataCache[plugin] = fnc 14 | } 15 | 16 | const customRoutes: {[key: string]: RouteOptions[]} = {} 17 | 18 | export const addCustomRoutes = (plugin: string, routes: RouteOptions[]) => { 19 | customRoutes[plugin] = routes 20 | } 21 | 22 | const pluginIconPath: {[key: string]: string} = {} 23 | 24 | export const setPluginIconPath = (plugin: string, path: string) => { 25 | pluginIconPath[plugin] = path 26 | } 27 | 28 | export default [ 29 | { 30 | url: '/get-group-list', 31 | method: 'get', 32 | handler: () => { 33 | return { 34 | success: true, 35 | data: Array.from(Bot.gl.values()).map(i => ({ ...i, label: `${i.group_name || ''}(${i.group_id})`, value: i.group_id })) 36 | } 37 | } 38 | }, 39 | { 40 | url: '/get-friend-list', 41 | method: 'get', 42 | handler: () => { 43 | return { 44 | success: true, 45 | data: Array.from(Bot.fl.values()).map(i => ({ ...i, label: `${i.user_name || ''}(${i.user_id})`, value: i.user_id })) 46 | } 47 | } 48 | }, 49 | { 50 | url: '/get-guoba-data', 51 | method: 'get', 52 | handler: async ({ query }) => { 53 | const { plugin } = query as { plugin: string } 54 | const guobaSupportPath = join(version.BotPath, 'plugins', plugin, 'guoba.support.js') 55 | const supportGuoba = (await import(`file://${guobaSupportPath}?t=${Date.now()}`)).supportGuoba 56 | const { configInfo: { getConfigData, schemas } } = supportGuoba() 57 | return { 58 | success: true, 59 | data: await getConfigData(), 60 | schemas 61 | } 62 | } 63 | }, 64 | { 65 | url: '/setting/:plugin', 66 | method: 'post', 67 | handler: async (req) => { 68 | const { plugin } = req.params as { plugin: string } 69 | let message = '未找到方法' 70 | if (guobaSetConfigDataCache[plugin]) { 71 | await guobaSetConfigDataCache[plugin](req.body, { 72 | Result: { 73 | ok: (_, msg) => { message = msg }, 74 | error: (_, msg) => { message = msg } 75 | } 76 | }) 77 | } 78 | return { success: true, message } 79 | } 80 | }, 81 | { 82 | url: '/image/:plugin', 83 | method: 'get', 84 | handler: (req, reply) => { 85 | const { plugin } = req.params as { plugin: string } 86 | const iconPath = pluginIconPath[plugin] 87 | if (iconPath) { 88 | const stream = fs.createReadStream(iconPath) 89 | const ext = iconPath.split('.').pop() 90 | reply.type(`image/${ext}`).send(stream) 91 | } else { 92 | reply.code(404).send('Not Found') 93 | } 94 | } 95 | } 96 | ] as RouteOptions[] 97 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build, Bump Version, and Commit 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: 拉取代码 14 | uses: actions/checkout@v4 15 | 16 | - name: 安装node 17 | uses: actions/setup-node@v4 18 | with: 19 | node-version: 20.10.0 20 | 21 | - name: 安装pnpm 22 | uses: pnpm/action-setup@v2 23 | with: 24 | version: 9.0.2 25 | 26 | - name: 安装依赖 27 | run: pnpm install 28 | 29 | - name: 编译 30 | run: pnpm run build 31 | 32 | - name: 设置时区 33 | run: | 34 | sudo timedatectl set-timezone Asia/Shanghai 35 | 36 | - name: 设置git用户名和邮箱 37 | run: | 38 | git config --global user.name 'github-actions[bot]' 39 | git config --global user.email 'github-actions[bot]@users.noreply.github.com' 40 | 41 | - name: 创建临时文件夹存储编译后的lib文件夹 42 | run: | 43 | mkdir ./../temp 44 | 45 | - name: 复制编译后的lib文件夹到临时文件夹 46 | run: | 47 | cp -r ./lib ./../temp/lib 48 | 49 | - name: 切换到 build 分支 50 | run: | 51 | git fetch origin build 52 | git checkout build 53 | 54 | - name: 删除build分支上的lib文件夹 55 | run: | 56 | rm -rf lib 57 | 58 | - name: 复制编译后的lib文件夹到 build 分支 59 | run: | 60 | cp -r ./../temp/lib ./ 61 | 62 | - name: 删除临时文件夹 63 | run: | 64 | rm -rf ./../temp 65 | 66 | - name: 从main分支复制文件到build分支 67 | run: | 68 | git checkout main -- package.json CHANGELOG.md pnpm-lock.yaml README.md index.js config 69 | 70 | 71 | - name: 创建 build.zip 72 | run: | 73 | find . -path "./.git" -prune -o -type f -print | zip -@ build.zip 74 | 75 | - name: 上传 build.zip 76 | uses: actions/upload-artifact@v4 77 | with: 78 | name: build-zip 79 | path: build.zip 80 | 81 | - name: 根据main分支的commit信息生成提交消息 82 | run: | 83 | rm -rf node_modules 84 | git add . 85 | git reset build.zip 86 | if [ -z "$(git diff --cached --name-only)" ]; then 87 | echo "No changes detected" 88 | exit 0 89 | else 90 | git commit -m "chore(build): ${{ github.event.head_commit.message }}" 91 | fi 92 | 93 | - name: 推送到 build 分支 94 | uses: ad-m/github-push-action@master 95 | with: 96 | branch: build 97 | github_token: ${{ secrets.GITHUB_TOKEN }} 98 | force_with_lease: true 99 | 100 | - name: 更新版本号 101 | uses: googleapis/release-please-action@v4 102 | id: release_please 103 | with: 104 | release-type: node 105 | token: ${{ secrets.GITHUB_TOKEN }} 106 | bump-minor-pre-major: true 107 | version-file: package.json 108 | fork: false 109 | create-release: true 110 | 111 | - name: 上传 release 112 | if: ${{ steps.release_please.outputs.release_created }} 113 | env: 114 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 115 | run: gh release upload ${{ steps.release_please.outputs.tag_name }} build.zip 116 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { createLoginKey } from '@/api/login' 2 | import { config, server } from './common' 3 | import { execSync } from 'child_process' 4 | import fs from 'fs' 5 | import '@/common/dev' 6 | 7 | server.startServer() 8 | 9 | let run = false 10 | export class YePanel extends plugin { 11 | constructor () { 12 | super({ 13 | name: 'YePanel', 14 | dsc: 'YePanel', 15 | event: 'message', 16 | priority: 1, 17 | rule: [ 18 | { 19 | reg: /^#?(小叶|YePanel|YP)(面板)?(登[录入陆]|login|signin|dl)$/i, 20 | fnc: 'login' 21 | }, 22 | { 23 | reg: /^#?(小叶|YePanel|YP)(面板)?(gitee)?安装web$/i, 24 | fnc: 'installWeb' 25 | } 26 | ] 27 | }) 28 | } 29 | 30 | async login (e: any) { 31 | if (!e.isMaster || e.group_id) return e.reply('Ciallo~(∠・ω< )⌒☆') 32 | const message = ['Ciallo~(∠・ω< )⌒☆'] 33 | const key = createLoginKey(e.self_id) 34 | const suffix = `YePanel/#/login?key=${key}` 35 | const { local, remote, custom } = await server.getIps() 36 | if (custom) { 37 | message.push( 38 | `自定义地址: \n${custom.replace(/\/$/, '')}${config.server.splicePath ? '/' + suffix : '?key=' + key}` 39 | ) 40 | } 41 | if (server.hasWeb) { 42 | message.push( 43 | `内网网址: \n${local.map(i => `http://${i}:${config.server.port}/${suffix}`).join('\n')}` 44 | ) 45 | if (remote) { 46 | message.push( 47 | `外网网址: \nhttp://${remote}:${config.server.port}/${suffix}` 48 | ) 49 | } 50 | message.push('-'.repeat(20)) 51 | } 52 | if (message.length === 1) { 53 | return e.reply('未安装前端web面板, 请发送 #小叶面板安装web 进行安装后重启机器人') 54 | } 55 | // if (!server.hasWeb || config.server.showPublic) { 56 | // if (custom) { 57 | // message.push( 58 | // `公共地址(自定义): \nhttp://gh.xasyer.icu/${suffix}&api=${custom}` 59 | // ) 60 | // } 61 | // message.push( 62 | // `公共地址(内网): \n${local.map(i => `http://gh.xasyer.icu/${suffix}&api=${i}:${config.server.port}`).join('\n')}` 63 | // ) 64 | // if (remote) { 65 | // message.push( 66 | // `公共地址(外网): \nhttp://gh.xasyer.icu/${suffix}&api=${remote}:${config.server.port}` 67 | // ) 68 | // } 69 | // if (message.length <= 3) { 70 | // message.push( 71 | // '如公共地址出现异常情况, 可以发送 #小叶面板安装web 将web服务克隆到本地运行' 72 | // ) 73 | // } 74 | // } 75 | message.push('请在浏览器中打开以上网址登录面板, 五分钟之内有效') 76 | message.push('祝你使用愉快!') 77 | const fordmsg = e.friend.makeForwardMsg(message.map(i => ({ message: i }))) 78 | return e.reply(fordmsg) 79 | } 80 | 81 | async installWeb (e: any) { 82 | if (!e.isMaster) return e.reply('Ciallo~(∠・ω< )⌒☆') 83 | if (server.hasWeb) { 84 | e.reply('已安装web服务, 无需重复安装') 85 | return 86 | } 87 | if (run) { 88 | return e.reply('web服务正在安装中, 请稍等...') 89 | } 90 | run = true 91 | e.reply('开始安装web服务, 请稍等...') 92 | const isProxy = ['gitee'].some(i => e.msg.includes(i)) 93 | const cmd = `git clone --depth=1 -b gh-pages ${isProxy ? 'https://gitee.com/xiaoye12123/YePanel.git' : 'https://github.com/XasYer/YePanel.git'} ./plugins/YePanel-Web/` 94 | try { 95 | execSync(cmd) 96 | } catch (error) { 97 | e.reply(`安装web服务失败: ${(error as Error).message}, 请检查网络或${isProxy ? '手动克隆仓库到plugins目录下' : '发送#小叶面板gitee安装web 使用gitee仓库进行安装'}, 安装命令: \n${cmd}`) 98 | run = false 99 | return 100 | } 101 | if (fs.existsSync(server.webPath)) { 102 | e.reply('Ciallo~(∠・ω< )⌒☆\n安装web服务成功, 请重启机器人后生效') 103 | } else { 104 | e.reply('安装web服务失败...') 105 | } 106 | run = false 107 | return true 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/api/system/terminal/index.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import os from 'os' 3 | import { join, resolve } from 'path' 4 | import { ChildProcessWithoutNullStreams, spawn } from 'child_process' 5 | import { WebSocket } from 'ws' 6 | import iconv from 'iconv-lite' 7 | import { RouteOptions } from 'fastify' 8 | 9 | function executeCommand (command: string, args: string[], ws: WebSocket, workingDirectory = './') { 10 | const isWindows = os.platform() === 'win32' 11 | const shell = isWindows ? 'powershell.exe' : true 12 | if (isWindows) { 13 | // 第一次使用会清空cmd, 无法输出日志 14 | // exec('chcp 65001') 15 | } 16 | // 处理目录移动 17 | if (command === 'cd') { 18 | const directory = join(workingDirectory, args[0] || '') 19 | if (fs.existsSync(directory)) { 20 | ws.send(JSON.stringify({ type: 'directory', content: resolve(directory), origin: { command, args } })) 21 | ws.send(JSON.stringify({ type: 'close', content: '进程退出码: 0', origin: { command, args } })) 22 | return null 23 | } 24 | } 25 | const res = spawn(command, args, { 26 | cwd: workingDirectory, 27 | shell 28 | }) 29 | if (res?.stdout && res?.stderr) { 30 | res.stdout.on('data', (data: Buffer) => { 31 | ws.send(JSON.stringify({ type: 'output', content: iconv.decode(data, 'gbk'), origin: { command, args } })) 32 | }) 33 | res.stderr.on('data', (data) => { 34 | ws.send(JSON.stringify({ type: 'error', content: iconv.decode(data, 'gbk'), origin: { command, args } })) 35 | }) 36 | res.on('error', (error) => { 37 | ws.send(JSON.stringify({ type: 'error', content: `Error: ${error.message}`, origin: { command, args } })) 38 | }) 39 | res.on('close', (code) => { 40 | ws.send(JSON.stringify({ type: 'close', content: `进程退出码: ${code}`, origin: { command, args } })) 41 | }) 42 | } 43 | return res 44 | } 45 | 46 | export default [ 47 | { 48 | url: '/terminal', 49 | method: 'get', 50 | handler: () => 'Ciallo~(∠・ω< )⌒☆', 51 | wsHandler: (connection) => { 52 | let childProcess: ChildProcessWithoutNullStreams | null = null 53 | connection.send(JSON.stringify({ type: 'directory', content: process.cwd() })) 54 | // 第一次的ls有异常, 不知道别的会不会 55 | executeCommand('ls', [], ({ send: () => {} } as unknown as WebSocket)) 56 | connection.on('message', message => { 57 | let data 58 | try { 59 | data = JSON.parse(message.toString()) 60 | } catch { 61 | connection.send(JSON.stringify({ type: 'error', success: false, content: 'Invalid message format' })) 62 | return 63 | } 64 | const { command, args, action, workingDirectory } = data 65 | switch (action) { 66 | case 'execute': 67 | if (childProcess) { 68 | childProcess.kill('SIGINT') 69 | } 70 | childProcess = executeCommand(command, args, connection, workingDirectory) 71 | break 72 | // 中断命令 73 | case 'terminate': 74 | if (childProcess) { 75 | childProcess.kill('SIGINT') // 发送中断信号 76 | childProcess = null // 清除子进程引用 77 | connection.send(JSON.stringify({ type: 'terminated', content: '命令已中断' })) 78 | } 79 | break 80 | // 心跳 81 | case 'ping': 82 | connection.send(JSON.stringify({ type: 'ping', content: 'pong' })) 83 | break 84 | default: 85 | break 86 | } 87 | }) 88 | connection.on('close', () => { 89 | if (childProcess) { 90 | childProcess.kill('SIGINT') 91 | childProcess = null 92 | } 93 | }) 94 | connection.on('error', () => { 95 | if (childProcess) { 96 | childProcess.kill('SIGINT') 97 | childProcess = null 98 | } 99 | }) 100 | } 101 | } 102 | ] as RouteOptions[] 103 | -------------------------------------------------------------------------------- /guoba.support.js: -------------------------------------------------------------------------------- 1 | import lodash from 'lodash' 2 | import { config } from './lib/common/index.js' 3 | 4 | export function supportGuoba () { 5 | return { 6 | pluginInfo: { 7 | name: 'YePanel', 8 | title: 'YePanel', 9 | author: '@XasYer', 10 | authorLink: 'https://github.com/XasYer', 11 | link: 'https://github.com/XasYer/YePanel', 12 | isV3: true, 13 | isV2: false, 14 | description: '适用于Yunzai的web管理面板', 15 | iconColor: 'bx:abacus' 16 | }, 17 | configInfo: { 18 | schemas: [ 19 | { 20 | component: 'Divider', 21 | label: '面板设置' 22 | }, 23 | { 24 | field: 'server.port', 25 | label: '面板端口', 26 | bottomHelpMessage: '启动面板时占用的端口号,不要与其他端口重复', 27 | component: 'InputNumber', 28 | componentProps: { 29 | placeholder: '请输入端口号' 30 | } 31 | }, 32 | { 33 | field: 'server.host', 34 | label: '域名', 35 | bottomHelpMessage: '若为auto,则自动获取当前服务器的ip', 36 | component: 'Input' 37 | }, 38 | { 39 | field: 'server.splicePath', 40 | label: '域名拼接', 41 | bottomHelpMessage: '自定义域名时是否拼接 /YePanel/#/login', 42 | component: 'Switch' 43 | }, 44 | { 45 | field: 'server.showPublic', 46 | label: '显示公共面板地址', 47 | bottomHelpMessage: 'yp登录 存在本地web时是否显示公共面板地址', 48 | component: 'Switch' 49 | }, 50 | { 51 | field: 'server.logs', 52 | label: '请求日志输出', 53 | bottomHelpMessage: 'api请求日志输出 可选request上的参数 或 false 关闭日志输出', 54 | component: 'GTags' 55 | }, 56 | { 57 | component: 'Divider', 58 | label: '统计设置' 59 | }, 60 | { 61 | field: 'stats.alone', 62 | label: '额外单独Bot统计', 63 | bottomHelpMessage: '是否单独统计每个Bot的数据', 64 | component: 'Switch' 65 | }, 66 | { 67 | component: 'Divider', 68 | label: '总计统计' 69 | }, 70 | { 71 | field: 'stats.totalStats.sent', 72 | label: '发送消息次数', 73 | bottomHelpMessage: '总计发送消息次数', 74 | component: 'Switch' 75 | }, 76 | { 77 | field: 'stats.totalStats.recv', 78 | label: '接收消息次数', 79 | bottomHelpMessage: '总计接收消息次数', 80 | component: 'Switch' 81 | }, 82 | { 83 | field: 'stats.totalStats.plugin', 84 | label: '插件触发次数', 85 | bottomHelpMessage: '总计触发插件次数', 86 | component: 'Switch' 87 | }, 88 | { 89 | component: 'Divider', 90 | label: '数量统计图表, 在一个图表中' 91 | }, 92 | { 93 | field: 'stats.countChart.sent', 94 | label: '发送消息次数', 95 | component: 'Switch' 96 | }, 97 | { 98 | field: 'stats.countChart.recv', 99 | label: '接收消息次数', 100 | component: 'Switch' 101 | }, 102 | { 103 | field: 'stats.countChart.plugin', 104 | label: '插件触发次数', 105 | component: 'Switch' 106 | }, 107 | { 108 | component: 'Divider', 109 | label: '排行榜统计图表, 每一项为一个图表' 110 | }, 111 | { 112 | field: 'stats.rankChart.sentType', 113 | label: '发送消息类型', 114 | component: 'Switch' 115 | }, 116 | { 117 | field: 'stats.rankChart.pluginUse', 118 | label: '插件触发次数', 119 | component: 'Switch' 120 | }, 121 | { 122 | field: 'stats.rankChart.pluginSent', 123 | label: '插件发送消息', 124 | component: 'Switch' 125 | }, 126 | { 127 | field: 'stats.rankChart.groupRecv', 128 | label: '群聊接收消息', 129 | component: 'Switch' 130 | }, 131 | { 132 | field: 'stats.rankChart.groupSent', 133 | label: '群聊发送消息', 134 | component: 'Switch' 135 | }, 136 | { 137 | field: 'stats.rankChart.userRecv', 138 | label: '用户接收消息', 139 | component: 'Switch' 140 | }, 141 | { 142 | field: 'stats.rankChart.userSent', 143 | label: '用户发送消息', 144 | component: 'Switch' 145 | } 146 | ], 147 | getConfigData () { 148 | return { 149 | server: config.getConfig('server'), 150 | stats: config.getConfig('stats') 151 | } 152 | }, 153 | setConfigData (data, { Result }) { 154 | const configs = { 155 | server: config.getConfig('server'), 156 | stats: config.getConfig('stats') 157 | } 158 | 159 | const updateConfig = (keyPath, value) => { 160 | const [rootKey, ...subKeys] = keyPath.split('.') 161 | const targetConfig = configs[rootKey] 162 | 163 | if (!targetConfig) return 164 | 165 | let currentConfig = targetConfig 166 | let isDifferent = false 167 | 168 | for (const key of subKeys.slice(0, -1)) { 169 | if (currentConfig[key] !== undefined) { 170 | currentConfig = currentConfig[key] 171 | } else { 172 | isDifferent = true 173 | break 174 | } 175 | } 176 | const lastKey = subKeys[subKeys.length - 1] 177 | if ( 178 | isDifferent || 179 | !lodash.isEqual(currentConfig[lastKey], value) 180 | ) { 181 | config.modify(rootKey, subKeys.join('.'), value) 182 | } 183 | } 184 | 185 | for (const [key, value] of Object.entries(data)) { 186 | updateConfig(key, value) 187 | } 188 | 189 | return Result.ok({}, '𝑪𝒊𝒂𝒍𝒍𝒐~(∠・ω< )⌒★') 190 | } 191 | 192 | } 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /src/common/config.ts: -------------------------------------------------------------------------------- 1 | import YAML from 'yaml' 2 | import fs from 'node:fs' 3 | import chokidar, { FSWatcher } from 'chokidar' 4 | import * as version from './version.js' 5 | import { FastifyRequest } from 'fastify' 6 | 7 | class Config { 8 | config: { 9 | [key: string]: object 10 | } = {} 11 | 12 | /** 监听文件 */ 13 | watcher: { 14 | [key: string]: FSWatcher 15 | } = { } 16 | 17 | constructor () { 18 | this.initCfg() 19 | } 20 | 21 | /** 初始化配置 */ 22 | initCfg () { 23 | const path = `${version.pluginPath}/config/config/` 24 | if (!fs.existsSync(path)) fs.mkdirSync(path) 25 | const pathDef = `${version.pluginPath}/config/default_config/` 26 | const files = fs.readdirSync(pathDef).filter(file => file.endsWith('.yaml')) 27 | const ignore = ['server.password'] 28 | for (const file of files) { 29 | if (!fs.existsSync(`${path}${file}`)) { 30 | fs.copyFileSync(`${pathDef}${file}`, `${path}${file}`) 31 | } else { 32 | const config = YAML.parse(fs.readFileSync(`${path}${file}`, 'utf8')) 33 | const defaultConfig = YAML.parse(fs.readFileSync(`${pathDef}${file}`, 'utf8')) 34 | let isChange = false 35 | const saveKeys: string[] = [] 36 | const merge = (defValue: any, value: any, prefix: string = '') => { 37 | const defKeys = Object.keys(defValue) 38 | const configKeys = Object.keys(value || {}) 39 | for (const key of defKeys) { 40 | switch (typeof defValue[key]) { 41 | case 'object': 42 | if (!Array.isArray(defValue[key]) && !ignore.includes(`${file.replace('.yaml', '')}.${key}`)) { 43 | defValue[key] = merge(defValue[key], value[key], key + '.') 44 | break 45 | } 46 | // eslint-disable-next-line no-fallthrough 47 | default: 48 | if (!configKeys.includes(key)) { 49 | isChange = true 50 | } else { 51 | defValue[key] = value[key] 52 | } 53 | saveKeys.push(`${prefix}${key}`) 54 | } 55 | } 56 | return defValue 57 | } 58 | const value = merge(defaultConfig, config) 59 | if (isChange) { 60 | fs.copyFileSync(`${pathDef}${file}`, `${path}${file}`) 61 | for (const key of saveKeys) { 62 | this.modify(file.replace('.yaml', ''), key, key.split('.').reduce((obj: any, key: string) => obj[key], value)) 63 | } 64 | } 65 | } 66 | this.watch(`${path}${file}`, file.replace('.yaml', ''), 'config') 67 | } 68 | } 69 | 70 | /** 服务配置 */ 71 | get server (): { 72 | port: number 73 | host: string 74 | splicePath: boolean 75 | showPublic: boolean 76 | logs: Array 77 | password: { 78 | [key: string]: { 79 | password: string 80 | nickname: string 81 | avatarUrl: string 82 | enable: boolean 83 | } 84 | } 85 | } { 86 | return this.getDefOrConfig('server') 87 | } 88 | 89 | get stats (): { 90 | alone: boolean, 91 | totalStats: { 92 | sent: boolean 93 | recv: boolean 94 | plugin: boolean 95 | }, 96 | countChart: { 97 | sent: boolean 98 | recv: boolean 99 | plugin: boolean 100 | }, 101 | rankChart: { 102 | sentType: boolean 103 | groupSent: boolean 104 | groupRecv: boolean 105 | userSent: boolean 106 | userRecv: boolean 107 | pluginUse: boolean 108 | pluginSent: boolean 109 | } 110 | } { 111 | return this.getDefOrConfig('stats') 112 | } 113 | 114 | /** 默认配置和用户配置 */ 115 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 116 | getDefOrConfig (name: string): any { 117 | const def = this.getdefSet(name) 118 | const config = this.getConfig(name) 119 | return { ...def, ...config } 120 | } 121 | 122 | /** 默认配置 */ 123 | getdefSet (name: string) { 124 | return this.getYaml('default_config', name) 125 | } 126 | 127 | /** 用户配置 */ 128 | getConfig (name: string) { 129 | return this.getYaml('config', name) 130 | } 131 | 132 | /** 133 | * 获取配置yaml 134 | * @param type 默认跑配置-defSet,用户配置-config 135 | * @param name 名称 136 | */ 137 | getYaml (type: string, name: string) { 138 | const file = `${version.pluginPath}/config/${type}/${name}.yaml` 139 | const key = `${type}.${name}` 140 | 141 | if (this.config[key]) return this.config[key] 142 | 143 | this.config[key] = YAML.parse( 144 | fs.readFileSync(file, 'utf8') 145 | ) 146 | 147 | this.watch(file, name, type) 148 | 149 | return this.config[key] 150 | } 151 | 152 | /** 监听配置文件 */ 153 | watch (file: string, name: string, type = 'default_config') { 154 | const key = `${type}.${name}` 155 | if (this.watcher[key]) return 156 | 157 | const watcher = chokidar.watch(file) 158 | watcher.on('change', async () => { 159 | delete this.config[key] 160 | logger.mark(`[${version.pluginName}][修改配置文件][${type}][${name}]`) 161 | }) 162 | 163 | this.watcher[key] = watcher 164 | } 165 | 166 | /** 167 | * 修改设置 168 | * @param name 文件名 169 | * @param key 修改的key值 170 | * @param value 修改的value值 171 | * @param type 配置文件或默认 172 | */ 173 | modify (name: string, key: string, value: string | number, type: 'default_config' | 'config' = 'config') { 174 | const path = `${version.pluginPath}/config/${type}/${name}.yaml` 175 | new YamlReader(path).set(key, value) 176 | delete this.config[`${type}.${name}`] 177 | } 178 | } 179 | 180 | class YamlReader { 181 | filePath: string 182 | document: YAML.Document 183 | /** 184 | * 创建一个YamlReader实例。 185 | * @param filePath - 文件路径 186 | */ 187 | constructor (filePath: string) { 188 | this.filePath = filePath 189 | this.document = this.parseDocument() 190 | } 191 | 192 | /** 193 | * 解析YAML文件并返回Document对象,保留注释。 194 | */ 195 | parseDocument () { 196 | const fileContent = fs.readFileSync(this.filePath, 'utf8') 197 | return YAML.parseDocument(fileContent) 198 | } 199 | 200 | /** 201 | * 修改指定参数的值。 202 | * @param key - 参数键名 203 | * @param value - 新的参数值 204 | */ 205 | set (key: string, value: any) { 206 | const keys = key.split('.') 207 | const lastKey = keys.pop() 208 | let current = this.document 209 | 210 | // 遍历嵌套键名,直到找到最后一个键 211 | for (const key of keys) { 212 | if (!current.has(key)) { 213 | current.set(key, new YAML.YAMLMap()) 214 | } 215 | current = current.get(key) as YAML.Document 216 | } 217 | 218 | // 设置最后一个键的值 219 | current.set(lastKey, value) 220 | this.write() 221 | } 222 | 223 | /** 224 | * 从YAML文件中删除指定参数。 225 | * @param key - 要删除的参数键名 226 | */ 227 | rm (key: string) { 228 | const keys = key.split('.') 229 | const lastKey = keys.pop() 230 | let current = this.document 231 | 232 | // 遍历嵌套键名,直到找到最后一个键 233 | for (const key of keys) { 234 | if (current.has(key)) { 235 | current = current.get(key) as YAML.Document 236 | } else { 237 | return // 如果键不存在,直接返回 238 | } 239 | } 240 | 241 | // 删除最后一个键 242 | current.delete(lastKey) 243 | this.write() 244 | } 245 | 246 | /** 247 | * 将更新后的Document对象写入YAML文件中。 248 | */ 249 | write () { 250 | fs.writeFileSync(this.filePath, this.document.toString(), 'utf8') 251 | } 252 | } 253 | 254 | export default new Config() 255 | -------------------------------------------------------------------------------- /src/api/system/files/index.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import moment from 'moment' 3 | import { join, dirname } from 'path' 4 | import { RouteOptions } from 'fastify' 5 | import { utils, version } from '@/common' 6 | 7 | const formatTime = (time: Date | number) => moment(time).utcOffset(8).format('YYYY/MM/DD HH:mm') 8 | 9 | export default [ 10 | { 11 | url: '/get-dir-data', 12 | method: 'get', 13 | handler: ({ query }) => { 14 | let { path } = query as { path: string } 15 | if (!path) { 16 | path = version.BotPath 17 | } 18 | path = path.replace(/\\/g, '/') 19 | const parentPath = join(path, '..').replace(/\\/g, '/') 20 | const data: { 21 | path: string, 22 | parentPath: string, 23 | files: { 24 | name: string, 25 | path: string, 26 | ext: string, 27 | isDir: boolean, 28 | size: string, 29 | rowSize: number, 30 | time: string, 31 | mtimeMs: number 32 | }[] 33 | } = { 34 | path, 35 | parentPath: parentPath === path ? '' : parentPath, 36 | files: [] 37 | } 38 | try { 39 | fs.readdirSync(path).forEach(name => { 40 | try { 41 | const filePath = join(path, name) 42 | const stat = fs.statSync(filePath) 43 | const isDir = stat.isDirectory() 44 | data.files.push({ 45 | name, 46 | path: filePath, 47 | ext: isDir ? 'folder' : name.split('.').pop() as string, 48 | isDir, 49 | size: isDir ? '' : utils.formatBytes(stat.size), 50 | rowSize: isDir ? 0 : stat.size, 51 | time: formatTime(stat.mtime), 52 | mtimeMs: stat.mtimeMs 53 | }) 54 | } catch { /* empty */ } 55 | }) 56 | } catch (error) { 57 | return { 58 | success: false, 59 | message: (error as Error).message 60 | } 61 | } 62 | return { 63 | success: true, 64 | data 65 | } 66 | } 67 | }, 68 | { 69 | url: '/upload-file', 70 | method: 'post', 71 | handler: async (request, reply) => { 72 | const parts = request.parts() 73 | const formData = { 74 | path: '', 75 | name: '', 76 | file: null 77 | } as { path: string, name: string, file: Buffer | null } 78 | for await (const part of parts) { 79 | if (part.type === 'file') { 80 | formData.name = part.filename 81 | formData.file = await part.toBuffer() 82 | } else if (part.fieldname === 'path') { 83 | formData.path = (part.value as string) 84 | } 85 | } 86 | const filePath = join(formData.path, formData.name) 87 | if (formData.path && formData.name && formData.file) { 88 | fs.writeFileSync(filePath, formData.file as any) 89 | } 90 | try { 91 | const stat = fs.statSync(filePath) 92 | const isDir = stat.isDirectory() 93 | reply.send({ 94 | success: true, 95 | data: { 96 | name: formData.name, 97 | path: filePath, 98 | ext: isDir ? 'folder' : formData.name.split('.').pop(), 99 | isDir, 100 | size: isDir ? '' : utils.formatBytes(stat.size), 101 | rowSize: isDir ? 0 : stat.size, 102 | time: formatTime(stat.mtime), 103 | mtimeMs: stat.mtimeMs 104 | } 105 | }) 106 | } catch (error) { 107 | return { 108 | success: false, 109 | message: (error as Error).message 110 | } 111 | } 112 | } 113 | }, 114 | { 115 | url: '/rename-file', 116 | method: 'post', 117 | handler: ({ body }) => { 118 | const { path, name, isDir } = body as { path: string, name: string, isDir: boolean } 119 | const newName = join(dirname(path), name) 120 | try { 121 | // 如果文件存在就是重命名,不存在就是创建文件 122 | if (fs.existsSync(path)) { 123 | fs.renameSync(path, newName) 124 | const stat = fs.statSync(newName) 125 | const isDir = stat.isDirectory() 126 | return { 127 | success: true, 128 | data: { 129 | name, 130 | path: newName, 131 | ext: isDir ? 'folder' : name.split('.').pop(), 132 | isDir, 133 | size: isDir ? '' : utils.formatBytes(stat.size), 134 | rowSize: isDir ? 0 : stat.size, 135 | time: formatTime(stat.mtime), 136 | mtimeMs: stat.mtimeMs 137 | } 138 | } 139 | } else { 140 | if (isDir) { 141 | fs.mkdirSync(newName) 142 | return { 143 | success: true 144 | } 145 | } else { 146 | fs.writeFileSync(newName, '', 'utf-8') 147 | return { 148 | success: true 149 | } 150 | } 151 | } 152 | } catch (error) { 153 | return { 154 | success: false, 155 | message: (error as Error).message 156 | } 157 | } 158 | } 159 | }, 160 | { 161 | url: '/delete-file', 162 | method: 'post', 163 | handler: ({ body }) => { 164 | const { paths } = body as { paths: { path: string, isDir: boolean }[] } 165 | const errors = [] 166 | for (const { path, isDir } of paths) { 167 | try { 168 | if (isDir) { 169 | fs.rmdirSync(path, { recursive: true }) 170 | } else { 171 | fs.unlinkSync(path) 172 | } 173 | } catch (error) { 174 | errors.push({ path, isDir, message: (error as Error).message }) 175 | } 176 | } 177 | return { 178 | success: true, 179 | errors 180 | } 181 | } 182 | }, 183 | { 184 | url: '/move-file', 185 | method: 'post', 186 | handler: ({ body }) => { 187 | const { paths, targetPath, action } = body as { paths: { path: string, name: string, isDir: boolean }[], targetPath: string, action: 'copy' |'move' } 188 | const errors = [] 189 | for (const { path, name, isDir } of paths) { 190 | try { 191 | const newPath = join(targetPath, name) 192 | if (action === 'copy') { 193 | fs.copyFileSync(path, newPath) 194 | } else if (action === 'move') { 195 | fs.renameSync(path, newPath) 196 | } 197 | } catch (error) { 198 | errors.push({ path, isDir, message: (error as Error).message }) 199 | } 200 | } 201 | return { 202 | success: true, 203 | errors 204 | } 205 | } 206 | }, 207 | { 208 | url: '/download-file', 209 | method: 'post', 210 | handler: ({ body }, reply) => { 211 | const { path, name } = body as { path: string, name: string } 212 | reply.header('Content-Type', 'application/octet-stream') 213 | reply.header('Content-Disposition', `attachment; filename="${encodeURIComponent(name)}"`) 214 | 215 | const fileStream = fs.createReadStream(path) 216 | 217 | fileStream.on('error', (err) => { 218 | reply.status(500).send(err.message) 219 | }) 220 | reply.send(fileStream) 221 | } 222 | }, 223 | { 224 | url: '/set-file-data', 225 | method: 'post', 226 | handler: ({ body }) => { 227 | const { path, data } = body as { path: string, data: string } 228 | try { 229 | fs.writeFileSync(path, data, 'utf-8') 230 | return { 231 | success: true 232 | } 233 | } catch (error) { 234 | return { 235 | success: false, 236 | message: (error as Error).message 237 | } 238 | } 239 | } 240 | } 241 | ] as RouteOptions[] 242 | -------------------------------------------------------------------------------- /src/api/Bot/plugins/index.ts: -------------------------------------------------------------------------------- 1 | import { getPlugins } from '@/api/welcome' 2 | import { exec } from 'child_process' 3 | import { RouteOptions } from 'fastify' 4 | import { existsSync, rmSync } from 'fs' 5 | 6 | type PluginInfo = { 7 | title: string 8 | name: string 9 | link: string 10 | authors: { 11 | name: string 12 | link: string 13 | }[] 14 | desc: string 15 | } 16 | 17 | // From Guoba-Plugin 18 | function parseReadmeLink (text: string, baseUrl: string) { 19 | const linkReg = /\[.*]\((.*)\)/g 20 | const imgReg = //g 21 | const checkUrl = (url: string) => { 22 | // 因为gitee的防盗链机制,所以只有gitee的链接才需要替换 23 | if (/gitee\.com/i.test(url)) { 24 | return /\.(png|jpeg|jpg|gif|bmp|svg|ico|icon|webp|webm|mp4)$/i.test(url) 25 | } 26 | return false 27 | } 28 | const fn = ($0: string, $1: string) => { 29 | let url = '' 30 | if (checkUrl($1)) { 31 | url = `YePanel/api/transit?url=${encodeURIComponent($1)}` 32 | return $0.replace($1, url) 33 | } 34 | if (/^https?/i.test($1)) { 35 | return $0 36 | } 37 | url = `${baseUrl}/${$1.replace(/^\//, '')}` 38 | if (checkUrl(url)) { 39 | url = `YePanel/api/transit?url=${encodeURIComponent(url)}` 40 | } 41 | return $0.replace($1, url) 42 | } 43 | text = text.replace(linkReg, fn) 44 | return text.replace(imgReg, fn) 45 | } 46 | 47 | const output: {[key: string]: {error?: Error | null, stdout?: string, stderr?: string, running?: boolean}} = {} 48 | 49 | const executeCommand = (command: string): Promise => { 50 | return new Promise((resolve, reject) => { 51 | output[command] = { running: true, error: null, stdout: '', stderr: '' } 52 | 53 | let timeout = false 54 | 55 | const timer = setTimeout(() => { 56 | timeout = true 57 | reject(new Error('还在异步执行中,可再次点击安装查看结果')) 58 | }, 1000 * 9) 59 | 60 | exec(command, (error, stdout, stderr) => { 61 | clearTimeout(timer) 62 | if (timeout) { 63 | output[command] = { running: false, error, stdout, stderr } 64 | return 65 | } 66 | if (error) { 67 | reject(error) 68 | } else { 69 | resolve(stdout) 70 | } 71 | }) 72 | }) 73 | } 74 | 75 | const installPackage = (name: string) => { 76 | if (existsSync(`plugins/${name}/package.json`)) { 77 | exec(`cd plugins/${name} && pnpm install`) 78 | } 79 | } 80 | 81 | export default [ 82 | { 83 | url: '/get-plugin-index-list', 84 | method: 'GET', 85 | handler: async () => { 86 | const pluginList: { 87 | main: PluginInfo[], 88 | function: PluginInfo[], 89 | game: PluginInfo[], 90 | wordgame: PluginInfo[], 91 | install: string[] 92 | } = { 93 | main: [], 94 | function: [], 95 | game: [], 96 | wordgame: [], 97 | install: [] 98 | } 99 | const fileName: { 100 | name: 'README.md' | 'Function-Plugin.md' | 'Game-Plugin.md' | 'WordGame-Plugin.md', 101 | id: 'main' | 'function' | 'game' | 'wordgame' 102 | }[] = [ 103 | { 104 | name: 'README.md', 105 | id: 'main' 106 | }, { 107 | name: 'Function-Plugin.md', 108 | id: 'function' 109 | }, { 110 | name: 'Game-Plugin.md', 111 | id: 'game' 112 | }, { 113 | name: 'WordGame-Plugin.md', 114 | id: 'wordgame' 115 | } 116 | ] 117 | const reg = /\[(.+?)\]\((.+?)\)/ 118 | for (const i of fileName) { 119 | const res = await fetch(`https://gitee.com/yhArcadia/Yunzai-Bot-plugins-index/raw/main/${i.name}`) 120 | .then(res => res.text()) 121 | 122 | const lines = res.split('\n') 123 | for (const line of lines) { 124 | if (/^\|(.*)\|$/.test(line) && !line.includes('☞') && line.includes('http')) { 125 | const [nameText, authorText, desc] = line.split('|').filter(Boolean) 126 | if (!nameText || !authorText || !desc) continue 127 | const [, name, link] = reg.exec?.(nameText) || [] 128 | if (!name || !link) continue 129 | const authorList = authorText.match(new RegExp(reg, 'g')) || [] 130 | if (!authorList.length) continue 131 | const authors = authorList.map(i => { 132 | const [, name, link] = reg.exec(i) || [] 133 | return { 134 | name, 135 | link 136 | } 137 | }).filter(i => i.name && i.link) 138 | if (!authors.length) continue 139 | const [, title, pluginName] = /^(.*)\s*[((]([^()]*)[))]$/.exec(name) || [] 140 | pluginList[i.id].push({ 141 | title: title || name, 142 | name: pluginName || name, 143 | link, 144 | authors, 145 | desc: desc.trim() 146 | }) 147 | } 148 | } 149 | } 150 | pluginList.install = getPlugins(true).plugins.filter(i => i.hasGit).map(i => i.name) 151 | return { 152 | success: true, 153 | data: pluginList 154 | } 155 | } 156 | }, 157 | { 158 | url: '/get-plugin-readme', 159 | method: 'GET', 160 | handler: async ({ query }) => { 161 | const { link: pluginLink } = query as { link: string } 162 | const branchs = ['main', 'master'] 163 | const [owner, repo] = pluginLink.split('/').slice(-2) 164 | const link = pluginLink.includes('github') ? `https://cdn.jsdelivr.net/gh/${owner}/${repo}@` : `https://gitee.com/${owner}/${repo}/raw/` 165 | for (const branch of branchs) { 166 | const baseUrl = `${link}${branch}` 167 | const url = `${baseUrl}/README.md` 168 | try { 169 | const res = await fetch(url) 170 | if (res.status === 200) { 171 | const text = await res.text() 172 | return { 173 | success: true, 174 | data: parseReadmeLink(text, baseUrl) 175 | } 176 | } 177 | } catch { } 178 | } 179 | return { 180 | success: false, 181 | message: 'main分支或master分支上不存在README.md文件' 182 | } 183 | } 184 | }, 185 | { 186 | url: '/install-plugin', 187 | method: 'POST', 188 | handler: async ({ body }) => { 189 | const { link, name, branch } = body as { link: string, name?: string, branch?: string } 190 | const pluginName = name || link.split('/').pop()?.replace(/\.git$/, '') as string 191 | const path = `plugins/${pluginName}` 192 | const cmd = `git clone --depth=1 --single-branch ${branch ? `-b ${branch}` : ''} ${link} "${path}"` 193 | if (output[cmd]) { 194 | for (let i = 0; i < 9; i++) { 195 | const sync = output[cmd] 196 | if (!sync.running) { 197 | delete output[cmd] 198 | if (sync.error) { 199 | return { 200 | success: false, 201 | message: sync.error.message 202 | } 203 | } else { 204 | installPackage(pluginName) 205 | return { 206 | success: true, 207 | data: sync.stdout 208 | } 209 | } 210 | } 211 | await new Promise(resolve => setTimeout(resolve, 1000)) 212 | } 213 | return { 214 | success: false, 215 | message: '还在异步执行中,可再次点击安装查看结果' 216 | } 217 | } 218 | if (existsSync(path)) { 219 | return { 220 | success: true 221 | } 222 | } 223 | try { 224 | const result = await executeCommand(cmd) 225 | installPackage(pluginName) 226 | return { 227 | success: true, 228 | data: result 229 | } 230 | } catch (error) { 231 | return { 232 | success: false, 233 | message: (error as Error).message 234 | } 235 | } 236 | } 237 | }, 238 | { 239 | url: '/uninstall-plugin', 240 | method: 'POST', 241 | handler: async ({ body }) => { 242 | const { name } = body as { name: string } 243 | try { 244 | rmSync(`plugins/${name}`, { recursive: true, force: true }) 245 | return { 246 | success: true 247 | } 248 | } catch (error) { 249 | return { 250 | success: false, 251 | message: (error as Error).message 252 | } 253 | } 254 | } 255 | } 256 | ] as RouteOptions[] 257 | -------------------------------------------------------------------------------- /src/common/server.ts: -------------------------------------------------------------------------------- 1 | import Fastify, { FastifyReply, FastifyRequest, RouteOptions } from 'fastify' 2 | import fastifyAuth from '@fastify/auth' 3 | import fastifyCors from '@fastify/cors' 4 | import fastifyWebSocket from '@fastify/websocket' 5 | import fastifyStatic from '@fastify/static' 6 | import fastifyMultipart from '@fastify/multipart' 7 | import fs from 'fs' 8 | import os from 'os' 9 | // @ts-ignore 10 | import chalk from 'chalk' 11 | import { join } from 'path' 12 | import { version, config } from '@/common' 13 | import { tokenAuth } from '@/api/login' 14 | 15 | export const webPath = join(version.BotPath, 'plugins', 'YePanel-Web') 16 | export const hasWeb = fs.existsSync(webPath) 17 | 18 | export async function startServer () { 19 | const start = Date.now() 20 | const fastify = Fastify() 21 | 22 | await fastify.register(fastifyCors, { 23 | origin: ['*'], 24 | methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], 25 | allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'], 26 | credentials: true 27 | }) 28 | await fastify.register(fastifyAuth) 29 | await fastify.register(fastifyWebSocket) 30 | await fastify.register(fastifyMultipart) 31 | 32 | if (hasWeb) { 33 | await fastify.register(fastifyStatic, { 34 | root: webPath, 35 | prefix: '/YePanel/' 36 | }) 37 | fastify.get('/', (request, reply) => { 38 | reply.redirect('/YePanel/') 39 | }) 40 | } else { 41 | logger.warn('[YePanel] 未安装前端web面板, 可能会无法使用, 如果没有安装前端面板, 可发送#小叶面板安装web') 42 | fastify.get('/', (request, reply) => { 43 | reply.type('text/html').send(` 44 | 45 | 46 | 47 | Ciallo~(∠・ω< )⌒☆ 48 | 49 | 50 |
51 |

Ciallo~(∠・ω< )⌒☆

52 |

如果看到了这个页面, 说明YePanel已经启动成功了

53 |

可使用 #小叶面板安装web 安装前端面板

54 |
55 | 56 | `) 57 | }) 58 | } 59 | 60 | fastify.addHook('onRequest', (request, reply, done) => { 61 | const keys = config.server.logs 62 | if (keys) { 63 | const logs = ['[YePanel Server]'] 64 | for (const i of keys) { 65 | const value = i.split('.').reduce((prev, curr) => prev && (prev as any)?.[curr], request) 66 | if (value) { 67 | logs.push(`${i}:`, objectToString(value)) 68 | } 69 | } 70 | logger.mark(chalk.rgb(255, 105, 180)(...logs)) 71 | logger.mark(chalk.rgb(255, 105, 180)('-'.repeat(30))) 72 | } 73 | done() 74 | }) 75 | 76 | fastify.addHook('onError', (request, reply, error, done) => { 77 | if (error.message !== 'Unauthorized') { 78 | logger.error(chalk.rgb(255, 105, 180)(`[YePanel Server] url: ${request.url} Error: `)) 79 | logger.error(error as any) 80 | } 81 | done() 82 | }) 83 | 84 | function verifyToken (request: FastifyRequest, reply: FastifyReply, done: (error?: Error | undefined) => void) { 85 | const token = request.headers.authorization || request.headers['sec-websocket-protocol'] || (request.query as {accessToken?: string})?.accessToken || '' 86 | 87 | if (tokenAuth(token.replace('Bearer ', ''))) { 88 | done() 89 | } else { 90 | done(new Error('Unauthorized')) 91 | } 92 | } 93 | 94 | async function loadRoutes (directory: string) { 95 | const items = fs.readdirSync(directory) 96 | 97 | for (const item of items) { 98 | const fullPath = join(directory, item) 99 | const stat = fs.statSync(fullPath) 100 | if (stat.isDirectory()) { 101 | await loadRoutes(fullPath) 102 | } else if (stat.isFile() && item === `index.${(version.isDev ? 'ts' : 'js')}`) { 103 | try { 104 | const route = ((await import(`file://${fullPath}`)).default as RouteOptions[]) 105 | for (const i of route) { 106 | if (!i.preHandler) { 107 | i.preHandler = fastify.auth([verifyToken]) 108 | } else { 109 | delete i.preHandler 110 | } 111 | fastify.route(i) 112 | } 113 | } catch { /* empty */ } 114 | } 115 | } 116 | } 117 | 118 | const srcPath = version.isDev ? 'src' : 'lib' 119 | 120 | await loadRoutes(join(version.pluginPath, srcPath, 'api')) 121 | 122 | const pluginsPath = join(version.BotPath, 'plugins') 123 | 124 | if (fs.existsSync(pluginsPath)) { 125 | // 加载插件的路由 126 | const pluginList = fs.readdirSync(pluginsPath) 127 | for (const plugin of pluginList) { 128 | const pluginPath = join(version.BotPath, 'plugins', plugin) 129 | const YePanelPath = join(pluginPath, 'YePanel') 130 | if (fs.statSync(pluginPath).isDirectory() && fs.existsSync(YePanelPath)) { 131 | try { 132 | const option = (await import(`file://${join(YePanelPath, 'index.js')}?t=${Date.now()}`)).default 133 | if (option.api?.length) { 134 | for (const i of option.api) { 135 | i.url = `/${plugin}${i.url}` 136 | if (!i.preHandler) { 137 | i.preHandler = fastify.auth([verifyToken]) 138 | } else { 139 | delete i.preHandler 140 | } 141 | fastify.route(i) 142 | } 143 | } 144 | } catch { /* empty */ } 145 | } 146 | } 147 | } 148 | 149 | fastify.listen({ port: config.server.port, host: '::' }, (err) => { 150 | if (err) { 151 | logger.error(`YePanel Error starting server: ${err}`) 152 | } else { 153 | getIps().then(res => { 154 | const end = Date.now() 155 | const logs = [ 156 | '-'.repeat(30), 157 | `YePanel v${version.pluginVersion} Server running successfully on ${end - start}ms`, 158 | ...res.custom ? ['自定义地址:', ` - ${res.custom}`] : [], 159 | '内网地址:', 160 | ...res.local.map(i => ` - http://${i}:${config.server.port}`), 161 | ...res.remote 162 | ? [ 163 | '外网地址:', 164 | ` - http://${res.remote}:${config.server.port}` 165 | ] 166 | : [], 167 | '-'.repeat(30) 168 | ] 169 | logs.forEach(i => { 170 | logger.info(chalk.rgb(255, 105, 180)(i)) 171 | }) 172 | }) 173 | } 174 | }) 175 | } 176 | 177 | export async function getIps () { 178 | const custom = config.server.host === 'auto' ? '' : config.server.host 179 | const networkInterfaces = os.networkInterfaces() 180 | const local = Object.values(networkInterfaces).flat().filter(i => i?.family === 'IPv4' && !i.internal).map(i => i?.address).filter(Boolean) as string[] 181 | const url = [ 182 | { 183 | api: 'https://v4.ip.zxinc.org/info.php?type=json', 184 | key: 'data.myip' 185 | }, 186 | { 187 | api: 'https://ipinfo.io/json', 188 | key: 'ip' 189 | } 190 | ] 191 | const redisKey = 'YePanel:ip' 192 | const remote = await redis.get(redisKey) as string | null 193 | if (remote || version.isDev) { 194 | return { 195 | local, 196 | remote, 197 | custom 198 | } 199 | } 200 | for (const i of url) { 201 | try { 202 | const info = await fetch(i.api).then(res => res.json()).then(res => { 203 | const remote = i.key.split('.').reduce((prev, curr) => prev && prev[curr], res) as string 204 | if (remote) { 205 | redis.set(redisKey, remote, { EX: 60 * 60 * 24 * 3 }) 206 | return { 207 | local, 208 | remote 209 | } 210 | } else { 211 | return { 212 | local, 213 | remote: '' 214 | } 215 | } 216 | }) 217 | if (info.remote) { 218 | return { 219 | ...info, 220 | custom 221 | } 222 | } 223 | } catch { /* empty */ } 224 | } 225 | return { 226 | local, 227 | remote: '', 228 | custom 229 | } 230 | } 231 | 232 | const objectToString = (obj: any): string => { 233 | if (Array.isArray(obj)) { 234 | return '[ ' + obj.map(objectToString).join(' ') + ' ]' 235 | } 236 | if (typeof obj === 'object') { 237 | try { 238 | return `{ ${Object.entries(obj) 239 | .map(([key, value]) => { 240 | const formattedValue = 241 | typeof value === 'string' ? `'${value}'` : value 242 | return `${key}: ${formattedValue}` 243 | }) 244 | .join(', ')} }` 245 | } catch { 246 | return String(obj) 247 | } 248 | } 249 | return obj 250 | } 251 | -------------------------------------------------------------------------------- /src/api/login/index.ts: -------------------------------------------------------------------------------- 1 | import { randomUUID } from 'crypto' 2 | import { RouteOptions } from 'fastify' 3 | import { config, version } from '@/common' 4 | import fs from 'fs' 5 | import { join } from 'path' 6 | import { setConfigDataCache, setPluginIconPath, addCustomRoutes } from '@/api/plugins' 7 | 8 | const token: { [uin: string]: string } = {} 9 | 10 | export const getToken = (uin: string) => token[uin] 11 | 12 | export const tokenAuth = (accesstoken: string) => { 13 | if (!accesstoken) return false 14 | const [accessToken, ...args] = accesstoken.split('.') 15 | const uin = args?.join('.') 16 | if (!getToken(uin) || accessToken !== getToken(uin)) { 17 | return false 18 | } 19 | return true 20 | } 21 | 22 | const fastLoginKey: { [key: string]: string } = {} 23 | 24 | export const createLoginKey = (uin: string) => { 25 | const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' 26 | let result = '' 27 | 28 | for (let i = 0; i < 6; i++) { 29 | const randomIndex = Math.floor(Math.random() * chars.length) 30 | result += chars[randomIndex] 31 | } 32 | fastLoginKey[result] = uin 33 | setTimeout(() => { 34 | delete fastLoginKey[result] 35 | }, 1000 * 60 * 5) // 5分钟过期 36 | return result 37 | } 38 | 39 | export default [ 40 | { 41 | url: '/login', 42 | method: 'post', 43 | preHandler: (request, reply, done) => done(), 44 | handler: ({ body }) => { 45 | let { username: uin, password: inputPassword } = body as { username: string, password: string } 46 | const account = (() => { 47 | if (fastLoginKey[uin]) { 48 | const bot = Bot[fastLoginKey[uin]] 49 | uin = fastLoginKey[uin] 50 | delete fastLoginKey[uin] 51 | return { 52 | password: '', 53 | nickname: bot.nickname, 54 | avatarUrl: bot.avatar, 55 | uin: bot.uin 56 | } 57 | } 58 | const account = config.server.password 59 | if (account[uin]?.enable) { 60 | return account[uin] 61 | } else if (Bot[uin]) { 62 | const bot = Bot[uin] 63 | return { 64 | password: account.default.password, 65 | nickname: account.default.nickname || bot.nickname, 66 | avatarUrl: account.default.avatarUrl || bot.avatar 67 | } 68 | } else { 69 | return {} 70 | } 71 | })() 72 | if (account.password != inputPassword) { 73 | return { 74 | message: '账号或密码错误' 75 | } 76 | } 77 | token[uin] = randomUUID() 78 | return { 79 | success: true, 80 | data: { 81 | avatar: account.avatarUrl, 82 | username: 'admin', 83 | nickname: account.nickname, 84 | roles: ['admin'], 85 | accessToken: token[uin] + '.' + uin, 86 | refreshToken: token[uin] + ':refreshToken.' + uin, 87 | uin, 88 | expires: '2030/10/30 00:00:00' 89 | } 90 | } 91 | } 92 | }, 93 | { 94 | url: '/get-async-routes', 95 | method: 'get', 96 | handler: async () => { 97 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 98 | const data: any = { 99 | router: [], 100 | code: {}, 101 | guoba: {} 102 | } 103 | const pluginsPath = join(version.BotPath, 'plugins') 104 | if (!fs.existsSync(pluginsPath)) { 105 | return { 106 | success: true, 107 | data 108 | } 109 | } 110 | // 读取plugins目录下所有文件名 111 | const pluginList = fs.readdirSync(pluginsPath) 112 | for (const plugin of pluginList) { 113 | const pluginPath = join(version.BotPath, 'plugins', plugin) 114 | // 判断是否为目录 115 | if (fs.statSync(pluginPath).isDirectory()) { 116 | const YePanelPath = join(pluginPath, 'YePanel') 117 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 118 | const router: any = {} 119 | // 判断是否存在YePanel目录 120 | if (fs.existsSync(YePanelPath)) { 121 | try { 122 | // 动态导入YePanel目录下的index.js文件 123 | const option = (await import(`file://${join(YePanelPath, 'index.js')}?t=${Date.now()}`)).default 124 | // 设置一级路由为插件名 125 | Object.assign(router, option.router) 126 | router.path = `/${plugin}` 127 | router.name = plugin 128 | // 给二级路由添加插件名的前缀 129 | for (const i of router.children) { 130 | i.path = `/${plugin}${i.path}` 131 | i.name = `${plugin}/${i.name}` 132 | i.component = 'plugins/YePanel/index' 133 | } 134 | data.code[plugin] = { main: {}, components: {} } 135 | // 收集Vue页面 136 | fs.readdirSync(YePanelPath).forEach(file => { 137 | if (file.endsWith('.vue')) { 138 | data.code[plugin].main[file.replace('.vue', '')] = fs.readFileSync(join(YePanelPath, file), 'utf-8') 139 | } 140 | }) 141 | // 收集Vue组件 142 | const componentPath = join(YePanelPath, 'components') 143 | if (fs.existsSync(componentPath) && fs.statSync(componentPath).isDirectory()) { 144 | fs.readdirSync(componentPath).forEach(file => { 145 | data.code[plugin].components[file.replace('.vue', '')] = fs.readFileSync(join(componentPath, file), 'utf-8') 146 | }) 147 | } 148 | addCustomRoutes(plugin, option.api) 149 | } catch { /* empty */ } 150 | } 151 | 152 | // 判断是否存在guoba.support.js 153 | const guobaSupportPath = join(pluginPath, 'guoba.support.js') 154 | if (fs.existsSync(guobaSupportPath)) { 155 | try { 156 | const supportGuoba = (await import(`file://${guobaSupportPath}?t=${Date.now()}`)).supportGuoba 157 | const { pluginInfo, configInfo: { setConfigData } } = supportGuoba() 158 | setConfigDataCache(plugin, setConfigData) 159 | if (pluginInfo.iconPath) { 160 | if (fs.existsSync(pluginInfo.iconPath)) { 161 | setPluginIconPath(plugin, pluginInfo.iconPath) 162 | pluginInfo.iconPath = `api:/image/${plugin}` 163 | } else { 164 | delete pluginInfo.iconPath 165 | } 166 | } 167 | if (!Array.isArray(pluginInfo.author)) { 168 | pluginInfo.author = [pluginInfo.author] 169 | } 170 | if (!Array.isArray(pluginInfo.authorLink)) { 171 | pluginInfo.authorLink = [pluginInfo.authorLink] 172 | } 173 | data.guoba[plugin] = { pluginInfo } 174 | // 已经有路由, 并且没有设置页面 175 | if (router.name) { 176 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 177 | if (!router.children.some((i: any) => i.name === `${plugin}/setting`)) { 178 | router.children.push({ 179 | path: `/${plugin}/setting`, 180 | name: `${plugin}/setting`, 181 | component: 'plugins/setting/index', 182 | meta: { 183 | title: '设置', 184 | icon: pluginInfo.iconPath || pluginInfo.icon, 185 | showParent: true 186 | } 187 | }) 188 | } 189 | } else { 190 | // 没有路由, 则创建 191 | Object.assign(router, { 192 | path: `/${plugin}`, 193 | name: plugin, 194 | meta: { 195 | title: pluginInfo.title, 196 | icon: pluginInfo.iconPath || pluginInfo.icon 197 | }, 198 | children: [ 199 | { 200 | path: `/${plugin}/setting`, 201 | name: `${plugin}/setting`, 202 | component: 'plugins/setting/index', 203 | meta: { 204 | title: pluginInfo.title, 205 | icon: pluginInfo.iconPath || pluginInfo.icon 206 | } 207 | } 208 | ] 209 | }) 210 | } 211 | } catch { /* empty */ } 212 | } 213 | 214 | if (router.name) { 215 | data.router.push(router) 216 | } 217 | } 218 | } 219 | return { 220 | success: true, 221 | data 222 | } 223 | } 224 | } 225 | ] as RouteOptions[] 226 | -------------------------------------------------------------------------------- /src/api/database/sqlite/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import fs from 'fs' 3 | import { join } from 'path' 4 | import { Sequelize } from 'sequelize' 5 | import { RouteOptions } from 'fastify' 6 | 7 | type tableInfo = { 8 | pk: 0 | 1, 9 | notnull: 0 | 1 | null, 10 | dflt_value: string | null, 11 | type: string, 12 | name: string, 13 | autoincrement: boolean, 14 | } 15 | 16 | const sequelizeCache: { [key: string]: { 17 | timer: NodeJS.Timeout, 18 | instance: Sequelize, 19 | total: { 20 | [key: string]: number 21 | }, 22 | tableInfo: { 23 | [key: string]: tableInfo 24 | } 25 | } } = {} 26 | 27 | function findSqlitePath (directory: string): string[] { 28 | const dbPath = [] 29 | const items = fs.readdirSync(directory) 30 | 31 | for (const item of items) { 32 | if (['node_modules', '.git', '.vscode'].includes(item)) { 33 | continue 34 | } 35 | const fullPath = join(directory, item) 36 | let stat: fs.Stats | undefined 37 | try { 38 | stat = fs.statSync(fullPath) 39 | } catch { 40 | continue 41 | } 42 | 43 | if (stat.isDirectory()) { 44 | dbPath.push(...findSqlitePath(fullPath)) 45 | } else if (stat.isFile() && item.endsWith('.db')) { 46 | if (/SQLite format 3/.test(fs.readFileSync(fullPath).subarray(0, 16).toString())) { 47 | dbPath.push(fullPath.replace(/\\/g, '/')) 48 | } 49 | } 50 | } 51 | return dbPath 52 | } 53 | 54 | function getSequelize (path: string) { 55 | if (!sequelizeCache[path]) { 56 | sequelizeCache[path] = { 57 | timer: setTimeout(() => { 58 | delete sequelizeCache[path] 59 | }, 1000 * 60 * 10), 60 | instance: new Sequelize({ 61 | dialect: 'sqlite', 62 | storage: path 63 | }), 64 | total: {}, 65 | tableInfo: {} 66 | } 67 | } 68 | clearTimeout(sequelizeCache[path].timer) 69 | sequelizeCache[path].timer = setTimeout(() => { 70 | delete sequelizeCache[path] 71 | }, 1000 * 60 * 10) 72 | return sequelizeCache[path] 73 | } 74 | 75 | function getFormattedDate () { 76 | const now = new Date() 77 | const datePart = now.toISOString().slice(0, 23) 78 | const timeZoneOffset = now.getTimezoneOffset() 79 | const offsetHours = String(Math.abs(Math.floor(timeZoneOffset / 60))).padStart(2, '0') 80 | const offsetMinutes = String(Math.abs(timeZoneOffset % 60)).padStart(2, '0') 81 | const sign = timeZoneOffset > 0 ? '-' : '+' 82 | 83 | return `${datePart.replace('T', ' ')} ${sign}${offsetHours}:${offsetMinutes}` 84 | } 85 | 86 | export default [ 87 | { 88 | url: '/get-sqlite-path', 89 | method: 'get', 90 | handler: () => { 91 | return { 92 | success: true, 93 | data: findSqlitePath('./') 94 | } 95 | } 96 | }, 97 | { 98 | url: '/get-sqlite-table', 99 | method: 'get', 100 | handler: async ({ query }) => { 101 | const { path } = query as { path: string } 102 | const sequelize = getSequelize(path).instance 103 | const [results] = await sequelize.query('SELECT name FROM sqlite_master WHERE type=\'table\';') as [{ name: string }[], unknown] 104 | return { 105 | success: true, 106 | data: results.map(item => (item.name)).filter(item => item !== 'sqlite_sequence') 107 | } 108 | } 109 | }, 110 | { 111 | url: '/get-sqlite-table-data', 112 | method: 'get', 113 | handler: async ({ query }) => { 114 | const { path, table, pageSize, pageNum, search } = query as { path: string, table: string, pageSize: number, pageNum: number, search: string } 115 | const offset = (pageNum * pageSize) - pageSize 116 | const { instance: sequelize, total, tableInfo } = getSequelize(path) 117 | if (!total[table]) { 118 | const [totalResults] = await sequelize.query(`SELECT COUNT(*) AS total FROM '${table}';`) as [{ total: number }[], unknown] 119 | total[table] = totalResults[0].total 120 | } 121 | let count = total[table] 122 | if (!tableInfo[table]) { 123 | const [tableInfoResults] = await sequelize.query(`PRAGMA table_info('${table}');`) as [tableInfo[], unknown] 124 | const info: any = {} 125 | for (const item of tableInfoResults) { 126 | if (item.pk) { 127 | const [results] = await sequelize.query(`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = '${table}';`) as [{ sql: string }[], unknown] 128 | item.autoincrement = /AUTOINCREMENT/.test(results[0].sql) 129 | } 130 | info[item.name] = item 131 | } 132 | tableInfo[table] = info 133 | } 134 | const sql = `SELECT * FROM '${table}' ${search ? `WHERE ${search}` : ''} LIMIT ${pageSize} OFFSET ${offset};` 135 | const [results] = await sequelize.query(sql) as [{ [key: string]: any }[], unknown] 136 | if (search) { 137 | const [searchResults] = await sequelize.query(`SELECT COUNT(*) AS total FROM '${table}' WHERE ${search};`) as [{ total: number }[], unknown] 138 | count = searchResults[0].total 139 | } 140 | return { 141 | success: true, 142 | data: results, 143 | total: count, 144 | tableInfo: tableInfo[table] 145 | } 146 | } 147 | }, 148 | { 149 | url: '/set-sqlite-table-data', 150 | method: 'post', 151 | handler: async ({ body }) => { 152 | const { path, table, data } = body as { path: string, table: string, data: { [key: string]: string | number | boolean } } 153 | const { instance: sequelize, tableInfo, total } = getSequelize(path) 154 | const type = data.createdAt ? 'update' : 'insert' 155 | delete data.createdAt 156 | delete data.updatedAt 157 | const keys = Object.keys(data) 158 | const values = Object.values(data) 159 | const updatedAt = getFormattedDate() 160 | const pk = keys.find(key => (tableInfo[table] as any)[key].pk) || 'createdAt' 161 | // 如果有创建时间就是修改 162 | const sql = type === 'update' 163 | ? `UPDATE '${table}' SET ${keys.map((key) => `${key} = ?`).join(', ')}, updatedAt = ? WHERE ${pk} = ?` 164 | : `INSERT INTO '${table}' (${keys.join(', ')}, createdAt, updatedAt) VALUES (${keys.map(() => '?').join(', ')}, ?, ?)` 165 | try { 166 | const [results, metadata] = await sequelize.query( 167 | sql, 168 | { 169 | replacements: type === 'update' ? [...values, updatedAt, data[pk]] : [...values, updatedAt, updatedAt] 170 | } 171 | ) 172 | const [totalResults] = await sequelize.query(`SELECT COUNT(*) AS total FROM '${table}';`) as [{ total: number }[], unknown] 173 | total[table] = totalResults[0].total 174 | return { 175 | success: true, 176 | results, 177 | metadata 178 | } 179 | } catch (error) { 180 | return { 181 | success: false, 182 | message: (error as Error).message 183 | } 184 | } 185 | } 186 | }, 187 | { 188 | url: '/delete-sqlite-table-data', 189 | method: 'post', 190 | handler: async ({ body }) => { 191 | const { path, table, data } = body as { path: string, table: string, data: { [key: string]: string | number | boolean } } 192 | const { instance: sequelize, tableInfo, total } = getSequelize(path) 193 | const keys = Object.keys(data) 194 | const pk = keys.find(key => (tableInfo[table] as any)[key].pk) || 'createdAt' 195 | try { 196 | const [results, metadata] = await sequelize.query( 197 | `DELETE FROM '${table}' WHERE ${pk} = ?`, 198 | { 199 | replacements: [data[pk]] 200 | } 201 | ) 202 | const [totalResults] = await sequelize.query(`SELECT COUNT(*) AS total FROM '${table}';`) as [{ total: number }[], unknown] 203 | total[table] = totalResults[0].total 204 | return { 205 | success: true, 206 | results, 207 | metadata 208 | } 209 | } catch (error) { 210 | return { 211 | success: false, 212 | message: (error as Error).message 213 | } 214 | } 215 | } 216 | }, 217 | { 218 | url: '/execute-sql', 219 | method: 'post', 220 | handler: async ({ body }) => { 221 | const { path, sql } = body as { path: string, sql: string } 222 | const { instance: sequelize } = getSequelize(path) 223 | try { 224 | const [results, metadata] = await sequelize.query(sql) 225 | return { 226 | success: true, 227 | data: { 228 | results, 229 | metadata 230 | } 231 | } 232 | } catch (error) { 233 | return { 234 | success: false, 235 | message: (error as Error).message 236 | } 237 | } 238 | } 239 | } 240 | ] as RouteOptions[] 241 | -------------------------------------------------------------------------------- /src/api/database/redis/index.ts: -------------------------------------------------------------------------------- 1 | import { RouteOptions } from 'fastify' 2 | import { utils } from '@/common' 3 | import { createClient } from 'redis' 4 | const cfg = await (async ()=>{ 5 | try { 6 | // @ts-ignore 7 | return (await import('../../../../../../lib/config/config.js')).default 8 | } catch { 9 | return { redis: {} } 10 | } 11 | })() 12 | 13 | type treeNode = { 14 | label: string 15 | key: string 16 | children: treeNode[] 17 | } 18 | 19 | const clients: {[key: string]: { client: ReturnType, timer: NodeJS.Timeout}} = {} 20 | 21 | const getRedisClient = async (db: string, host?: string, port?: number, username?: string, password?: string): Promise | null> => { 22 | if (!host) { 23 | host = cfg.redis.host 24 | port = cfg.redis.port 25 | username = cfg.redis.username 26 | password = cfg.redis.password 27 | } 28 | const key = `${host}:${port}:${username}:${password}:${db}` 29 | if (clients[key]) { 30 | return clients[key].client 31 | } 32 | try { 33 | const client = await createClient({ 34 | socket: { 35 | host, 36 | port 37 | }, 38 | username, 39 | password, 40 | database: Number(db) 41 | }).connect() 42 | clients[key] = { 43 | client, 44 | timer: setTimeout(() => { 45 | client.disconnect() 46 | delete clients[key] 47 | }, 1000 * 60 * 30) // 缓存30分钟 48 | } 49 | return client 50 | } catch { 51 | return null 52 | } 53 | } 54 | 55 | export async function getRedisKeys (sep = ':', db: string, host: string, port: number, username: string, password: string, lazy = false) { 56 | const redis = await getRedisClient(db, host, port, username, password) 57 | if (!redis) { 58 | return [] 59 | } 60 | function addKeyToTree (tree: treeNode[], parts: string[], fullKey: string) { 61 | if (parts.length === 0) return 62 | 63 | const [firstPart, ...restParts] = parts 64 | let node = tree.find((item) => item.label === firstPart) 65 | 66 | const currentKey = fullKey ? `${fullKey}:${firstPart}` : firstPart 67 | 68 | if (!node) { 69 | node = { 70 | label: firstPart, 71 | key: currentKey, 72 | children: [] 73 | } 74 | tree.push(node) 75 | } 76 | 77 | addKeyToTree(node.children, restParts, currentKey) 78 | } 79 | const keysTree: treeNode[] = [] 80 | let cursor = 0 81 | do { 82 | const MATCH = !lazy ? '*' : sep ? `${sep}:*` : '*' 83 | const res = await redis.scan(cursor, { MATCH, COUNT: 10000 }) 84 | cursor = res.cursor 85 | const keys = res.keys 86 | 87 | keys.forEach((key: string) => { 88 | if (lazy) { 89 | if (sep) { 90 | if (key.startsWith(sep + ':')) { 91 | const remaining = key.substring(sep.length + 1) 92 | const nextPart = remaining.split(':')[0] 93 | if (nextPart && !keysTree.some(i => i.label === nextPart)) { 94 | keysTree.push({ 95 | label: nextPart, 96 | key: `${sep}:${nextPart}`, 97 | children: [] 98 | }) 99 | } 100 | } 101 | } else { 102 | if (key.includes(':')) { 103 | const firstPart = key.split(':')[0] 104 | if (!keysTree.some(i => i.label === firstPart)) { 105 | keysTree.push({ 106 | label: firstPart, 107 | key: firstPart, 108 | children: [] 109 | }) 110 | } 111 | } else if (!keysTree.some(i => i.label === key)) { 112 | keysTree.push({ 113 | label: key, 114 | key, 115 | children: [] 116 | }) 117 | } 118 | } 119 | } else { 120 | const parts = key.split(sep) 121 | addKeyToTree(keysTree, parts, '') 122 | } 123 | }) 124 | } while (cursor != 0) 125 | 126 | return keysTree 127 | } 128 | 129 | export default [ 130 | { 131 | url: '/get-redis-info', 132 | method: 'get', 133 | handler: async () => { 134 | const data = await redis.info() 135 | const redisInfo: { [key: string]: string } = {} 136 | data.split('\n').forEach((line: string) => { 137 | if (line && !line.startsWith('#') && line.includes(':')) { 138 | const index = line.indexOf(':') 139 | const key = line.substring(0, index) 140 | const value = line.substring(index + 1) 141 | redisInfo[key.trim()] = value.trim() 142 | } 143 | }) 144 | redisInfo.uptime_formatted = utils.formatDuration(Number(redisInfo.uptime_in_seconds)) 145 | redisInfo.slelct_database = cfg.redis.db 146 | const [, databases] = await redis.sendCommand(['CONFIG', 'GET', 'databases']) as [string, string] 147 | redisInfo.databases = databases 148 | return { 149 | success: true, 150 | data: redisInfo 151 | } 152 | } 153 | }, 154 | { 155 | url: '/get-redis-connection', 156 | method: 'get', 157 | handler: async ({ query }) => { 158 | const { host, port, db, username, password } = query as { host: string, port: number, db: string, username: string, password: string } 159 | const redis = await getRedisClient(db, host, port, username, password) 160 | if (!redis) { 161 | return { 162 | success: false, 163 | message: '测试连接redis失败' 164 | } 165 | } 166 | return { 167 | success: true 168 | } 169 | } 170 | }, 171 | { 172 | url: '/get-redis-keys', 173 | method: 'get', 174 | handler: async ({ query }) => { 175 | const { sep, db, lazy, host, port, username, password } = query as { sep: string, db: string, lazy: boolean, host: string, port: number, username: string, password: string } 176 | const keys = await getRedisKeys(sep, db, host, port, username, password, lazy) 177 | return { 178 | success: true, 179 | data: keys 180 | } 181 | } 182 | }, 183 | { 184 | url: '/get-redis-value', 185 | method: 'get', 186 | handler: async ({ query }) => { 187 | const { key, db, host, port, username, password } = query as { key: string, db: string, host: string, port: number, username: string, password: string } 188 | const redis = await getRedisClient(db, host, port, username, password) 189 | if (!redis) { 190 | return { 191 | success: false, 192 | message: '连接redis失败' 193 | } 194 | } 195 | try { 196 | const value = await redis.get(key) 197 | const expire = await redis.ttl(key) 198 | return { 199 | success: true, 200 | data: { 201 | key, 202 | value, 203 | expire 204 | } 205 | } 206 | } catch { 207 | const type = await redis.type(key) 208 | return { 209 | success: false, 210 | message: `暂未支持${type}类型,目前仅支持查看和修改string类型` 211 | } 212 | } 213 | } 214 | }, 215 | { 216 | url: '/set-redis-value', 217 | method: 'post', 218 | handler: async ({ body }) => { 219 | const { key: oldKey, value, db, expire, newKey, host, port, username, password } = body as { key: string, value: string, db: string, expire: number, newKey: string, host: string, port: number, username: string, password: string } 220 | const redis = await getRedisClient(db, host, port, username, password) 221 | if (!redis) { 222 | return { 223 | success: false, 224 | message: '连接redis失败' 225 | } 226 | } 227 | const key = newKey || oldKey 228 | if (newKey) { 229 | await redis.rename(oldKey, newKey) 230 | } 231 | if (expire === -2) { 232 | await redis.sendCommand(['GETSET', key, value]) 233 | } else if (expire === -1) { 234 | await redis.set(key, value) 235 | } else { 236 | await redis.set(key, value, { EX: expire }) 237 | } 238 | return { 239 | success: true, 240 | data: { 241 | key, 242 | value 243 | } 244 | } 245 | } 246 | }, 247 | { 248 | url: '/delete-redis-keys', 249 | method: 'post', 250 | handler: async ({ body }) => { 251 | const { keys, db, host, port, username, password } = body as { keys: string[], db: string, host: string, port: number, username: string, password: string } 252 | const errorKeys = [] 253 | const successKeys = [] 254 | const redis = await getRedisClient(db, host, port, username, password) 255 | if (!redis) { 256 | return { 257 | success: false, 258 | message: '连接redis失败' 259 | } 260 | } 261 | for (const key of keys) { 262 | try { 263 | await redis.del(key) 264 | successKeys.push(key) 265 | } catch { 266 | errorKeys.push(key) 267 | } 268 | } 269 | return { 270 | success: true, 271 | data: { 272 | errorKeys, 273 | successKeys 274 | } 275 | } 276 | } 277 | } 278 | ] as RouteOptions[] 279 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # YePanel 2 | 3 |
4 | 5 | **提供 web 面板管理功能** 6 | 7 |
8 | 9 | ![GitHub release (latest by date)](https://img.shields.io/github/v/release/XasYer/YePanel) 10 | ![GitHub stars](https://img.shields.io/github/stars/XasYer/YePanel?style=social) 11 | ![GitHub forks](https://img.shields.io/github/forks/XasYer/YePanel?style=social) 12 | ![GitHub license](https://img.shields.io/github/license/XasYer/YePanel) 13 | ![GitHub issues](https://img.shields.io/github/issues/XasYer/YePanel) 14 | ![GitHub pull requests](https://img.shields.io/github/issues-pr/XasYer/YePanel) 15 |
16 | 17 | 18 | 19 |
20 | 21 | ![Star History Chart](https://api.star-history.com/svg?repos=XasYer/YePanel&type=Date) 22 | 23 | ## 安装 24 | 25 | 1. clone build 分支 (推荐) 26 | 27 | ```sh 28 | git clone --depth=1 -b build https://github.com/XasYer/YePanel.git ./plugins/YePanel/ 29 | ``` 30 | 31 | > 如果你的网络环境较差,无法连接到 Github,可以使用 Gitee 镜像仓库,可能会更新不及时 32 | > 33 | > ```sh 34 | > git clone --depth=1 -b build https://gitee.com/xiaoye12123/YePanel.git ./plugins/YePanel/ 35 | > ``` 36 | 37 | 安装依赖 38 | 39 | ```sh 40 | pnpm install --filter=YePanel -P 41 | ``` 42 | 43 | 2. clone main 分支自行编译 (不推荐) 44 | ```sh 45 | git clone --depth=1 https://github.com/XasYer/YePanel.git ./plugins/YePanel/ 46 | cd plugins/YePanel/ 47 | pnpm install 48 | pnpm run build 49 | ``` 50 | 51 | ## 使用 52 | 53 | 快捷登录: **私聊bot发送`#小叶面板登录`** 54 | 55 | YePanel 目前只提供 api 接口,需要安装web面板后才能使用。需要服务器有公网 ip 或 gui,才能访问面板。 56 | 57 | 用户名和密码可在`config/server.yaml`中编辑,密码默认为`123456`,用户名可以为任何已登录的 Bot 账号, 58 | api 接口地址为`http://ip:port`,**ip 为服务器公网 ip,port 更换为配置文件中设置的端口,如果在外网环境中访问面板,需要开放端口**。 59 | 60 | 使用方式为 1 或 2 时, 可以直接访问`http://ip:port`。 61 | 62 | 95 | 96 | ### 安装 web 面板 97 | 98 | clone gh-pages 分支到 plugins 目录下以`YePanel-Web`命名, 此时启动时会自动挂载到 server.yaml 中配置的端口下。 99 | 100 | ```sh 101 | git clone --depth=1 -b gh-pages https://github.com/XasYer/YePanel.git ./plugins/YePanel-Web/ 102 | ``` 103 | 104 | > [!TIP] 105 | > 网络问题导致 clone 失败时, 可以使用以下命令克隆 106 | > 107 | > ```sh 108 | > git clone --depth=1 -b gh-pages https://gitee.com/xiaoye12123/YePanel.git ./plugins/YePanel-Web/ 109 | > ``` 110 | 111 | ~~放到 plugins 目录蹭一下#全部更新~~ 112 | 113 | ### clone web 分支自行编译 (不推荐) 114 | 115 | 在任意目录下执行以下命令 116 | 117 | ```sh 118 | git clone --depth=1 -b web https://github.com/XasYer/YePanel.git 119 | cd YePanel 120 | pnpm install 121 | ``` 122 | 123 | 调试 124 | 125 | ```sh 126 | pnpm run dev 127 | ``` 128 | 129 | 编译 130 | 131 | ```sh 132 | pnpm run build 133 | ``` 134 | 135 | ## 联系方式 136 | 137 | - QQ 群: [741577559](http://qm.qq.com/cgi-bin/qm/qr?_wv=1027&k=IvPaOVo_p-6n--FaLm1v39ML9EZaBRCm&authKey=YPs0p%2FRh8MGPQrWZgn99fk4kGB5PtRAoOYIUqK71FBsBYCDdekxCEHFFHnznpYA1&noverify=0&group_code=741577559) 138 | 139 | ## 页面预览 140 | 141 |
142 | 登录页面 143 | 144 | ![登录页面](https://cdn.jsdelivr.net/gh/XasYer/YePanel@web/public/login.png) 145 | 146 |
147 | 148 |
149 | 面板首页 150 | 151 | ![面板首页](https://cdn.jsdelivr.net/gh/XasYer/YePanel@web/public/welcome.png) 152 | 153 |
154 | 155 |
156 | 沙盒测试 157 | 158 | ![沙盒测试](https://cdn.jsdelivr.net/gh/XasYer/YePanel@web/public/sendbox.png) 159 | 160 |
161 | 162 |
163 | 数据统计 164 | 165 | ![数据统计](https://cdn.jsdelivr.net/gh/XasYer/YePanel@web/public/stats.png) 166 | 167 |
168 | 169 |
170 | 插件列表 171 | 172 | ![插件列表](https://cdn.jsdelivr.net/gh/XasYer/YePanel@web/public/plugins.png) 173 | 174 |
175 | 176 |
177 | 远程终端 178 | 179 | ![远程终端](https://cdn.jsdelivr.net/gh/XasYer/YePanel@web/public/terminal.png) 180 | 181 |
182 | 183 |
184 | 实时日志 185 | 186 | ![实时日志](https://cdn.jsdelivr.net/gh/XasYer/YePanel@web/public/realtimeLog.png) 187 | 188 |
189 | 190 |
191 | 文件管理 192 | 193 | ![文件管理](https://cdn.jsdelivr.net/gh/XasYer/YePanel@web/public/files.png) 194 | 195 |
196 | 197 |
198 | redis 199 | 200 | ![redis信息](https://cdn.jsdelivr.net/gh/XasYer/YePanel@web/public/redisInfo.png) 201 | ![redis数据](https://cdn.jsdelivr.net/gh/XasYer/YePanel@web/public/redisData.png) 202 | 203 |
204 | 205 |
206 | sqlite 207 | 208 | ![sqlite](https://cdn.jsdelivr.net/gh/XasYer/YePanel@web/public/sqlite.png) 209 | 210 |
211 | 212 |
213 | vue开发 214 | 215 | ![vue开发](https://cdn.jsdelivr.net/gh/XasYer/YePanel@web/public/vue.png) 216 | 217 |
218 | 219 | ## 插件开发 220 | 221 | ### vue 页面 222 | 223 | 使用[vue3-sfc-loader](https://github.com/FranckFreiburger/vue3-sfc-loader)进行组件加载。 224 | 225 | #### 文件存放位置 226 | 227 | 需要在自己的插件目录下新建`YePanel`文件夹 228 | 229 | 其中包含以下文件: 230 | 231 | 1. `index.js` 用于注册路由以及 api 接口 232 | 2. `*.vue` 页面文件 233 | 3. `components` 组件文件夹(可选) 234 | 235 | #### index.js 236 | 237 | ```js 238 | // 应当默认导出一个对象 239 | export default { 240 | // 前端路由配置 241 | router: { 242 | meta: { 243 | // 路由显示的名字 244 | title: "YePanel", 245 | // 路由图标 https://icon-sets.iconify.design/ 246 | icon: "vaadin:panel", 247 | }, 248 | // 如果插件适配了锅巴,并且有setting.vue页面,则不会显示锅巴页面 249 | // 子路由 仅支持二级路由 250 | children: [ 251 | { 252 | // 显示的url 需要带上 / 253 | path: "/test", 254 | // 对应当前目录下的 .vue文件 即显示的组件 255 | name: "test", 256 | meta: { 257 | // 路由显示的名字 258 | title: "设置", 259 | // 路由图标 https://icon-sets.iconify.design/ 260 | icon: "ant-design:setting-filled", 261 | // 是否显示父级菜单, 如果子路由只有一个的话会生成二级路由 262 | // 如果为false 并且只有一个子路由 则不会显示父级菜单 263 | showParent: true, 264 | }, 265 | }, 266 | ], 267 | }, 268 | // 使用fastify.route注册路由 269 | api: [ 270 | { 271 | // 接口的url 需要带上 / 不用判断是否和其他接口重复 在收集api时会在前带上plugin名字 272 | url: "/get-data", 273 | // 请求方法 274 | method: "post", 275 | // 如果不需要鉴权可以取消这段注释 276 | // preHandler: (request, reply, done) => done(), 277 | // 回调函数 278 | handler: (request, reply) => { 279 | return { 280 | success: true, 281 | }; 282 | }, 283 | // 可以有wsHandler进行ws连接 不需要onopen, 连接即open 284 | // wsHandler: (ws, request) => {} 285 | }, 286 | ], 287 | }; 288 | ``` 289 | 290 | #### \*.vue 291 | 292 | ```vue 293 | 296 | 297 | 323 | ``` 324 | 325 | #### components 文件夹 326 | 327 | 此文件夹下可存放`*.vue`组件文件。 328 | 329 | 可选, 用于存放组件文件。使用时直接导入`yourComponentName.vue`即可, 不需要`./components/`前缀。 330 | 331 | 注意: 组件加载的先后顺序为文件名顺序, 如果在组件加载前导入则会加载失败。 332 | 333 | ## 常见问题 334 | 335 | ### 插件加载错误 336 | 337 | `YePanel Error [ERR_MODULE_NOT_FOUND]: Cannot find module 'Yunzai\plugins\YePanel\lib\index.js' imported from Yunzai\plugins\YePanel\index.js` 338 | 339 | 请检查是不是拉取的 build 分支,而不是 main 分支。 340 | 如果为 main 分支,请自行编译。或删除后重新拉取 build 分支。 341 | 342 | ### 请求/login 失败:Network Error 343 | 344 | 1. 是否开放 server.yaml 中设置的端口 345 | 2. 是否访问地址为`http`而不是`https` 346 | 3. 是否填写错端口号或 ip 地址 347 | 348 | ## 贡献者 349 | 350 | > 🌟 星光闪烁,你们的智慧如同璀璨的夜空。感谢所有为 **YePanel** 做出贡献的人! 351 | 352 | 353 | 354 | 355 | 356 | ![Alt](https://repobeats.axiom.co/api/embed/56c04a0e5e63aef877943a8f31e46278d9c3a6c0.svg "Repobeats analytics image") 357 | 358 | ## 鸣谢 359 | 360 | 感谢以下开源项目: (排名不分先后) 361 | 362 | - [element-plus](https://github.com/element-plus/element-plus) 363 | - [vue-pure-admin](https://github.com/pure-admin/vue-pure-admin) 364 | - [vue3-sfc-loader](https://github.com/FranckFreiburger/vue3-sfc-loader) 365 | - [fastify](https://github.com/fastify/fastify) 366 | - [echarts](https://github.com/apache/echarts) 367 | - [iconify](https://github.com/iconify/iconify) 368 | - [karin-plugin-manage](https://github.com/HalcyonAlcedo/karin-plugin-manage) 369 | - [yenai-plugin](https://github.com/yeyang52/yenai-plugin) 370 | 371 | ## 其他 372 | 373 | 如果觉得此插件对你有帮助的话,可以点一个 star,你的支持就是不断更新的动力~ 374 | -------------------------------------------------------------------------------- /src/api/Bot/sandbox/index.ts: -------------------------------------------------------------------------------- 1 | import { version } from '@/common' 2 | import { RouteOptions } from 'fastify' 3 | import { WebSocket } from 'ws' 4 | import fs from 'fs' 5 | import { randomUUID } from 'crypto' 6 | 7 | export default [ 8 | { 9 | method: 'get', 10 | url: '/sandbox', 11 | handler: () => 'Ciallo~(∠・ω< )⌒☆', 12 | wsHandler: (connection) => { 13 | let uin: string = 'YePanel.sandbox.' 14 | connection.on('message', (message) => { 15 | let data 16 | try { 17 | data = JSON.parse(message.toString()) 18 | } catch { 19 | connection.send(JSON.stringify({ type: 'error', success: false, content: 'Invalid message format' })) 20 | return 21 | } 22 | switch (data.type) { 23 | case 'create': 24 | uin += data.uin 25 | createSendbox(data.uin, data.nickname, data.avatar, connection) 26 | logger.mark(`${uin} 已连接`) 27 | break 28 | case 'message': 29 | createMessage(data.uin, data.userId, data.groupId, data.msgId, data.content, data.permission) 30 | break 31 | case 'poke': 32 | createPoke(data.uin, data.operatorId, data.targetId, data.userId, data.groupId, data.permission) 33 | break 34 | default: 35 | break 36 | } 37 | }) 38 | connection.on('close', () => { 39 | delete Bot[uin] 40 | if (version.BotName === 'Miao') { 41 | if (Array.isArray(Bot.adapter)) { 42 | const index = Bot.adapter.findIndex(i => i == uin) 43 | if (index > -1) { 44 | Bot.adapter.splice(index, 1) 45 | } 46 | } 47 | } else if (version.BotName === 'TRSS') { 48 | const index = (Bot.uin as string[]).findIndex(i => i == uin) 49 | if (index > -1) { 50 | (Bot.uin as string[]).splice(index, 1) 51 | } 52 | } 53 | 54 | logger.mark(`${uin} 已断开连接`) 55 | }) 56 | } 57 | } 58 | ] as RouteOptions[] 59 | 60 | function createSendbox (id: string, nickname: string, avatar: string, ws: WebSocket): void { 61 | const nameList = [ 62 | 'Alice', 63 | 'Ben', 64 | 'Chris', 65 | 'David', 66 | 'Emma', 67 | 'Frank', 68 | 'Grace', 69 | 'Henry', 70 | 'Isabel', 71 | 'Jack', 72 | 'Kevin', 73 | 'Lucy', 74 | 'Michael', 75 | 'Nancy', 76 | 'Olivia', 77 | 'Paul', 78 | 'Quinn', 79 | 'Ryan', 80 | 'Sarah', 81 | 'Tom', 82 | 'Ursula', 83 | 'Victor', 84 | 'William', 85 | 'Xavier', 86 | 'Yvonne', 87 | 'Zoe' 88 | ] 89 | const key = 'YePanel.sandbox.' 90 | const uin = key + id 91 | const bot = Bot[uin] || {} 92 | Bot[uin] = { 93 | uin, 94 | self_id: uin, 95 | nickname: key + (nickname || bot.nickname || id), 96 | avatar: avatar || bot.avatar || '', 97 | adapter: { 98 | id: 'sandbox', 99 | name: 'YePanel' 100 | }, 101 | version: { 102 | version: version.pluginVersion, 103 | id: 'sandbox', 104 | name: 'YePanel' 105 | }, 106 | stat: { 107 | start_time: Date.now() / 1000 108 | }, 109 | pickFriend (userId: string) { 110 | const info = this.fl.get(userId) || {} 111 | return { 112 | info, 113 | ...info, 114 | sendMsg: (message: string) => { 115 | return this.sendPrivateMsg(userId, message) 116 | }, 117 | makeForwardMsg (msg: any) { 118 | return { type: 'node', data: msg } 119 | }, 120 | getInfo () { 121 | return info 122 | }, 123 | poke: () => { 124 | return this.sendPrivateMsg(userId, { type: 'poke', qq: userId }) 125 | } 126 | } 127 | }, 128 | pickUser (userId: string) { 129 | return this.pickFriend(userId) 130 | }, 131 | pickGroup (groupId: string) { 132 | const info = this.gl.get(groupId) || {} 133 | return { 134 | info, 135 | ...info, 136 | sendMsg: (message: string) => { 137 | return this.sendGroupMsg(groupId, message) 138 | }, 139 | makeForwardMsg (msg: any) { 140 | return { type: 'node', data: msg } 141 | }, 142 | getInfo () { 143 | return info 144 | }, 145 | pokeMember: (userId: string) => { 146 | return this.sendGroupMsg(groupId, { type: 'poke', qq: userId }) 147 | } 148 | } 149 | }, 150 | pickMember (groupId: string, userId: string) { 151 | const info = { 152 | ...this.fl.get(userId) || {}, 153 | ...this.gl.get(groupId) || {} 154 | } 155 | return { 156 | info, 157 | ...info, 158 | ...this.pickFriend(userId), 159 | poke: () => this.sendGroupMsg(groupId, { type: 'poke', qq: userId }) 160 | } 161 | }, 162 | sendPrivateMsg (userId: string, message: any) { 163 | const msgId = userId + '.' + randomUUID() 164 | ws.send?.(JSON.stringify({ type: 'friend', id: userId, content: dealMessage(message), msgId })) 165 | logger.info(`${logger.blue(`[${uin} => ${userId}]`)} 发送私聊消息:${JSON.stringify(message).replace(/data:image\/png;base64,.*?(,|]|")/g, 'base64://...$1')}`) 166 | return new Promise((resolve) => resolve({ message_id: msgId })) 167 | }, 168 | sendGroupMsg (groupId: string, message: string) { 169 | const msgId = groupId + '.' + randomUUID() 170 | ws.send?.(JSON.stringify({ type: 'group', id: groupId, content: dealMessage(message), msgId })) 171 | logger.info(`${logger.blue(`[${uin} => ${groupId}]`)} 发送群聊消息:${JSON.stringify(message).replace(/data:image\/png;base64,.*?(,|]|")/g, 'base64://...$1')}`) 172 | return new Promise((resolve) => resolve({ message_id: msgId })) 173 | }, 174 | getFriendList () { 175 | return this.fl 176 | }, 177 | getGroupList () { 178 | return this.gl 179 | }, 180 | getGroupMemberList (groupId: string) { 181 | return this.gml.get(groupId) || new Map() 182 | }, 183 | fl: nameList.reduce((acc, cur) => { 184 | acc.set(cur, { 185 | user_id: cur, 186 | nickname: cur, 187 | bot_id: uin 188 | }) 189 | return acc 190 | }, new Map()), 191 | gl: ['sandbox.group'].reduce((acc, cur) => { 192 | acc.set(cur, { 193 | groupId: cur, 194 | group_name: cur, 195 | bot_id: uin 196 | }) 197 | return acc 198 | }, new Map()), 199 | gml: ['sandbox.group'].reduce((acc, cur) => { 200 | acc.set(cur, nameList.reduce((acc, cur) => { 201 | acc.set(cur, { 202 | user_id: cur, 203 | groupId: cur, 204 | nickname: cur, 205 | bot_id: uin 206 | }) 207 | return acc 208 | }, new Map())) 209 | return acc 210 | }, new Map()) 211 | } 212 | if (version.BotName === 'Miao') { 213 | if (!Bot.adapter) { 214 | Bot.adapter = [Bot.uin] 215 | Bot.adapter.push(uin) 216 | } else if (Array.isArray(Bot.adapter)) { 217 | if (!Bot.adapter.includes(uin)) { 218 | Bot.adapter.push(uin) 219 | } 220 | } 221 | } else if (version.BotName === 'TRSS') { 222 | if (!(Bot.uin as string[]).includes(uin)) { 223 | (Bot.uin as string[]).push(uin) 224 | } 225 | } 226 | } 227 | 228 | function createMessage (id: string, userId: string, groupId: string, msgId: string, content: string, permission: 'owner' | 'admin' | 'user' | 'master' = 'user') { 229 | const key = 'YePanel.sandbox.' 230 | const uin = key + id 231 | const bot = Bot[uin] 232 | const e = { 233 | bot: Bot[uin], 234 | adapter: bot.version, 235 | message_id: msgId, 236 | sender: { 237 | user_id: userId, 238 | nickname: userId, 239 | role: permission 240 | }, 241 | nickname: userId, 242 | user_id: userId, 243 | self_id: uin, 244 | time: Date.now(), 245 | message: [{ type: 'text', text: content }], 246 | raw_message: content, 247 | isMaster: permission === 'master', 248 | post_type: 'message', 249 | ...groupId 250 | ? { 251 | message_type: 'group', 252 | sub_type: 'normal', 253 | group_id: groupId, 254 | group_name: groupId 255 | } 256 | : { 257 | message_type: 'private', 258 | sub_type: 'friend' 259 | }, 260 | friend: bot.pickFriend(userId), 261 | group: groupId ? bot.pickGroup(groupId) : undefined, 262 | member: (groupId && userId) ? bot.pickMember(groupId, userId) : undefined 263 | } 264 | let event = `${e.post_type}.${e.message_type}.${e.sub_type}` 265 | logger.info(`${logger.blue(`[${e.self_id}]`)} ${groupId ? '群' : '好友'}消息:[${groupId ? groupId + ', ' : ''}${e.nickname}] ${e.raw_message}`) 266 | if (version.BotName === 'TRSS') { 267 | Bot.em(event, e) 268 | } else { 269 | // eslint-disable-next-line no-constant-condition 270 | while (true) { 271 | Bot.emit(event, e) 272 | const i = event.lastIndexOf('.') 273 | if (i == -1) break 274 | event = event.slice(0, i) 275 | } 276 | } 277 | } 278 | 279 | function dealMessage (message: any) { 280 | if (!Array.isArray(message)) message = [message] 281 | for (const i in message) { 282 | if (typeof message[i] !== 'object') { 283 | message[i] = { type: 'text', text: message[i] } 284 | } 285 | switch (message[i].type) { 286 | case 'image': 287 | // 不加 file:// 的本地图片路径 288 | if (typeof message[i].file === 'string') { 289 | if (fs.existsSync(message[i].file)) { 290 | message[i].file = `file://${message[i].file}` 291 | } 292 | } 293 | if (message[i].file.startsWith?.('file://')) { 294 | try { 295 | message[i].file = fs.readFileSync(message[i].file.replace('file://', '')) 296 | } catch { 297 | try { 298 | message[i].file = fs.readFileSync(message[i].file.replace('file:///', '')) 299 | } catch { 300 | message[i].file = '' 301 | } 302 | } 303 | } 304 | if (message[i].file.startsWith?.('base64://')) { 305 | message[i].file = `data:image/png;base64,${message[i].file.replace('base64://', '')}` 306 | } 307 | if (Buffer.isBuffer(message[i].file)) { 308 | message[i].file = `data:image/png;base64,${message[i].file.toString('base64')}` 309 | } 310 | break 311 | case 'node': 312 | message = dealMessage(message[i].data.map((i: { message: any }) => i.message)) 313 | break 314 | default: 315 | break 316 | } 317 | } 318 | return message 319 | } 320 | 321 | function createPoke (id: string, operatorId: string, targetId: string, userId: string, groupId?: string, permission: 'owner' | 'admin' | 'user' | 'master' = 'user') { 322 | const key = 'YePanel.sandbox.' 323 | const uin = key + id 324 | const bot = Bot[uin] 325 | const e = { 326 | bot: Bot[uin], 327 | adapter: bot.version, 328 | user_id: groupId ? targetId : userId, 329 | self_id: uin, 330 | isMaster: permission === 'master', 331 | post_type: 'notice', 332 | operator_id: operatorId, 333 | target_id: targetId == id ? uin : targetId, 334 | ...groupId 335 | ? { 336 | notice_type: 'group', 337 | sub_type: 'poke', 338 | groupId, 339 | group_name: groupId 340 | } 341 | : { 342 | notice_type: 'friend', 343 | sub_type: 'poke' 344 | }, 345 | friend: bot.pickFriend(userId), 346 | group: groupId ? bot.pickGroup(groupId) : undefined, 347 | member: (groupId && userId) ? bot.pickMember(groupId, userId) : undefined 348 | } 349 | let event = `${e.post_type}.${e.notice_type}.${e.sub_type}` 350 | logger.info(`${logger.blue(`[${e.self_id}]`)} ${groupId ? '群' : '好友'}事件:[${groupId ? groupId + ', ' : ''}${e.operator_id}] 戳了戳 [${e.target_id}]`) 351 | if (version.BotName === 'TRSS') { 352 | Bot.em(event, e) 353 | } else { 354 | // eslint-disable-next-line no-constant-condition 355 | while (true) { 356 | Bot.emit(event, e) 357 | const i = event.lastIndexOf('.') 358 | if (i == -1) break 359 | event = event.slice(0, i) 360 | } 361 | } 362 | } 363 | -------------------------------------------------------------------------------- /src/api/welcome/index.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import os from 'os' 3 | import _ from 'lodash' 4 | import { join } from 'path' 5 | import { utils, version } from '@/common' 6 | import si from 'systeminformation' 7 | import { RouteOptions } from 'fastify' 8 | import moment from 'moment' 9 | import { execSync, ExecSyncOptionsWithStringEncoding } from 'child_process' 10 | 11 | type systemInfo = { 12 | title: string, 13 | value: number, 14 | color: string, 15 | status?: string, 16 | info: string[], 17 | model?: string 18 | } 19 | 20 | const pluginsCache: { 21 | info: string, 22 | plugins: { 23 | hasPackage: boolean, 24 | hasGit: boolean, 25 | name: string 26 | }[] 27 | } = { 28 | info: '', 29 | plugins: [] 30 | } 31 | 32 | export function getPlugins (force = false) { 33 | if (pluginsCache.plugins.length && !force) { 34 | return pluginsCache 35 | } 36 | // 获取插件数量插件包目录包含package.json或.git目录才被视为一个插件包 37 | const dir = join(version.BotPath, 'plugins') 38 | if (!fs.existsSync(dir)) { 39 | return pluginsCache 40 | } 41 | const dirArr = fs.readdirSync(dir, { withFileTypes: true }) 42 | const exc = ['example'] 43 | const plugins = dirArr.map(i => { 44 | let hasPackage = false 45 | let hasGit = false 46 | if (i.isDirectory()) { 47 | if (fs.existsSync(join(dir, i.name, 'package.json')) && !exc.includes(i.name)) { 48 | hasPackage = true 49 | } 50 | const gitPath = join(dir, i.name, '.git') 51 | if (fs.existsSync(gitPath) && fs.statSync(gitPath).isDirectory()) { 52 | hasGit = true 53 | } 54 | } 55 | return { 56 | hasPackage, 57 | hasGit, 58 | name: i.name 59 | } 60 | }).filter(i => i.hasPackage || i.hasGit) 61 | // 获取js插件数量,以.js结尾的文件视为一个插件 62 | const jsDir = join(dir, 'example') 63 | let js = 0 64 | try { 65 | js = fs.readdirSync(jsDir) 66 | ?.filter(item => item.endsWith('.js')) 67 | ?.length 68 | } catch { /* empty */ } 69 | pluginsCache.plugins = plugins 70 | pluginsCache.info = `${plugins.length} plugins | ${js} js` 71 | return pluginsCache 72 | } 73 | 74 | const getColor = (value: number) => { 75 | if (value >= 90) { 76 | return '#d56565' 77 | } else if (value >= 70) { 78 | return '#FFD700' 79 | } else { 80 | return '#73a9c6' 81 | } 82 | } 83 | 84 | const infoCache: { 85 | arch?: string, 86 | hostname?: string, 87 | release?: string, 88 | cpu?: string 89 | gpu?: string, 90 | node?: string, 91 | v8?: string, 92 | git?: string, 93 | } = {} 94 | 95 | export default [ 96 | { 97 | url: '/get-system-info', 98 | method: 'get', 99 | handler: async () => { 100 | if (!infoCache.arch) { 101 | const { node, v8, git } = await si.versions('node,v8,git') 102 | Object.assign(infoCache, { 103 | arch: `${os.type()} ${os.arch()}`, 104 | hostname: os.hostname(), 105 | release: os.release(), 106 | node, 107 | v8, 108 | git 109 | }) 110 | } 111 | const plugins = getPlugins() 112 | const info: {key: string, value?: string}[] = [] 113 | info.push({ key: '操作系统', value: infoCache.arch }) 114 | info.push({ key: '主机名称', value: infoCache.hostname }) 115 | info.push({ key: '系统版本', value: infoCache.release }) 116 | info.push({ key: '运行时间', value: utils.formatDuration(os.uptime()) }) 117 | info.push({ key: '插件数量', value: plugins.info }) 118 | info.push({ key: `${version.BotName}-Yunzai`, value: version.BotVersion }) 119 | info.push({ key: 'Node', value: infoCache.node }) 120 | info.push({ key: 'V8', value: infoCache.v8 }) 121 | info.push({ key: 'Git', value: infoCache.git }) 122 | return { 123 | success: true, 124 | data: { 125 | info: info.filter(i => i.value), 126 | plugins: plugins.plugins, 127 | BotName: version.BotName 128 | } 129 | } 130 | } 131 | }, 132 | { 133 | url: '/get-system-cpu', 134 | method: 'get', 135 | handler: async () => { 136 | const { brand, manufacturer, speed, cores } = await si.cpu() 137 | const { currentLoad } = await si.currentLoad() 138 | infoCache.cpu = manufacturer && brand && `${manufacturer} ${brand}` 139 | return { 140 | success: true, 141 | data: [ 142 | { 143 | title: 'CPU', 144 | value: Math.round(currentLoad), 145 | color: getColor(currentLoad), 146 | info: [ 147 | `${manufacturer} ${cores}核 ${speed}GHz`, 148 | `CPU满载率 ${Math.round(currentLoad)}%` 149 | ], 150 | model: infoCache.cpu 151 | } 152 | ] 153 | } 154 | } 155 | }, 156 | { 157 | url: '/get-system-ram', 158 | method: 'get', 159 | handler: async () => { 160 | const { total, active, swaptotal, swapused } = await si.mem() 161 | const ramCurrentLoad = Math.round(Number((active / total).toFixed(2)) * 100) 162 | const result: systemInfo[] = [ 163 | { 164 | title: 'RAM', 165 | value: ramCurrentLoad, 166 | color: getColor(ramCurrentLoad), 167 | info: [ 168 | `${utils.formatBytes(active)} / ${utils.formatBytes(total)}` 169 | ] 170 | } 171 | ] 172 | if (swaptotal) { 173 | const swapCurrentLoad = Math.round(Number((swapused / swaptotal).toFixed(2)) * 100) 174 | result.push({ 175 | title: 'SWAP', 176 | value: swapCurrentLoad, 177 | color: getColor(swapCurrentLoad), 178 | info: [ 179 | `${utils.formatBytes(swapused)} / ${utils.formatBytes(swaptotal)}` 180 | ] 181 | }) 182 | } else { 183 | result.push({ 184 | title: 'SWAP', 185 | value: 0, 186 | color: '', 187 | status: 'exception', 188 | info: ['没有获取到数据'] 189 | }) 190 | } 191 | return { 192 | success: true, 193 | data: result 194 | } 195 | } 196 | }, 197 | { 198 | url: '/get-system-node', 199 | method: 'get', 200 | handler: async () => { 201 | const memory = process.memoryUsage() 202 | // 总共 203 | const rss = utils.formatBytes(memory.rss) 204 | // 堆 205 | const heapTotal = utils.formatBytes(memory.heapTotal) 206 | // 栈 207 | const heapUsed = utils.formatBytes(memory.heapUsed) 208 | // 占用率 209 | const occupy = Number((memory.rss / (os.totalmem() - os.freemem())).toFixed(2)) * 100 210 | return { 211 | success: true, 212 | data: [ 213 | { 214 | title: 'Node', 215 | value: Math.round(occupy), 216 | color: getColor(occupy), 217 | info: [ 218 | `总 ${rss}`, 219 | `${heapTotal} | ${heapUsed}` 220 | ] 221 | } 222 | ] 223 | } 224 | } 225 | }, 226 | { 227 | url: '/get-system-gpu', 228 | method: 'get', 229 | handler: async () => { 230 | const { controllers } = await si.graphics() 231 | const graphics = controllers?.find(item => 232 | item.memoryUsed && item.memoryFree && item.utilizationGpu 233 | ) 234 | const result: systemInfo[] = [] 235 | if (graphics) { 236 | const { 237 | vendor, temperatureGpu, utilizationGpu, 238 | memoryTotal, memoryUsed, model 239 | } = graphics 240 | infoCache.gpu = model 241 | result.push({ 242 | title: 'GPU', 243 | value: Math.round(utilizationGpu as number), 244 | color: getColor(utilizationGpu as number), 245 | info: [ 246 | `${((memoryUsed as number) / 1024).toFixed(2)}G / ${((memoryTotal as number) / 1024).toFixed(2)}G`, 247 | `${vendor} ${temperatureGpu}°C` 248 | ], 249 | model: infoCache.gpu 250 | }) 251 | } else { 252 | result.push({ 253 | title: 'GPU', 254 | value: 0, 255 | color: '', 256 | status: 'exception', 257 | info: ['没有获取到数据'] 258 | }) 259 | } 260 | return { 261 | success: true, 262 | data: result 263 | } 264 | } 265 | }, 266 | { 267 | url: '/get-system-fs', 268 | method: 'get', 269 | handler: async () => { 270 | const HardDisk = _.uniqWith(await si.fsSize(), 271 | (a, b) => 272 | a.used === b.used && a.size === b.size && a.use === b.use && a.available === b.available 273 | ).filter(item => item.size && item.used && item.available && item.use) 274 | return { 275 | success: true, 276 | data: HardDisk.map(item => ({ 277 | ...item, 278 | used: utils.formatBytes(item.used), 279 | size: utils.formatBytes(item.size), 280 | use: Math.round(item.use), 281 | color: getColor(item.use) 282 | })) 283 | } 284 | } 285 | }, 286 | { 287 | url: '/get-bot-info', 288 | method: 'get', 289 | handler: async () => { 290 | const botList = version.BotName === 'TRSS' ? Bot.uin : (Bot?.adapter && Bot.adapter.includes(Bot.uin)) ? Bot.adapter : [Bot.uin] 291 | const botInfo = [] 292 | for (const uin of (botList as string[])) { 293 | const bot = Bot[uin] 294 | if (!bot) continue 295 | const keys = [ 296 | `Yz:count:send:msg:bot:${uin}:total`, 297 | `Yz:count:receive:msg:bot:${uin}:total` 298 | ] 299 | 300 | const values = await redis.mGet(keys) || [] 301 | 302 | botInfo.push({ 303 | uin, 304 | avatar: bot.avatar, 305 | nickname: bot.nickname || '未知', 306 | version: bot.version?.version || '未知', 307 | platform: bot.version?.name || '未知', 308 | sent: values[0] || bot.stat?.sent_msg_cnt || 0, 309 | recv: values[1] || bot.stat?.recv_msg_cnt || 0, 310 | time: utils.formatDuration(Date.now() / 1000 - bot.stat?.start_time), 311 | friend: bot.fl?.size || 0, 312 | group: bot.gl?.size || 0 313 | }) 314 | } 315 | return { 316 | success: true, 317 | data: botInfo 318 | } 319 | } 320 | }, 321 | { 322 | url: '/get-message-info', 323 | method: 'get', 324 | handler: async () => { 325 | const data: { 326 | sent: number[], 327 | recv: number[], 328 | time: string[] 329 | } = { 330 | sent: [], 331 | recv: [], 332 | time: [] 333 | } 334 | const date = moment().subtract(1, 'days') 335 | for (let i = 0; i < 30; i++) { 336 | const time = date.format('YYYY:MM:DD') 337 | const keys = version.BotName === 'TRSS' 338 | ? [ 339 | `Yz:count:send:msg:total:${time}`, 340 | `Yz:count:receive:msg:total:${time}` 341 | ] 342 | : [ 343 | `Yz:count:sendMsg:day:${date.format('MMDD')}`, 344 | `YePanel:recv:${time}` 345 | ] 346 | const value: Array = await redis.mGet(keys) 347 | if (value.some(i => i !== null)) { 348 | data.sent.unshift(Number(value[0])) 349 | data.recv.unshift(Number(value[1])) 350 | data.time.unshift(time.replace(/:/g, '-')) 351 | } 352 | date.add(-1, 'days') 353 | } 354 | return { 355 | success: true, 356 | data 357 | } 358 | } 359 | }, 360 | { 361 | url: '/get-update-log', 362 | method: 'get', 363 | handler: async ({ query }) => { 364 | const { plugin } = query as { plugin: string } 365 | try { 366 | const arg: ExecSyncOptionsWithStringEncoding = { 367 | encoding: 'utf-8', 368 | cwd: plugin ? join(version.BotPath, 'plugins', plugin) : undefined 369 | } 370 | const exec = (cmd: string) => execSync(cmd, arg).toString().trim() 371 | const log = exec('git log -100 --pretty="%h||[%cd] %s" --date=format:"%F %T"') 372 | const branch = exec('git branch --show-current') 373 | const remote = exec(`git config branch.${branch}.remote`) 374 | const url = exec(`git config remote.${remote}.url`) 375 | return { 376 | success: true, 377 | data: { 378 | log: log.split('\n'), 379 | url: url.toString().replace(/.git$/, '') 380 | } 381 | } 382 | } catch (error) { 383 | return { 384 | success: false, 385 | message: (error as Error).message 386 | } 387 | } 388 | } 389 | } 390 | ] as RouteOptions[] 391 | -------------------------------------------------------------------------------- /src/api/Bot/stats/index.ts: -------------------------------------------------------------------------------- 1 | import moment from 'moment' 2 | import { config, version, utils } from '@/common' 3 | // @ts-ignore 4 | import PluginLoader from '../../../../../../lib/plugins/loader.js' 5 | import { RouteOptions } from 'fastify' 6 | 7 | Bot.on('message', (e) => { 8 | const day = utils.getTime() 9 | 10 | if (version.BotName === 'Miao') { 11 | // 接收消息数量 12 | incr(`YePanel:recv:${day}`, 31) 13 | if (config.stats.totalStats.recv) { 14 | incr('YePanel:recv:total', 0) 15 | } 16 | if (config.stats.alone && e.self_id) { 17 | incr(`YePanel:recv:${e.self_id}:total`, 0) 18 | incr(`YePanel:${e.self_id}:recv:${day}`, 31) 19 | } 20 | } 21 | 22 | // 接收群消息数量 23 | if (e.group_id && config.stats.rankChart.groupRecv) { 24 | const key = e?.group_name ? `${e.group_name}(${e.group_id})` : e.group_id 25 | incr(`YePanel:recv:group:${key}:${day}`, 31) 26 | if (config.stats.alone && e.self_id) { 27 | incr(`YePanel:${e.self_id}:recv:group:${key}:${day}`, 31) 28 | } 29 | } 30 | 31 | // 接收用户消息数量 32 | if (e.user_id && config.stats.rankChart.userRecv) { 33 | const key = e?.sender?.nickname ? `${e.sender.nickname}(${e.user_id})` : e.user_id 34 | incr(`YePanel:recv:user:${key}:${day}`, 31) 35 | if (config.stats.alone && e.self_id) { 36 | incr(`YePanel:${e.self_id}:recv:user:${key}:${day}`, 31) 37 | } 38 | } 39 | }) 40 | 41 | // 插件调用统计 42 | if (config.stats.rankChart.pluginUse || config.stats.countChart.plugin) { 43 | // 代理PluginLoader的 filtPermission 函数, 每次触发插件都会调用, 判断logFnc 44 | PluginLoader.filtPermission = new Proxy(PluginLoader.filtPermission, { 45 | apply (target, thisArg, args) { 46 | const res = target.apply(thisArg, args) 47 | if (res) { 48 | const day = utils.getTime() 49 | const [e] = args 50 | // 插件今日总调用数量 51 | if (config.stats.countChart.plugin) { 52 | incr(`YePanel:plugin:total:${day}`, 31) 53 | if (config.stats.alone && e?.self_id) { 54 | incr(`YePanel:${e.self_id}:plugin:total:${day}`, 31) 55 | } 56 | } 57 | // 插件今日总调用数量应该用use:total的 现在累计调用数量只能用totla:total了 58 | if (config.stats.totalStats.plugin) { 59 | incr('YePanel:plugin:total:total', 0) 60 | if (config.stats.alone && e?.self_id) { 61 | incr(`YePanel:${e.self_id}:plugin:total:total`, 0) 62 | } 63 | } 64 | // 插件调用排行榜 65 | if (config.stats.rankChart.pluginUse) { 66 | const { name, fnc } = getLog(e?.logFnc) 67 | if (name && fnc) { 68 | incr(`YePanel:plugin:use:${name}(${fnc}):${day}`) 69 | if (config.stats.alone && e?.self_id) { 70 | incr(`YePanel:${e.self_id}:plugin:use:${name}(${fnc}):${day}`) 71 | } 72 | } 73 | } 74 | } 75 | return res 76 | } 77 | }) 78 | } 79 | 80 | // 发送统计 81 | if ( 82 | config.stats.rankChart.groupSent || config.stats.rankChart.userSent || config.stats.rankChart.pluginSent || 83 | config.stats.rankChart.sentType 84 | ) { 85 | // 代理 count 每次发送消息都会调用 86 | PluginLoader.count = new Proxy(PluginLoader.count, { 87 | apply (target, thisArg, args) { 88 | const [e, type, msg] = args 89 | const day = utils.getTime() 90 | // 总发送消息统计 91 | if (!msg) { 92 | if (config.stats.totalStats.sent) { 93 | incr('YePanel:sent:total', 0) 94 | } 95 | if (config.stats.alone && e.self_id) { 96 | incr(`YePanel:${e.self_id}:sent:total`, 0) 97 | incr(`YePanel:${e.self_id}:sent:${day}`, 31) 98 | } 99 | } 100 | // 群消息发送数量 101 | if (e?.group_id && config.stats.rankChart.groupSent && type !== 'receive') { 102 | const key = e?.group_name ? `${e.group_name}(${e.group_id})` : e.group_id 103 | incr(`YePanel:sent:group:${key}:${day}`) 104 | if (config.stats.alone && e.self_id) { 105 | incr(`YePanel:${e.self_id}:sent:group:${key}:${day}`) 106 | } 107 | } 108 | // 用户消息发送数量 109 | if (e?.user_id && config.stats.rankChart.userSent && type !== 'receive') { 110 | const key = e?.sender?.nickname ? `${e.sender.nickname}(${e.user_id})` : e.user_id 111 | incr(`YePanel:sent:user:${key}:${day}`) 112 | if (config.stats.alone && e.self_id) { 113 | incr(`YePanel:${e.self_id}:sent:user:${key}:${day}`) 114 | } 115 | } 116 | // 插件发送消息排行 117 | if (e?.logFnc && config.stats.rankChart.pluginSent) { 118 | const { name, fnc } = getLog(e.logFnc) 119 | if (name && fnc) { 120 | incr(`YePanel:plugin:sent:${name}(${fnc}):${day}`) 121 | if (config.stats.alone && e.self_id) { 122 | incr(`YePanel:${e.self_id}:plugin:sent:${name}(${fnc}):${day}`) 123 | } 124 | } 125 | } 126 | // 发送消息类型排行 127 | if (config.stats.rankChart.sentType && type !== 'receive') { 128 | const message = version.BotName === 'Miao' ? type : msg 129 | for (const i of Array.isArray(message) ? message : [message]) { 130 | incr(`YePanel:sent:type:${i?.type || 'text'}:${day}`) 131 | if (config.stats.alone && e.self_id) { 132 | incr(`YePanel:${e.self_id}:sent:type:${i?.type || 'text'}:${day}`) 133 | } 134 | } 135 | } 136 | return target.apply(thisArg, args) 137 | } 138 | }) 139 | } 140 | 141 | function getLog (log: string) { 142 | const info = { 143 | name: '', 144 | fnc: '' 145 | } 146 | if (log) { 147 | const reg = version.BotName === 'Miao' ? /\[(.+?)\]\[(.+?)\]/ : /\[(.+?)\((.+?)\)\]/ 148 | try { 149 | const [, name, fnc] = reg.exec(log) || [] 150 | if (name && fnc) { 151 | return { name: name.replace('34m[', ''), fnc } 152 | } 153 | } catch { } 154 | } 155 | return info 156 | } 157 | 158 | function incr (key: string, day: number = 8) { 159 | redis.incr(key).then((i: number) => { 160 | if (i == 1 && day > 0) { 161 | redis.expire(key, 60 * 60 * 24 * day).catch(() => {}) 162 | } 163 | }).catch(() => {}) 164 | } 165 | 166 | type ChartData = { 167 | name: string, 168 | value: number 169 | }[] 170 | 171 | function sort (data: ChartData) { 172 | data.sort((a, b) => b.value - a.value) 173 | if (data.length > 10) { 174 | data.pop() 175 | } 176 | } 177 | 178 | async function scan (MATCH: string, getName: (key: string) => string) { 179 | const ChartData: ChartData = [] 180 | let cursor = 0 181 | do { 182 | const res = await redis.scan(cursor, { MATCH, COUNT: 10000 }) 183 | cursor = res.cursor 184 | for (const key of res.keys) { 185 | const name = getName(key) 186 | if (!name) continue 187 | const value = Number(await redis.get(key)) 188 | ChartData.push({ name, value }) 189 | sort(ChartData) 190 | } 191 | } while (cursor !== 0) 192 | return ChartData 193 | } 194 | 195 | export default [ 196 | { 197 | url: '/get-stats-count-data', 198 | method: 'get', 199 | handler: async ({ query }: { query: { uin?: string } }) => { 200 | const uin = query.uin ? `${query.uin}:` : '' 201 | const data: { 202 | sent: number[], 203 | recv: number[], 204 | plugin: number[], 205 | time: string[] 206 | } = { 207 | sent: [], 208 | recv: [], 209 | plugin: [], 210 | time: [] 211 | } 212 | const countConfig = config.stats.countChart 213 | if (!countConfig.sent && !countConfig.recv && !countConfig.plugin) { 214 | return { 215 | success: true, 216 | data 217 | } 218 | } 219 | const date = moment() 220 | for (let i = 0; i < 30; i++) { 221 | const time = date.format('YYYY:MM:DD') 222 | const timeKey = time.replace(/:/g, '-') 223 | 224 | const tasks: Promise[] = [] 225 | // 发送消息数量 226 | if (countConfig.sent) { 227 | if (version.BotName === 'Miao') { 228 | if (uin) { 229 | tasks.push(redis.get(`YePanel:${uin}sent:${time}`)) 230 | } else { 231 | tasks.push(redis.get(`Yz:count:sendMsg:day:${date.format('MMDD')}`)) 232 | } 233 | } else if (version.BotName === 'TRSS') { 234 | tasks.push(redis.get(`Yz:count:send:msg:${uin ? `bot:${uin}` : 'total:'}${time}`)) 235 | } 236 | } else { 237 | tasks.push(Promise.resolve(false)) 238 | } 239 | 240 | // 接收消息数量 241 | if (countConfig.recv) { 242 | if (version.BotName === 'Miao') { 243 | tasks.push(redis.get(`YePanel:${uin}recv:${time}`)) 244 | } else if (version.BotName === 'TRSS') { 245 | tasks.push(redis.get(`Yz:count:receive:msg:${uin ? `bot:${uin}` : 'total:'}${time}`)) 246 | } 247 | } else { 248 | tasks.push(Promise.resolve(false)) 249 | } 250 | 251 | // 插件总调用数量 252 | if (countConfig.plugin) { 253 | tasks.push(redis.get(`YePanel:${uin}plugin:total:${time}`)) 254 | } else { 255 | tasks.push(Promise.resolve(false)) 256 | } 257 | 258 | const values = await Promise.all(tasks) 259 | if (values.some(v => v !== false)) { 260 | data.time.unshift(timeKey) 261 | if (values[0] !== false) { 262 | data.sent.unshift(Number(values[0])) 263 | } 264 | if (values[1] !== false) { 265 | data.recv.unshift(Number(values[1])) 266 | } 267 | if (values[2] !== false) { 268 | data.plugin.unshift(Number(values[2])) 269 | } 270 | } 271 | 272 | date.add(-1, 'days') 273 | } 274 | return { 275 | success: true, 276 | data 277 | } 278 | } 279 | }, 280 | { 281 | url: '/get-stats-rank-data', 282 | method: 'get', 283 | handler: async ({ query }) => { 284 | const data: { 285 | pluginSent: ChartData | false, 286 | pluginUse: ChartData | false, 287 | groupRecv: ChartData | false, 288 | groupSent: ChartData | false, 289 | userRecv: ChartData | false, 290 | userSent: ChartData | false, 291 | sentType: ChartData | false 292 | } = { 293 | pluginSent: false, 294 | pluginUse: false, 295 | groupRecv: false, 296 | groupSent: false, 297 | userRecv: false, 298 | userSent: false, 299 | sentType: false 300 | } 301 | const { time, uin } = query as {time: string, uin?: string} 302 | const uinKey = uin ? `${uin}:` : '' 303 | const keys = [ 304 | { config: 'pluginSent', redis: 'plugin:sent' }, 305 | { config: 'pluginUse', redis: 'plugin:use' }, 306 | { config: 'groupRecv', redis: 'recv:group' }, 307 | { config: 'groupSent', redis: 'sent:group' }, 308 | { config: 'userRecv', redis: 'recv:user' }, 309 | { config: 'userSent', redis: 'sent:user' }, 310 | { config: 'sentType', redis: 'sent:type' } 311 | ] as { 312 | config: 'pluginSent'|'pluginUse'|'groupRecv'|'groupSent'|'userRecv'|'userSent'|'sentType', 313 | redis: 'plugin:sent'|'plugin:use'|'recv:group'|'sent:group'|'recv:user'|'sent:user'|'sent:type', 314 | }[] 315 | for (const i of keys) { 316 | if (config.stats.rankChart[i.config]) { 317 | const rkey = `YePanel:${uinKey}${i.redis}:*:${time.replace(/-/g, ':')}` 318 | const getName = (key: string) => { 319 | const reg = new RegExp(rkey.replace('*', '(.+?)')) 320 | return reg.exec(key)?.[1] || '' 321 | } 322 | data[i.config] = await scan(rkey, getName) 323 | } 324 | } 325 | return { 326 | success: true, 327 | data 328 | } 329 | } 330 | }, 331 | { 332 | url: '/get-stats-total-data', 333 | method: 'get', 334 | handler: async ({ query }: { query: { uin?: string } }) => { 335 | const uin = query.uin ? `${query.uin}:` : '' 336 | const data: { 337 | sent: number | false, 338 | recv: number | false, 339 | plugin: number | false, 340 | } = { 341 | sent: false, 342 | recv: false, 343 | plugin: false 344 | } 345 | const keys = [ 346 | { config: 'sent', trss: 'send' }, 347 | { config: 'recv', trss: 'receive' } 348 | ] as { 349 | config: 'sent'|'recv', 350 | trss: 'send'|'receive' 351 | }[] 352 | for (const i of keys) { 353 | if (config.stats.totalStats[i.config]) { 354 | if (version.BotName === 'TRSS') { 355 | data[i.config] = Number(await redis.get(`Yz:count:${i.trss}:msg:${uin ? `bot:${uin}` : 'total:'}total`)) 356 | } else if (version.BotName === 'Miao') { 357 | data[i.config] = Number(await redis.get(`YePanel:${uin}${i.config}:total`)) 358 | } 359 | } 360 | } 361 | if (config.stats.totalStats.plugin) { 362 | data.plugin = Number(await redis.get(`YePanel:${uin}plugin:total:total`)) 363 | } 364 | return { 365 | success: true, 366 | data 367 | } 368 | } 369 | } 370 | ] as RouteOptions[] 371 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [1.10.9](https://github.com/XasYer/YePanel/compare/v1.10.8...v1.10.9) (2025-02-06) 4 | 5 | 6 | ### Bug Fixes 7 | 8 | * 可能出现的没有文件或文件夹 ([74e843b](https://github.com/XasYer/YePanel/commit/74e843bd4c6385fb30ff73fc8758d96098d6618f)) 9 | * 文件管理中文文件名 ([067e280](https://github.com/XasYer/YePanel/commit/067e2806f549639467453555afc42422a835e627)) 10 | 11 | 12 | ### Performance Improvements 13 | 14 | * sqlite可执行自定义sql语句 ([2bde4d9](https://github.com/XasYer/YePanel/commit/2bde4d97119c8eebd93711d0d5cbce0f67338f3d)) 15 | * 初次适配单独运行 ([af28ffe](https://github.com/XasYer/YePanel/commit/af28ffe8c1cee880c84298febadb5721dee0a1a4)) 16 | * 域名到期 ([bf944de](https://github.com/XasYer/YePanel/commit/bf944de27fe8b024ccf38143478d812075a015e0)) 17 | 18 | ## [1.10.8](https://github.com/XasYer/YePanel/compare/v1.10.7...v1.10.8) (2024-11-18) 19 | 20 | 21 | ### Bug Fixes 22 | 23 | * username & password ([d850dcb](https://github.com/XasYer/YePanel/commit/d850dcbf05746dce16ff82c3e9cd882e1863b96f)) 24 | 25 | ## [1.10.7](https://github.com/XasYer/YePanel/compare/v1.10.6...v1.10.7) (2024-11-18) 26 | 27 | 28 | ### Performance Improvements 29 | 30 | * redis可选连接自定义地址 ([8263a76](https://github.com/XasYer/YePanel/commit/8263a76196aaa8abfc18eccdf737fc537ae20b85)) 31 | 32 | ## [1.10.6](https://github.com/XasYer/YePanel/compare/v1.10.5...v1.10.6) (2024-11-14) 33 | 34 | 35 | ### Bug Fixes 36 | 37 | * 优化实时日志 ([25670f8](https://github.com/XasYer/YePanel/commit/25670f8bcc0797f9b5b8c551363326477d38b3b3)) 38 | 39 | ## [1.10.5](https://github.com/XasYer/YePanel/compare/v1.10.4...v1.10.5) (2024-11-12) 40 | 41 | 42 | ### Bug Fixes 43 | 44 | * 优化首页数据 ([59ec708](https://github.com/XasYer/YePanel/commit/59ec708d7998ce1b8a2336c25f2ea4b5faeb07ee)) 45 | 46 | ## [1.10.4](https://github.com/XasYer/YePanel/compare/v1.10.3...v1.10.4) (2024-11-04) 47 | 48 | 49 | ### Bug Fixes 50 | 51 | * get-system-info单独请求 ([a56be36](https://github.com/XasYer/YePanel/commit/a56be36193b75270a1b1496e34dc398d584e5cb0)) 52 | 53 | ## [1.10.3](https://github.com/XasYer/YePanel/compare/v1.10.2...v1.10.3) (2024-11-01) 54 | 55 | 56 | ### Bug Fixes 57 | 58 | * 单独Bot调用统计 ([f4b556e](https://github.com/XasYer/YePanel/commit/f4b556e63c79acc9a5ff1aea1a284edeae2c9343)) 59 | * 沙盒新增接收at,button,poke,发送poke ([f60f139](https://github.com/XasYer/YePanel/commit/f60f139fca817ef82c3828822619c552d427f91f)) 60 | 61 | ## [1.10.2](https://github.com/XasYer/YePanel/compare/v1.10.1...v1.10.2) (2024-10-30) 62 | 63 | 64 | ### Bug Fixes 65 | 66 | * redis数据目前仅支持string类型 ([24a0b93](https://github.com/XasYer/YePanel/commit/24a0b93f100032039c3e3c8c2be167846886debc)) 67 | * 插件名中文括号 ([eb5324f](https://github.com/XasYer/YePanel/commit/eb5324fd47e7cddb54d7d472bb1793f4001813c3)) 68 | * 数据统计可区分Bot,增加总计数据 ([8d97d19](https://github.com/XasYer/YePanel/commit/8d97d194243737d8119d6281cec6891b98cea6a2)) 69 | 70 | ## [1.10.1](https://github.com/XasYer/YePanel/compare/v1.10.0...v1.10.1) (2024-10-29) 71 | 72 | 73 | ### Bug Fixes 74 | 75 | * redis可查看全部db ([2d60110](https://github.com/XasYer/YePanel/commit/2d60110b729f101ce69421cfa475518e777180a0)) 76 | * redis可查看所有db ([0b34177](https://github.com/XasYer/YePanel/commit/0b34177dc0764bd147c62011b6e33447fed8e070)) 77 | 78 | ## [1.10.0](https://github.com/XasYer/YePanel/compare/v1.9.2...v1.10.0) (2024-10-29) 79 | 80 | 81 | ### Features 82 | 83 | * 插件列表 ([c5d0d33](https://github.com/XasYer/YePanel/commit/c5d0d33dcdb4e30197e7ac3aaa634198b7e5e5d2)) 84 | 85 | ## [1.9.2](https://github.com/XasYer/YePanel/compare/v1.9.1...v1.9.2) (2024-10-27) 86 | 87 | 88 | ### Bug Fixes 89 | 90 | * 日志输出时机 ([e7f7c77](https://github.com/XasYer/YePanel/commit/e7f7c77f3f6fc96211abfa5e869431f600a4958c)) 91 | * 沙盒发送base64图片 ([af0241a](https://github.com/XasYer/YePanel/commit/af0241a3cb64216ddd8f816ecf2383ae760d339c)) 92 | 93 | ## [1.9.1](https://github.com/XasYer/YePanel/compare/v1.9.0...v1.9.1) (2024-10-25) 94 | 95 | 96 | ### Bug Fixes 97 | 98 | * 可显示自定义域名 ([2b150dc](https://github.com/XasYer/YePanel/commit/2b150dc66f08a8760918e8bd0b1d092e01693f91)) 99 | 100 | ## [1.9.0](https://github.com/XasYer/YePanel/compare/v1.8.11...v1.9.0) (2024-10-25) 101 | 102 | 103 | ### Features 104 | 105 | * 新增指令`#小叶面板登录` ([441a016](https://github.com/XasYer/YePanel/commit/441a016397934cfb5355da1732d8b4a54fe99749)) 106 | 107 | ## [1.8.11](https://github.com/XasYer/YePanel/compare/v1.8.10...v1.8.11) (2024-10-25) 108 | 109 | 110 | ### Bug Fixes 111 | 112 | * build ([714a8ac](https://github.com/XasYer/YePanel/commit/714a8aca1891d1afb436f2c68aedfb0c25950ff8)) 113 | * 部分api更换成get ([0361efe](https://github.com/XasYer/YePanel/commit/0361efec3019d1254d91a1905cda74e94911ddc1)) 114 | 115 | ## [1.8.10](https://github.com/XasYer/YePanel/compare/v1.8.9...v1.8.10) (2024-10-23) 116 | 117 | 118 | ### Bug Fixes 119 | 120 | * 一处trss输出 ([a7267f9](https://github.com/XasYer/YePanel/commit/a7267f9edee36e3886e17edf27d62720dc0050d8)) 121 | * 更新日志可点击跳转 ([bc5db5c](https://github.com/XasYer/YePanel/commit/bc5db5cfac6c4414af5072f0e1eb147acad12f35)) 122 | 123 | ## [1.8.9](https://github.com/XasYer/YePanel/compare/v1.8.8...v1.8.9) (2024-10-23) 124 | 125 | 126 | ### Bug Fixes 127 | 128 | * config ([d1d36f5](https://github.com/XasYer/YePanel/commit/d1d36f543970dab5151e6c64e27b7ebb7d14d2c3)) 129 | 130 | ## [1.8.8](https://github.com/XasYer/YePanel/compare/v1.8.7...v1.8.8) (2024-10-23) 131 | 132 | 133 | ### Bug Fixes 134 | 135 | * 添加发送消息类型统计 ([0553125](https://github.com/XasYer/YePanel/commit/0553125618b12152e8dc03cf57f668394a4aa246)) 136 | * 添加消息类型统计 ([4eb1c41](https://github.com/XasYer/YePanel/commit/4eb1c4120e965bec69d311c19be516bc22882f7f)) 137 | * 添加消息类型统计 ([6ea5162](https://github.com/XasYer/YePanel/commit/6ea516245f4c708ba91e542e28188882a7a4f3a2)) 138 | 139 | ## [1.8.7](https://github.com/XasYer/YePanel/compare/v1.8.6...v1.8.7) (2024-10-22) 140 | 141 | 142 | ### Bug Fixes 143 | 144 | * 不判断有没有数据 ([da37360](https://github.com/XasYer/YePanel/commit/da3736085d7bc1eb6df3b7fd544d330213eb85aa)) 145 | 146 | ## [1.8.6](https://github.com/XasYer/YePanel/compare/v1.8.5...v1.8.6) (2024-10-22) 147 | 148 | 149 | ### Bug Fixes 150 | 151 | * 配置账号头像未生效 ([48ff34f](https://github.com/XasYer/YePanel/commit/48ff34f0632dbc537711f73c7370b69c089585d2)) 152 | 153 | ## [1.8.5](https://github.com/XasYer/YePanel/compare/v1.8.4...v1.8.5) (2024-10-22) 154 | 155 | 156 | ### Bug Fixes 157 | 158 | * 储存名字 ([869d1c6](https://github.com/XasYer/YePanel/commit/869d1c6b7b35c98a21d25cd35124f455b67d18cf)) 159 | 160 | ## [1.8.4](https://github.com/XasYer/YePanel/compare/v1.8.3...v1.8.4) (2024-10-22) 161 | 162 | 163 | ### Bug Fixes 164 | 165 | * 按日期获取 ([83f270d](https://github.com/XasYer/YePanel/commit/83f270d19d994805e1ad98ec0c3b581de1267e0e)) 166 | 167 | ## [1.8.3](https://github.com/XasYer/YePanel/compare/v1.8.2...v1.8.3) (2024-10-22) 168 | 169 | 170 | ### Bug Fixes 171 | 172 | * 存7天 ([88a5f41](https://github.com/XasYer/YePanel/commit/88a5f4165c5a132d42562217f09e5813e1c4d661)) 173 | * 获取7天 ([14483b6](https://github.com/XasYer/YePanel/commit/14483b60c82d46eb3697b4218f9e23ecfbe202f5)) 174 | 175 | ## [1.8.2](https://github.com/XasYer/YePanel/compare/v1.8.1...v1.8.2) (2024-10-22) 176 | 177 | 178 | ### Bug Fixes 179 | 180 | * 单独获取数据 ([6fd6f2d](https://github.com/XasYer/YePanel/commit/6fd6f2de3f042cb8028b980543323c24c780c27a)) 181 | 182 | ## [1.8.1](https://github.com/XasYer/YePanel/compare/v1.8.0...v1.8.1) (2024-10-22) 183 | 184 | 185 | ### Bug Fixes 186 | 187 | * build.zip ([9c4d0de](https://github.com/XasYer/YePanel/commit/9c4d0de371f30f07d7192da9459a9f74b444441b)) 188 | * node_modules ([cc16cb4](https://github.com/XasYer/YePanel/commit/cc16cb4941ed44f54146d654900c3380db4f367b)) 189 | * 忘记.gitignore了 ([b79576f](https://github.com/XasYer/YePanel/commit/b79576f49e45147e4ca7b82a08ce4de6ccf91a86)) 190 | * 数据统计 ([46ba93b](https://github.com/XasYer/YePanel/commit/46ba93b31919396b626b91ee85cf8a51357c84dd)) 191 | 192 | ## [1.8.0](https://github.com/XasYer/YePanel/compare/v1.7.4...v1.8.0) (2024-10-22) 193 | 194 | 195 | ### Features 196 | 197 | * 数据统计 ([144a9ce](https://github.com/XasYer/YePanel/commit/144a9ce0866a3866692a43b88a0ac091b1ecd2e4)) 198 | 199 | 200 | ### Bug Fixes 201 | 202 | * build ([a371129](https://github.com/XasYer/YePanel/commit/a3711291ec869da6d99da91779de3dd60af25e01)) 203 | 204 | ## [1.7.4](https://github.com/XasYer/YePanel/compare/v1.7.3...v1.7.4) (2024-10-20) 205 | 206 | 207 | ### Bug Fixes 208 | 209 | * sandbox ([0e25172](https://github.com/XasYer/YePanel/commit/0e25172b890e4d6b144fb53792889a235bd6f95f)) 210 | 211 | ## [1.7.3](https://github.com/XasYer/YePanel/compare/v1.7.2...v1.7.3) (2024-10-19) 212 | 213 | 214 | ### Bug Fixes 215 | 216 | * 样式 ([d4ee1ff](https://github.com/XasYer/YePanel/commit/d4ee1ffee3c3dc4b3e87bac09bdae393855982c3)) 217 | * 访问根目录提示 ([bb971ef](https://github.com/XasYer/YePanel/commit/bb971efd89006463941d8102b076b4064bd5664d)) 218 | 219 | ## [1.7.2](https://github.com/XasYer/YePanel/compare/v1.7.1...v1.7.2) (2024-10-19) 220 | 221 | 222 | ### Bug Fixes 223 | 224 | * 获取公网ip ([ab901a7](https://github.com/XasYer/YePanel/commit/ab901a70de624d9fd7535a4b7d6acbc8d68b49bf)) 225 | 226 | ## [1.7.1](https://github.com/XasYer/YePanel/compare/v1.7.0...v1.7.1) (2024-10-19) 227 | 228 | 229 | ### Bug Fixes 230 | 231 | * 可配置输出调用日志 ([636c20f](https://github.com/XasYer/YePanel/commit/636c20fab03458f6131a14c4c8b1966899fc460f)) 232 | 233 | ## [1.7.0](https://github.com/XasYer/YePanel/compare/v1.6.1...v1.7.0) (2024-10-19) 234 | 235 | 236 | ### Features 237 | 238 | * 可将web面板挂载到本地 ([1f576d7](https://github.com/XasYer/YePanel/commit/1f576d7250250333d179b182c13593a064fbd6ba)) 239 | 240 | ## [1.6.1](https://github.com/XasYer/YePanel/compare/v1.6.0...v1.6.1) (2024-10-18) 241 | 242 | 243 | ### Bug Fixes 244 | 245 | * 编译错误 ([c3902ca](https://github.com/XasYer/YePanel/commit/c3902cafad12dca2749e102affdc56112e900c98)) 246 | 247 | ## [1.6.0](https://github.com/XasYer/YePanel/compare/v1.5.5...v1.6.0) (2024-10-18) 248 | 249 | 250 | ### Features 251 | 252 | * 沙盒测试 ([1d51584](https://github.com/XasYer/YePanel/commit/1d5158468b2cec3bfadb0a8d992fa459882138d2)) 253 | 254 | ## [1.5.5](https://github.com/XasYer/YePanel/compare/v1.5.4...v1.5.5) (2024-10-14) 255 | 256 | 257 | ### Bug Fixes 258 | 259 | * 路由icon不使用base64 ([6d170fb](https://github.com/XasYer/YePanel/commit/6d170fb4c60590cf7d691c4eb311be00bb6d3d6d)) 260 | 261 | ## [1.5.4](https://github.com/XasYer/YePanel/compare/v1.5.3...v1.5.4) (2024-10-12) 262 | 263 | 264 | ### Bug Fixes 265 | 266 | * build timezone ([7ae70c8](https://github.com/XasYer/YePanel/commit/7ae70c8ab70c1d0bcdd4243c9b097c77751e3f6d)) 267 | 268 | ## [1.5.3](https://github.com/XasYer/YePanel/compare/v1.5.2...v1.5.3) (2024-10-12) 269 | 270 | 271 | ### Bug Fixes 272 | 273 | * plugin update log ([71775e8](https://github.com/XasYer/YePanel/commit/71775e85784c61c1f63b4376bd88596cf8bed4f4)) 274 | * ts->js ([167df32](https://github.com/XasYer/YePanel/commit/167df3234a233fa4ff818d35f094ca45f0d278a3)) 275 | 276 | ## [1.5.2](https://github.com/XasYer/YePanel/compare/v1.5.1...v1.5.2) (2024-10-12) 277 | 278 | 279 | ### Bug Fixes 280 | 281 | * 按需导入 ([218292e](https://github.com/XasYer/YePanel/commit/218292e535da4479b2b8805159be44aa1832b2ba)) 282 | 283 | ## [1.5.1](https://github.com/XasYer/YePanel/compare/v1.5.0...v1.5.1) (2024-10-12) 284 | 285 | 286 | ### Bug Fixes 287 | 288 | * 按需请求数据 ([321b081](https://github.com/XasYer/YePanel/commit/321b08171e9e3238c62b58294c373a7905264d17)) 289 | 290 | ## [1.5.0](https://github.com/XasYer/YePanel/compare/v1.4.1...v1.5.0) (2024-10-12) 291 | 292 | 293 | ### Features 294 | 295 | * 首页展示更多信息 ([6db27cd](https://github.com/XasYer/YePanel/commit/6db27cd55e16de1494bd49579dda407deeea745b)) 296 | 297 | ## [1.4.1](https://github.com/XasYer/YePanel/compare/v1.4.0...v1.4.1) (2024-10-11) 298 | 299 | 300 | ### Bug Fixes 301 | 302 | * terminal不显示路径 ([3dd1e21](https://github.com/XasYer/YePanel/commit/3dd1e21d356a318de3354826ba7586dc61979965)) 303 | 304 | ## [1.4.0](https://github.com/XasYer/YePanel/compare/v1.3.0...v1.4.0) (2024-10-11) 305 | 306 | 307 | ### Features 308 | 309 | * 使用fastify ([30d0d9b](https://github.com/XasYer/YePanel/commit/30d0d9b62e049b62b4fddfb75acaaca71f37c52f)) 310 | 311 | ## [1.3.0](https://github.com/XasYer/YePanel/compare/v1.2.0...v1.3.0) (2024-10-10) 312 | 313 | 314 | ### Features 315 | 316 | * sqlite数据库查看 ([673590b](https://github.com/XasYer/YePanel/commit/673590b5f088394b2dd208b05927a3cd29f72972)) 317 | 318 | ## [1.2.0](https://github.com/XasYer/YePanel/compare/v1.1.0...v1.2.0) (2024-10-09) 319 | 320 | 321 | ### Features 322 | 323 | * 适配guoba.support.js ([04353eb](https://github.com/XasYer/YePanel/commit/04353eb93b00c333c8dae1b917c4b9ca1f74c6e1)) 324 | 325 | ## [1.1.0](https://github.com/XasYer/YePanel/compare/v1.0.2...v1.1.0) (2024-10-06) 326 | 327 | 328 | ### Features 329 | 330 | * 插件适配页面 ([3d23418](https://github.com/XasYer/YePanel/commit/3d23418174b5094a8b8cea5c700c3226c7f6836f)) 331 | 332 | ## [1.0.2](https://github.com/XasYer/YePanel/compare/v1.0.1...v1.0.2) (2024-10-03) 333 | 334 | 335 | ### Bug Fixes 336 | 337 | * build ([14204e1](https://github.com/XasYer/YePanel/commit/14204e17609601a18ccce9ca29f308939febd224)) 338 | 339 | ## [1.0.1](https://github.com/XasYer/YePanel/compare/v1.0.0...v1.0.1) (2024-10-03) 340 | 341 | 342 | ### Bug Fixes 343 | 344 | * changelog ([a51bc61](https://github.com/XasYer/YePanel/commit/a51bc61baf70b005f176579535e14c88757f5390)) 345 | 346 | ## 1.0.0 (2024-10-03) 347 | 348 | 349 | ### Features 350 | 351 | * 基础功能 ([fbbf2db](https://github.com/XasYer/YePanel/commit/fbbf2db346671bbe531b9371835dfda6636bf19f)) 352 | --------------------------------------------------------------------------------