mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-03-01 16:20:25 +00:00
Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29888cb38b | ||
|
|
6ea4c9ec65 | ||
|
|
4bec3aa597 | ||
|
|
38c320d2c9 | ||
|
|
76cbd8a1c1 | ||
|
|
0779628be5 | ||
|
|
34ca919c4d | ||
|
|
b1b357347b | ||
|
|
129d63f66e | ||
|
|
699b46acbd | ||
|
|
7f05aee11d | ||
|
|
542036f46e | ||
|
|
b958e9e803 | ||
|
|
73fcfb5900 | ||
|
|
adabc4da46 | ||
|
|
bf073b544b | ||
|
|
a71219062a | ||
|
|
001fe01ace | ||
|
|
0aa0c44634 | ||
|
|
93126e514e | ||
|
|
1ae10ae0c6 | ||
|
|
4b693bf6e2 | ||
|
|
574c257591 | ||
|
|
d680328762 | ||
|
|
d711cdecaf | ||
|
|
c5f1792009 | ||
|
|
a5e705e6a4 | ||
|
|
007f1db339 | ||
|
|
008fb39f8f |
2
.github/prompt/default.md
vendored
2
.github/prompt/default.md
vendored
@@ -2,7 +2,7 @@
|
||||
[使用文档](https://napneko.github.io/)
|
||||
|
||||
## Windows 一键包
|
||||
我们为提供了的轻量化一键部署方案
|
||||
我们提供了轻量化的一键部署方案
|
||||
相对于普通需要安装QQ的方案,下面已内置QQ和Napcat 阅读使用文档参考
|
||||
|
||||
你可以下载
|
||||
|
||||
2
.github/prompt/release_note_prompt.txt
vendored
2
.github/prompt/release_note_prompt.txt
vendored
@@ -31,7 +31,7 @@
|
||||
[使用文档](https://napneko.github.io/)
|
||||
|
||||
## Windows 一键包
|
||||
我们为提供了的轻量化一键部署方案
|
||||
我们提供了轻量化的一键部署方案
|
||||
相对于普通需要安装QQ的方案,下面已内置QQ和Napcat 阅读使用文档参考
|
||||
|
||||
你可以下载
|
||||
|
||||
2
.github/workflows/auto-release.yml
vendored
2
.github/workflows/auto-release.yml
vendored
@@ -50,7 +50,7 @@ jobs:
|
||||
- name: Copy OpenAPI Schema
|
||||
run: |
|
||||
mkdir -p napcat-docs/src/api/${{ env.version }}
|
||||
cp packages/napcat-schema/openapi.json napcat-docs/src/api/${{ env.version }}/openapi.json
|
||||
cp packages/napcat-schema/dist/openapi.json napcat-docs/src/api/${{ env.version }}/openapi.json
|
||||
echo "OpenAPI schema copied to napcat-docs/src/api/${{ env.version }}/openapi.json"
|
||||
|
||||
- name: Commit and Push
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"name": "napcat-types",
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"types": "./napcat-types/index.d.ts",
|
||||
"files": [
|
||||
"**/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/ws": "^8.5.12",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/winston": "^2.4.4",
|
||||
"@types/yaml": "^1.9.7",
|
||||
"@types/ip": "^1.1.3"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://registry.npmjs.org/",
|
||||
"access": "public",
|
||||
"tag": "latest"
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,14 @@ import fs from 'fs';
|
||||
import { stat } from 'fs/promises';
|
||||
import crypto, { randomUUID } from 'crypto';
|
||||
import path from 'node:path';
|
||||
import http from 'node:http';
|
||||
import tls from 'node:tls';
|
||||
import { solveProblem } from '@/napcat-common/src/helper';
|
||||
|
||||
export interface HttpDownloadOptions {
|
||||
url: string;
|
||||
headers?: Record<string, string> | string;
|
||||
proxy?: string;
|
||||
}
|
||||
|
||||
type Uri2LocalRes = {
|
||||
@@ -96,6 +99,7 @@ export function calculateFileMD5 (filePath: string): Promise<string> {
|
||||
|
||||
async function tryDownload (options: string | HttpDownloadOptions, useReferer: boolean = false): Promise<Response> {
|
||||
let url: string;
|
||||
let proxy: string | undefined;
|
||||
let headers: Record<string, string> = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4606.71 Safari/537.36',
|
||||
};
|
||||
@@ -104,6 +108,7 @@ async function tryDownload (options: string | HttpDownloadOptions, useReferer: b
|
||||
headers['Host'] = new URL(url).hostname;
|
||||
} else {
|
||||
url = options.url;
|
||||
proxy = options.proxy;
|
||||
if (options.headers) {
|
||||
if (typeof options.headers === 'string') {
|
||||
headers = JSON.parse(options.headers);
|
||||
@@ -115,6 +120,18 @@ async function tryDownload (options: string | HttpDownloadOptions, useReferer: b
|
||||
if (useReferer && !headers['Referer']) {
|
||||
headers['Referer'] = url;
|
||||
}
|
||||
|
||||
// 如果配置了代理,使用代理下载
|
||||
if (proxy) {
|
||||
try {
|
||||
const response = await httpDownloadWithProxy(url, headers, proxy);
|
||||
return new Response(response, { status: 200, statusText: 'OK' });
|
||||
} catch (proxyError) {
|
||||
// 如果代理失败,记录错误并尝试直接下载
|
||||
console.error('代理下载失败,尝试直接下载:', proxyError);
|
||||
}
|
||||
}
|
||||
|
||||
const fetchRes = await fetch(url, { headers, redirect: 'follow' }).catch((err) => {
|
||||
if (err.cause) {
|
||||
throw err.cause;
|
||||
@@ -124,6 +141,220 @@ async function tryDownload (options: string | HttpDownloadOptions, useReferer: b
|
||||
return fetchRes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 HTTP/HTTPS 代理下载文件
|
||||
*/
|
||||
function httpDownloadWithProxy (url: string, headers: Record<string, string>, proxy: string): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const targetUrl = new URL(url);
|
||||
const proxyUrl = new URL(proxy);
|
||||
|
||||
const isTargetHttps = targetUrl.protocol === 'https:';
|
||||
const proxyPort = parseInt(proxyUrl.port) || (proxyUrl.protocol === 'https:' ? 443 : 80);
|
||||
|
||||
// 代理认证头
|
||||
const proxyAuthHeader = proxyUrl.username && proxyUrl.password
|
||||
? { 'Proxy-Authorization': 'Basic ' + Buffer.from(`${decodeURIComponent(proxyUrl.username)}:${decodeURIComponent(proxyUrl.password)}`).toString('base64') }
|
||||
: {};
|
||||
|
||||
if (isTargetHttps) {
|
||||
// HTTPS 目标:需要通过 CONNECT 建立隧道
|
||||
const connectReq = http.request({
|
||||
host: proxyUrl.hostname,
|
||||
port: proxyPort,
|
||||
method: 'CONNECT',
|
||||
path: `${targetUrl.hostname}:${targetUrl.port || 443}`,
|
||||
headers: {
|
||||
'Host': `${targetUrl.hostname}:${targetUrl.port || 443}`,
|
||||
...proxyAuthHeader,
|
||||
},
|
||||
});
|
||||
|
||||
connectReq.on('connect', (res, socket) => {
|
||||
if (res.statusCode !== 200) {
|
||||
socket.destroy();
|
||||
reject(new Error(`代理 CONNECT 失败: ${res.statusCode} ${res.statusMessage}`));
|
||||
return;
|
||||
}
|
||||
|
||||
// 在隧道上建立 TLS 连接
|
||||
const tlsSocket = tls.connect({
|
||||
socket: socket,
|
||||
servername: targetUrl.hostname,
|
||||
rejectUnauthorized: true,
|
||||
}, () => {
|
||||
// TLS 握手成功,发送 HTTP 请求
|
||||
const requestPath = targetUrl.pathname + targetUrl.search;
|
||||
const requestHeaders = {
|
||||
...headers,
|
||||
'Host': targetUrl.hostname,
|
||||
'Connection': 'close',
|
||||
};
|
||||
|
||||
const headerLines = Object.entries(requestHeaders)
|
||||
.map(([key, value]) => `${key}: ${value}`)
|
||||
.join('\r\n');
|
||||
|
||||
const httpRequest = `GET ${requestPath} HTTP/1.1\r\n${headerLines}\r\n\r\n`;
|
||||
tlsSocket.write(httpRequest);
|
||||
});
|
||||
|
||||
// 解析 HTTP 响应
|
||||
let responseData = Buffer.alloc(0);
|
||||
let headersParsed = false;
|
||||
let statusCode = 0;
|
||||
let isChunked = false;
|
||||
let bodyData = Buffer.alloc(0);
|
||||
let redirectLocation: string | null = null;
|
||||
|
||||
tlsSocket.on('data', (chunk: Buffer) => {
|
||||
responseData = Buffer.concat([responseData, chunk]);
|
||||
|
||||
if (!headersParsed) {
|
||||
const headerEndIndex = responseData.indexOf('\r\n\r\n');
|
||||
if (headerEndIndex !== -1) {
|
||||
headersParsed = true;
|
||||
const headerStr = responseData.subarray(0, headerEndIndex).toString();
|
||||
const headerLines = headerStr.split('\r\n');
|
||||
|
||||
// 解析状态码
|
||||
const statusLine = headerLines[0];
|
||||
const statusMatch = statusLine?.match(/HTTP\/\d\.\d\s+(\d+)/);
|
||||
statusCode = statusMatch ? parseInt(statusMatch[1]!) : 0;
|
||||
|
||||
// 解析响应头
|
||||
for (const line of headerLines.slice(1)) {
|
||||
const [key, ...valueParts] = line.split(':');
|
||||
const value = valueParts.join(':').trim();
|
||||
if (key?.toLowerCase() === 'transfer-encoding' && value.toLowerCase() === 'chunked') {
|
||||
isChunked = true;
|
||||
} else if (key?.toLowerCase() === 'location') {
|
||||
redirectLocation = value;
|
||||
}
|
||||
}
|
||||
|
||||
bodyData = responseData.subarray(headerEndIndex + 4);
|
||||
}
|
||||
} else {
|
||||
bodyData = Buffer.concat([bodyData, chunk]);
|
||||
}
|
||||
});
|
||||
|
||||
tlsSocket.on('end', () => {
|
||||
// 处理重定向
|
||||
if (statusCode >= 300 && statusCode < 400 && redirectLocation) {
|
||||
const redirectUrl = redirectLocation.startsWith('http')
|
||||
? redirectLocation
|
||||
: `${targetUrl.protocol}//${targetUrl.host}${redirectLocation}`;
|
||||
httpDownloadWithProxy(redirectUrl, headers, proxy).then(resolve).catch(reject);
|
||||
return;
|
||||
}
|
||||
|
||||
if (statusCode !== 200) {
|
||||
reject(new Error(`下载失败: ${statusCode}`));
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理 chunked 编码
|
||||
if (isChunked) {
|
||||
resolve(parseChunkedBody(bodyData));
|
||||
} else {
|
||||
resolve(bodyData);
|
||||
}
|
||||
});
|
||||
|
||||
tlsSocket.on('error', (err) => {
|
||||
reject(new Error(`TLS 连接错误: ${err.message}`));
|
||||
});
|
||||
});
|
||||
|
||||
connectReq.on('error', (err) => {
|
||||
reject(new Error(`代理连接错误: ${err.message}`));
|
||||
});
|
||||
|
||||
connectReq.end();
|
||||
} else {
|
||||
// HTTP 目标:直接通过代理请求
|
||||
const req = http.request({
|
||||
host: proxyUrl.hostname,
|
||||
port: proxyPort,
|
||||
method: 'GET',
|
||||
path: url, // 完整 URL
|
||||
headers: {
|
||||
...headers,
|
||||
'Host': targetUrl.hostname,
|
||||
...proxyAuthHeader,
|
||||
},
|
||||
}, (response) => {
|
||||
handleResponse(response, resolve, reject, url, headers, proxy);
|
||||
});
|
||||
|
||||
req.on('error', (err) => {
|
||||
reject(new Error(`代理请求错误: ${err.message}`));
|
||||
});
|
||||
|
||||
req.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 chunked 编码的响应体
|
||||
*/
|
||||
function parseChunkedBody (data: Buffer): Buffer {
|
||||
const chunks: Buffer[] = [];
|
||||
let offset = 0;
|
||||
|
||||
while (offset < data.length) {
|
||||
// 查找 chunk 大小行的结束
|
||||
const lineEnd = data.indexOf('\r\n', offset);
|
||||
if (lineEnd === -1) break;
|
||||
|
||||
const sizeStr = data.subarray(offset, lineEnd).toString().split(';')[0]; // 忽略 chunk 扩展
|
||||
const chunkSize = parseInt(sizeStr!, 16);
|
||||
|
||||
if (chunkSize === 0) break; // 最后一个 chunk
|
||||
|
||||
const chunkStart = lineEnd + 2;
|
||||
const chunkEnd = chunkStart + chunkSize;
|
||||
|
||||
if (chunkEnd > data.length) break;
|
||||
|
||||
chunks.push(data.subarray(chunkStart, chunkEnd));
|
||||
offset = chunkEnd + 2; // 跳过 chunk 数据后的 \r\n
|
||||
}
|
||||
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 HTTP 响应
|
||||
*/
|
||||
function handleResponse (
|
||||
response: http.IncomingMessage,
|
||||
resolve: (value: Buffer) => void,
|
||||
reject: (reason: Error) => void,
|
||||
_url: string,
|
||||
headers: Record<string, string>,
|
||||
proxy: string
|
||||
): void {
|
||||
// 处理重定向
|
||||
if (response.statusCode && response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
|
||||
httpDownloadWithProxy(response.headers.location, headers, proxy).then(resolve).catch(reject);
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.statusCode !== 200) {
|
||||
reject(new Error(`下载失败: ${response.statusCode} ${response.statusMessage}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
response.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
response.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
response.on('error', reject);
|
||||
}
|
||||
|
||||
export async function httpDownload (options: string | HttpDownloadOptions): Promise<Buffer> {
|
||||
const useReferer = typeof options === 'string';
|
||||
let resp = await tryDownload(options);
|
||||
@@ -176,7 +407,7 @@ export async function checkUriType (Uri: string) {
|
||||
return { Uri, Type: FileUriType.Unknown };
|
||||
}
|
||||
|
||||
export async function uriToLocalFile (dir: string, uri: string, filename: string = randomUUID(), headers?: Record<string, string>): Promise<Uri2LocalRes> {
|
||||
export async function uriToLocalFile (dir: string, uri: string, filename: string = randomUUID(), headers?: Record<string, string>, proxy?: string): Promise<Uri2LocalRes> {
|
||||
const { Uri: HandledUri, Type: UriType } = await checkUriType(uri);
|
||||
|
||||
const filePath = path.join(dir, filename);
|
||||
@@ -191,7 +422,7 @@ export async function uriToLocalFile (dir: string, uri: string, filename: string
|
||||
}
|
||||
|
||||
case FileUriType.Remote: {
|
||||
const buffer = await httpDownload({ url: HandledUri, headers: headers ?? {} });
|
||||
const buffer = await httpDownload({ url: HandledUri, headers: headers ?? {}, proxy });
|
||||
fs.writeFileSync(filePath, buffer);
|
||||
return { success: true, errMsg: '', fileName: filename, path: filePath };
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ export class CreateFlashTask extends OneBotAction<CreateFlashTaskPayload, any> {
|
||||
override actionName = ActionName.CreateFlashTask;
|
||||
override payloadSchema = CreateFlashTaskPayloadSchema;
|
||||
override returnSchema = Type.Any({ description: '任务创建结果' });
|
||||
override actionSummary = '创建闪照任务';
|
||||
override actionSummary = '创建闪传任务';
|
||||
override actionTags = ['文件扩展'];
|
||||
override payloadExample = {
|
||||
files: 'C:\\test.jpg',
|
||||
|
||||
@@ -12,7 +12,7 @@ export class GetFlashFileList extends OneBotAction<GetFlashFileListPayload, any>
|
||||
override actionName = ActionName.GetFlashFileList;
|
||||
override payloadSchema = GetFlashFileListPayloadSchema;
|
||||
override returnSchema = Type.Any({ description: '文件列表' });
|
||||
override actionSummary = '获取闪照文件列表';
|
||||
override actionSummary = '获取闪传文件列表';
|
||||
override actionTags = ['文件扩展'];
|
||||
override payloadExample = {
|
||||
fileset_id: 'set_123'
|
||||
|
||||
@@ -14,7 +14,7 @@ export class GetFlashFileUrl extends OneBotAction<GetFlashFileUrlPayload, any> {
|
||||
override actionName = ActionName.GetFlashFileUrl;
|
||||
override payloadSchema = GetFlashFileUrlPayloadSchema;
|
||||
override returnSchema = Type.Any({ description: '文件下载链接' });
|
||||
override actionSummary = '获取闪照文件链接';
|
||||
override actionSummary = '获取闪传文件链接';
|
||||
override actionTags = ['文件扩展'];
|
||||
override payloadExample = {
|
||||
fileset_id: 'set_123'
|
||||
|
||||
@@ -15,7 +15,7 @@ export class SendFlashMsg extends OneBotAction<SendFlashMsgPayload, any> {
|
||||
override actionName = ActionName.SendFlashMsg;
|
||||
override payloadSchema = SendFlashMsgPayloadSchema;
|
||||
override returnSchema = Type.Any({ description: '发送结果' });
|
||||
override actionSummary = '发送闪照消息';
|
||||
override actionSummary = '发送闪传消息';
|
||||
override actionTags = ['文件扩展'];
|
||||
override payloadExample = {
|
||||
fileset_id: 'set_123',
|
||||
|
||||
@@ -1288,7 +1288,8 @@ export class OneBotMsgApi {
|
||||
}
|
||||
realUri = await this.handleObfuckName(realUri) ?? realUri;
|
||||
try {
|
||||
const { path, fileName, errMsg, success } = await uriToLocalFile(this.core.NapCatTempPath, realUri);
|
||||
const proxy = this.obContext.configLoader.configData.imageDownloadProxy || undefined;
|
||||
const { path, fileName, errMsg, success } = await uriToLocalFile(this.core.NapCatTempPath, realUri, undefined, undefined, proxy);
|
||||
if (!success) {
|
||||
this.core.context.logger.logError('文件处理失败', errMsg);
|
||||
throw new Error('文件处理失败: ' + errMsg);
|
||||
|
||||
@@ -82,6 +82,7 @@ export const OneBotConfigSchema = Type.Object({
|
||||
musicSignUrl: Type.String({ default: '' }),
|
||||
enableLocalFile2Url: Type.Boolean({ default: false }),
|
||||
parseMultMsg: Type.Boolean({ default: false }),
|
||||
imageDownloadProxy: Type.String({ default: '' }),
|
||||
});
|
||||
|
||||
export type OneBotConfig = Static<typeof OneBotConfigSchema>;
|
||||
|
||||
@@ -9,50 +9,152 @@ import path from 'path';
|
||||
|
||||
export interface PluginPackageJson {
|
||||
name?: string;
|
||||
plugin?: string;
|
||||
version?: string;
|
||||
main?: string;
|
||||
description?: string;
|
||||
author?: string;
|
||||
}
|
||||
|
||||
export interface PluginConfigItem {
|
||||
key: string;
|
||||
type: 'string' | 'number' | 'boolean' | 'select' | 'multi-select' | 'html' | 'text';
|
||||
label: string;
|
||||
description?: string;
|
||||
default?: any;
|
||||
options?: { label: string; value: string | number; }[];
|
||||
placeholder?: string;
|
||||
/** 标记此字段为响应式:值变化时触发 schema 刷新 */
|
||||
reactive?: boolean;
|
||||
/** 是否隐藏此字段 */
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
/** 插件配置 UI 控制器 - 用于动态控制配置界面 */
|
||||
export interface PluginConfigUIController {
|
||||
/** 更新整个 schema */
|
||||
updateSchema: (schema: PluginConfigSchema) => void;
|
||||
/** 更新单个字段 */
|
||||
updateField: (key: string, field: Partial<PluginConfigItem>) => void;
|
||||
/** 移除字段 */
|
||||
removeField: (key: string) => void;
|
||||
/** 添加字段 */
|
||||
addField: (field: PluginConfigItem, afterKey?: string) => void;
|
||||
/** 显示字段 */
|
||||
showField: (key: string) => void;
|
||||
/** 隐藏字段 */
|
||||
hideField: (key: string) => void;
|
||||
/** 获取当前配置值 */
|
||||
getCurrentConfig: () => Record<string, any>;
|
||||
}
|
||||
|
||||
export class NapCatConfig {
|
||||
static text (key: string, label: string, defaultValue?: string, description?: string, reactive?: boolean): PluginConfigItem {
|
||||
return { key, type: 'string', label, default: defaultValue, description, reactive };
|
||||
}
|
||||
static number (key: string, label: string, defaultValue?: number, description?: string, reactive?: boolean): PluginConfigItem {
|
||||
return { key, type: 'number', label, default: defaultValue, description, reactive };
|
||||
}
|
||||
static boolean (key: string, label: string, defaultValue?: boolean, description?: string, reactive?: boolean): PluginConfigItem {
|
||||
return { key, type: 'boolean', label, default: defaultValue, description, reactive };
|
||||
}
|
||||
static select (key: string, label: string, options: { label: string; value: string | number; }[], defaultValue?: string | number, description?: string, reactive?: boolean): PluginConfigItem {
|
||||
return { key, type: 'select', label, options, default: defaultValue, description, reactive };
|
||||
}
|
||||
static multiSelect (key: string, label: string, options: { label: string; value: string | number; }[], defaultValue?: (string | number)[], description?: string, reactive?: boolean): PluginConfigItem {
|
||||
return { key, type: 'multi-select', label, options, default: defaultValue, description, reactive };
|
||||
}
|
||||
static html (content: string): PluginConfigItem {
|
||||
return { key: `_html_${Math.random().toString(36).slice(2)}`, type: 'html', label: '', default: content };
|
||||
}
|
||||
static plainText (content: string): PluginConfigItem {
|
||||
return { key: `_text_${Math.random().toString(36).slice(2)}`, type: 'text', label: '', default: content };
|
||||
}
|
||||
static combine (...items: PluginConfigItem[]): PluginConfigSchema {
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
||||
export type PluginConfigSchema = PluginConfigItem[];
|
||||
|
||||
/**
|
||||
* 插件日志接口 - 简化的日志 API
|
||||
*/
|
||||
export interface PluginLogger {
|
||||
/** 普通日志 */
|
||||
log (...args: any[]): void;
|
||||
/** 调试日志 */
|
||||
debug (...args: any[]): void;
|
||||
/** 信息日志 */
|
||||
info (...args: any[]): void;
|
||||
/** 警告日志 */
|
||||
warn (...args: any[]): void;
|
||||
/** 错误日志 */
|
||||
error (...args: any[]): void;
|
||||
}
|
||||
|
||||
export interface NapCatPluginContext {
|
||||
core: NapCatCore;
|
||||
oneBot: NapCatOneBot11Adapter;
|
||||
actions: ActionMap;
|
||||
pluginName: string;
|
||||
pluginPath: string;
|
||||
configPath: string;
|
||||
dataPath: string;
|
||||
NapCatConfig: typeof NapCatConfig;
|
||||
adapterName: string;
|
||||
pluginManager: OB11PluginMangerAdapter;
|
||||
/** 插件日志器 - 自动添加插件名称前缀 */
|
||||
logger: PluginLogger;
|
||||
}
|
||||
|
||||
export interface PluginModule<T extends OB11EmitEventContent = OB11EmitEventContent> {
|
||||
plugin_init: (
|
||||
core: NapCatCore,
|
||||
obContext: NapCatOneBot11Adapter,
|
||||
actions: ActionMap,
|
||||
instance: OB11PluginMangerAdapter
|
||||
) => void | Promise<void>;
|
||||
plugin_init: (ctx: NapCatPluginContext) => void | Promise<void>;
|
||||
plugin_onmessage?: (
|
||||
adapter: string,
|
||||
core: NapCatCore,
|
||||
obCtx: NapCatOneBot11Adapter,
|
||||
ctx: NapCatPluginContext,
|
||||
event: OB11Message,
|
||||
actions: ActionMap,
|
||||
instance: OB11PluginMangerAdapter
|
||||
) => void | Promise<void>;
|
||||
plugin_onevent?: (
|
||||
adapter: string,
|
||||
core: NapCatCore,
|
||||
obCtx: NapCatOneBot11Adapter,
|
||||
ctx: NapCatPluginContext,
|
||||
event: T,
|
||||
actions: ActionMap,
|
||||
instance: OB11PluginMangerAdapter
|
||||
) => void | Promise<void>;
|
||||
plugin_cleanup?: (
|
||||
core: NapCatCore,
|
||||
obContext: NapCatOneBot11Adapter,
|
||||
actions: ActionMap,
|
||||
instance: OB11PluginMangerAdapter
|
||||
ctx: NapCatPluginContext
|
||||
) => void | Promise<void>;
|
||||
plugin_config_schema?: PluginConfigSchema;
|
||||
plugin_config_ui?: PluginConfigSchema;
|
||||
plugin_get_config?: (ctx: NapCatPluginContext) => any | Promise<any>;
|
||||
plugin_set_config?: (ctx: NapCatPluginContext, config: any) => void | Promise<void>;
|
||||
/**
|
||||
* 配置界面控制器 - 当配置界面打开时调用
|
||||
* 返回清理函数,在界面关闭时调用
|
||||
*/
|
||||
plugin_config_controller?: (
|
||||
ctx: NapCatPluginContext,
|
||||
ui: PluginConfigUIController,
|
||||
initialConfig: Record<string, any>
|
||||
) => void | (() => void) | Promise<void | (() => void)>;
|
||||
/**
|
||||
* 响应式字段变化回调 - 当标记为 reactive 的字段值变化时调用
|
||||
*/
|
||||
plugin_on_config_change?: (
|
||||
ctx: NapCatPluginContext,
|
||||
ui: PluginConfigUIController,
|
||||
key: string,
|
||||
value: any,
|
||||
currentConfig: Record<string, any>
|
||||
) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface LoadedPlugin {
|
||||
name: string;
|
||||
fileId: string; // 文件系统目录名,用于路径解析
|
||||
version?: string;
|
||||
pluginPath: string;
|
||||
entryPath: string;
|
||||
packageJson?: PluginPackageJson;
|
||||
module: PluginModule;
|
||||
context: NapCatPluginContext; // Store context
|
||||
}
|
||||
|
||||
export interface PluginStatusConfig {
|
||||
@@ -62,11 +164,15 @@ export interface PluginStatusConfig {
|
||||
export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
|
||||
private readonly pluginPath: string;
|
||||
private readonly configPath: string;
|
||||
private loadedPlugins: Map<string, LoadedPlugin> = new Map();
|
||||
/** 插件注册表: 包名(id) -> 插件数据 */
|
||||
private pluginRegistry: Map<string, LoadedPlugin> = new Map();
|
||||
/** 失败的插件: ID -> 错误信息 */
|
||||
private failedPlugins: Map<string, string> = new Map();
|
||||
declare config: PluginConfig;
|
||||
public NapCatConfig = NapCatConfig;
|
||||
|
||||
override get isActive (): boolean {
|
||||
return this.isEnable && this.loadedPlugins.size > 0;
|
||||
return this.isEnable && this.pluginRegistry.size > 0;
|
||||
}
|
||||
|
||||
constructor (
|
||||
@@ -123,100 +229,49 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
|
||||
const items = fs.readdirSync(this.pluginPath, { withFileTypes: true });
|
||||
const pluginConfig = this.loadPluginConfig();
|
||||
|
||||
// 扫描文件和目录
|
||||
// 扫描文件和目录 (Only support directories as plugins now)
|
||||
for (const item of items) {
|
||||
let pluginName = '';
|
||||
if (item.isFile()) {
|
||||
pluginName = path.parse(item.name).name;
|
||||
} else if (item.isDirectory()) {
|
||||
pluginName = item.name;
|
||||
}
|
||||
|
||||
// Check if plugin is disabled in config
|
||||
if (pluginConfig[pluginName] === false) {
|
||||
this.logger.log(`[Plugin Adapter] Plugin ${pluginName} is disabled in config, skipping`);
|
||||
if (!item.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 先读取 package.json 获取包名(id)
|
||||
const packageJsonPath = path.join(this.pluginPath, item.name, 'package.json');
|
||||
let pluginId = item.name; // 默认使用目录名
|
||||
if (fs.existsSync(packageJsonPath)) {
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
|
||||
if (pkg.name) {
|
||||
pluginId = pkg.name;
|
||||
}
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
// Check if plugin is disabled in config (by id)
|
||||
if (pluginConfig[pluginId] === false) {
|
||||
this.logger.log(`[Plugin Adapter] Plugin ${pluginId} is disabled in config, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.isFile()) {
|
||||
// 处理单文件插件
|
||||
await this.loadFilePlugin(item.name);
|
||||
} else if (item.isDirectory()) {
|
||||
// 处理目录插件
|
||||
await this.loadDirectoryPlugin(item.name);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`[Plugin Adapter] Loaded ${this.loadedPlugins.size} plugins`
|
||||
`[Plugin Adapter] Loaded ${this.pluginRegistry.size} plugins`
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.logError('[Plugin Adapter] Error loading plugins:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载单文件插件 (.mjs, .js)
|
||||
*/
|
||||
public async loadFilePlugin (filename: string): Promise<void> {
|
||||
// 只处理支持的文件类型
|
||||
if (!this.isSupportedFile(filename)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filePath = path.join(this.pluginPath, filename);
|
||||
const pluginName = path.parse(filename).name;
|
||||
const pluginConfig = this.loadPluginConfig();
|
||||
|
||||
// Check if plugin is disabled in config
|
||||
if (pluginConfig[pluginName] === false) {
|
||||
this.logger.log(`[Plugin Adapter] Plugin ${pluginName} is disabled by user`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const module = await this.importModule(filePath);
|
||||
if (!this.isValidPluginModule(module)) {
|
||||
this.logger.logWarn(
|
||||
`[Plugin Adapter] File ${filename} is not a valid plugin (missing plugin methods)`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const plugin: LoadedPlugin = {
|
||||
name: pluginName,
|
||||
pluginPath: this.pluginPath,
|
||||
entryPath: filePath,
|
||||
module,
|
||||
};
|
||||
|
||||
await this.registerPlugin(plugin);
|
||||
} catch (error) {
|
||||
this.logger.logError(
|
||||
`[Plugin Adapter] Error loading file plugin ${filename}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
// loadFilePlugin removed
|
||||
|
||||
/**
|
||||
* 加载目录插件
|
||||
*/
|
||||
public async loadDirectoryPlugin (dirname: string): Promise<void> {
|
||||
const pluginDir = path.join(this.pluginPath, dirname);
|
||||
const pluginConfig = this.loadPluginConfig();
|
||||
|
||||
// Ideally we'd get the name from package.json first, but we can use dirname as a fallback identifier initially.
|
||||
// However, the list scan uses item.name (dirname) as the key. Let's stick to using dirname/filename as the config key for simplicity and consistency.
|
||||
// Wait, package.json name might override. But for management, consistent ID is better.
|
||||
// Let's check config after parsing package.json?
|
||||
// User expects to disable 'plugin-name'. But if multiple folders have same name? Not handled.
|
||||
// Let's use dirname as the key for config to be consistent with file system.
|
||||
|
||||
if (pluginConfig[dirname] === false) {
|
||||
this.logger.log(`[Plugin Adapter] Plugin ${dirname} is disabled by user`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 尝试读取 package.json
|
||||
@@ -235,8 +290,15 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if disabled by package name IF package.json exists?
|
||||
// No, file system name is more reliable ID for resource management here.
|
||||
// 获取插件 id(包名)
|
||||
const pluginId = packageJson?.name || dirname;
|
||||
|
||||
// 检查插件是否被禁用 (by id)
|
||||
const pluginConfig = this.loadPluginConfig();
|
||||
if (pluginConfig[pluginId] === false) {
|
||||
this.logger.log(`[Plugin Adapter] Plugin ${pluginId} is disabled by user`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 确定入口文件
|
||||
const entryFile = this.findEntryFile(pluginDir, packageJson);
|
||||
@@ -258,12 +320,14 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
|
||||
}
|
||||
|
||||
const plugin: LoadedPlugin = {
|
||||
name: packageJson?.name || dirname,
|
||||
name: pluginId, // 使用包名作为 id
|
||||
fileId: dirname, // 保留目录名用于路径解析
|
||||
version: packageJson?.version,
|
||||
pluginPath: pluginDir,
|
||||
entryPath,
|
||||
packageJson,
|
||||
module,
|
||||
context: {} as NapCatPluginContext // Will be populated in registerPlugin
|
||||
};
|
||||
|
||||
await this.registerPlugin(plugin);
|
||||
@@ -301,13 +365,7 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为支持的文件类型
|
||||
*/
|
||||
private isSupportedFile (filename: string): boolean {
|
||||
const ext = path.extname(filename).toLowerCase();
|
||||
return ['.mjs', '.js'].includes(ext);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 动态导入模块
|
||||
@@ -332,14 +390,47 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
|
||||
*/
|
||||
private async registerPlugin (plugin: LoadedPlugin): Promise<void> {
|
||||
// 检查名称冲突
|
||||
if (this.loadedPlugins.has(plugin.name)) {
|
||||
if (this.pluginRegistry.has(plugin.name)) {
|
||||
this.logger.logWarn(
|
||||
`[Plugin Adapter] Plugin name conflict: ${plugin.name}, skipping...`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.loadedPlugins.set(plugin.name, plugin);
|
||||
// Create Context
|
||||
const dataPath = path.join(plugin.pluginPath, 'data');
|
||||
const configPath = path.join(dataPath, 'config.json');
|
||||
|
||||
// Create plugin-specific logger with prefix
|
||||
const pluginPrefix = `[Plugin: ${plugin.name}]`;
|
||||
const coreLogger = this.logger;
|
||||
const pluginLogger: PluginLogger = {
|
||||
log: (...args: any[]) => coreLogger.log(pluginPrefix, ...args),
|
||||
debug: (...args: any[]) => coreLogger.logDebug(pluginPrefix, ...args),
|
||||
info: (...args: any[]) => coreLogger.log(pluginPrefix, ...args),
|
||||
warn: (...args: any[]) => coreLogger.logWarn(pluginPrefix, ...args),
|
||||
error: (...args: any[]) => coreLogger.logError(pluginPrefix, ...args),
|
||||
};
|
||||
|
||||
const context: NapCatPluginContext = {
|
||||
core: this.core,
|
||||
oneBot: this.obContext,
|
||||
actions: this.actions,
|
||||
pluginName: plugin.name, // Use package name for identification
|
||||
pluginPath: plugin.pluginPath,
|
||||
dataPath: dataPath,
|
||||
configPath: configPath,
|
||||
NapCatConfig: NapCatConfig,
|
||||
adapterName: this.name,
|
||||
pluginManager: this,
|
||||
logger: pluginLogger
|
||||
};
|
||||
|
||||
plugin.context = context; // Store context on plugin object
|
||||
|
||||
// 注册到映射表
|
||||
this.pluginRegistry.set(plugin.name, plugin);
|
||||
|
||||
this.logger.log(
|
||||
`[Plugin Adapter] Registered plugin: ${plugin.name}${plugin.version ? ` v${plugin.version}` : ''
|
||||
}`
|
||||
@@ -347,18 +438,16 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
|
||||
|
||||
// 调用插件初始化方法(必须存在)
|
||||
try {
|
||||
await plugin.module.plugin_init(
|
||||
this.core,
|
||||
this.obContext,
|
||||
this.actions,
|
||||
this
|
||||
);
|
||||
await plugin.module.plugin_init(context);
|
||||
this.logger.log(`[Plugin Adapter] Initialized plugin: ${plugin.name}`);
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
this.logger.logError(
|
||||
`[Plugin Adapter] Error initializing plugin ${plugin.name}:`,
|
||||
error
|
||||
);
|
||||
// Mark as failed
|
||||
this.failedPlugins.set(plugin.name, error.message || 'Initialization failed');
|
||||
this.pluginRegistry.delete(plugin.name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -366,7 +455,7 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
|
||||
* 卸载插件
|
||||
*/
|
||||
private async unloadPlugin (pluginName: string): Promise<void> {
|
||||
const plugin = this.loadedPlugins.get(pluginName);
|
||||
const plugin = this.pluginRegistry.get(pluginName);
|
||||
if (!plugin) {
|
||||
return;
|
||||
}
|
||||
@@ -374,12 +463,7 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
|
||||
// 调用插件清理方法
|
||||
if (typeof plugin.module.plugin_cleanup === 'function') {
|
||||
try {
|
||||
await plugin.module.plugin_cleanup(
|
||||
this.core,
|
||||
this.obContext,
|
||||
this.actions,
|
||||
this
|
||||
);
|
||||
await plugin.module.plugin_cleanup(plugin.context);
|
||||
this.logger.log(`[Plugin Adapter] Cleaned up plugin: ${pluginName}`);
|
||||
} catch (error) {
|
||||
this.logger.logError(
|
||||
@@ -389,7 +473,8 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
|
||||
}
|
||||
}
|
||||
|
||||
this.loadedPlugins.delete(pluginName);
|
||||
// 从映射表中移除
|
||||
this.pluginRegistry.delete(pluginName);
|
||||
this.logger.log(`[Plugin Adapter] Unloaded plugin: ${pluginName}`);
|
||||
}
|
||||
|
||||
@@ -405,66 +490,33 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
|
||||
return this.loadPluginConfig();
|
||||
}
|
||||
|
||||
public setPluginStatus (pluginName: string, enable: boolean): void {
|
||||
/**
|
||||
* 设置插件状态(启用/禁用)
|
||||
* @param pluginId 插件包名(id)
|
||||
* @param enable 是否启用
|
||||
*/
|
||||
public setPluginStatus (pluginId: string, enable: boolean): void {
|
||||
const config = this.loadPluginConfig();
|
||||
config[pluginName] = enable;
|
||||
config[pluginId] = enable;
|
||||
this.savePluginConfig(config);
|
||||
|
||||
// If disabling, unload immediately if loaded
|
||||
// 如果禁用且插件已加载,则卸载
|
||||
if (!enable) {
|
||||
// Note: pluginName passed here might be the package name or the filename/dirname
|
||||
// But our registerPlugin uses plugin.name which comes from package.json or dirname.
|
||||
// This mismatch is tricky.
|
||||
// Ideally, we should use a consistent ID.
|
||||
// Let's assume pluginName passed here effectively matches the ID used in loadedPlugins.
|
||||
// But wait, loadDirectoryPlugin logic: name = packageJson.name || dirname.
|
||||
// config key = dirname.
|
||||
// If packageJson.name != dirname, we have a problem.
|
||||
// To fix this properly:
|
||||
// 1. We need to know which LoadedPlugin corresponds to the enabled/disabled item.
|
||||
// 2. Or we iterate loadedPlugins and find match.
|
||||
|
||||
for (const [_, loaded] of this.loadedPlugins.entries()) {
|
||||
const dirOrFile = path.basename(loaded.pluginPath === this.pluginPath ? loaded.entryPath : loaded.pluginPath);
|
||||
const ext = path.extname(dirOrFile);
|
||||
const simpleName = ext ? path.parse(dirOrFile).name : dirOrFile; // filename without ext
|
||||
|
||||
// But wait, config key is the FILENAME (with ext for files?).
|
||||
// In Scan loop:
|
||||
// pluginName = path.parse(item.name).name (for file)
|
||||
// pluginName = item.name (for dir)
|
||||
// config[pluginName] check.
|
||||
|
||||
// So if file is "test.js", pluginName is "test". Config key "test".
|
||||
// If dir is "test-plugin", pluginName is "test-plugin". Config key "test-plugin".
|
||||
|
||||
// loadedPlugin.name might be distinct.
|
||||
// So we need to match loadedPlugin back to its fs source to unload it?
|
||||
|
||||
// loadedPlugin.entryPath or pluginPath helps.
|
||||
// If it's a file plugin: loaded.entryPath ends with pluginName + ext.
|
||||
// If it's a dir plugin: loaded.pluginPath ends with pluginName.
|
||||
|
||||
if (pluginName === simpleName) {
|
||||
this.unloadPlugin(loaded.name).catch(e => this.logger.logError('Error unloading', e));
|
||||
const plugin = this.pluginRegistry.get(pluginId);
|
||||
if (plugin) {
|
||||
this.unloadPlugin(pluginId).catch(e => this.logger.logError('Error unloading', e));
|
||||
}
|
||||
}
|
||||
}
|
||||
// If enabling, we need to load it.
|
||||
// But we can just rely on the API handler to call loadFile/DirectoryPlugin which now checks config.
|
||||
// Wait, if I call loadFilePlugin("test.js") and config says enable=true, it loads.
|
||||
// API handler needs to change to pass filename/dirname.
|
||||
}
|
||||
|
||||
async onEvent<T extends OB11EmitEventContent> (event: T) {
|
||||
if (!this.isEnable) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 遍历所有已加载的插件,调用它们的事件处理方法
|
||||
try {
|
||||
await Promise.allSettled(
|
||||
Array.from(this.loadedPlugins.values()).map((plugin) =>
|
||||
Array.from(this.pluginRegistry.values()).map((plugin) =>
|
||||
this.callPluginEventHandler(plugin, event)
|
||||
)
|
||||
);
|
||||
@@ -484,12 +536,8 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
|
||||
// 优先使用 plugin_onevent 方法
|
||||
if (typeof plugin.module.plugin_onevent === 'function') {
|
||||
await plugin.module.plugin_onevent(
|
||||
this.name,
|
||||
this.core,
|
||||
this.obContext,
|
||||
event,
|
||||
this.actions,
|
||||
this
|
||||
plugin.context,
|
||||
event
|
||||
);
|
||||
}
|
||||
|
||||
@@ -499,12 +547,8 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
|
||||
typeof plugin.module.plugin_onmessage === 'function'
|
||||
) {
|
||||
await plugin.module.plugin_onmessage(
|
||||
this.name,
|
||||
this.core,
|
||||
this.obContext,
|
||||
event as OB11Message,
|
||||
this.actions,
|
||||
this
|
||||
plugin.context,
|
||||
event as OB11Message
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -527,7 +571,7 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
|
||||
await this.loadPlugins();
|
||||
|
||||
this.logger.log(
|
||||
`[Plugin Adapter] Plugin adapter opened with ${this.loadedPlugins.size} plugins loaded`
|
||||
`[Plugin Adapter] Plugin adapter opened with ${this.pluginRegistry.size} plugins loaded`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -540,7 +584,7 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
|
||||
this.isEnable = false;
|
||||
|
||||
// 卸载所有插件
|
||||
const pluginNames = Array.from(this.loadedPlugins.keys());
|
||||
const pluginNames = Array.from(this.pluginRegistry.keys());
|
||||
for (const pluginName of pluginNames) {
|
||||
await this.unloadPlugin(pluginName);
|
||||
}
|
||||
@@ -563,54 +607,119 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
|
||||
* 获取已加载的插件列表
|
||||
*/
|
||||
public getLoadedPlugins (): LoadedPlugin[] {
|
||||
return Array.from(this.loadedPlugins.values());
|
||||
return Array.from(this.pluginRegistry.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件信息
|
||||
* 通过包名(id)获取插件信息
|
||||
*/
|
||||
public getPluginInfo (pluginName: string): LoadedPlugin | undefined {
|
||||
return this.loadedPlugins.get(pluginName);
|
||||
public getPluginInfo (pluginId: string): LoadedPlugin | undefined {
|
||||
return this.pluginRegistry.get(pluginId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 id 加载插件
|
||||
*/
|
||||
public async loadPluginById (id: string): Promise<boolean> {
|
||||
// 扫描文件系统查找 fileId
|
||||
if (!fs.existsSync(this.pluginPath)) {
|
||||
this.logger.logWarn(`[Plugin Adapter] Plugin ${id} not found in filesystem`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const items = fs.readdirSync(this.pluginPath, { withFileTypes: true });
|
||||
for (const item of items) {
|
||||
if (!item.isDirectory()) continue;
|
||||
|
||||
const packageJsonPath = path.join(this.pluginPath, item.name, 'package.json');
|
||||
if (fs.existsSync(packageJsonPath)) {
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
|
||||
if (pkg.name === id) {
|
||||
await this.loadDirectoryPlugin(item.name);
|
||||
return this.pluginRegistry.has(id);
|
||||
}
|
||||
} catch (e) { }
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.logWarn(`[Plugin Adapter] Plugin ${id} not found in filesystem`);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 卸载并删除插件
|
||||
*/
|
||||
public async uninstallPlugin (id: string, cleanData: boolean = false): Promise<void> {
|
||||
const plugin = this.pluginRegistry.get(id);
|
||||
if (!plugin) {
|
||||
throw new Error(`Plugin ${id} not found or not loaded`);
|
||||
}
|
||||
|
||||
const pluginPath = plugin.context.pluginPath;
|
||||
const dataPath = plugin.context.dataPath;
|
||||
|
||||
// 先卸载插件
|
||||
await this.unloadPlugin(id);
|
||||
|
||||
// 删除插件目录
|
||||
if (fs.existsSync(pluginPath)) {
|
||||
fs.rmSync(pluginPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// 清理数据
|
||||
if (cleanData && fs.existsSync(dataPath)) {
|
||||
fs.rmSync(dataPath, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重载指定插件
|
||||
*/
|
||||
public async reloadPlugin (pluginName: string): Promise<boolean> {
|
||||
const plugin = this.loadedPlugins.get(pluginName);
|
||||
public async reloadPlugin (pluginId: string): Promise<boolean> {
|
||||
const plugin = this.pluginRegistry.get(pluginId);
|
||||
if (!plugin) {
|
||||
this.logger.logWarn(`[Plugin Adapter] Plugin ${pluginName} not found`);
|
||||
this.logger.logWarn(`[Plugin Adapter] Plugin ${pluginId} not found`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const dirname = path.basename(plugin.pluginPath);
|
||||
|
||||
try {
|
||||
// 卸载插件
|
||||
await this.unloadPlugin(pluginName);
|
||||
await this.unloadPlugin(pluginId);
|
||||
|
||||
// 重新加载插件
|
||||
// Use logic to re-determine if it is directory or file based on original paths
|
||||
// Note: we can't fully trust fs status if it's gone.
|
||||
const isDirectory =
|
||||
plugin.pluginPath !== this.pluginPath; // Simple check: if path is nested, it's a dir plugin
|
||||
|
||||
if (isDirectory) {
|
||||
const dirname = path.basename(plugin.pluginPath);
|
||||
await this.loadDirectoryPlugin(dirname);
|
||||
} else {
|
||||
const filename = path.basename(plugin.entryPath);
|
||||
await this.loadFilePlugin(filename);
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`[Plugin Adapter] Plugin ${pluginName} reloaded successfully`
|
||||
`[Plugin Adapter] Plugin ${pluginId} reloaded successfully`
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.logger.logError(
|
||||
`[Plugin Adapter] Error reloading plugin ${pluginName}:`,
|
||||
`[Plugin Adapter] Error reloading plugin ${pluginId}:`,
|
||||
error
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件数据目录路径
|
||||
*/
|
||||
public getPluginDataPath (pluginId: string): string {
|
||||
const plugin = this.pluginRegistry.get(pluginId);
|
||||
if (!plugin) {
|
||||
throw new Error(`Plugin ${pluginId} not found`);
|
||||
}
|
||||
return plugin.context.dataPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件配置文件路径
|
||||
*/
|
||||
public getPluginConfigPath (pluginId: string): string {
|
||||
return path.join(this.getPluginDataPath(pluginId), 'config.json');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import path from 'path';
|
||||
|
||||
export interface PluginPackageJson {
|
||||
name?: string;
|
||||
plugin?: string;
|
||||
version?: string;
|
||||
main?: string;
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ export class OB11WebSocketClientAdapter extends IOB11NetworkAdapter<WebsocketCli
|
||||
let isClosedByError = false;
|
||||
|
||||
this.connection = new WebSocket(this.config.url, {
|
||||
maxPayload: 1024 * 1024 * 1024,
|
||||
maxPayload: 50 * 1024 * 1024, // 50 MB
|
||||
handshakeTimeout: 2000,
|
||||
perMessageDeflate: false,
|
||||
headers: {
|
||||
|
||||
@@ -32,7 +32,7 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
|
||||
this.wsServer = new WebSocketServer({
|
||||
port: this.config.port,
|
||||
host: this.config.host === '0.0.0.0' ? '' : this.config.host,
|
||||
maxPayload: 1024 * 1024 * 1024,
|
||||
maxPayload: 50 * 1024 * 1024, // 50 MB
|
||||
});
|
||||
this.createServer(this.wsServer);
|
||||
}
|
||||
@@ -237,7 +237,7 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
|
||||
this.wsServer = new WebSocketServer({
|
||||
port: newConfig.port,
|
||||
host: newConfig.host === '0.0.0.0' ? '' : newConfig.host,
|
||||
maxPayload: 1024 * 1024 * 1024,
|
||||
maxPayload: 50 * 1024 * 1024, // 50 MB
|
||||
});
|
||||
this.createServer(this.wsServer);
|
||||
if (newConfig.enable) {
|
||||
|
||||
@@ -1,45 +1,182 @@
|
||||
import type { ActionMap } from 'napcat-types/napcat-onebot/action/index';
|
||||
import { EventType } from 'napcat-types/napcat-onebot/event/index';
|
||||
import type { PluginModule } from 'napcat-types/napcat-onebot/network/plugin-manger';
|
||||
import type { PluginModule, PluginLogger, PluginConfigSchema, PluginConfigUIController } from 'napcat-types/napcat-onebot/network/plugin-manger';
|
||||
import type { OB11Message, OB11PostSendMsg } from 'napcat-types/napcat-onebot/types/index';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { NetworkAdapterConfig } from 'napcat-types/napcat-onebot/config/config';
|
||||
|
||||
|
||||
let actions: ActionMap | undefined = undefined;
|
||||
let startTime: number = Date.now();
|
||||
let logger: PluginLogger | null = null;
|
||||
|
||||
/**
|
||||
* 插件初始化
|
||||
*/
|
||||
const plugin_init: PluginModule['plugin_init'] = async (_core, _obContext, _actions, _instance) => {
|
||||
console.log('[Plugin: builtin] NapCat 内置插件已初始化');
|
||||
actions = _actions;
|
||||
interface BuiltinPluginConfig {
|
||||
prefix: string;
|
||||
enableReply: boolean;
|
||||
description: string;
|
||||
theme?: string;
|
||||
features?: string[];
|
||||
apiUrl?: string;
|
||||
apiEndpoints?: string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
let currentConfig: BuiltinPluginConfig = {
|
||||
prefix: '#napcat',
|
||||
enableReply: true,
|
||||
description: '这是一个内置插件的配置示例'
|
||||
};
|
||||
|
||||
|
||||
export let plugin_config_ui: PluginConfigSchema = [];
|
||||
|
||||
const plugin_init: PluginModule['plugin_init'] = async (ctx) => {
|
||||
logger = ctx.logger;
|
||||
logger.info('NapCat 内置插件已初始化');
|
||||
plugin_config_ui = ctx.NapCatConfig.combine(
|
||||
ctx.NapCatConfig.html('<div style="padding: 10px; background: rgba(0,0,0,0.05); border-radius: 8px;"><h3>👋 Welcome to NapCat Builtin Plugin</h3><p>This is a demonstration of the plugin configuration interface with reactive fields.</p></div>'),
|
||||
ctx.NapCatConfig.text('prefix', 'Command Prefix', '#napcat', 'The prefix to trigger the version info command'),
|
||||
ctx.NapCatConfig.boolean('enableReply', 'Enable Reply', true, 'Switch to enable or disable the reply functionality'),
|
||||
// 代表监听 apiUrl 字段的变化
|
||||
{ ...ctx.NapCatConfig.text('apiUrl', 'API URL', '', 'Enter an API URL to load available endpoints'), reactive: true },
|
||||
ctx.NapCatConfig.select('theme', 'Theme Selection', [
|
||||
{ label: 'Light Mode', value: 'light' },
|
||||
{ label: 'Dark Mode', value: 'dark' },
|
||||
{ label: 'Auto', value: 'auto' }
|
||||
], 'light', 'Select a theme for the response (Demo purpose only)'),
|
||||
ctx.NapCatConfig.multiSelect('features', 'Enabled Features', [
|
||||
{ label: 'Version Info', value: 'version' },
|
||||
{ label: 'Status Report', value: 'status' },
|
||||
{ label: 'Debug Log', value: 'debug' }
|
||||
], ['version'], 'Select features to enable'),
|
||||
ctx.NapCatConfig.text('description', 'Description', '这是一个内置插件的配置示例', 'A multi-line text area for notes')
|
||||
);
|
||||
|
||||
// Try to load config
|
||||
try {
|
||||
// Use ctx.configPath
|
||||
if (fs.existsSync(ctx.configPath)) {
|
||||
const savedConfig = JSON.parse(fs.readFileSync(ctx.configPath, 'utf-8'));
|
||||
Object.assign(currentConfig, savedConfig);
|
||||
}
|
||||
} catch (e) {
|
||||
logger?.warn('Failed to load config', e);
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
export const plugin_get_config: PluginModule['plugin_get_config'] = async () => {
|
||||
return currentConfig;
|
||||
};
|
||||
|
||||
export const plugin_set_config: PluginModule['plugin_set_config'] = async (ctx, config: BuiltinPluginConfig) => {
|
||||
currentConfig = config;
|
||||
if (ctx && ctx.configPath) {
|
||||
try {
|
||||
const configPath = ctx.configPath;
|
||||
const configDir = path.dirname(configPath);
|
||||
if (!fs.existsSync(configDir)) {
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
|
||||
} catch (e) {
|
||||
logger?.error('Failed to save config', e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 消息处理
|
||||
* 当收到包含 #napcat 的消息时,回复版本信息
|
||||
* 响应式配置控制器 - 当插件配置界面打开时调用
|
||||
* 用于初始化动态 UI 控制
|
||||
*/
|
||||
const plugin_onmessage: PluginModule['plugin_onmessage'] = async (adapter, _core, _obCtx, event, _actions, instance) => {
|
||||
if (event.post_type !== EventType.MESSAGE || !event.raw_message.startsWith('#napcat')) {
|
||||
export const plugin_config_controller: PluginModule['plugin_config_controller'] = async (_ctx, ui, initialConfig) => {
|
||||
logger?.info('配置控制器已初始化', initialConfig);
|
||||
|
||||
// 如果初始配置中有 apiUrl,立即加载端点
|
||||
if (initialConfig['apiUrl']) {
|
||||
await loadEndpointsForUrl(ui, initialConfig['apiUrl'] as string);
|
||||
}
|
||||
|
||||
// 返回清理函数
|
||||
return () => {
|
||||
logger?.info('配置控制器已清理');
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 响应式字段变化处理 - 当标记为 reactive 的字段值变化时调用
|
||||
*/
|
||||
export const plugin_on_config_change: PluginModule['plugin_on_config_change'] = async (_ctx, ui, key, value, _currentConfig: Partial<BuiltinPluginConfig>) => {
|
||||
logger?.info(`配置字段变化: ${key} = ${value}`);
|
||||
|
||||
if (key === 'apiUrl') {
|
||||
await loadEndpointsForUrl(ui, value as string);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据 API URL 动态加载端点列表
|
||||
*/
|
||||
async function loadEndpointsForUrl (ui: PluginConfigUIController, apiUrl: string) {
|
||||
if (!apiUrl) {
|
||||
// URL 为空时,移除端点选择字段
|
||||
ui.removeField('apiEndpoints');
|
||||
return;
|
||||
}
|
||||
|
||||
// 模拟从 API 获取端点列表(实际使用时可以 fetch 真实 API)
|
||||
const mockEndpoints = [
|
||||
{ label: `${apiUrl}/users`, value: '/users' },
|
||||
{ label: `${apiUrl}/posts`, value: '/posts' },
|
||||
{ label: `${apiUrl}/comments`, value: '/comments' },
|
||||
{ label: `${apiUrl}/albums`, value: '/albums' },
|
||||
];
|
||||
|
||||
// 动态添加或更新端点选择字段
|
||||
const currentSchema = ui.getCurrentConfig();
|
||||
if ('apiEndpoints' in currentSchema) {
|
||||
// 更新现有字段的选项
|
||||
ui.updateField('apiEndpoints', {
|
||||
options: mockEndpoints,
|
||||
description: `从 ${apiUrl} 加载的端点`
|
||||
});
|
||||
} else {
|
||||
// 添加新字段
|
||||
ui.addField({
|
||||
key: 'apiEndpoints',
|
||||
type: 'multi-select',
|
||||
label: 'API Endpoints',
|
||||
description: `从 ${apiUrl} 加载的端点`,
|
||||
options: mockEndpoints,
|
||||
default: []
|
||||
}, 'apiUrl');
|
||||
}
|
||||
}
|
||||
|
||||
const plugin_onmessage: PluginModule['plugin_onmessage'] = async (_ctx, event) => {
|
||||
if (currentConfig.enableReply === false) {
|
||||
return;
|
||||
}
|
||||
const prefix = currentConfig.prefix || '#napcat';
|
||||
if (event.post_type !== EventType.MESSAGE || !event.raw_message.startsWith(prefix)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const versionInfo = await getVersionInfo(adapter, instance.config);
|
||||
const versionInfo = await getVersionInfo(_ctx.actions, _ctx.adapterName, _ctx.pluginManager.config);
|
||||
if (!versionInfo) return;
|
||||
|
||||
const message = formatVersionMessage(versionInfo);
|
||||
await sendMessage(event, message, adapter, instance.config);
|
||||
await sendMessage(_ctx.actions, event, message, _ctx.adapterName, _ctx.pluginManager.config);
|
||||
|
||||
console.log('[Plugin: builtin] 已回复版本信息');
|
||||
logger?.info('已回复版本信息');
|
||||
} catch (error) {
|
||||
console.error('[Plugin: builtin] 处理消息时发生错误:', error);
|
||||
logger?.error('处理消息时发生错误:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取版本信息(完美的类型推导,无需 as 断言)
|
||||
*/
|
||||
async function getVersionInfo (adapter: string, config: any) {
|
||||
async function getVersionInfo (actions: ActionMap, adapter: string, config: NetworkAdapterConfig) {
|
||||
if (!actions) return null;
|
||||
|
||||
try {
|
||||
@@ -50,14 +187,11 @@ async function getVersionInfo (adapter: string, config: any) {
|
||||
protocolVersion: data.protocol_version,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[Plugin: builtin] 获取版本信息失败:', error);
|
||||
logger?.error('获取版本信息失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化运行时间
|
||||
*/
|
||||
function formatUptime (ms: number): string {
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
@@ -75,20 +209,12 @@ function formatUptime (ms: number): string {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化版本信息消息
|
||||
*/
|
||||
function formatVersionMessage (info: { appName: string; appVersion: string; protocolVersion: string; }) {
|
||||
const uptime = Date.now() - startTime;
|
||||
return `NapCat 信息\n版本: ${info.appVersion}\n平台: ${process.platform}${process.arch === 'x64' ? ' (64-bit)' : ''}\n运行时间: ${formatUptime(uptime)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息(完美的类型推导)
|
||||
*/
|
||||
async function sendMessage (event: OB11Message, message: string, adapter: string, config: any) {
|
||||
if (!actions) return;
|
||||
|
||||
async function sendMessage (actions: ActionMap, event: OB11Message, message: string, adapter: string, config: NetworkAdapterConfig) {
|
||||
const params: OB11PostSendMsg = {
|
||||
message,
|
||||
message_type: event.message_type,
|
||||
@@ -99,7 +225,7 @@ async function sendMessage (event: OB11Message, message: string, adapter: string
|
||||
try {
|
||||
await actions.call('send_msg', params, adapter, config);
|
||||
} catch (error) {
|
||||
console.error('[Plugin: builtin] 发送消息失败:', error);
|
||||
logger?.error('发送消息失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "napcat-plugin-builtin",
|
||||
"plugin": "内置插件",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"main": "index.mjs",
|
||||
"description": "NapCat 内置插件",
|
||||
"author": "NapNeko",
|
||||
"dependencies": {
|
||||
"napcat-types": "0.0.3"
|
||||
"napcat-types": "0.0.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.1"
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
# Example Plugin
|
||||
## Install
|
||||
安装只需要将dist产物 `index.mjs` 目录放入 napcat根目录下`plugins/example`,如果没有这个目录请创建。
|
||||
@@ -1,22 +0,0 @@
|
||||
import type { createActionMap } from 'napcat-types/dist/napcat-onebot/action/index.js';
|
||||
import { EventType } from 'napcat-types/dist/napcat-onebot/event/index.js';
|
||||
import type { PluginModule } from 'napcat-types/dist/napcat-onebot/network/plugin-manger';
|
||||
|
||||
/**
|
||||
* 导入 napcat 包时候不使用 @/napcat...,直接使用 napcat...
|
||||
* 因为 @/napcat... 会导致打包时包含整个 napcat 包,而不是只包含需要的部分
|
||||
*/
|
||||
|
||||
// action 作为参数传递时请用这个
|
||||
let actionMap: ReturnType<typeof createActionMap> | undefined = undefined;
|
||||
|
||||
const plugin_init: PluginModule['plugin_init'] = async (_core, _obContext, _actions, _instance) => {
|
||||
console.log('[Plugin: example] 插件已初始化');
|
||||
actionMap = _actions;
|
||||
};
|
||||
const plugin_onmessage: PluginModule['plugin_onmessage'] = async (adapter, _core, _obCtx, event, actions, instance) => {
|
||||
if (event.post_type === EventType.MESSAGE && event.raw_message.includes('ping')) {
|
||||
await actions.get('send_group_msg')?.handle({ group_id: String(event.group_id), message: 'pong' }, adapter, instance.config);
|
||||
}
|
||||
};
|
||||
export { plugin_init, plugin_onmessage, actionMap };
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"name": "napcat-plugin",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"main": "index.mjs",
|
||||
"description": "一个高级的 NapCat 插件示例",
|
||||
"dependencies": {
|
||||
"napcat-types": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.1"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build"
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": [
|
||||
"*.ts",
|
||||
"**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import { resolve } from 'path';
|
||||
import nodeResolve from '@rollup/plugin-node-resolve';
|
||||
import { builtinModules } from 'module';
|
||||
|
||||
const nodeModules = [...builtinModules, builtinModules.map((m) => `node:${m}`)].flat();
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
conditions: ['node', 'default'],
|
||||
alias: {
|
||||
'@/napcat-core': resolve(__dirname, '../napcat-core'),
|
||||
'@': resolve(__dirname, '../'),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
sourcemap: false,
|
||||
target: 'esnext',
|
||||
minify: false,
|
||||
lib: {
|
||||
entry: 'index.ts',
|
||||
formats: ['es'],
|
||||
fileName: () => 'index.mjs',
|
||||
},
|
||||
rollupOptions: {
|
||||
external: [...nodeModules],
|
||||
},
|
||||
},
|
||||
plugins: [nodeResolve()],
|
||||
});
|
||||
@@ -105,7 +105,8 @@ export function generateOpenAPI () {
|
||||
retcode: 0,
|
||||
data: schemas.returnExample || {},
|
||||
message: '',
|
||||
wording: ''
|
||||
wording: '',
|
||||
stream: 'normal-action'
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -119,7 +120,8 @@ export function generateOpenAPI () {
|
||||
retcode: error.code,
|
||||
data: null,
|
||||
message: error.description,
|
||||
wording: error.description
|
||||
wording: error.description,
|
||||
stream: 'normal-action'
|
||||
}
|
||||
};
|
||||
});
|
||||
@@ -132,7 +134,8 @@ export function generateOpenAPI () {
|
||||
retcode: 1400,
|
||||
data: null,
|
||||
message: '请求参数错误或业务逻辑执行失败',
|
||||
wording: '请求参数错误或业务逻辑执行失败'
|
||||
wording: '请求参数错误或业务逻辑执行失败',
|
||||
stream: 'normal-action'
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -171,7 +174,8 @@ export function generateOpenAPI () {
|
||||
retcode: { type: 'number', description: '返回码' },
|
||||
data: { ...cleanReturn, description: '数据' },
|
||||
message: { type: 'string', description: '消息' },
|
||||
wording: { type: 'string', description: '提示' }
|
||||
wording: { type: 'string', description: '提示' },
|
||||
stream: { type: 'string', description: '流式响应', enum: ['stream-action', 'normal-action'] }
|
||||
},
|
||||
required: ['status', 'retcode', 'data']
|
||||
},
|
||||
|
||||
@@ -38,6 +38,11 @@ let isElectron = false;
|
||||
let isRestarting = false;
|
||||
let isShuttingDown = false;
|
||||
|
||||
// 进程崩溃保护:记录最近的异常退出时间戳
|
||||
const recentCrashTimestamps: number[] = [];
|
||||
const CRASH_TIME_WINDOW = 10000; // 10秒时间窗口
|
||||
const MAX_CRASHES_IN_WINDOW = 3; // 最大崩溃次数
|
||||
|
||||
/**
|
||||
* 获取进程类型名称(用于日志)
|
||||
*/
|
||||
@@ -217,7 +222,23 @@ async function startWorker (passQuickLogin: boolean = true, secretKey?: string,
|
||||
}
|
||||
// 如果不是由于主动重启或关闭引起的退出,尝试自动重新拉起
|
||||
if (!isRestarting && !isShuttingDown) {
|
||||
logger.logWarn(`[NapCat] [${processType}] Worker进程意外退出,正在尝试重新拉起...`);
|
||||
const now = Date.now();
|
||||
|
||||
// 清理超出时间窗口的崩溃记录
|
||||
while (recentCrashTimestamps.length > 0 && now - recentCrashTimestamps[0]! > CRASH_TIME_WINDOW) {
|
||||
recentCrashTimestamps.shift();
|
||||
}
|
||||
|
||||
// 记录本次崩溃
|
||||
recentCrashTimestamps.push(now);
|
||||
|
||||
// 检查是否超过崩溃阈值
|
||||
if (recentCrashTimestamps.length >= MAX_CRASHES_IN_WINDOW) {
|
||||
logger.logError(`[NapCat] [${processType}] Worker进程在 ${CRASH_TIME_WINDOW / 1000} 秒内异常退出 ${MAX_CRASHES_IN_WINDOW} 次,主进程退出`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
logger.logWarn(`[NapCat] [${processType}] Worker进程意外退出 (${recentCrashTimestamps.length}/${MAX_CRASHES_IN_WINDOW}),正在尝试重新拉起...`);
|
||||
startWorker(true).catch(e => {
|
||||
logger.logError(`[NapCat] [${processType}] 重新拉起Worker进程失败:`, e);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/// <reference path="./external-shims.d.ts" />
|
||||
// 聚合导出核心库的所有内容(包括枚举、类和类型)
|
||||
export * from '../napcat-core/index';
|
||||
|
||||
|
||||
@@ -14,13 +14,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/ws": "^8.5.12",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/winston": "^2.4.4",
|
||||
"@types/yaml": "^1.9.7",
|
||||
"@types/ip": "^1.1.3",
|
||||
"@sinclair/typebox": "^0.34.38"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "napcat-types",
|
||||
"version": "0.0.4",
|
||||
"version": "0.0.10",
|
||||
"private": false,
|
||||
"type": "module",
|
||||
"types": "./napcat-types/index.d.ts",
|
||||
@@ -9,13 +9,6 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/ws": "^8.5.12",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/winston": "^2.4.4",
|
||||
"@types/yaml": "^1.9.7",
|
||||
"@types/ip": "^1.1.3",
|
||||
"@sinclair/typebox": "^0.34.38"
|
||||
},
|
||||
"publishConfig": {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// 复制 cp README.md dist/ && cp package.public.json dist/package.json && cp external-shims.d.ts dist/
|
||||
// 复制 cp README.md dist/ && cp package.public.json dist/package.json
|
||||
import { copyFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
@@ -11,7 +11,3 @@ await copyFile(
|
||||
join(__dirname, 'README.md'),
|
||||
join(__dirname, 'dist', 'README.md')
|
||||
);
|
||||
await copyFile(
|
||||
join(__dirname, 'external-shims.d.ts'),
|
||||
join(__dirname, 'dist', 'external-shims.d.ts')
|
||||
);
|
||||
@@ -5,6 +5,111 @@ import { fileURLToPath } from 'node:url';
|
||||
const __dirname = fileURLToPath(new URL('../', import.meta.url));
|
||||
const distDir = join(__dirname, 'dist');
|
||||
|
||||
// 允许保留的包(白名单)
|
||||
const ALLOWED_PACKAGES = [
|
||||
'@sinclair/typebox',
|
||||
'node:', // node: 前缀的内置模块
|
||||
];
|
||||
|
||||
// 外部包类型到 any 的映射
|
||||
const EXTERNAL_TYPE_REPLACEMENTS = {
|
||||
// winston
|
||||
'winston.Logger': 'any',
|
||||
'winston.transport': 'any',
|
||||
// express
|
||||
'express.Express': 'any',
|
||||
'express.Application': 'any',
|
||||
'express.Router': 'any',
|
||||
'Express': 'any',
|
||||
'Request': 'any',
|
||||
'Response': 'any',
|
||||
'NextFunction': 'any',
|
||||
// ws
|
||||
'WebSocket': 'any',
|
||||
'WebSocketServer': 'any',
|
||||
'RawData': 'any',
|
||||
// ajv
|
||||
'Ajv': 'any',
|
||||
'AnySchema': 'any',
|
||||
'ValidateFunction': 'any',
|
||||
'ValidateFunction<T>': 'any',
|
||||
// inversify
|
||||
'Container': 'any',
|
||||
// async-mutex
|
||||
'Mutex': 'any',
|
||||
'Semaphore': 'any',
|
||||
// napcat-protobuf
|
||||
'NapProtoDecodeStructType': 'any',
|
||||
'NapProtoEncodeStructType': 'any',
|
||||
'NapProtoDecodeStructType<T>': 'any',
|
||||
'NapProtoEncodeStructType<T>': 'any',
|
||||
};
|
||||
|
||||
function isAllowedImport (importPath) {
|
||||
return ALLOWED_PACKAGES.some(pkg => importPath.startsWith(pkg));
|
||||
}
|
||||
|
||||
function removeExternalImports (content) {
|
||||
const lines = content.split('\n');
|
||||
const resultLines = [];
|
||||
|
||||
for (const line of lines) {
|
||||
// 匹配 import 语句
|
||||
const importMatch = line.match(/^import\s+.*\s+from\s+['"]([^'"]+)['"]/);
|
||||
if (importMatch) {
|
||||
const importPath = importMatch[1];
|
||||
// 如果是相对路径或白名单包,保留
|
||||
if (importPath.startsWith('.') || importPath.startsWith('/') || isAllowedImport(importPath)) {
|
||||
resultLines.push(line);
|
||||
}
|
||||
// 否则移除该 import
|
||||
continue;
|
||||
}
|
||||
resultLines.push(line);
|
||||
}
|
||||
|
||||
return resultLines.join('\n');
|
||||
}
|
||||
|
||||
function replaceExternalTypes (content) {
|
||||
let result = content;
|
||||
|
||||
// 替换带泛型的类型(先处理复杂的)
|
||||
result = result.replace(/NapProtoDecodeStructType<[^>]+>/g, 'any');
|
||||
result = result.replace(/NapProtoEncodeStructType<[^>]+>/g, 'any');
|
||||
result = result.replace(/ValidateFunction<[^>]+>/g, 'any');
|
||||
|
||||
// 替换 winston.Logger 等带命名空间的类型
|
||||
result = result.replace(/winston\.Logger/g, 'any');
|
||||
result = result.replace(/winston\.transport/g, 'any');
|
||||
result = result.replace(/express\.Express/g, 'any');
|
||||
result = result.replace(/express\.Application/g, 'any');
|
||||
result = result.replace(/express\.Router/g, 'any');
|
||||
|
||||
// 替换独立的类型名(需要小心不要替换变量名)
|
||||
// 使用类型上下文的模式匹配
|
||||
const typeContextPatterns = [
|
||||
// : Type
|
||||
/:\s*(WebSocket|WebSocketServer|RawData|Ajv|AnySchema|ValidateFunction|Container|Mutex|Semaphore|NapProtoDecodeStructType|NapProtoEncodeStructType|Express|Request|Response|NextFunction)(?=\s*[;,)\]\}|&]|$)/g,
|
||||
// <Type>
|
||||
/<(WebSocket|WebSocketServer|RawData|Ajv|AnySchema|ValidateFunction|Container|Mutex|Semaphore|NapProtoDecodeStructType|NapProtoEncodeStructType|Express|Request|Response|NextFunction)>/g,
|
||||
// Type[]
|
||||
/(WebSocket|WebSocketServer|RawData|Ajv|AnySchema|ValidateFunction|Container|Mutex|Semaphore|NapProtoDecodeStructType|NapProtoEncodeStructType|Express|Request|Response|NextFunction)\[\]/g,
|
||||
// extends Type
|
||||
/extends\s+(WebSocket|WebSocketServer|RawData|Ajv|AnySchema|ValidateFunction|Container|Mutex|Semaphore|NapProtoDecodeStructType|NapProtoEncodeStructType|Express|Request|Response|NextFunction)(?=\s*[{,])/g,
|
||||
// implements Type
|
||||
/implements\s+(WebSocket|WebSocketServer|RawData|Ajv|AnySchema|ValidateFunction|Container|Mutex|Semaphore|NapProtoDecodeStructType|NapProtoEncodeStructType|Express|Request|Response|NextFunction)(?=\s*[{,])/g,
|
||||
];
|
||||
|
||||
for (const pattern of typeContextPatterns) {
|
||||
result = result.replace(pattern, (match, typeName) => {
|
||||
return match.replace(typeName, 'any');
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function traverseDirectory (dir) {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
|
||||
@@ -23,7 +128,13 @@ async function processFile (filePath) {
|
||||
// Read file content
|
||||
let content = await readFile(filePath, 'utf-8');
|
||||
|
||||
// Replace "export declare enum" with "export enum"
|
||||
// 1. 移除外部包的 import
|
||||
content = removeExternalImports(content);
|
||||
|
||||
// 2. 替换外部类型为 any
|
||||
content = replaceExternalTypes(content);
|
||||
|
||||
// 3. Replace "export declare enum" with "export enum"
|
||||
content = content.replace(/export declare enum/g, 'export enum');
|
||||
|
||||
// Write back the modified content
|
||||
@@ -33,7 +144,7 @@ async function processFile (filePath) {
|
||||
const newPath = filePath.replace(/\.d\.ts$/, '.ts');
|
||||
await rename(filePath, newPath);
|
||||
|
||||
console.log(`Processed: ${basename(filePath)} -> ${basename(newPath)}`);
|
||||
//console.log(`Processed: ${basename(filePath)} -> ${basename(newPath)}`);
|
||||
}
|
||||
|
||||
console.log('Starting post-build processing...');
|
||||
|
||||
@@ -39,9 +39,6 @@
|
||||
"../napcat-onebot/**/*.ts",
|
||||
"../napcat-common/**/*.ts"
|
||||
],
|
||||
"files": [
|
||||
"./external-shims.d.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
|
||||
230
packages/napcat-webui-backend/src/api/Mirror.ts
Normal file
230
packages/napcat-webui-backend/src/api/Mirror.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import { RequestHandler } from 'express';
|
||||
import { sendSuccess, sendError } from '@/napcat-webui-backend/src/utils/response';
|
||||
import {
|
||||
GITHUB_FILE_MIRRORS,
|
||||
GITHUB_RAW_MIRRORS,
|
||||
buildMirrorUrl,
|
||||
getMirrorConfig,
|
||||
setCustomMirror,
|
||||
clearMirrorCache
|
||||
} from 'napcat-common/src/mirror';
|
||||
import https from 'https';
|
||||
import http from 'http';
|
||||
|
||||
export interface MirrorTestResult {
|
||||
mirror: string;
|
||||
latency: number;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试单个镜像的延迟
|
||||
*/
|
||||
async function testMirrorLatency (mirror: string, testUrl: string, timeout: number = 5000): Promise<MirrorTestResult> {
|
||||
const url = mirror ? buildMirrorUrl(testUrl, mirror) : testUrl;
|
||||
const start = Date.now();
|
||||
|
||||
return new Promise<MirrorTestResult>((resolve) => {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
const isHttps = urlObj.protocol === 'https:';
|
||||
const client = isHttps ? https : http;
|
||||
|
||||
const req = client.request({
|
||||
hostname: urlObj.hostname,
|
||||
port: urlObj.port || (isHttps ? 443 : 80),
|
||||
path: urlObj.pathname + urlObj.search,
|
||||
method: 'HEAD',
|
||||
timeout,
|
||||
headers: {
|
||||
'User-Agent': 'NapCat-Mirror-Test',
|
||||
},
|
||||
}, (res) => {
|
||||
const statusCode = res.statusCode || 0;
|
||||
const isValid = statusCode >= 200 && statusCode < 400;
|
||||
resolve({
|
||||
mirror: mirror || 'https://github.com',
|
||||
latency: Date.now() - start,
|
||||
success: isValid,
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (e) => {
|
||||
resolve({
|
||||
mirror: mirror || 'https://github.com',
|
||||
latency: Date.now() - start,
|
||||
success: false,
|
||||
error: e.message,
|
||||
});
|
||||
});
|
||||
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
resolve({
|
||||
mirror: mirror || 'https://github.com',
|
||||
latency: timeout,
|
||||
success: false,
|
||||
error: 'Timeout',
|
||||
});
|
||||
});
|
||||
|
||||
req.end();
|
||||
} catch (e: any) {
|
||||
resolve({
|
||||
mirror: mirror || 'https://github.com',
|
||||
latency: Date.now() - start,
|
||||
success: false,
|
||||
error: e.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有可用的镜像列表
|
||||
*/
|
||||
export const GetMirrorListHandler: RequestHandler = async (_req, res) => {
|
||||
try {
|
||||
const config = getMirrorConfig();
|
||||
return sendSuccess(res, {
|
||||
fileMirrors: GITHUB_FILE_MIRRORS.filter(m => m),
|
||||
rawMirrors: GITHUB_RAW_MIRRORS,
|
||||
customMirror: config.customMirror,
|
||||
timeout: config.timeout,
|
||||
});
|
||||
} catch (e: any) {
|
||||
return sendError(res, e.message);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 设置自定义镜像
|
||||
*/
|
||||
export const SetCustomMirrorHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
const { mirror } = req.body;
|
||||
setCustomMirror(mirror || '');
|
||||
clearMirrorCache();
|
||||
return sendSuccess(res, { message: 'Mirror set successfully' });
|
||||
} catch (e: any) {
|
||||
return sendError(res, e.message);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* SSE 实时测速所有镜像
|
||||
*/
|
||||
export const TestMirrorsSSEHandler: RequestHandler = async (req, res) => {
|
||||
const { type = 'file' } = req.query;
|
||||
|
||||
// 设置 SSE 响应头
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.flushHeaders();
|
||||
|
||||
const sendProgress = (data: any) => {
|
||||
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
||||
};
|
||||
|
||||
try {
|
||||
// 选择镜像列表
|
||||
let mirrors: string[];
|
||||
let testUrl: string;
|
||||
|
||||
if (type === 'raw') {
|
||||
mirrors = GITHUB_RAW_MIRRORS;
|
||||
testUrl = 'https://raw.githubusercontent.com/NapNeko/NapCatQQ/main/README.md';
|
||||
} else {
|
||||
mirrors = GITHUB_FILE_MIRRORS.filter(m => m);
|
||||
testUrl = 'https://github.com/NapNeko/NapCatQQ/releases/latest';
|
||||
}
|
||||
|
||||
// 添加原始 URL 测试
|
||||
if (!mirrors.includes('')) {
|
||||
mirrors = ['', ...mirrors];
|
||||
}
|
||||
|
||||
sendProgress({
|
||||
type: 'start',
|
||||
total: mirrors.length,
|
||||
message: `开始测试 ${mirrors.length} 个镜像源...`,
|
||||
});
|
||||
|
||||
const results: MirrorTestResult[] = [];
|
||||
const timeout = 5000;
|
||||
|
||||
// 逐个测试并实时推送结果
|
||||
for (let i = 0; i < mirrors.length; i++) {
|
||||
const mirror = mirrors[i] ?? '';
|
||||
const displayName = mirror || 'https://github.com (原始)';
|
||||
|
||||
sendProgress({
|
||||
type: 'testing',
|
||||
index: i,
|
||||
total: mirrors.length,
|
||||
mirror: displayName,
|
||||
message: `正在测试: ${displayName}`,
|
||||
});
|
||||
|
||||
const result = await testMirrorLatency(mirror, testUrl, timeout);
|
||||
results.push(result);
|
||||
|
||||
sendProgress({
|
||||
type: 'result',
|
||||
index: i,
|
||||
total: mirrors.length,
|
||||
result: {
|
||||
...result,
|
||||
mirror: result.mirror || 'https://github.com (原始)',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 按延迟排序
|
||||
const sortedResults = results
|
||||
.filter(r => r.success)
|
||||
.sort((a, b) => a.latency - b.latency);
|
||||
|
||||
const failedResults = results.filter(r => !r.success);
|
||||
|
||||
sendProgress({
|
||||
type: 'complete',
|
||||
results: sortedResults,
|
||||
failed: failedResults,
|
||||
fastest: sortedResults[0] || null,
|
||||
message: `测试完成!${sortedResults.length} 个可用,${failedResults.length} 个失败`,
|
||||
});
|
||||
|
||||
res.end();
|
||||
} catch (e: any) {
|
||||
sendProgress({
|
||||
type: 'error',
|
||||
error: e.message,
|
||||
});
|
||||
res.end();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 快速测试单个镜像
|
||||
*/
|
||||
export const TestSingleMirrorHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
const { mirror, type = 'file' } = req.body;
|
||||
|
||||
let testUrl: string;
|
||||
if (type === 'raw') {
|
||||
testUrl = 'https://raw.githubusercontent.com/NapNeko/NapCatQQ/main/README.md';
|
||||
} else {
|
||||
testUrl = 'https://github.com/NapNeko/NapCatQQ/releases/latest';
|
||||
}
|
||||
|
||||
const result = await testMirrorLatency(mirror || '', testUrl, 5000);
|
||||
|
||||
return sendSuccess(res, result);
|
||||
} catch (e: any) {
|
||||
return sendError(res, e.message);
|
||||
}
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
|
||||
import { sendError, sendSuccess } from '@/napcat-webui-backend/src/utils/response';
|
||||
import { NapCatOneBot11Adapter } from '@/napcat-onebot/index';
|
||||
import { OB11PluginMangerAdapter } from '@/napcat-onebot/network/plugin-manger';
|
||||
import { webUiPathWrapper } from '@/napcat-webui-backend/index';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
@@ -13,6 +14,49 @@ const getPluginManager = (): OB11PluginMangerAdapter | null => {
|
||||
return ob11.networkManager.findSomeAdapter('plugin_manager') as OB11PluginMangerAdapter;
|
||||
};
|
||||
|
||||
// Helper to get OneBot context
|
||||
const getOneBotContext = (): NapCatOneBot11Adapter | null => {
|
||||
return WebUiDataRuntime.getOneBotContext() as NapCatOneBot11Adapter;
|
||||
};
|
||||
|
||||
/**
|
||||
* 手动注册插件管理器到 NetworkManager
|
||||
*/
|
||||
export const RegisterPluginManagerHandler: RequestHandler = async (_req, res) => {
|
||||
const ob11 = getOneBotContext();
|
||||
if (!ob11) {
|
||||
return sendError(res, 'OneBot context not found');
|
||||
}
|
||||
|
||||
// 检查是否已经注册
|
||||
const existingManager = ob11.networkManager.findSomeAdapter('plugin_manager');
|
||||
if (existingManager) {
|
||||
return sendError(res, '插件管理器已经注册');
|
||||
}
|
||||
|
||||
try {
|
||||
// 确保插件目录存在
|
||||
const pluginPath = webUiPathWrapper.pluginPath;
|
||||
if (!fs.existsSync(pluginPath)) {
|
||||
fs.mkdirSync(pluginPath, { recursive: true });
|
||||
}
|
||||
|
||||
// 创建并注册插件管理器
|
||||
const pluginManager = new OB11PluginMangerAdapter(
|
||||
'plugin_manager',
|
||||
ob11.core,
|
||||
ob11,
|
||||
ob11.actions
|
||||
);
|
||||
|
||||
await ob11.networkManager.registerAdapterAndOpen(pluginManager);
|
||||
|
||||
return sendSuccess(res, { message: '插件管理器注册成功' });
|
||||
} catch (e: any) {
|
||||
return sendError(res, '注册插件管理器失败: ' + e.message);
|
||||
}
|
||||
};
|
||||
|
||||
export const GetPluginListHandler: RequestHandler = async (_req, res) => {
|
||||
const pluginManager = getPluginManager();
|
||||
if (!pluginManager) {
|
||||
@@ -20,34 +64,19 @@ export const GetPluginListHandler: RequestHandler = async (_req, res) => {
|
||||
return sendSuccess(res, { plugins: [], pluginManagerNotFound: true });
|
||||
}
|
||||
|
||||
// 辅助函数:根据文件名/路径生成唯一ID(作为配置键)
|
||||
const getPluginId = (fsName: string, isFile: boolean): string => {
|
||||
if (isFile) {
|
||||
return path.parse(fsName).name;
|
||||
}
|
||||
return fsName;
|
||||
};
|
||||
|
||||
const loadedPlugins = pluginManager.getLoadedPlugins();
|
||||
const loadedPluginMap = new Map<string, any>(); // Map ID -> Loaded Info
|
||||
const loadedPluginMap = new Map<string, any>(); // Map id -> Loaded Info
|
||||
|
||||
// 1. 整理已加载的插件
|
||||
for (const p of loadedPlugins) {
|
||||
// 计算 ID:需要回溯到加载时的入口信息
|
||||
// 对于已加载的插件,我们通过判断 pluginPath 是否等于根 pluginPath 来判断它是单文件还是目录
|
||||
const isFilePlugin = p.pluginPath === pluginManager.getPluginPath();
|
||||
const fsName = isFilePlugin ? path.basename(p.entryPath) : path.basename(p.pluginPath);
|
||||
const id = getPluginId(fsName, isFilePlugin);
|
||||
|
||||
loadedPluginMap.set(id, {
|
||||
name: p.packageJson?.name || p.name, // 优先使用 package.json 的 name
|
||||
id: id,
|
||||
loadedPluginMap.set(p.name, {
|
||||
name: p.packageJson?.plugin || p.name, // 优先显示 package.json 的 plugin 字段
|
||||
id: p.name, // 包名,用于 API 操作
|
||||
version: p.version || '0.0.0',
|
||||
description: p.packageJson?.description || '',
|
||||
author: p.packageJson?.author || '',
|
||||
status: 'active',
|
||||
filename: fsName, // 真实文件/目录名
|
||||
loadedName: p.name // 运行时注册的名称,用于重载/卸载
|
||||
hasConfig: !!(p.module.plugin_config_schema || p.module.plugin_config_ui)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -60,15 +89,25 @@ export const GetPluginListHandler: RequestHandler = async (_req, res) => {
|
||||
const items = fs.readdirSync(pluginPath, { withFileTypes: true });
|
||||
|
||||
for (const item of items) {
|
||||
let id = '';
|
||||
if (!item.isDirectory()) continue;
|
||||
|
||||
if (item.isFile()) {
|
||||
if (!['.js', '.mjs'].includes(path.extname(item.name))) continue;
|
||||
id = getPluginId(item.name, true);
|
||||
} else if (item.isDirectory()) {
|
||||
id = getPluginId(item.name, false);
|
||||
} else {
|
||||
continue;
|
||||
// 读取 package.json 获取插件信息
|
||||
let id = item.name;
|
||||
let name = item.name;
|
||||
let version = '0.0.0';
|
||||
let description = '';
|
||||
let author = '';
|
||||
|
||||
const packageJsonPath = path.join(pluginPath, item.name, 'package.json');
|
||||
if (fs.existsSync(packageJsonPath)) {
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
|
||||
id = pkg.name || id;
|
||||
name = pkg.plugin || pkg.name || name;
|
||||
version = pkg.version || version;
|
||||
description = pkg.description || description;
|
||||
author = pkg.author || author;
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
const isActiveConfig = pluginConfig[id] !== false; // 默认为 true
|
||||
@@ -78,37 +117,14 @@ export const GetPluginListHandler: RequestHandler = async (_req, res) => {
|
||||
const loadedInfo = loadedPluginMap.get(id);
|
||||
allPlugins.push(loadedInfo);
|
||||
} else {
|
||||
// 未加载 (可能是被禁用,或者加载失败,或者新增未运行)
|
||||
let version = '0.0.0';
|
||||
let description = '';
|
||||
let author = '';
|
||||
// 默认显示名称为 ID (文件名/目录名)
|
||||
let name = id;
|
||||
|
||||
try {
|
||||
// 尝试读取 package.json 获取信息
|
||||
if (item.isDirectory()) {
|
||||
const packageJsonPath = path.join(pluginPath, item.name, 'package.json');
|
||||
if (fs.existsSync(packageJsonPath)) {
|
||||
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
|
||||
version = pkg.version || version;
|
||||
description = pkg.description || description;
|
||||
author = pkg.author || author;
|
||||
// 如果 package.json 有 name,优先使用
|
||||
name = pkg.name || name;
|
||||
}
|
||||
}
|
||||
} catch (e) { }
|
||||
|
||||
allPlugins.push({
|
||||
name: name,
|
||||
id: id,
|
||||
name,
|
||||
id,
|
||||
version,
|
||||
description,
|
||||
author,
|
||||
// 如果配置是 false,则为 disabled;否则是 stopped (应启动但未启动)
|
||||
status: isActiveConfig ? 'stopped' : 'disabled',
|
||||
filename: item.name
|
||||
status: isActiveConfig ? 'stopped' : 'disabled'
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -117,66 +133,26 @@ export const GetPluginListHandler: RequestHandler = async (_req, res) => {
|
||||
return sendSuccess(res, { plugins: allPlugins, pluginManagerNotFound: false });
|
||||
};
|
||||
|
||||
export const ReloadPluginHandler: RequestHandler = async (req, res) => {
|
||||
const { name } = req.body;
|
||||
// Note: we should probably accept ID or Name. But ReloadPlugin uses valid loaded name.
|
||||
// Let's stick to name for now, but be aware of ambiguity.
|
||||
if (!name) return sendError(res, 'Plugin Name is required');
|
||||
|
||||
const pluginManager = getPluginManager();
|
||||
if (!pluginManager) {
|
||||
return sendError(res, '插件管理器未加载,请检查 plugins 目录是否存在');
|
||||
}
|
||||
|
||||
const success = await pluginManager.reloadPlugin(name);
|
||||
if (success) {
|
||||
return sendSuccess(res, { message: 'Reloaded successfully' });
|
||||
} else {
|
||||
return sendError(res, 'Failed to reload plugin');
|
||||
}
|
||||
};
|
||||
|
||||
export const SetPluginStatusHandler: RequestHandler = async (req, res) => {
|
||||
const { enable, filename } = req.body;
|
||||
// We Use filename / id to control config
|
||||
// Front-end should pass the 'filename' or 'id' as the key identifier
|
||||
const { enable, id } = req.body;
|
||||
|
||||
if (!filename) return sendError(res, 'Plugin Filename/ID is required');
|
||||
if (!id) return sendError(res, 'Plugin id is required');
|
||||
|
||||
const pluginManager = getPluginManager();
|
||||
if (!pluginManager) {
|
||||
return sendError(res, 'Plugin Manager not found');
|
||||
}
|
||||
|
||||
// Calculate ID from filename (remove ext if file)
|
||||
// Or just use the logic consistent with loadPlugins
|
||||
let id = filename;
|
||||
// If it has extension .js/.mjs, remove it to get the ID used in config
|
||||
if (filename.endsWith('.js') || filename.endsWith('.mjs')) {
|
||||
id = path.parse(filename).name;
|
||||
}
|
||||
|
||||
try {
|
||||
// 设置插件状态
|
||||
pluginManager.setPluginStatus(id, enable);
|
||||
|
||||
// If enabling, trigger load
|
||||
// 如果启用,需要加载插件
|
||||
if (enable) {
|
||||
const pluginPath = pluginManager.getPluginPath();
|
||||
const fullPath = path.join(pluginPath, filename);
|
||||
|
||||
if (fs.statSync(fullPath).isDirectory()) {
|
||||
await pluginManager.loadDirectoryPlugin(filename);
|
||||
} else {
|
||||
await pluginManager.loadFilePlugin(filename);
|
||||
const loaded = await pluginManager.loadPluginById(id);
|
||||
if (!loaded) {
|
||||
return sendError(res, 'Plugin not found: ' + id);
|
||||
}
|
||||
} else {
|
||||
// Disabling is handled inside setPluginStatus usually if implemented,
|
||||
// OR we can explicitly unload here using the loaded name.
|
||||
// The Manager's setPluginStatus implementation (if added) might logic this out.
|
||||
// But our current Manager implementation just saves config.
|
||||
// Wait, I updated Manager to try to unload.
|
||||
// Let's rely on Manager's setPluginStatus or do it here?
|
||||
// I implemented a basic unload loop in Manager.setPluginStatus.
|
||||
}
|
||||
|
||||
return sendSuccess(res, { message: 'Status updated successfully' });
|
||||
@@ -186,41 +162,294 @@ export const SetPluginStatusHandler: RequestHandler = async (req, res) => {
|
||||
};
|
||||
|
||||
export const UninstallPluginHandler: RequestHandler = async (req, res) => {
|
||||
const { name, filename } = req.body;
|
||||
// If it's loaded, we use name. If it's disabled, we might use filename.
|
||||
const { id, cleanData } = req.body;
|
||||
|
||||
if (!id) return sendError(res, 'Plugin id is required');
|
||||
|
||||
const pluginManager = getPluginManager();
|
||||
if (!pluginManager) {
|
||||
return sendError(res, 'Plugin Manager not found');
|
||||
}
|
||||
|
||||
// Check if loaded
|
||||
const plugin = pluginManager.getPluginInfo(name);
|
||||
let fsPath = '';
|
||||
|
||||
if (plugin) {
|
||||
// Active plugin
|
||||
await pluginManager.unregisterPlugin(name);
|
||||
if (plugin.pluginPath === pluginManager.getPluginPath()) {
|
||||
fsPath = plugin.entryPath;
|
||||
} else {
|
||||
fsPath = plugin.pluginPath;
|
||||
}
|
||||
} else {
|
||||
// Disabled or not loaded
|
||||
if (filename) {
|
||||
fsPath = path.join(pluginManager.getPluginPath(), filename);
|
||||
} else {
|
||||
return sendError(res, 'Plugin not found, provide filename if disabled');
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (fs.existsSync(fsPath)) {
|
||||
fs.rmSync(fsPath, { recursive: true, force: true });
|
||||
}
|
||||
await pluginManager.uninstallPlugin(id, cleanData);
|
||||
return sendSuccess(res, { message: 'Uninstalled successfully' });
|
||||
} catch (e: any) {
|
||||
return sendError(res, 'Failed to uninstall: ' + e.message);
|
||||
}
|
||||
};
|
||||
|
||||
export const GetPluginConfigHandler: RequestHandler = async (req, res) => {
|
||||
const id = req.query['id'] as string;
|
||||
|
||||
if (!id) return sendError(res, 'Plugin id is required');
|
||||
|
||||
const pluginManager = getPluginManager();
|
||||
if (!pluginManager) return sendError(res, 'Plugin Manager not found');
|
||||
|
||||
const plugin = pluginManager.getPluginInfo(id);
|
||||
if (!plugin) return sendError(res, 'Plugin not loaded');
|
||||
|
||||
// 获取配置值
|
||||
let config = {};
|
||||
if (plugin.module.plugin_get_config) {
|
||||
try {
|
||||
config = await plugin.module.plugin_get_config(plugin.context);
|
||||
} catch (e) { }
|
||||
} else {
|
||||
// Default behavior: read from default config path
|
||||
try {
|
||||
const configPath = plugin.context?.configPath || pluginManager.getPluginConfigPath(id);
|
||||
if (fs.existsSync(configPath)) {
|
||||
config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||
}
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
// 获取静态 schema
|
||||
const schema = plugin.module.plugin_config_schema || plugin.module.plugin_config_ui || [];
|
||||
|
||||
// 检查是否支持动态控制
|
||||
const supportReactive = !!(plugin.module.plugin_config_controller || plugin.module.plugin_on_config_change);
|
||||
|
||||
return sendSuccess(res, { schema, config, supportReactive });
|
||||
};
|
||||
|
||||
/** 活跃的 SSE 连接 */
|
||||
const activeConfigSessions = new Map<string, {
|
||||
res: any;
|
||||
cleanup?: () => void;
|
||||
currentConfig: Record<string, any>;
|
||||
}>();
|
||||
|
||||
/**
|
||||
* 插件配置 SSE 连接 - 用于动态更新配置界面
|
||||
*/
|
||||
export const PluginConfigSSEHandler: RequestHandler = (req, res): void => {
|
||||
const id = req.query['id'] as string;
|
||||
const initialConfigStr = req.query['config'] as string;
|
||||
|
||||
if (!id) {
|
||||
res.status(400).json({ error: 'Plugin id is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const pluginManager = getPluginManager();
|
||||
if (!pluginManager) {
|
||||
res.status(400).json({ error: 'Plugin Manager not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const plugin = pluginManager.getPluginInfo(id);
|
||||
if (!plugin) {
|
||||
res.status(400).json({ error: 'Plugin not loaded' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 设置 SSE 头
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.setHeader('X-Accel-Buffering', 'no');
|
||||
res.flushHeaders();
|
||||
|
||||
// 生成会话 ID
|
||||
const sessionId = `${id}_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
||||
|
||||
// 解析初始配置
|
||||
let currentConfig: Record<string, any> = {};
|
||||
if (initialConfigStr) {
|
||||
try {
|
||||
currentConfig = JSON.parse(initialConfigStr);
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
// 发送 SSE 消息的辅助函数
|
||||
const sendSSE = (event: string, data: any) => {
|
||||
res.write(`event: ${event}\n`);
|
||||
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
||||
};
|
||||
|
||||
// 创建 UI 控制器
|
||||
const uiController = {
|
||||
updateSchema: (schema: any[]) => {
|
||||
sendSSE('schema', { type: 'full', schema });
|
||||
},
|
||||
updateField: (key: string, field: any) => {
|
||||
sendSSE('schema', { type: 'updateField', key, field });
|
||||
},
|
||||
removeField: (key: string) => {
|
||||
sendSSE('schema', { type: 'removeField', key });
|
||||
},
|
||||
addField: (field: any, afterKey?: string) => {
|
||||
sendSSE('schema', { type: 'addField', field, afterKey });
|
||||
},
|
||||
showField: (key: string) => {
|
||||
sendSSE('schema', { type: 'showField', key });
|
||||
},
|
||||
hideField: (key: string) => {
|
||||
sendSSE('schema', { type: 'hideField', key });
|
||||
},
|
||||
getCurrentConfig: () => currentConfig
|
||||
};
|
||||
|
||||
// 存储会话
|
||||
activeConfigSessions.set(sessionId, { res, currentConfig });
|
||||
|
||||
// 发送连接成功消息
|
||||
sendSSE('connected', { sessionId });
|
||||
|
||||
// 调用插件的控制器初始化(异步处理)
|
||||
(async () => {
|
||||
let cleanup: (() => void) | undefined;
|
||||
if (plugin.module.plugin_config_controller) {
|
||||
try {
|
||||
const result = await plugin.module.plugin_config_controller(
|
||||
plugin.context,
|
||||
uiController,
|
||||
currentConfig
|
||||
);
|
||||
if (typeof result === 'function') {
|
||||
cleanup = result;
|
||||
}
|
||||
} catch (e: any) {
|
||||
sendSSE('error', { message: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 更新会话的 cleanup
|
||||
const session = activeConfigSessions.get(sessionId);
|
||||
if (session) {
|
||||
session.cleanup = cleanup;
|
||||
}
|
||||
})();
|
||||
|
||||
// 心跳保持连接
|
||||
const heartbeat = setInterval(() => {
|
||||
sendSSE('ping', { time: Date.now() });
|
||||
}, 30000);
|
||||
|
||||
// 连接关闭时清理
|
||||
req.on('close', () => {
|
||||
clearInterval(heartbeat);
|
||||
const session = activeConfigSessions.get(sessionId);
|
||||
if (session?.cleanup) {
|
||||
try {
|
||||
session.cleanup();
|
||||
} catch (e) { }
|
||||
}
|
||||
activeConfigSessions.delete(sessionId);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 插件配置字段变化通知
|
||||
*/
|
||||
export const PluginConfigChangeHandler: RequestHandler = async (req, res) => {
|
||||
const { id, sessionId, key, value, currentConfig } = req.body;
|
||||
|
||||
if (!id || !sessionId || !key) {
|
||||
return sendError(res, 'Missing required parameters');
|
||||
}
|
||||
|
||||
const pluginManager = getPluginManager();
|
||||
if (!pluginManager) return sendError(res, 'Plugin Manager not found');
|
||||
|
||||
const plugin = pluginManager.getPluginInfo(id);
|
||||
if (!plugin) return sendError(res, 'Plugin not loaded');
|
||||
|
||||
// 获取会话
|
||||
const session = activeConfigSessions.get(sessionId);
|
||||
if (!session) {
|
||||
return sendError(res, 'Session not found');
|
||||
}
|
||||
|
||||
// 更新会话中的当前配置
|
||||
session.currentConfig = currentConfig || {};
|
||||
|
||||
// 如果插件有响应式处理器,调用它
|
||||
if (plugin.module.plugin_on_config_change) {
|
||||
const uiController = {
|
||||
updateSchema: (schema: any[]) => {
|
||||
session.res.write(`event: schema\n`);
|
||||
session.res.write(`data: ${JSON.stringify({ type: 'full', schema })}\n\n`);
|
||||
},
|
||||
updateField: (fieldKey: string, field: any) => {
|
||||
session.res.write(`event: schema\n`);
|
||||
session.res.write(`data: ${JSON.stringify({ type: 'updateField', key: fieldKey, field })}\n\n`);
|
||||
},
|
||||
removeField: (fieldKey: string) => {
|
||||
session.res.write(`event: schema\n`);
|
||||
session.res.write(`data: ${JSON.stringify({ type: 'removeField', key: fieldKey })}\n\n`);
|
||||
},
|
||||
addField: (field: any, afterKey?: string) => {
|
||||
session.res.write(`event: schema\n`);
|
||||
session.res.write(`data: ${JSON.stringify({ type: 'addField', field, afterKey })}\n\n`);
|
||||
},
|
||||
showField: (fieldKey: string) => {
|
||||
session.res.write(`event: schema\n`);
|
||||
session.res.write(`data: ${JSON.stringify({ type: 'showField', key: fieldKey })}\n\n`);
|
||||
},
|
||||
hideField: (fieldKey: string) => {
|
||||
session.res.write(`event: schema\n`);
|
||||
session.res.write(`data: ${JSON.stringify({ type: 'hideField', key: fieldKey })}\n\n`);
|
||||
},
|
||||
getCurrentConfig: () => session.currentConfig
|
||||
};
|
||||
|
||||
try {
|
||||
await plugin.module.plugin_on_config_change(
|
||||
plugin.context,
|
||||
uiController,
|
||||
key,
|
||||
value,
|
||||
currentConfig || {}
|
||||
);
|
||||
} catch (e: any) {
|
||||
session.res.write(`event: error\n`);
|
||||
session.res.write(`data: ${JSON.stringify({ message: e.message })}\n\n`);
|
||||
}
|
||||
}
|
||||
|
||||
return sendSuccess(res, { message: 'Change processed' });
|
||||
};
|
||||
|
||||
export const SetPluginConfigHandler: RequestHandler = async (req, res) => {
|
||||
const { id, config } = req.body;
|
||||
if (!id || !config) return sendError(res, 'Plugin id and config required');
|
||||
|
||||
const pluginManager = getPluginManager();
|
||||
if (!pluginManager) return sendError(res, 'Plugin Manager not found');
|
||||
|
||||
const plugin = pluginManager.getPluginInfo(id);
|
||||
if (!plugin) return sendError(res, 'Plugin not loaded');
|
||||
|
||||
if (plugin.module.plugin_set_config) {
|
||||
try {
|
||||
await plugin.module.plugin_set_config(plugin.context, config);
|
||||
return sendSuccess(res, { message: 'Config updated' });
|
||||
} catch (e: any) {
|
||||
return sendError(res, 'Error updating config: ' + e.message);
|
||||
}
|
||||
} else if (plugin.module.plugin_config_schema || plugin.module.plugin_config_ui || plugin.module.plugin_config_controller) {
|
||||
// Default behavior: write to default config path
|
||||
try {
|
||||
const configPath = plugin.context?.configPath || pluginManager.getPluginConfigPath(id);
|
||||
|
||||
const configDir = path.dirname(configPath);
|
||||
if (!fs.existsSync(configDir)) {
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
|
||||
|
||||
// Auto-Reload plugin to apply changes
|
||||
await pluginManager.reloadPlugin(id);
|
||||
|
||||
return sendSuccess(res, { message: 'Config saved and plugin reloaded' });
|
||||
} catch (e: any) {
|
||||
return sendError(res, 'Error saving config: ' + e.message);
|
||||
}
|
||||
} else {
|
||||
return sendError(res, 'Plugin does not support config update');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -8,6 +8,16 @@ import { createWriteStream } from 'fs';
|
||||
import compressing from 'compressing';
|
||||
import { findAvailableDownloadUrl, GITHUB_RAW_MIRRORS } from 'napcat-common/src/mirror';
|
||||
import { webUiPathWrapper } from '@/napcat-webui-backend/index';
|
||||
import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
|
||||
import { NapCatOneBot11Adapter } from '@/napcat-onebot/index';
|
||||
import { OB11PluginMangerAdapter } from '@/napcat-onebot/network/plugin-manger';
|
||||
|
||||
// Helper to get the plugin manager adapter
|
||||
const getPluginManager = (): OB11PluginMangerAdapter | null => {
|
||||
const ob11 = WebUiDataRuntime.getOneBotContext() as NapCatOneBot11Adapter;
|
||||
if (!ob11) return null;
|
||||
return ob11.networkManager.findSomeAdapter('plugin_manager') as OB11PluginMangerAdapter;
|
||||
};
|
||||
|
||||
// 插件商店源配置
|
||||
const PLUGIN_STORE_SOURCES = [
|
||||
@@ -242,6 +252,15 @@ export const InstallPluginFromStoreHandler: RequestHandler = async (req, res) =>
|
||||
// 删除临时文件
|
||||
fs.unlinkSync(tempZipPath);
|
||||
|
||||
// 如果 pluginManager 存在,立即注册插件
|
||||
const pluginManager = getPluginManager();
|
||||
if (pluginManager) {
|
||||
// 检查是否已注册,避免重复注册
|
||||
if (!pluginManager.getPluginInfo(id)) {
|
||||
await pluginManager.loadPluginById(id);
|
||||
}
|
||||
}
|
||||
|
||||
return sendSuccess(res, {
|
||||
message: 'Plugin installed successfully',
|
||||
plugin: plugin,
|
||||
@@ -315,6 +334,16 @@ export const InstallPluginFromStoreSSEHandler: RequestHandler = async (req, res)
|
||||
sendProgress('解压完成,正在清理...', 90);
|
||||
fs.unlinkSync(tempZipPath);
|
||||
|
||||
// 如果 pluginManager 存在,立即注册插件
|
||||
const pluginManager = getPluginManager();
|
||||
if (pluginManager) {
|
||||
// 检查是否已注册,避免重复注册
|
||||
if (!pluginManager.getPluginInfo(id)) {
|
||||
sendProgress('正在注册插件...', 95);
|
||||
await pluginManager.loadPluginById(id);
|
||||
}
|
||||
}
|
||||
|
||||
sendProgress('安装成功!', 100);
|
||||
res.write(`data: ${JSON.stringify({
|
||||
success: true,
|
||||
|
||||
@@ -82,6 +82,7 @@ export const OneBotConfigSchema = Type.Object({
|
||||
musicSignUrl: Type.String({ default: '' }),
|
||||
enableLocalFile2Url: Type.Boolean({ default: false }),
|
||||
parseMultMsg: Type.Boolean({ default: false }),
|
||||
imageDownloadProxy: Type.String({ default: '' }),
|
||||
});
|
||||
|
||||
export type OneBotConfig = Static<typeof OneBotConfigSchema>;
|
||||
|
||||
23
packages/napcat-webui-backend/src/router/Mirror.ts
Normal file
23
packages/napcat-webui-backend/src/router/Mirror.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Router } from 'express';
|
||||
import {
|
||||
GetMirrorListHandler,
|
||||
SetCustomMirrorHandler,
|
||||
TestMirrorsSSEHandler,
|
||||
TestSingleMirrorHandler
|
||||
} from '@/napcat-webui-backend/src/api/Mirror';
|
||||
|
||||
const router: Router = Router();
|
||||
|
||||
// 获取镜像列表
|
||||
router.get('/List', GetMirrorListHandler);
|
||||
|
||||
// 设置自定义镜像
|
||||
router.post('/SetCustom', SetCustomMirrorHandler);
|
||||
|
||||
// SSE 实时测速
|
||||
router.get('/Test/SSE', TestMirrorsSSEHandler);
|
||||
|
||||
// 测试单个镜像
|
||||
router.post('/Test', TestSingleMirrorHandler);
|
||||
|
||||
export { router as MirrorRouter };
|
||||
@@ -1,13 +1,31 @@
|
||||
import { Router } from 'express';
|
||||
import { GetPluginListHandler, ReloadPluginHandler, SetPluginStatusHandler, UninstallPluginHandler } from '@/napcat-webui-backend/src/api/Plugin';
|
||||
import { GetPluginStoreListHandler, GetPluginStoreDetailHandler, InstallPluginFromStoreHandler, InstallPluginFromStoreSSEHandler } from '@/napcat-webui-backend/src/api/PluginStore';
|
||||
import {
|
||||
GetPluginListHandler,
|
||||
SetPluginStatusHandler,
|
||||
UninstallPluginHandler,
|
||||
GetPluginConfigHandler,
|
||||
SetPluginConfigHandler,
|
||||
RegisterPluginManagerHandler,
|
||||
PluginConfigSSEHandler,
|
||||
PluginConfigChangeHandler
|
||||
} from '@/napcat-webui-backend/src/api/Plugin';
|
||||
import {
|
||||
GetPluginStoreListHandler,
|
||||
GetPluginStoreDetailHandler,
|
||||
InstallPluginFromStoreHandler,
|
||||
InstallPluginFromStoreSSEHandler
|
||||
} from '@/napcat-webui-backend/src/api/PluginStore';
|
||||
|
||||
const router: Router = Router();
|
||||
|
||||
router.get('/List', GetPluginListHandler);
|
||||
router.post('/Reload', ReloadPluginHandler);
|
||||
router.post('/SetStatus', SetPluginStatusHandler);
|
||||
router.post('/Uninstall', UninstallPluginHandler);
|
||||
router.get('/Config', GetPluginConfigHandler);
|
||||
router.post('/Config', SetPluginConfigHandler);
|
||||
router.get('/Config/SSE', PluginConfigSSEHandler);
|
||||
router.post('/Config/Change', PluginConfigChangeHandler);
|
||||
router.post('/RegisterManager', RegisterPluginManagerHandler);
|
||||
|
||||
// 插件商店相关路由
|
||||
router.get('/Store/List', GetPluginStoreListHandler);
|
||||
|
||||
@@ -18,6 +18,7 @@ import { UpdateNapCatRouter } from './UpdateNapCat';
|
||||
import DebugRouter from '@/napcat-webui-backend/src/api/Debug';
|
||||
import { ProcessRouter } from './Process';
|
||||
import { PluginRouter } from './Plugin';
|
||||
import { MirrorRouter } from './Mirror';
|
||||
|
||||
const router: Router = Router();
|
||||
|
||||
@@ -50,5 +51,7 @@ router.use('/Debug', DebugRouter);
|
||||
router.use('/Process', ProcessRouter);
|
||||
// router:插件管理相关路由
|
||||
router.use('/Plugin', PluginRouter);
|
||||
// router:镜像管理相关路由
|
||||
router.use('/Mirror', MirrorRouter);
|
||||
|
||||
export { router as ALLRouter };
|
||||
|
||||
Binary file not shown.
@@ -3,26 +3,28 @@ import { Switch } from '@heroui/switch';
|
||||
import { Chip } from '@heroui/chip';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { MdDeleteForever, MdPublishedWithChanges } from 'react-icons/md';
|
||||
import { MdDeleteForever, MdSettings } from 'react-icons/md';
|
||||
|
||||
import DisplayCardContainer from './container';
|
||||
import { PluginItem } from '@/controllers/plugin_manager';
|
||||
|
||||
export interface PluginDisplayCardProps {
|
||||
data: PluginItem;
|
||||
onReload: () => Promise<void>;
|
||||
onToggleStatus: () => Promise<void>;
|
||||
onUninstall: () => Promise<void>;
|
||||
onConfig?: () => void;
|
||||
hasConfig?: boolean;
|
||||
}
|
||||
|
||||
const PluginDisplayCard: React.FC<PluginDisplayCardProps> = ({
|
||||
data,
|
||||
onReload,
|
||||
onToggleStatus,
|
||||
onUninstall,
|
||||
onConfig,
|
||||
hasConfig = false,
|
||||
}) => {
|
||||
const { name, version, author, description, status } = data;
|
||||
const isEnabled = status !== 'disabled';
|
||||
const isEnabled = status === 'active';
|
||||
const [processing, setProcessing] = useState(false);
|
||||
|
||||
const handleToggle = () => {
|
||||
@@ -30,11 +32,6 @@ const PluginDisplayCard: React.FC<PluginDisplayCardProps> = ({
|
||||
onToggleStatus().finally(() => setProcessing(false));
|
||||
};
|
||||
|
||||
const handleReload = () => {
|
||||
setProcessing(true);
|
||||
onReload().finally(() => setProcessing(false));
|
||||
};
|
||||
|
||||
const handleUninstall = () => {
|
||||
setProcessing(true);
|
||||
onUninstall().finally(() => setProcessing(false));
|
||||
@@ -44,20 +41,8 @@ const PluginDisplayCard: React.FC<PluginDisplayCardProps> = ({
|
||||
<DisplayCardContainer
|
||||
className='w-full max-w-[420px]'
|
||||
action={
|
||||
<div className='flex flex-col gap-2 w-full'>
|
||||
<div className='flex gap-2 w-full'>
|
||||
<Button
|
||||
fullWidth
|
||||
radius='full'
|
||||
size='sm'
|
||||
variant='flat'
|
||||
className='flex-1 bg-default-100 dark:bg-default-50 text-default-600 font-medium hover:bg-primary/20 hover:text-primary transition-colors'
|
||||
startContent={<MdPublishedWithChanges size={16} />}
|
||||
onPress={handleReload}
|
||||
isDisabled={!isEnabled || processing}
|
||||
>
|
||||
重载
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
radius='full'
|
||||
@@ -71,6 +56,20 @@ const PluginDisplayCard: React.FC<PluginDisplayCardProps> = ({
|
||||
卸载
|
||||
</Button>
|
||||
</div>
|
||||
{hasConfig && (
|
||||
<Button
|
||||
fullWidth
|
||||
radius='full'
|
||||
size='sm'
|
||||
variant='flat'
|
||||
className='bg-default-100 dark:bg-default-50 text-default-600 font-medium hover:bg-secondary/20 hover:text-secondary transition-colors'
|
||||
startContent={<MdSettings size={16} />}
|
||||
onPress={onConfig}
|
||||
>
|
||||
配置
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
enableSwitch={
|
||||
<Switch
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { Chip } from '@heroui/chip';
|
||||
import { useState } from 'react';
|
||||
import { IoMdDownload } from 'react-icons/io';
|
||||
import { IoMdDownload, IoMdRefresh, IoMdCheckmarkCircle } from 'react-icons/io';
|
||||
|
||||
import DisplayCardContainer from './container';
|
||||
import { PluginStoreItem } from '@/types/plugin-store';
|
||||
|
||||
export type InstallStatus = 'not-installed' | 'installed' | 'update-available';
|
||||
|
||||
export interface PluginStoreCardProps {
|
||||
data: PluginStoreItem;
|
||||
onInstall: () => Promise<void>;
|
||||
installStatus?: InstallStatus;
|
||||
installedVersion?: string;
|
||||
}
|
||||
|
||||
const PluginStoreCard: React.FC<PluginStoreCardProps> = ({
|
||||
data,
|
||||
onInstall,
|
||||
installStatus = 'not-installed',
|
||||
}) => {
|
||||
const { name, version, author, description, tags, id } = data;
|
||||
const [processing, setProcessing] = useState(false);
|
||||
@@ -23,19 +28,65 @@ const PluginStoreCard: React.FC<PluginStoreCardProps> = ({
|
||||
onInstall().finally(() => setProcessing(false));
|
||||
};
|
||||
|
||||
// 根据安装状态返回按钮配置
|
||||
const getButtonConfig = () => {
|
||||
switch (installStatus) {
|
||||
case 'installed':
|
||||
return {
|
||||
text: '重新安装',
|
||||
icon: <IoMdRefresh size={16} />,
|
||||
color: 'default' as const,
|
||||
};
|
||||
case 'update-available':
|
||||
return {
|
||||
text: '更新',
|
||||
icon: <IoMdDownload size={16} />,
|
||||
color: 'success' as const,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
text: '安装',
|
||||
icon: <IoMdDownload size={16} />,
|
||||
color: 'primary' as const,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const buttonConfig = getButtonConfig();
|
||||
|
||||
return (
|
||||
<DisplayCardContainer
|
||||
className='w-full max-w-[420px]'
|
||||
title={name}
|
||||
tag={
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
{installStatus === 'installed' && (
|
||||
<Chip
|
||||
color="success"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
startContent={<IoMdCheckmarkCircle size={14} />}
|
||||
>
|
||||
已安装
|
||||
</Chip>
|
||||
)}
|
||||
{installStatus === 'update-available' && (
|
||||
<Chip
|
||||
color="warning"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
可更新
|
||||
</Chip>
|
||||
)}
|
||||
<Chip
|
||||
className="ml-auto"
|
||||
color="primary"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
v{version}
|
||||
</Chip>
|
||||
</div>
|
||||
}
|
||||
enableSwitch={undefined}
|
||||
action={
|
||||
@@ -43,13 +94,13 @@ const PluginStoreCard: React.FC<PluginStoreCardProps> = ({
|
||||
fullWidth
|
||||
radius='full'
|
||||
size='sm'
|
||||
color='primary'
|
||||
startContent={<IoMdDownload size={16} />}
|
||||
color={buttonConfig.color}
|
||||
startContent={buttonConfig.icon}
|
||||
onPress={handleInstall}
|
||||
isLoading={processing}
|
||||
isDisabled={processing}
|
||||
>
|
||||
安装
|
||||
{buttonConfig.text}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from '@heroui/modal';
|
||||
import { Button } from '@heroui/button';
|
||||
import { Chip } from '@heroui/chip';
|
||||
import { Tooltip } from '@heroui/tooltip';
|
||||
import { IoMdFlash, IoMdCheckmark, IoMdClose } from 'react-icons/io';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import MirrorManager, { MirrorTestResult } from '@/controllers/mirror_manager';
|
||||
|
||||
interface MirrorSelectorModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (mirror: string | undefined) => void;
|
||||
currentMirror?: string;
|
||||
type?: 'file' | 'raw';
|
||||
}
|
||||
|
||||
export default function MirrorSelectorModal ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSelect,
|
||||
currentMirror,
|
||||
type = 'file',
|
||||
}: MirrorSelectorModalProps) {
|
||||
const [mirrors, setMirrors] = useState<string[]>([]);
|
||||
const [selectedMirror, setSelectedMirror] = useState<string>(currentMirror || 'auto');
|
||||
const [testResults, setTestResults] = useState<Map<string, MirrorTestResult>>(new Map());
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const [testProgress, setTestProgress] = useState(0);
|
||||
const [testMessage, setTestMessage] = useState('');
|
||||
const [fastestMirror, setFastestMirror] = useState<string | null>(null);
|
||||
|
||||
// 加载镜像列表
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadMirrors();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const loadMirrors = async () => {
|
||||
try {
|
||||
const data = await MirrorManager.getMirrorList();
|
||||
const mirrorList = type === 'raw' ? data.rawMirrors : data.fileMirrors;
|
||||
setMirrors(mirrorList);
|
||||
if (data.customMirror) {
|
||||
setSelectedMirror(data.customMirror);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load mirrors:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const startSpeedTest = () => {
|
||||
setIsTesting(true);
|
||||
setTestProgress(0);
|
||||
setTestResults(new Map());
|
||||
setFastestMirror(null);
|
||||
setTestMessage('准备测速...');
|
||||
|
||||
MirrorManager.testMirrorsSSE(type, {
|
||||
onStart: (data) => {
|
||||
setTestMessage(data.message);
|
||||
},
|
||||
onTesting: (data) => {
|
||||
setTestProgress((data.index / data.total) * 100);
|
||||
setTestMessage(data.message);
|
||||
},
|
||||
onResult: (data) => {
|
||||
setTestResults((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
newMap.set(data.result.mirror, data.result);
|
||||
return newMap;
|
||||
});
|
||||
setTestProgress(((data.index + 1) / data.total) * 100);
|
||||
},
|
||||
onComplete: (data) => {
|
||||
setIsTesting(false);
|
||||
setTestProgress(100);
|
||||
setTestMessage(data.message);
|
||||
if (data.fastest) {
|
||||
setFastestMirror(data.fastest.mirror);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
setIsTesting(false);
|
||||
setTestMessage(`测速失败: ${error}`);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
const mirror = selectedMirror === 'auto' ? undefined : selectedMirror;
|
||||
onSelect(mirror);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size="2xl"
|
||||
scrollBehavior="inside"
|
||||
classNames={{
|
||||
backdrop: 'z-[200]',
|
||||
wrapper: 'z-[200]',
|
||||
}}
|
||||
>
|
||||
<ModalContent>
|
||||
<ModalHeader className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between pr-8">
|
||||
<span>选择镜像源</span>
|
||||
<Button
|
||||
size="sm"
|
||||
color="primary"
|
||||
variant="flat"
|
||||
startContent={!isTesting && <IoMdFlash />}
|
||||
onPress={startSpeedTest}
|
||||
isLoading={isTesting}
|
||||
>
|
||||
{isTesting ? '测速中...' : '一键测速'}
|
||||
</Button>
|
||||
</div>
|
||||
{isTesting && (
|
||||
<div className="mt-2">
|
||||
<div className="w-full bg-default-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${testProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-default-500 mt-1">{testMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* 自动选择选项 */}
|
||||
<MirrorOption
|
||||
value="auto"
|
||||
label="自动选择"
|
||||
description="系统自动选择最快的镜像源"
|
||||
isSelected={selectedMirror === 'auto'}
|
||||
onSelect={() => setSelectedMirror('auto')}
|
||||
badge={<Chip size="sm" color="primary" variant="flat">推荐</Chip>}
|
||||
/>
|
||||
|
||||
{/* 原始 GitHub */}
|
||||
<MirrorOption
|
||||
value="https://github.com"
|
||||
label="GitHub 原始"
|
||||
description="直连 GitHub(可能较慢)"
|
||||
isSelected={selectedMirror === 'https://github.com'}
|
||||
onSelect={() => setSelectedMirror('https://github.com')}
|
||||
testResult={testResults.get('https://github.com (原始)')}
|
||||
isFastest={fastestMirror === 'https://github.com (原始)'}
|
||||
/>
|
||||
|
||||
{/* 镜像列表 */}
|
||||
{mirrors.map((mirror) => {
|
||||
if (!mirror) return null;
|
||||
const result = testResults.get(mirror);
|
||||
const isFastest = fastestMirror === mirror;
|
||||
|
||||
let hostname = mirror;
|
||||
try {
|
||||
hostname = new URL(mirror).hostname;
|
||||
} catch { }
|
||||
|
||||
return (
|
||||
<MirrorOption
|
||||
key={mirror}
|
||||
value={mirror}
|
||||
label={hostname}
|
||||
description={mirror}
|
||||
isSelected={selectedMirror === mirror}
|
||||
onSelect={() => setSelectedMirror(mirror)}
|
||||
testResult={result}
|
||||
isFastest={isFastest}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="light" onPress={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button color="primary" onPress={handleConfirm}>
|
||||
确认选择
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// 镜像选项组件
|
||||
interface MirrorOptionProps {
|
||||
value: string;
|
||||
label: string;
|
||||
description: string;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
testResult?: MirrorTestResult;
|
||||
isFastest?: boolean;
|
||||
badge?: React.ReactNode;
|
||||
}
|
||||
|
||||
function MirrorOption ({
|
||||
label,
|
||||
description,
|
||||
isSelected,
|
||||
onSelect,
|
||||
testResult,
|
||||
isFastest,
|
||||
badge,
|
||||
}: MirrorOptionProps) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'flex items-center justify-between p-3 rounded-lg cursor-pointer transition-all',
|
||||
'bg-content1 hover:bg-content2 border-2',
|
||||
isSelected ? 'border-primary' : 'border-transparent',
|
||||
isFastest && 'ring-2 ring-success'
|
||||
)}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium">{label}</p>
|
||||
<p className="text-xs text-default-500 truncate">{description}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-2">
|
||||
{badge}
|
||||
{isFastest && !badge && (
|
||||
<Chip size="sm" color="success" variant="flat">最快</Chip>
|
||||
)}
|
||||
{testResult && <MirrorStatus result={testResult} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 镜像状态显示组件
|
||||
function MirrorStatus ({ result }: { result: MirrorTestResult; }) {
|
||||
const formatLatency = (latency: number) => {
|
||||
if (latency >= 5000) return '>5s';
|
||||
if (latency >= 1000) return `${(latency / 1000).toFixed(1)}s`;
|
||||
return `${latency}ms`;
|
||||
};
|
||||
|
||||
if (!result.success) {
|
||||
return (
|
||||
<Tooltip content={result.error || '连接失败'}>
|
||||
<Chip
|
||||
size="sm"
|
||||
color="danger"
|
||||
variant="flat"
|
||||
startContent={<IoMdClose size={14} />}
|
||||
>
|
||||
失败
|
||||
</Chip>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
const getColor = (): 'success' | 'warning' | 'danger' => {
|
||||
if (result.latency < 300) return 'success';
|
||||
if (result.latency < 1000) return 'warning';
|
||||
return 'danger';
|
||||
};
|
||||
|
||||
return (
|
||||
<Chip
|
||||
size="sm"
|
||||
color={getColor()}
|
||||
variant="flat"
|
||||
startContent={<IoMdCheckmark size={14} />}
|
||||
>
|
||||
{formatLatency(result.latency)}
|
||||
</Chip>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { Tab, Tabs } from '@heroui/tabs';
|
||||
import { Chip } from '@heroui/chip';
|
||||
import { useLocalStorage } from '@uidotdev/usehooks';
|
||||
import clsx from 'clsx';
|
||||
import { forwardRef, useEffect, useImperativeHandle, useState, useCallback } from 'react';
|
||||
import { forwardRef, useEffect, useImperativeHandle, useState, useCallback, useMemo } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { IoChevronDown, IoSend, IoSettingsSharp, IoCopy } from 'react-icons/io5';
|
||||
import { TbCode, TbMessageCode } from 'react-icons/tb';
|
||||
@@ -59,16 +59,30 @@ const OneBotApiDebug = forwardRef<OneBotApiDebugRef, OneBotApiDebugProps>((props
|
||||
const [responseHeight, setResponseHeight] = useState(240);
|
||||
const [storedHeight, setStoredHeight] = useLocalStorage('napcat_debug_response_height', 240);
|
||||
|
||||
const parsedRequest = parseTypeBox(data?.payload);
|
||||
// 使用 useMemo 缓存解析结果,避免每次渲染都重新解析
|
||||
const parsedRequest = useMemo(() => {
|
||||
try {
|
||||
return parseTypeBox(data?.payload);
|
||||
} catch (e) {
|
||||
console.error('Error parsing request schema:', e);
|
||||
return [];
|
||||
}
|
||||
}, [data?.payload]);
|
||||
|
||||
// 将返回值的 data 结构包装进 BaseResponseSchema 进行展示
|
||||
// 使用解构属性的方式重新构建对象,确保 parseTypeBox 能够识别为 object 类型
|
||||
const parsedResponse = useMemo(() => {
|
||||
try {
|
||||
const wrappedResponseSchema = Type.Object({
|
||||
...BaseResponseSchema.properties,
|
||||
data: data?.response || Type.Any({ description: '数据' })
|
||||
});
|
||||
|
||||
const parsedResponse = parseTypeBox(wrappedResponseSchema);
|
||||
return parseTypeBox(wrappedResponseSchema);
|
||||
} catch (e) {
|
||||
console.error('Error parsing response schema:', e);
|
||||
return [];
|
||||
}
|
||||
}, [data?.response]);
|
||||
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
|
||||
const hasBackground = !!backgroundImage;
|
||||
|
||||
@@ -166,7 +180,12 @@ const OneBotApiDebug = forwardRef<OneBotApiDebugRef, OneBotApiDebugProps>((props
|
||||
if (data?.payloadExample) {
|
||||
setRequestBody(JSON.stringify(data.payloadExample, null, 2));
|
||||
} else {
|
||||
try {
|
||||
setRequestBody(JSON.stringify(generateDefaultFromTypeBox(data?.payload), null, 2));
|
||||
} catch (e) {
|
||||
console.error('Error generating default:', e);
|
||||
setRequestBody('{}');
|
||||
}
|
||||
}
|
||||
setResponseContent('');
|
||||
setResponseStatus(null);
|
||||
@@ -320,7 +339,14 @@ const OneBotApiDebug = forwardRef<OneBotApiDebugRef, OneBotApiDebugProps>((props
|
||||
)}
|
||||
</ChatInputModal>
|
||||
<Tooltip content="生成示例" closeDelay={0}>
|
||||
<Button isIconOnly size='sm' variant='light' radius='sm' className='w-8 h-8' onPress={() => setRequestBody(JSON.stringify(generateDefaultFromTypeBox(data?.payload), null, 2))}>
|
||||
<Button isIconOnly size='sm' variant='light' radius='sm' className='w-8 h-8' onPress={() => {
|
||||
try {
|
||||
setRequestBody(JSON.stringify(generateDefaultFromTypeBox(data?.payload), null, 2));
|
||||
} catch (e) {
|
||||
console.error('Error generating default:', e);
|
||||
toast.error('生成示例失败');
|
||||
}
|
||||
}}>
|
||||
<TbCode size={16} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
@@ -127,6 +127,20 @@ const SchemaContainer: React.FC<{
|
||||
};
|
||||
|
||||
const RenderSchema: React.FC<{ schema: ParsedSchema; }> = ({ schema }) => {
|
||||
// 处理循环引用和截断的情况,直接显示提示而不继续递归
|
||||
if (schema.isCircularRef || schema.isTruncated) {
|
||||
return (
|
||||
<div className='mb-2 flex items-center gap-1 pl-5'>
|
||||
{schema.name && (
|
||||
<span className='text-default-400'>{schema.name}</span>
|
||||
)}
|
||||
<Chip size='sm' color='default' variant='flat'>
|
||||
{schema.description || '...'}
|
||||
</Chip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (schema.type === 'object') {
|
||||
return (
|
||||
<SchemaContainer schema={schema}>
|
||||
|
||||
@@ -8,18 +8,22 @@ import { Switch } from '@heroui/switch';
|
||||
import { Pagination } from '@heroui/pagination';
|
||||
import { Tabs, Tab } from '@heroui/tabs';
|
||||
import { Input } from '@heroui/input';
|
||||
import { Button } from '@heroui/button';
|
||||
import { useLocalStorage, useDebounce } from '@uidotdev/usehooks';
|
||||
import { useRequest } from 'ahooks';
|
||||
import clsx from 'clsx';
|
||||
import { FaCircleInfo, FaQq } from 'react-icons/fa6';
|
||||
import { IoLogoChrome, IoLogoOctocat, IoSearch } from 'react-icons/io5';
|
||||
import { IoMdFlash, IoMdCheckmark, IoMdSettings } from 'react-icons/io';
|
||||
import { RiMacFill } from 'react-icons/ri';
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
import key from '@/const/key';
|
||||
import WebUIManager from '@/controllers/webui_manager';
|
||||
import MirrorManager from '@/controllers/mirror_manager';
|
||||
import useDialog from '@/hooks/use-dialog';
|
||||
import Modal from '@/components/modal';
|
||||
import MirrorSelectorModal from '@/components/mirror_selector_modal';
|
||||
import { hasNewVersion, compareVersion } from '@/utils/version';
|
||||
|
||||
|
||||
@@ -304,17 +308,54 @@ const VersionSelectDialogContent: React.FC<VersionSelectDialogProps> = ({
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const debouncedSearch = useDebounce(searchQuery, 300);
|
||||
|
||||
// 镜像相关状态
|
||||
const [selectedMirror, setSelectedMirror] = useState<string | undefined>(undefined);
|
||||
const { data: mirrorsData } = useRequest(WebUIManager.getMirrors, {
|
||||
cacheKey: 'napcat-mirrors',
|
||||
staleTime: 60 * 60 * 1000,
|
||||
});
|
||||
const mirrors = mirrorsData?.mirrors || [];
|
||||
const [mirrorLatency, setMirrorLatency] = useState<number | null>(null);
|
||||
const [mirrorTesting, setMirrorTesting] = useState(false);
|
||||
const [mirrorModalOpen, setMirrorModalOpen] = useState(false);
|
||||
|
||||
const pageSize = 15;
|
||||
// 测试当前镜像速度
|
||||
const testCurrentMirror = async () => {
|
||||
setMirrorTesting(true);
|
||||
try {
|
||||
const result = await MirrorManager.testSingleMirror(selectedMirror || '', 'file');
|
||||
if (result.success) {
|
||||
setMirrorLatency(result.latency);
|
||||
} else {
|
||||
setMirrorLatency(null);
|
||||
}
|
||||
} catch (e) {
|
||||
setMirrorLatency(null);
|
||||
} finally {
|
||||
setMirrorTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatLatency = (latency: number) => {
|
||||
if (latency >= 5000) return '>5s';
|
||||
if (latency >= 1000) return `${(latency / 1000).toFixed(1)}s`;
|
||||
return `${latency}ms`;
|
||||
};
|
||||
|
||||
const getLatencyColor = (latency: number | null): 'success' | 'warning' | 'danger' | 'default' => {
|
||||
if (latency === null) return 'default';
|
||||
if (latency < 300) return 'success';
|
||||
if (latency < 1000) return 'warning';
|
||||
return 'danger';
|
||||
};
|
||||
|
||||
const getMirrorDisplayName = () => {
|
||||
if (!selectedMirror) return '自动选择';
|
||||
try {
|
||||
return new URL(selectedMirror).hostname;
|
||||
} catch {
|
||||
return selectedMirror;
|
||||
}
|
||||
};
|
||||
|
||||
// 获取所有可用版本(带分页、过滤和搜索)
|
||||
// 懒加载:根据 activeTab 只获取对应类型的版本
|
||||
const pageSize = 15;
|
||||
const { data: releasesData, loading: releasesLoading, error: releasesError } = useRequest(
|
||||
() => WebUIManager.getAllReleases({
|
||||
page: currentPage,
|
||||
@@ -502,7 +543,61 @@ const VersionSelectDialogContent: React.FC<VersionSelectDialogProps> = ({
|
||||
<Tab key='action' title='临时版本 (Action)' />
|
||||
</Tabs>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{/* 下载镜像状态卡片 */}
|
||||
<Card className="bg-default-100/50 shadow-sm">
|
||||
<CardBody className="py-2 px-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-default-500">镜像源:</span>
|
||||
<span className="text-sm font-medium">{getMirrorDisplayName()}</span>
|
||||
{mirrorLatency !== null && (
|
||||
<Chip
|
||||
size="sm"
|
||||
color={getLatencyColor(mirrorLatency)}
|
||||
variant="flat"
|
||||
startContent={<IoMdCheckmark size={12} />}
|
||||
>
|
||||
{formatLatency(mirrorLatency)}
|
||||
</Chip>
|
||||
)}
|
||||
{mirrorLatency === null && !mirrorTesting && (
|
||||
<Chip size="sm" color="default" variant="flat">
|
||||
未测试
|
||||
</Chip>
|
||||
)}
|
||||
{mirrorTesting && (
|
||||
<Chip size="sm" color="primary" variant="flat">
|
||||
测速中...
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Tooltip content="测速">
|
||||
<Button
|
||||
isIconOnly
|
||||
size="sm"
|
||||
variant="light"
|
||||
onPress={testCurrentMirror}
|
||||
isLoading={mirrorTesting}
|
||||
>
|
||||
<IoMdFlash size={16} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content="切换镜像">
|
||||
<Button
|
||||
isIconOnly
|
||||
size="sm"
|
||||
variant="light"
|
||||
onPress={() => setMirrorModalOpen(true)}
|
||||
>
|
||||
<IoMdSettings size={16} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* 搜索框 */}
|
||||
<Input
|
||||
placeholder='搜索版本号...'
|
||||
@@ -518,31 +613,9 @@ const VersionSelectDialogContent: React.FC<VersionSelectDialogProps> = ({
|
||||
onClear={() => setSearchQuery('')}
|
||||
classNames={{
|
||||
inputWrapper: 'h-9',
|
||||
base: 'flex-1'
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 镜像选择 */}
|
||||
<Select
|
||||
placeholder="自动选择 (默认)"
|
||||
selectedKeys={selectedMirror ? [selectedMirror] : ['default']}
|
||||
onSelectionChange={(keys) => {
|
||||
const m = Array.from(keys)[0] as string;
|
||||
setSelectedMirror(m === 'default' ? undefined : m);
|
||||
}}
|
||||
size="sm"
|
||||
className="w-48"
|
||||
classNames={{ trigger: 'h-9 min-h-9' }}
|
||||
aria-label="选择镜像源"
|
||||
>
|
||||
{['default', ...mirrors].map(m => (
|
||||
<SelectItem key={m} textValue={m === 'default' ? '自动选择' : m}>
|
||||
{m === 'default' ? '自动选择 (默认)' : m}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 版本选择 */}
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
@@ -703,6 +776,18 @@ const VersionSelectDialogContent: React.FC<VersionSelectDialogProps> = ({
|
||||
{isSelectedDowngrade ? '确认降级更新' : '更新到此版本'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 镜像选择弹窗 */}
|
||||
<MirrorSelectorModal
|
||||
isOpen={mirrorModalOpen}
|
||||
onClose={() => setMirrorModalOpen(false)}
|
||||
currentMirror={selectedMirror}
|
||||
onSelect={(mirror) => {
|
||||
setSelectedMirror(mirror || undefined);
|
||||
setMirrorLatency(null);
|
||||
}}
|
||||
type="file"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
109
packages/napcat-webui-frontend/src/controllers/mirror_manager.ts
Normal file
109
packages/napcat-webui-frontend/src/controllers/mirror_manager.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { EventSourcePolyfill } from 'event-source-polyfill';
|
||||
import { serverRequest } from '@/utils/request';
|
||||
import key from '@/const/key';
|
||||
|
||||
export interface MirrorTestResult {
|
||||
mirror: string;
|
||||
latency: number;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface MirrorListResponse {
|
||||
fileMirrors: string[];
|
||||
rawMirrors: string[];
|
||||
customMirror?: string;
|
||||
timeout: number;
|
||||
}
|
||||
|
||||
export default class MirrorManager {
|
||||
/**
|
||||
* 获取镜像列表
|
||||
*/
|
||||
public static async getMirrorList (): Promise<MirrorListResponse> {
|
||||
const { data } = await serverRequest.get<ServerResponse<MirrorListResponse>>('/Mirror/List');
|
||||
return data.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置自定义镜像
|
||||
*/
|
||||
public static async setCustomMirror (mirror: string): Promise<void> {
|
||||
await serverRequest.post('/Mirror/SetCustom', { mirror });
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试单个镜像
|
||||
*/
|
||||
public static async testSingleMirror (mirror: string, type: 'file' | 'raw' = 'file'): Promise<MirrorTestResult> {
|
||||
const { data } = await serverRequest.post<ServerResponse<MirrorTestResult>>('/Mirror/Test', { mirror, type });
|
||||
return data.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* SSE 实时测速所有镜像
|
||||
*/
|
||||
public static testMirrorsSSE (
|
||||
type: 'file' | 'raw' = 'file',
|
||||
callbacks: {
|
||||
onStart?: (data: { total: number; message: string; }) => void;
|
||||
onTesting?: (data: { index: number; total: number; mirror: string; message: string; }) => void;
|
||||
onResult?: (data: { index: number; total: number; result: MirrorTestResult; }) => void;
|
||||
onComplete?: (data: { results: MirrorTestResult[]; failed: MirrorTestResult[]; fastest: MirrorTestResult | null; message: string; }) => void;
|
||||
onError?: (error: string) => void;
|
||||
}
|
||||
): EventSourcePolyfill {
|
||||
const token = localStorage.getItem(key.token);
|
||||
if (!token) {
|
||||
throw new Error('未登录');
|
||||
}
|
||||
const _token = JSON.parse(token);
|
||||
|
||||
const eventSource = new EventSourcePolyfill(
|
||||
`/api/Mirror/Test/SSE?type=${type}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${_token}`,
|
||||
Accept: 'text/event-stream',
|
||||
},
|
||||
withCredentials: true,
|
||||
}
|
||||
);
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
switch (data.type) {
|
||||
case 'start':
|
||||
callbacks.onStart?.(data);
|
||||
break;
|
||||
case 'testing':
|
||||
callbacks.onTesting?.(data);
|
||||
break;
|
||||
case 'result':
|
||||
callbacks.onResult?.(data);
|
||||
break;
|
||||
case 'complete':
|
||||
callbacks.onComplete?.(data);
|
||||
eventSource.close();
|
||||
break;
|
||||
case 'error':
|
||||
callbacks.onError?.(data.error);
|
||||
eventSource.close();
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse SSE message:', e);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
console.error('SSE连接出错:', error);
|
||||
callbacks.onError?.('连接中断');
|
||||
eventSource.close();
|
||||
};
|
||||
|
||||
return eventSource;
|
||||
}
|
||||
}
|
||||
@@ -1,59 +1,189 @@
|
||||
import { serverRequest } from '@/utils/request';
|
||||
import { PluginStoreList, PluginStoreItem } from '@/types/plugin-store';
|
||||
|
||||
/** 插件状态 */
|
||||
export type PluginStatus = 'active' | 'disabled' | 'stopped';
|
||||
|
||||
/** 插件信息 */
|
||||
export interface PluginItem {
|
||||
/** 显示名称 (优先 package.json 的 plugin 字段) */
|
||||
name: string;
|
||||
/** 包名 (package name),用于 API 操作 */
|
||||
id: string;
|
||||
/** 版本号 */
|
||||
version: string;
|
||||
/** 描述 */
|
||||
description: string;
|
||||
/** 作者 */
|
||||
author: string;
|
||||
status: 'active' | 'disabled' | 'stopped';
|
||||
filename?: string;
|
||||
/** 状态: active-运行中, disabled-已禁用, stopped-已停止 */
|
||||
status: PluginStatus;
|
||||
/** 是否有配置项 */
|
||||
hasConfig?: boolean;
|
||||
}
|
||||
|
||||
/** 插件列表响应 */
|
||||
export interface PluginListResponse {
|
||||
plugins: PluginItem[];
|
||||
pluginManagerNotFound: boolean;
|
||||
}
|
||||
|
||||
/** 插件配置项定义 */
|
||||
export interface PluginConfigSchemaItem {
|
||||
key: string;
|
||||
type: 'string' | 'number' | 'boolean' | 'select' | 'multi-select' | 'html' | 'text';
|
||||
label: string;
|
||||
description?: string;
|
||||
default?: any;
|
||||
options?: { label: string; value: string | number; }[];
|
||||
placeholder?: string;
|
||||
/** 标记此字段为响应式:值变化时触发 schema 刷新 */
|
||||
reactive?: boolean;
|
||||
/** 是否隐藏此字段 */
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
/** 插件配置响应 */
|
||||
export interface PluginConfigResponse {
|
||||
schema: PluginConfigSchemaItem[];
|
||||
config: Record<string, unknown>;
|
||||
/** 是否支持响应式更新 */
|
||||
supportReactive?: boolean;
|
||||
}
|
||||
|
||||
/** 服务端响应 */
|
||||
export interface ServerResponse<T> {
|
||||
code: number;
|
||||
message: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件管理器 API
|
||||
*/
|
||||
export default class PluginManager {
|
||||
public static async getPluginList () {
|
||||
/**
|
||||
* 获取插件列表
|
||||
*/
|
||||
public static async getPluginList (): Promise<PluginListResponse> {
|
||||
const { data } = await serverRequest.get<ServerResponse<PluginListResponse>>('/Plugin/List');
|
||||
return data.data;
|
||||
}
|
||||
|
||||
public static async reloadPlugin (name: string) {
|
||||
await serverRequest.post<ServerResponse<void>>('/Plugin/Reload', { name });
|
||||
/**
|
||||
* 手动注册插件管理器到 NetworkManager
|
||||
*/
|
||||
public static async registerPluginManager (): Promise<{ message: string; }> {
|
||||
const { data } = await serverRequest.post<ServerResponse<{ message: string; }>>('/Plugin/RegisterManager');
|
||||
return data.data;
|
||||
}
|
||||
|
||||
public static async setPluginStatus (name: string, enable: boolean, filename?: string) {
|
||||
await serverRequest.post<ServerResponse<void>>('/Plugin/SetStatus', { name, enable, filename });
|
||||
/**
|
||||
* 设置插件状态(启用/禁用)
|
||||
* @param id 插件包名
|
||||
* @param enable 是否启用
|
||||
*/
|
||||
public static async setPluginStatus (id: string, enable: boolean): Promise<void> {
|
||||
await serverRequest.post<ServerResponse<void>>('/Plugin/SetStatus', { id, enable });
|
||||
}
|
||||
|
||||
public static async uninstallPlugin (name: string, filename?: string) {
|
||||
await serverRequest.post<ServerResponse<void>>('/Plugin/Uninstall', { name, filename });
|
||||
/**
|
||||
* 卸载插件
|
||||
* @param id 插件包名
|
||||
* @param cleanData 是否清理数据
|
||||
*/
|
||||
public static async uninstallPlugin (id: string, cleanData?: boolean): Promise<void> {
|
||||
await serverRequest.post<ServerResponse<void>>('/Plugin/Uninstall', { id, cleanData });
|
||||
}
|
||||
|
||||
// 插件商店相关方法
|
||||
public static async getPluginStoreList () {
|
||||
// ==================== 插件商店 ====================
|
||||
|
||||
/**
|
||||
* 获取插件商店列表
|
||||
*/
|
||||
public static async getPluginStoreList (): Promise<PluginStoreList> {
|
||||
const { data } = await serverRequest.get<ServerResponse<PluginStoreList>>('/Plugin/Store/List');
|
||||
return data.data;
|
||||
}
|
||||
|
||||
public static async getPluginStoreDetail (id: string) {
|
||||
/**
|
||||
* 获取插件商店详情
|
||||
* @param id 插件 ID
|
||||
*/
|
||||
public static async getPluginStoreDetail (id: string): Promise<PluginStoreItem> {
|
||||
const { data } = await serverRequest.get<ServerResponse<PluginStoreItem>>(`/Plugin/Store/Detail/${id}`);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
public static async installPluginFromStore (id: string, mirror?: string) {
|
||||
// 插件安装可能需要较长时间(下载+解压),设置5分钟超时
|
||||
await serverRequest.post<ServerResponse<void>>('/Plugin/Store/Install', { id, mirror }, {
|
||||
timeout: 300000, // 5分钟
|
||||
/**
|
||||
* 从商店安装插件
|
||||
* @param id 插件 ID
|
||||
* @param mirror 镜像源
|
||||
*/
|
||||
public static async installPluginFromStore (id: string, mirror?: string): Promise<void> {
|
||||
await serverRequest.post<ServerResponse<void>>(
|
||||
'/Plugin/Store/Install',
|
||||
{ id, mirror },
|
||||
{ timeout: 300000 } // 5分钟超时
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== 插件配置 ====================
|
||||
|
||||
/**
|
||||
* 获取插件配置
|
||||
* @param id 插件包名
|
||||
*/
|
||||
public static async getPluginConfig (id: string): Promise<PluginConfigResponse> {
|
||||
const { data } = await serverRequest.get<ServerResponse<PluginConfigResponse>>('/Plugin/Config', {
|
||||
params: { id }
|
||||
});
|
||||
return data.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置插件配置
|
||||
* @param id 插件包名
|
||||
* @param config 配置内容
|
||||
*/
|
||||
public static async setPluginConfig (id: string, config: Record<string, unknown>): Promise<void> {
|
||||
await serverRequest.post<ServerResponse<void>>('/Plugin/Config', { id, config });
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知配置字段变化
|
||||
* @param id 插件包名
|
||||
* @param sessionId SSE 会话 ID
|
||||
* @param key 变化的字段
|
||||
* @param value 新值
|
||||
* @param currentConfig 当前配置
|
||||
*/
|
||||
public static async notifyConfigChange (
|
||||
id: string,
|
||||
sessionId: string,
|
||||
key: string,
|
||||
value: unknown,
|
||||
currentConfig: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
await serverRequest.post<ServerResponse<void>>('/Plugin/Config/Change', {
|
||||
id,
|
||||
sessionId,
|
||||
key,
|
||||
value,
|
||||
currentConfig
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取配置 SSE URL
|
||||
* @param id 插件包名
|
||||
* @param config 初始配置
|
||||
*/
|
||||
public static getConfigSSEUrl (id: string, config?: Record<string, unknown>): string {
|
||||
const params = new URLSearchParams({ id });
|
||||
if (config) {
|
||||
params.set('config', JSON.stringify(config));
|
||||
}
|
||||
return `/api/Plugin/Config/SSE?${params.toString()}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,12 +22,14 @@ const OneBotConfigCard = () => {
|
||||
musicSignUrl: '',
|
||||
enableLocalFile2Url: false,
|
||||
parseMultMsg: false,
|
||||
imageDownloadProxy: '',
|
||||
},
|
||||
});
|
||||
const reset = () => {
|
||||
setOnebotValue('musicSignUrl', config.musicSignUrl);
|
||||
setOnebotValue('enableLocalFile2Url', config.enableLocalFile2Url);
|
||||
setOnebotValue('parseMultMsg', config.parseMultMsg);
|
||||
setOnebotValue('imageDownloadProxy', config.imageDownloadProxy);
|
||||
};
|
||||
|
||||
const onSubmit = handleOnebotSubmit(async (data) => {
|
||||
@@ -104,6 +106,22 @@ const OneBotConfigCard = () => {
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name='imageDownloadProxy'
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
label='图片下载代理'
|
||||
placeholder='请输入代理地址,如 http://127.0.0.1:7890'
|
||||
classNames={{
|
||||
inputWrapper:
|
||||
'bg-default-100/50 dark:bg-white/5 backdrop-blur-md border border-transparent hover:bg-default-200/50 dark:hover:bg-white/10 transition-all shadow-sm data-[hover=true]:border-default-300',
|
||||
input: 'bg-transparent text-default-700 placeholder:text-default-400',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<SaveButtons
|
||||
onSubmit={onSubmit}
|
||||
reset={reset}
|
||||
|
||||
@@ -112,9 +112,17 @@ export default function HttpDebug () {
|
||||
const executeCommand = (commandId: string, mode: CommandPaletteExecuteMode) => {
|
||||
const api = commandId as OneBotHttpApiPath;
|
||||
const item = oneBotHttpApi[api];
|
||||
const body = item?.payloadExample
|
||||
? JSON.stringify(item.payloadExample, null, 2)
|
||||
: (item?.payload ? JSON.stringify(generateDefaultFromTypeBox(item.payload), null, 2) : '{}');
|
||||
let body = '{}';
|
||||
if (item?.payloadExample) {
|
||||
body = JSON.stringify(item.payloadExample, null, 2);
|
||||
} else if (item?.payload) {
|
||||
try {
|
||||
body = JSON.stringify(generateDefaultFromTypeBox(item.payload), null, 2);
|
||||
} catch (e) {
|
||||
console.error('Error generating default:', e);
|
||||
body = '{}';
|
||||
}
|
||||
}
|
||||
|
||||
handleSelectApi(api);
|
||||
// 确保请求参数可见
|
||||
|
||||
@@ -2,11 +2,13 @@ import { Button } from '@heroui/button';
|
||||
import { useEffect, useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { IoMdRefresh } from 'react-icons/io';
|
||||
import { useDisclosure } from '@heroui/modal';
|
||||
|
||||
import PageLoading from '@/components/page_loading';
|
||||
import PluginDisplayCard from '@/components/display_card/plugin_card';
|
||||
import PluginManager, { PluginItem } from '@/controllers/plugin_manager';
|
||||
import useDialog from '@/hooks/use-dialog';
|
||||
import PluginConfigModal from '@/pages/dashboard/plugin_config_modal';
|
||||
|
||||
export default function PluginPage () {
|
||||
const [plugins, setPlugins] = useState<PluginItem[]>([]);
|
||||
@@ -14,16 +16,20 @@ export default function PluginPage () {
|
||||
const [pluginManagerNotFound, setPluginManagerNotFound] = useState(false);
|
||||
const dialog = useDialog();
|
||||
|
||||
const { isOpen, onOpen, onOpenChange } = useDisclosure();
|
||||
const [currentPluginId, setCurrentPluginId] = useState<string>('');
|
||||
|
||||
const loadPlugins = async () => {
|
||||
setLoading(true);
|
||||
setPluginManagerNotFound(false);
|
||||
try {
|
||||
const result = await PluginManager.getPluginList();
|
||||
if (result.pluginManagerNotFound) {
|
||||
const listResult = await PluginManager.getPluginList();
|
||||
|
||||
if (listResult.pluginManagerNotFound) {
|
||||
setPluginManagerNotFound(true);
|
||||
setPlugins([]);
|
||||
} else {
|
||||
setPlugins(result.plugins);
|
||||
setPlugins(listResult.plugins);
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast.error(e.message);
|
||||
@@ -36,23 +42,14 @@ export default function PluginPage () {
|
||||
loadPlugins();
|
||||
}, []);
|
||||
|
||||
const handleReload = async (name: string) => {
|
||||
const loadingToast = toast.loading('重载中...');
|
||||
try {
|
||||
await PluginManager.reloadPlugin(name);
|
||||
toast.success('重载成功', { id: loadingToast });
|
||||
loadPlugins();
|
||||
} catch (e: any) {
|
||||
toast.error(e.message, { id: loadingToast });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleToggle = async (plugin: PluginItem) => {
|
||||
const isEnable = plugin.status !== 'active';
|
||||
const actionText = isEnable ? '启用' : '禁用';
|
||||
const loadingToast = toast.loading(`${actionText}中...`);
|
||||
try {
|
||||
await PluginManager.setPluginStatus(plugin.name, isEnable, plugin.filename);
|
||||
await PluginManager.setPluginStatus(plugin.id, isEnable);
|
||||
toast.success(`${actionText}成功`, { id: loadingToast });
|
||||
loadPlugins();
|
||||
} catch (e: any) {
|
||||
@@ -64,11 +61,31 @@ export default function PluginPage () {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
dialog.confirm({
|
||||
title: '卸载插件',
|
||||
content: `确定要卸载插件「${plugin.name}」吗? 此操作不可恢复。`,
|
||||
content: (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p>确定要卸载插件「{plugin.name}」吗? 此操作不可恢复。</p>
|
||||
<p className="text-small text-default-500">如果插件创建了数据文件,是否一并删除?</p>
|
||||
</div>
|
||||
),
|
||||
// This 'dialog' utility might not support returning a value from UI interacting.
|
||||
// We might need to implement a custom confirmation flow if we want a checkbox.
|
||||
// Alternatively, use two buttons? "Uninstall & Clean", "Uninstall Only"?
|
||||
// Standard dialog usually has Confirm/Cancel.
|
||||
// Let's stick to a simpler "Uninstall" and then maybe a second prompt? Or just clean data?
|
||||
// User requested: "Uninstall prompts whether to clean data".
|
||||
// Let's use `window.confirm` for the second step or assume `dialog.confirm` is flexible enough?
|
||||
// I will implement a two-step confirmation or try to modify the dialog hook if visible (not visible here).
|
||||
// Let's use a standard `window.confirm` for the data cleanup question if the custom dialog doesn't support complex return.
|
||||
// Better: Inside onConfirm, ask again?
|
||||
onConfirm: async () => {
|
||||
// Ask for data cleanup
|
||||
// Since we are in an async callback, we can use another dialog or confirm.
|
||||
// Native confirm is ugly but works reliably for logic:
|
||||
const cleanData = window.confirm(`是否同时清理插件「${plugin.name}」的数据文件?\n点击“确定”清理数据,点击“取消”仅卸载插件。`);
|
||||
|
||||
const loadingToast = toast.loading('卸载中...');
|
||||
try {
|
||||
await PluginManager.uninstallPlugin(plugin.name, plugin.filename);
|
||||
await PluginManager.uninstallPlugin(plugin.id, cleanData);
|
||||
toast.success('卸载成功', { id: loadingToast });
|
||||
loadPlugins();
|
||||
resolve();
|
||||
@@ -84,11 +101,22 @@ export default function PluginPage () {
|
||||
});
|
||||
};
|
||||
|
||||
const handleConfig = (plugin: PluginItem) => {
|
||||
setCurrentPluginId(plugin.id);
|
||||
onOpen();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>插件管理 - NapCat WebUI</title>
|
||||
<div className='p-2 md:p-4 relative'>
|
||||
<PageLoading loading={loading} />
|
||||
<PluginConfigModal
|
||||
isOpen={isOpen}
|
||||
onOpenChange={onOpenChange}
|
||||
pluginId={currentPluginId}
|
||||
/>
|
||||
|
||||
<div className='flex mb-6 items-center gap-4'>
|
||||
<h1 className="text-2xl font-bold">插件管理</h1>
|
||||
<Button
|
||||
@@ -117,11 +145,18 @@ export default function PluginPage () {
|
||||
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 justify-start items-stretch gap-x-2 gap-y-4'>
|
||||
{plugins.map(plugin => (
|
||||
<PluginDisplayCard
|
||||
key={plugin.name}
|
||||
key={plugin.id}
|
||||
data={plugin}
|
||||
onReload={() => handleReload(plugin.name)}
|
||||
onToggleStatus={() => handleToggle(plugin)}
|
||||
onUninstall={() => handleUninstall(plugin)}
|
||||
onConfig={() => {
|
||||
if (plugin.hasConfig) {
|
||||
handleConfig(plugin);
|
||||
} else {
|
||||
toast.error('此插件没有配置哦');
|
||||
}
|
||||
}}
|
||||
hasConfig={true}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from '@heroui/modal';
|
||||
import { Button } from '@heroui/button';
|
||||
import { Input } from '@heroui/input';
|
||||
import { Select, SelectItem } from '@heroui/select';
|
||||
import { Switch } from '@heroui/switch';
|
||||
import { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { EventSourcePolyfill } from 'event-source-polyfill';
|
||||
import PluginManager, { PluginConfigSchemaItem } from '@/controllers/plugin_manager';
|
||||
import key from '@/const/key';
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onOpenChange: () => void;
|
||||
/** 插件包名 (id) */
|
||||
pluginId: string;
|
||||
}
|
||||
|
||||
/** Schema 更新事件类型 */
|
||||
interface SchemaUpdateEvent {
|
||||
type: 'full' | 'updateField' | 'removeField' | 'addField' | 'showField' | 'hideField';
|
||||
schema?: PluginConfigSchemaItem[];
|
||||
key?: string;
|
||||
field?: Partial<PluginConfigSchemaItem>;
|
||||
afterKey?: string;
|
||||
}
|
||||
|
||||
export default function PluginConfigModal ({ isOpen, onOpenChange, pluginId }: Props) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [schema, setSchema] = useState<PluginConfigSchemaItem[]>([]);
|
||||
const [config, setConfig] = useState<Record<string, unknown>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [supportReactive, setSupportReactive] = useState(false);
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
const [connected, setConnected] = useState(false);
|
||||
|
||||
// SSE 连接引用
|
||||
const eventSourceRef = useRef<EventSourcePolyfill | null>(null);
|
||||
// 当前配置引用(用于 SSE 回调)
|
||||
const configRef = useRef<Record<string, unknown>>({});
|
||||
|
||||
// 同步 config 到 ref
|
||||
useEffect(() => {
|
||||
configRef.current = config;
|
||||
}, [config]);
|
||||
|
||||
/** 处理 schema 更新事件 */
|
||||
const handleSchemaUpdate = useCallback((event: SchemaUpdateEvent) => {
|
||||
switch (event.type) {
|
||||
case 'full':
|
||||
if (event.schema) {
|
||||
setSchema(event.schema);
|
||||
}
|
||||
break;
|
||||
case 'updateField':
|
||||
if (event.key && event.field) {
|
||||
setSchema(prev => prev.map(item =>
|
||||
item.key === event.key ? { ...item, ...event.field } : item
|
||||
));
|
||||
}
|
||||
break;
|
||||
case 'removeField':
|
||||
if (event.key) {
|
||||
setSchema(prev => prev.filter(item => item.key !== event.key));
|
||||
}
|
||||
break;
|
||||
case 'addField':
|
||||
if (event.field) {
|
||||
setSchema(prev => {
|
||||
const newField = event.field as PluginConfigSchemaItem;
|
||||
// 检查字段是否已存在,如果存在则更新
|
||||
const existingIndex = prev.findIndex(item => item.key === newField.key);
|
||||
if (existingIndex !== -1) {
|
||||
// 字段已存在,更新它
|
||||
const newSchema = [...prev];
|
||||
newSchema[existingIndex] = { ...newSchema[existingIndex], ...newField };
|
||||
return newSchema;
|
||||
}
|
||||
// 字段不存在,添加新字段
|
||||
if (event.afterKey) {
|
||||
const index = prev.findIndex(item => item.key === event.afterKey);
|
||||
if (index !== -1) {
|
||||
const newSchema = [...prev];
|
||||
newSchema.splice(index + 1, 0, newField);
|
||||
return newSchema;
|
||||
}
|
||||
}
|
||||
return [...prev, newField];
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'showField':
|
||||
if (event.key) {
|
||||
setSchema(prev => prev.map(item =>
|
||||
item.key === event.key ? { ...item, hidden: false } : item
|
||||
));
|
||||
}
|
||||
break;
|
||||
case 'hideField':
|
||||
if (event.key) {
|
||||
setSchema(prev => prev.map(item =>
|
||||
item.key === event.key ? { ...item, hidden: true } : item
|
||||
));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/** 建立 SSE 连接 */
|
||||
const connectSSE = useCallback((initialConfig: Record<string, unknown>) => {
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
}
|
||||
|
||||
const token = localStorage.getItem(key.token);
|
||||
if (!token) {
|
||||
console.warn('未登录,无法建立 SSE 连接');
|
||||
return;
|
||||
}
|
||||
const _token = JSON.parse(token);
|
||||
|
||||
const url = PluginManager.getConfigSSEUrl(pluginId, initialConfig);
|
||||
const es = new EventSourcePolyfill(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${_token}`,
|
||||
Accept: 'text/event-stream',
|
||||
},
|
||||
withCredentials: true,
|
||||
});
|
||||
eventSourceRef.current = es;
|
||||
|
||||
es.addEventListener('connected', (e) => {
|
||||
const data = JSON.parse((e as MessageEvent).data);
|
||||
setSessionId(data.sessionId);
|
||||
setConnected(true);
|
||||
});
|
||||
|
||||
es.addEventListener('schema', (e) => {
|
||||
const data = JSON.parse((e as MessageEvent).data);
|
||||
handleSchemaUpdate(data);
|
||||
});
|
||||
|
||||
es.addEventListener('error', (e) => {
|
||||
try {
|
||||
const data = JSON.parse((e as MessageEvent).data);
|
||||
toast.error('插件错误: ' + data.message);
|
||||
} catch {
|
||||
// SSE 连接错误
|
||||
setConnected(false);
|
||||
}
|
||||
});
|
||||
|
||||
es.onerror = () => {
|
||||
setConnected(false);
|
||||
};
|
||||
}, [pluginId, handleSchemaUpdate]);
|
||||
|
||||
/** 关闭 SSE 连接 */
|
||||
const disconnectSSE = useCallback(() => {
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
setSessionId(null);
|
||||
setConnected(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && pluginId) {
|
||||
loadConfig();
|
||||
}
|
||||
return () => {
|
||||
disconnectSSE();
|
||||
};
|
||||
}, [isOpen, pluginId, disconnectSSE]);
|
||||
|
||||
/** 初始加载配置 */
|
||||
const loadConfig = async () => {
|
||||
setLoading(true);
|
||||
setSchema([]);
|
||||
setConfig({});
|
||||
setSupportReactive(false);
|
||||
disconnectSSE();
|
||||
|
||||
try {
|
||||
const data = await PluginManager.getPluginConfig(pluginId);
|
||||
setSchema(data.schema || []);
|
||||
setConfig(data.config || {});
|
||||
setSupportReactive(!!data.supportReactive);
|
||||
|
||||
// 如果支持响应式,建立 SSE 连接
|
||||
if (data.supportReactive) {
|
||||
connectSSE(data.config || {});
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast.error('加载配置失败: ' + e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await PluginManager.setPluginConfig(pluginId, config);
|
||||
toast.success('Configuration saved');
|
||||
onOpenChange();
|
||||
} catch (e: any) {
|
||||
toast.error('Save failed: ' + e.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** 更新配置 */
|
||||
const updateConfig = useCallback((key: string, value: any) => {
|
||||
setConfig((prev) => {
|
||||
const newConfig = { ...prev, [key]: value };
|
||||
|
||||
// 如果是响应式字段且已连接 SSE,通知后端
|
||||
const field = schema.find(item => item.key === key);
|
||||
if (field?.reactive && sessionId && connected) {
|
||||
PluginManager.notifyConfigChange(pluginId, sessionId, key, value, newConfig)
|
||||
.catch(e => console.error('通知配置变化失败:', e));
|
||||
}
|
||||
|
||||
return newConfig;
|
||||
});
|
||||
}, [schema, sessionId, connected, pluginId]);
|
||||
|
||||
const renderField = (item: PluginConfigSchemaItem) => {
|
||||
const value = config[item.key] ?? item.default;
|
||||
|
||||
switch (item.type) {
|
||||
case 'string':
|
||||
return (
|
||||
<Input
|
||||
key={item.key}
|
||||
label={item.label}
|
||||
placeholder={item.placeholder || item.description}
|
||||
value={value || ''}
|
||||
onValueChange={(val) => updateConfig(item.key, val)}
|
||||
description={item.description}
|
||||
className="mb-4"
|
||||
/>
|
||||
);
|
||||
case 'number':
|
||||
return (
|
||||
<Input
|
||||
key={item.key}
|
||||
type="number"
|
||||
label={item.label}
|
||||
placeholder={item.placeholder || item.description}
|
||||
value={String(value ?? 0)}
|
||||
onValueChange={(val) => updateConfig(item.key, Number(val))}
|
||||
description={item.description}
|
||||
className="mb-4"
|
||||
/>
|
||||
);
|
||||
case 'boolean':
|
||||
return (
|
||||
<div key={item.key} className="flex justify-between items-center mb-4 p-2 bg-default-100 rounded-lg">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-small">{item.label}</span>
|
||||
{item.description && <span className="text-tiny text-default-500">{item.description}</span>}
|
||||
</div>
|
||||
<Switch
|
||||
isSelected={!!value}
|
||||
onValueChange={(val) => updateConfig(item.key, val)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
case 'select': {
|
||||
const selectedValue = value !== undefined ? String(value) : undefined;
|
||||
const options = item.options || [];
|
||||
return (
|
||||
<Select
|
||||
key={item.key}
|
||||
label={item.label}
|
||||
placeholder={item.placeholder || 'Select an option'}
|
||||
selectedKeys={selectedValue ? [selectedValue] : []}
|
||||
onSelectionChange={(keys) => {
|
||||
const val = Array.from(keys)[0];
|
||||
const opt = options.find(o => String(o.value) === val);
|
||||
updateConfig(item.key, opt ? opt.value : val);
|
||||
}}
|
||||
description={item.description}
|
||||
className="mb-4"
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<SelectItem key={String(opt.value)} textValue={opt.label}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
case 'multi-select': {
|
||||
const selectedKeys = Array.isArray(value) ? value.map(String) : [];
|
||||
const options = item.options || [];
|
||||
return (
|
||||
<Select
|
||||
key={item.key}
|
||||
label={item.label}
|
||||
placeholder={item.placeholder || 'Select options'}
|
||||
selectionMode="multiple"
|
||||
selectedKeys={new Set(selectedKeys)}
|
||||
onSelectionChange={(keys) => {
|
||||
const selected = Array.from(keys).map(k => {
|
||||
const opt = options.find(o => String(o.value) === k);
|
||||
return opt ? opt.value : k;
|
||||
});
|
||||
updateConfig(item.key, selected);
|
||||
}}
|
||||
description={item.description}
|
||||
className="mb-4"
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<SelectItem key={String(opt.value)} textValue={opt.label}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
case 'html':
|
||||
return (
|
||||
<div key={item.key} className="mb-4">
|
||||
{item.label && <h4 className="text-small font-bold mb-1">{item.label}</h4>}
|
||||
<div dangerouslySetInnerHTML={{ __html: item.default || '' }} className="prose dark:prose-invert max-w-none" />
|
||||
{item.description && <p className="text-tiny text-default-500 mt-1">{item.description}</p>}
|
||||
</div>
|
||||
);
|
||||
case 'text':
|
||||
return (
|
||||
<div key={item.key} className="mb-4">
|
||||
{item.label && <h4 className="text-small font-bold mb-1">{item.label}</h4>}
|
||||
<div className="whitespace-pre-wrap text-default-700">{item.default || ''}</div>
|
||||
{item.description && <p className="text-tiny text-default-500 mt-1">{item.description}</p>}
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange} size="2xl" scrollBehavior="inside">
|
||||
<ModalContent>
|
||||
{(onClose) => (
|
||||
<>
|
||||
<ModalHeader className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
插件配置: {pluginId}
|
||||
{supportReactive && (
|
||||
<span className={`text-tiny px-2 py-0.5 rounded ${connected ? 'bg-success-100 text-success-600' : 'bg-warning-100 text-warning-600'}`}>
|
||||
{connected ? '已连接' : '未连接'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
{loading ? (
|
||||
<div className="flex justify-center p-8">Loading configuration...</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{schema.length === 0 ? (
|
||||
<div className="text-center text-default-500">No configuration schema available.</div>
|
||||
) : (
|
||||
schema.filter(item => !item.hidden).map(renderField)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color="danger" variant="light" onPress={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
<Button color="primary" onPress={handleSave} isLoading={saving}>
|
||||
Save
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { Input } from '@heroui/input';
|
||||
import { Select, SelectItem } from '@heroui/select';
|
||||
import { Tab, Tabs } from '@heroui/tabs';
|
||||
import { Card, CardBody } from '@heroui/card';
|
||||
import { Tooltip } from '@heroui/tooltip';
|
||||
import { Spinner } from '@heroui/spinner';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { IoMdRefresh, IoMdSearch } from 'react-icons/io';
|
||||
import { IoMdRefresh, IoMdSearch, IoMdSettings } from 'react-icons/io';
|
||||
import clsx from 'clsx';
|
||||
import { useRequest } from 'ahooks';
|
||||
import { EventSourcePolyfill } from 'event-source-polyfill';
|
||||
|
||||
import PageLoading from '@/components/page_loading';
|
||||
import PluginStoreCard from '@/components/display_card/plugin_store_card';
|
||||
import PluginManager from '@/controllers/plugin_manager';
|
||||
import WebUIManager from '@/controllers/webui_manager';
|
||||
import PluginStoreCard, { InstallStatus } from '@/components/display_card/plugin_store_card';
|
||||
import PluginManager, { PluginItem } from '@/controllers/plugin_manager';
|
||||
import MirrorSelectorModal from '@/components/mirror_selector_modal';
|
||||
import { PluginStoreItem } from '@/types/plugin-store';
|
||||
import useDialog from '@/hooks/use-dialog';
|
||||
import key from '@/const/key';
|
||||
@@ -35,23 +35,32 @@ const EmptySection: React.FC<EmptySectionProps> = ({ isEmpty }) => {
|
||||
|
||||
export default function PluginStorePage () {
|
||||
const [plugins, setPlugins] = useState<PluginStoreItem[]>([]);
|
||||
const [installedPlugins, setInstalledPlugins] = useState<PluginItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [activeTab, setActiveTab] = useState<string>('all');
|
||||
const [pluginManagerNotFound, setPluginManagerNotFound] = useState(false);
|
||||
const dialog = useDialog();
|
||||
|
||||
// 获取镜像列表
|
||||
const { data: mirrorsData } = useRequest(WebUIManager.getMirrors, {
|
||||
cacheKey: 'napcat-mirrors',
|
||||
staleTime: 60 * 60 * 1000,
|
||||
});
|
||||
const mirrors = mirrorsData?.mirrors || [];
|
||||
// 商店列表源相关状态
|
||||
const [storeSourceModalOpen, setStoreSourceModalOpen] = useState(false);
|
||||
const [currentStoreSource, setCurrentStoreSource] = useState<string | undefined>(undefined);
|
||||
|
||||
// 下载镜像弹窗状态(安装时使用)
|
||||
const [downloadMirrorModalOpen, setDownloadMirrorModalOpen] = useState(false);
|
||||
const [pendingInstallPlugin, setPendingInstallPlugin] = useState<PluginStoreItem | null>(null);
|
||||
const [selectedDownloadMirror, setSelectedDownloadMirror] = useState<string | undefined>(undefined);
|
||||
|
||||
const loadPlugins = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await PluginManager.getPluginStoreList();
|
||||
setPlugins(data.plugins);
|
||||
|
||||
// 检查插件管理器是否已加载
|
||||
const listResult = await PluginManager.getPluginList();
|
||||
setPluginManagerNotFound(listResult.pluginManagerNotFound);
|
||||
setInstalledPlugins(listResult.plugins || []);
|
||||
} catch (e: any) {
|
||||
toast.error(e.message);
|
||||
} finally {
|
||||
@@ -61,7 +70,7 @@ export default function PluginStorePage () {
|
||||
|
||||
useEffect(() => {
|
||||
loadPlugins();
|
||||
}, []);
|
||||
}, [currentStoreSource]);
|
||||
|
||||
// 按标签分类和搜索
|
||||
const categorizedPlugins = useMemo(() => {
|
||||
@@ -90,6 +99,23 @@ export default function PluginStorePage () {
|
||||
return categories;
|
||||
}, [plugins, searchQuery]);
|
||||
|
||||
// 获取插件的安装状态和已安装版本
|
||||
const getPluginInstallInfo = (plugin: PluginStoreItem): { status: InstallStatus; installedVersion?: string; } => {
|
||||
// 通过 id (包名) 或 name 匹配已安装的插件
|
||||
const installed = installedPlugins.find(p => p.id === plugin.id);
|
||||
|
||||
if (!installed) {
|
||||
return { status: 'not-installed' };
|
||||
}
|
||||
|
||||
// 使用不等于判断:版本不同就显示更新
|
||||
if (installed.version !== plugin.version) {
|
||||
return { status: 'update-available', installedVersion: installed.version };
|
||||
}
|
||||
|
||||
return { status: 'installed', installedVersion: installed.version };
|
||||
};
|
||||
|
||||
const tabs = useMemo(() => {
|
||||
return [
|
||||
{ key: 'all', title: '全部', count: categorizedPlugins.all?.length || 0 },
|
||||
@@ -101,60 +127,9 @@ export default function PluginStorePage () {
|
||||
}, [categorizedPlugins]);
|
||||
|
||||
const handleInstall = async (plugin: PluginStoreItem) => {
|
||||
// 检测是否是 GitHub 下载链接
|
||||
const githubPattern = /^https:\/\/github\.com\//;
|
||||
const isGitHubUrl = githubPattern.test(plugin.downloadUrl);
|
||||
|
||||
// 如果是 GitHub 链接,弹出镜像选择对话框
|
||||
if (isGitHubUrl) {
|
||||
let selectedMirror: string | undefined = undefined;
|
||||
|
||||
dialog.confirm({
|
||||
title: '安装插件',
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm mb-2">
|
||||
插件名称: <span className="font-semibold">{plugin.name}</span>
|
||||
</p>
|
||||
<p className="text-sm mb-2">
|
||||
版本: <span className="font-semibold">v{plugin.version}</span>
|
||||
</p>
|
||||
<p className="text-sm text-default-500 mb-4">
|
||||
{plugin.description}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">选择下载镜像源</label>
|
||||
<Select
|
||||
placeholder="自动选择 (默认)"
|
||||
defaultSelectedKeys={['default']}
|
||||
onSelectionChange={(keys) => {
|
||||
const m = Array.from(keys)[0] as string;
|
||||
selectedMirror = m === 'default' ? undefined : m;
|
||||
}}
|
||||
size="sm"
|
||||
aria-label="选择镜像源"
|
||||
>
|
||||
{['default', ...mirrors].map(m => (
|
||||
<SelectItem key={m} textValue={m === 'default' ? '自动选择' : m}>
|
||||
{m === 'default' ? '自动选择 (默认)' : m}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
confirmText: '开始安装',
|
||||
cancelText: '取消',
|
||||
onConfirm: async () => {
|
||||
await installPluginWithSSE(plugin.id, selectedMirror);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// 非 GitHub 链接,直接安装
|
||||
await installPluginWithSSE(plugin.id);
|
||||
}
|
||||
// 弹窗选择下载镜像
|
||||
setPendingInstallPlugin(plugin);
|
||||
setDownloadMirrorModalOpen(true);
|
||||
};
|
||||
|
||||
const installPluginWithSSE = async (pluginId: string, mirror?: string) => {
|
||||
@@ -195,6 +170,35 @@ export default function PluginStorePage () {
|
||||
} else if (data.success) {
|
||||
toast.success('插件安装成功!', { id: loadingToast });
|
||||
eventSource.close();
|
||||
// 刷新插件列表
|
||||
loadPlugins();
|
||||
// 安装成功后检查插件管理器状态
|
||||
if (pluginManagerNotFound) {
|
||||
dialog.confirm({
|
||||
title: '插件管理器未加载',
|
||||
content: (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-default-600">
|
||||
插件已安装成功,但插件管理器尚未加载。
|
||||
</p>
|
||||
<p className="text-sm text-default-600">
|
||||
是否立即注册插件管理器?注册后插件才能正常运行。
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
confirmText: '注册插件管理器',
|
||||
cancelText: '稍后再说',
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await PluginManager.registerPluginManager();
|
||||
toast.success('插件管理器注册成功');
|
||||
setPluginManagerNotFound(false);
|
||||
} catch (e: any) {
|
||||
toast.error('注册失败: ' + e.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
} else if (data.message) {
|
||||
toast.loading(data.message, { id: loadingToast });
|
||||
}
|
||||
@@ -213,25 +217,57 @@ export default function PluginStorePage () {
|
||||
}
|
||||
};
|
||||
|
||||
const getStoreSourceDisplayName = () => {
|
||||
if (!currentStoreSource) return '默认源';
|
||||
try {
|
||||
return new URL(currentStoreSource).hostname;
|
||||
} catch {
|
||||
return currentStoreSource;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>插件商店 - NapCat WebUI</title>
|
||||
<div className="p-2 md:p-4 relative">
|
||||
<PageLoading loading={loading} />
|
||||
|
||||
{/* 头部 */}
|
||||
<div className="flex mb-6 items-center gap-4">
|
||||
<div className="flex mb-6 items-center justify-between flex-wrap gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="text-2xl font-bold">插件商店</h1>
|
||||
<Button
|
||||
isIconOnly
|
||||
className="bg-default-100/50 hover:bg-default-200/50 text-default-700 backdrop-blur-md"
|
||||
radius="full"
|
||||
onPress={loadPlugins}
|
||||
isLoading={loading}
|
||||
>
|
||||
<IoMdRefresh size={24} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 商店列表源卡片 */}
|
||||
<Card className="bg-default-100/50 backdrop-blur-md shadow-sm">
|
||||
<CardBody className="py-2 px-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-default-500">列表源:</span>
|
||||
<span className="text-sm font-medium">{getStoreSourceDisplayName()}</span>
|
||||
</div>
|
||||
<Tooltip content="切换列表源">
|
||||
<Button
|
||||
isIconOnly
|
||||
size="sm"
|
||||
variant="light"
|
||||
onPress={() => setStoreSourceModalOpen(true)}
|
||||
>
|
||||
<IoMdSettings size={16} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 搜索框 */}
|
||||
<div className="mb-6">
|
||||
<Input
|
||||
@@ -244,6 +280,14 @@ export default function PluginStorePage () {
|
||||
</div>
|
||||
|
||||
{/* 标签页 */}
|
||||
<div className="relative">
|
||||
{/* 加载遮罩 - 只遮住插件列表区域 */}
|
||||
{loading && (
|
||||
<div className="absolute inset-0 bg-zinc-500/10 z-30 flex justify-center items-center backdrop-blur-sm rounded-lg">
|
||||
<Spinner size='lg' />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Tabs
|
||||
aria-label="Plugin Store Categories"
|
||||
className="max-w-full"
|
||||
@@ -261,18 +305,55 @@ export default function PluginStorePage () {
|
||||
>
|
||||
<EmptySection isEmpty={!categorizedPlugins[tab.key]?.length} />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 justify-start items-stretch gap-x-2 gap-y-4">
|
||||
{categorizedPlugins[tab.key]?.map((plugin) => (
|
||||
{categorizedPlugins[tab.key]?.map((plugin) => {
|
||||
const installInfo = getPluginInstallInfo(plugin);
|
||||
return (
|
||||
<PluginStoreCard
|
||||
key={plugin.id}
|
||||
data={plugin}
|
||||
installStatus={installInfo.status}
|
||||
installedVersion={installInfo.installedVersion}
|
||||
onInstall={() => handleInstall(plugin)}
|
||||
/>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Tab>
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 商店列表源选择弹窗 */}
|
||||
<MirrorSelectorModal
|
||||
isOpen={storeSourceModalOpen}
|
||||
onClose={() => setStoreSourceModalOpen(false)}
|
||||
onSelect={(mirror) => {
|
||||
setCurrentStoreSource(mirror);
|
||||
}}
|
||||
currentMirror={currentStoreSource}
|
||||
type="raw"
|
||||
/>
|
||||
|
||||
{/* 下载镜像选择弹窗 */}
|
||||
<MirrorSelectorModal
|
||||
isOpen={downloadMirrorModalOpen}
|
||||
onClose={() => {
|
||||
setDownloadMirrorModalOpen(false);
|
||||
setPendingInstallPlugin(null);
|
||||
}}
|
||||
onSelect={(mirror) => {
|
||||
setSelectedDownloadMirror(mirror);
|
||||
// 选择后立即开始安装
|
||||
if (pendingInstallPlugin) {
|
||||
setDownloadMirrorModalOpen(false);
|
||||
installPluginWithSSE(pendingInstallPlugin.id, mirror);
|
||||
setPendingInstallPlugin(null);
|
||||
}
|
||||
}}
|
||||
currentMirror={selectedDownloadMirror}
|
||||
type="file"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { PayloadAction, createSlice } from '@reduxjs/toolkit';
|
||||
import type { RootState } from '@/store';
|
||||
|
||||
interface ConfigState {
|
||||
value: OneBotConfig
|
||||
value: OneBotConfig;
|
||||
}
|
||||
|
||||
const initialState: ConfigState = {
|
||||
@@ -18,6 +18,7 @@ const initialState: ConfigState = {
|
||||
musicSignUrl: '',
|
||||
enableLocalFile2Url: false,
|
||||
parseMultMsg: true,
|
||||
imageDownloadProxy: '',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,64 +1,65 @@
|
||||
interface AdapterConfigInner {
|
||||
name: string
|
||||
enable: boolean
|
||||
debug: boolean
|
||||
token: string
|
||||
name: string;
|
||||
enable: boolean;
|
||||
debug: boolean;
|
||||
token: string;
|
||||
}
|
||||
|
||||
interface AdapterConfig extends AdapterConfigInner {
|
||||
[key: string]: string | boolean | number
|
||||
[key: string]: string | boolean | number;
|
||||
}
|
||||
|
||||
type MessageFormat = 'array' | 'string';
|
||||
|
||||
interface HttpServerConfig extends AdapterConfig {
|
||||
port: number
|
||||
host: string
|
||||
enableCors: boolean
|
||||
enableWebsocket: boolean
|
||||
messagePostFormat: MessageFormat
|
||||
port: number;
|
||||
host: string;
|
||||
enableCors: boolean;
|
||||
enableWebsocket: boolean;
|
||||
messagePostFormat: MessageFormat;
|
||||
}
|
||||
|
||||
interface HttpClientConfig extends AdapterConfig {
|
||||
url: string
|
||||
messagePostFormat: MessageFormat
|
||||
reportSelfMessage: boolean
|
||||
url: string;
|
||||
messagePostFormat: MessageFormat;
|
||||
reportSelfMessage: boolean;
|
||||
}
|
||||
|
||||
interface WebsocketServerConfig extends AdapterConfig {
|
||||
host: string
|
||||
port: number
|
||||
messagePostFormat: MessageFormat
|
||||
reportSelfMessage: boolean
|
||||
enableForcePushEvent: boolean
|
||||
heartInterval: number
|
||||
host: string;
|
||||
port: number;
|
||||
messagePostFormat: MessageFormat;
|
||||
reportSelfMessage: boolean;
|
||||
enableForcePushEvent: boolean;
|
||||
heartInterval: number;
|
||||
}
|
||||
|
||||
interface WebsocketClientConfig extends AdapterConfig {
|
||||
url: string
|
||||
messagePostFormat: MessageFormat
|
||||
reportSelfMessage: boolean
|
||||
reconnectInterval: number
|
||||
token: string
|
||||
debug: boolean
|
||||
heartInterval: number
|
||||
url: string;
|
||||
messagePostFormat: MessageFormat;
|
||||
reportSelfMessage: boolean;
|
||||
reconnectInterval: number;
|
||||
token: string;
|
||||
debug: boolean;
|
||||
heartInterval: number;
|
||||
}
|
||||
|
||||
interface HttpSseServerConfig extends HttpServerConfig {
|
||||
reportSelfMessage: boolean
|
||||
reportSelfMessage: boolean;
|
||||
}
|
||||
|
||||
interface NetworkConfig {
|
||||
httpServers: Array<HttpServerConfig>
|
||||
httpClients: Array<HttpClientConfig>
|
||||
httpSseServers: Array<HttpSseServerConfig>
|
||||
websocketServers: Array<WebsocketServerConfig>
|
||||
websocketClients: Array<WebsocketClientConfig>
|
||||
httpServers: Array<HttpServerConfig>;
|
||||
httpClients: Array<HttpClientConfig>;
|
||||
httpSseServers: Array<HttpSseServerConfig>;
|
||||
websocketServers: Array<WebsocketServerConfig>;
|
||||
websocketClients: Array<WebsocketClientConfig>;
|
||||
}
|
||||
|
||||
interface OneBotConfig {
|
||||
network: NetworkConfig // 网络配置
|
||||
musicSignUrl: string // 音乐签名地址
|
||||
enableLocalFile2Url: boolean
|
||||
parseMultMsg: boolean
|
||||
network: NetworkConfig; // 网络配置
|
||||
musicSignUrl: string; // 音乐签名地址
|
||||
enableLocalFile2Url: boolean;
|
||||
parseMultMsg: boolean;
|
||||
imageDownloadProxy: string; // 图片下载代理地址
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ export type ParsedSchema = {
|
||||
enum?: any[];
|
||||
children?: ParsedSchema[];
|
||||
description?: string;
|
||||
isCircularRef?: boolean; // 标记循环引用
|
||||
isTruncated?: boolean; // 标记被截断
|
||||
};
|
||||
|
||||
// 定义基础响应结构 (TypeBox 格式)
|
||||
@@ -20,37 +22,79 @@ export const BaseResponseSchema = Type.Object({
|
||||
echo: Type.Optional(Type.String({ description: '回显' })),
|
||||
});
|
||||
|
||||
export function parseTypeBox (schema: TSchema | undefined, name?: string, isRoot = true): ParsedSchema | ParsedSchema[] {
|
||||
// 最大解析深度
|
||||
const MAX_PARSE_DEPTH = 4;
|
||||
// 最大生成深度
|
||||
const MAX_GENERATE_DEPTH = 3;
|
||||
// anyOf/oneOf 最大解析选项数量
|
||||
const MAX_UNION_OPTIONS = 5;
|
||||
|
||||
export function parseTypeBox (
|
||||
schema: TSchema | undefined,
|
||||
name?: string,
|
||||
isRoot = true,
|
||||
visitedIds: Set<string> = new Set(),
|
||||
depth = 0
|
||||
): ParsedSchema | ParsedSchema[] {
|
||||
// 基础检查
|
||||
if (!schema) {
|
||||
return isRoot ? [] : { name, type: 'unknown', optional: false };
|
||||
}
|
||||
|
||||
// 如果是根节点解析,且我们需要将其包装在 BaseResponse 中(通常用于 response)
|
||||
// 但这里我们根据传入的 schema 决定
|
||||
// 深度限制检查
|
||||
if (depth > MAX_PARSE_DEPTH) {
|
||||
return { name, type: 'object', optional: false, description: '...', isCircularRef: true };
|
||||
}
|
||||
|
||||
// $id 循环引用检查
|
||||
const schemaId = schema.$id;
|
||||
if (schemaId && visitedIds.has(schemaId)) {
|
||||
return { name, type: 'object', optional: false, description: `(${schemaId})`, isCircularRef: true };
|
||||
}
|
||||
|
||||
// 创建副本并添加当前 $id
|
||||
const newVisitedIds = new Set(visitedIds);
|
||||
if (schemaId) {
|
||||
newVisitedIds.add(schemaId);
|
||||
}
|
||||
|
||||
const description = schema.description;
|
||||
const optional = false; // TypeBox schema doesn't store optionality in the same way Zod does, usually handled by parent object
|
||||
|
||||
// Handle specific types
|
||||
const optional = false;
|
||||
const type = schema.type;
|
||||
|
||||
// 常量值
|
||||
if (schema.const !== undefined) {
|
||||
return { name, type: 'value', value: schema.const, optional, description };
|
||||
}
|
||||
|
||||
// 枚举
|
||||
if (schema.enum) {
|
||||
return { name, type: 'enum', enum: schema.enum, optional, description };
|
||||
}
|
||||
|
||||
// 联合类型 (anyOf/oneOf) - 限制解析的选项数量
|
||||
if (schema.anyOf || schema.oneOf) {
|
||||
const options = (schema.anyOf || schema.oneOf) as TSchema[];
|
||||
const children = options.map(opt => parseTypeBox(opt, undefined, false) as ParsedSchema);
|
||||
const allOptions = (schema.anyOf || schema.oneOf) as TSchema[];
|
||||
// 只取前 MAX_UNION_OPTIONS 个选项
|
||||
const options = allOptions.slice(0, MAX_UNION_OPTIONS);
|
||||
const children = options.map(opt => parseTypeBox(opt, undefined, false, newVisitedIds, depth + 1) as ParsedSchema);
|
||||
|
||||
// 如果有更多选项被截断
|
||||
if (allOptions.length > MAX_UNION_OPTIONS) {
|
||||
children.push({
|
||||
name: undefined,
|
||||
type: 'object',
|
||||
optional: false,
|
||||
description: `... 还有 ${allOptions.length - MAX_UNION_OPTIONS} 个类型`,
|
||||
isTruncated: true
|
||||
});
|
||||
}
|
||||
return { name, type: 'union', children, optional, description };
|
||||
}
|
||||
|
||||
// allOf 交叉类型
|
||||
if (schema.allOf) {
|
||||
const parts = schema.allOf as TSchema[];
|
||||
// 如果全是对象,尝试合并属性
|
||||
const allProperties: Record<string, TSchema> = {};
|
||||
const allRequired: string[] = [];
|
||||
let canMerge = true;
|
||||
@@ -64,18 +108,19 @@ export function parseTypeBox (schema: TSchema | undefined, name?: string, isRoot
|
||||
});
|
||||
|
||||
if (canMerge) {
|
||||
return parseTypeBox({ ...schema, type: 'object', properties: allProperties, required: allRequired }, name, isRoot);
|
||||
return parseTypeBox({ ...schema, type: 'object', properties: allProperties, required: allRequired }, name, isRoot, newVisitedIds, depth);
|
||||
}
|
||||
// 无法简单合并,当作联合展示
|
||||
const children = parts.map(part => parseTypeBox(part, undefined, false) as ParsedSchema);
|
||||
const children = parts.slice(0, MAX_UNION_OPTIONS).map(part => parseTypeBox(part, undefined, false, newVisitedIds, depth + 1) as ParsedSchema);
|
||||
return { name, type: 'intersection', children, optional, description };
|
||||
}
|
||||
|
||||
// 对象类型
|
||||
if (type === 'object') {
|
||||
const properties = schema.properties || {};
|
||||
const required = schema.required || [];
|
||||
const children = Object.keys(properties).map(key => {
|
||||
const child = parseTypeBox(properties[key], key, false) as ParsedSchema;
|
||||
const keys = Object.keys(properties);
|
||||
const children = keys.map(key => {
|
||||
const child = parseTypeBox(properties[key], key, false, newVisitedIds, depth + 1) as ParsedSchema;
|
||||
child.optional = !required.includes(key);
|
||||
return child;
|
||||
});
|
||||
@@ -83,12 +128,17 @@ export function parseTypeBox (schema: TSchema | undefined, name?: string, isRoot
|
||||
return { name, type: 'object', children, optional, description };
|
||||
}
|
||||
|
||||
// 数组类型
|
||||
if (type === 'array') {
|
||||
const items = schema.items as TSchema;
|
||||
const child = parseTypeBox(items, undefined, false) as ParsedSchema;
|
||||
if (items) {
|
||||
const child = parseTypeBox(items, undefined, false, newVisitedIds, depth + 1) as ParsedSchema;
|
||||
return { name, type: 'array', children: [child], optional, description };
|
||||
}
|
||||
return { name, type: 'array', children: [], optional, description };
|
||||
}
|
||||
|
||||
// 基础类型
|
||||
if (type === 'string') return { name, type: 'string', optional, description };
|
||||
if (type === 'number' || type === 'integer') return { name, type: 'number', optional, description };
|
||||
if (type === 'boolean') return { name, type: 'boolean', optional, description };
|
||||
@@ -97,24 +147,76 @@ export function parseTypeBox (schema: TSchema | undefined, name?: string, isRoot
|
||||
return { name, type: type || 'unknown', optional, description };
|
||||
}
|
||||
|
||||
export function generateDefaultFromTypeBox (schema: TSchema | undefined): any {
|
||||
export function generateDefaultFromTypeBox (
|
||||
schema: TSchema | undefined,
|
||||
visitedIds: Set<string> = new Set(),
|
||||
depth = 0
|
||||
): any {
|
||||
// 基础检查
|
||||
if (!schema) return {};
|
||||
|
||||
// 深度限制
|
||||
if (depth > MAX_GENERATE_DEPTH) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// $id 循环引用检查
|
||||
const schemaId = schema.$id;
|
||||
if (schemaId && visitedIds.has(schemaId)) {
|
||||
return schema.type === 'array' ? [] : schema.type === 'object' ? {} : null;
|
||||
}
|
||||
|
||||
// 创建副本并添加当前 $id
|
||||
const newVisitedIds = new Set(visitedIds);
|
||||
if (schemaId) {
|
||||
newVisitedIds.add(schemaId);
|
||||
}
|
||||
|
||||
// 常量/默认值/枚举
|
||||
if (schema.const !== undefined) return schema.const;
|
||||
if (schema.default !== undefined) return schema.default;
|
||||
if (schema.enum) return schema.enum[0];
|
||||
if (schema.anyOf || schema.oneOf) return generateDefaultFromTypeBox((schema.anyOf || schema.oneOf)[0]);
|
||||
|
||||
// 联合类型 - 优先选择简单类型
|
||||
if (schema.anyOf || schema.oneOf) {
|
||||
const options = (schema.anyOf || schema.oneOf) as TSchema[];
|
||||
// 优先找简单类型
|
||||
const stringOption = options.find(opt => opt.type === 'string');
|
||||
if (stringOption) return '';
|
||||
const numberOption = options.find(opt => opt.type === 'number' || opt.type === 'integer');
|
||||
if (numberOption) return 0;
|
||||
const boolOption = options.find(opt => opt.type === 'boolean');
|
||||
if (boolOption) return false;
|
||||
// 否则只取第一个
|
||||
if (options.length > 0) {
|
||||
return generateDefaultFromTypeBox(options[0], newVisitedIds, depth + 1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const type = schema.type;
|
||||
|
||||
// 对象类型
|
||||
if (type === 'object') {
|
||||
const obj: any = {};
|
||||
const props = schema.properties || {};
|
||||
const required = schema.required || [];
|
||||
|
||||
// 只为必填字段和浅层字段生成默认值
|
||||
for (const key in props) {
|
||||
// Only generate defaults for required properties or if we want a full example
|
||||
obj[key] = generateDefaultFromTypeBox(props[key]);
|
||||
if (required.includes(key) || depth < 1) {
|
||||
obj[key] = generateDefaultFromTypeBox(props[key], newVisitedIds, depth + 1);
|
||||
}
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
if (type === 'array') return [];
|
||||
|
||||
// 数组类型 - 返回空数组
|
||||
if (type === 'array') {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 基础类型
|
||||
if (type === 'string') return '';
|
||||
if (type === 'number' || type === 'integer') return 0;
|
||||
if (type === 'boolean') return false;
|
||||
|
||||
@@ -25,13 +25,15 @@ import {
|
||||
export type LiteralValue = string | number | boolean | null;
|
||||
|
||||
export type ParsedSchema = {
|
||||
name?: string
|
||||
type: string | string[]
|
||||
optional: boolean
|
||||
value?: LiteralValue
|
||||
enum?: LiteralValue[]
|
||||
children?: ParsedSchema[]
|
||||
description?: string
|
||||
name?: string;
|
||||
type: string | string[];
|
||||
optional: boolean;
|
||||
value?: LiteralValue;
|
||||
enum?: LiteralValue[];
|
||||
children?: ParsedSchema[];
|
||||
description?: string;
|
||||
isCircularRef?: boolean; // 标记循环引用
|
||||
isTruncated?: boolean; // 标记被截断
|
||||
};
|
||||
|
||||
export function parse (
|
||||
|
||||
95
pnpm-lock.yaml
generated
95
pnpm-lock.yaml
generated
@@ -210,21 +210,11 @@ importers:
|
||||
specifier: ^1.6.7
|
||||
version: 1.6.7
|
||||
|
||||
packages/napcat-plugin:
|
||||
dependencies:
|
||||
napcat-types:
|
||||
specifier: workspace:*
|
||||
version: link:../napcat-types
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^22.0.1
|
||||
version: 22.19.1
|
||||
|
||||
packages/napcat-plugin-builtin:
|
||||
dependencies:
|
||||
napcat-types:
|
||||
specifier: 0.0.3
|
||||
version: 0.0.3
|
||||
specifier: 0.0.10
|
||||
version: 0.0.10
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^22.0.1
|
||||
@@ -318,30 +308,9 @@ importers:
|
||||
'@sinclair/typebox':
|
||||
specifier: ^0.34.38
|
||||
version: 0.34.41
|
||||
'@types/cors':
|
||||
specifier: ^2.8.17
|
||||
version: 2.8.19
|
||||
'@types/express':
|
||||
specifier: ^4.17.21
|
||||
version: 4.17.25
|
||||
'@types/ip':
|
||||
specifier: ^1.1.3
|
||||
version: 1.1.3
|
||||
'@types/multer':
|
||||
specifier: ^1.4.12
|
||||
version: 1.4.13
|
||||
'@types/node':
|
||||
specifier: ^22.10.7
|
||||
version: 22.19.1
|
||||
'@types/winston':
|
||||
specifier: ^2.4.4
|
||||
version: 2.4.4
|
||||
'@types/ws':
|
||||
specifier: ^8.5.12
|
||||
version: 8.18.1
|
||||
'@types/yaml':
|
||||
specifier: ^1.9.7
|
||||
version: 1.9.7
|
||||
devDependencies:
|
||||
napcat-core:
|
||||
specifier: workspace:*
|
||||
@@ -2974,15 +2943,9 @@ packages:
|
||||
'@types/event-source-polyfill@1.0.5':
|
||||
resolution: {integrity: sha512-iaiDuDI2aIFft7XkcwMzDWLqo7LVDixd2sR6B4wxJut9xcp/Ev9bO4EFg4rm6S9QxATLBj5OPxdeocgmhjwKaw==}
|
||||
|
||||
'@types/express-serve-static-core@4.19.8':
|
||||
resolution: {integrity: sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==}
|
||||
|
||||
'@types/express-serve-static-core@5.1.0':
|
||||
resolution: {integrity: sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA==}
|
||||
|
||||
'@types/express@4.17.25':
|
||||
resolution: {integrity: sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==}
|
||||
|
||||
'@types/express@5.0.5':
|
||||
resolution: {integrity: sha512-LuIQOcb6UmnF7C1PCFmEU1u2hmiHL43fgFQX67sN3H4Z+0Yk0Neo++mFsBjhOAuLzvlQeqAAkeDOZrJs9rzumQ==}
|
||||
|
||||
@@ -2998,9 +2961,6 @@ packages:
|
||||
'@types/http-errors@2.0.5':
|
||||
resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==}
|
||||
|
||||
'@types/ip@1.1.3':
|
||||
resolution: {integrity: sha512-64waoJgkXFTYnCYDUWgSATJ/dXEBanVkaP5d4Sbk7P6U7cTTMhxVyROTckc6JKdwCrgnAjZMn0k3177aQxtDEA==}
|
||||
|
||||
'@types/js-cookie@3.0.6':
|
||||
resolution: {integrity: sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==}
|
||||
|
||||
@@ -3084,17 +3044,9 @@ packages:
|
||||
'@types/use-sync-external-store@0.0.6':
|
||||
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
|
||||
|
||||
'@types/winston@2.4.4':
|
||||
resolution: {integrity: sha512-BVGCztsypW8EYwJ+Hq+QNYiT/MUyCif0ouBH+flrY66O5W+KIXAMML6E/0fJpm7VjIzgangahl5S03bJJQGrZw==}
|
||||
deprecated: This is a stub types definition. winston provides its own type definitions, so you do not need this installed.
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
|
||||
|
||||
'@types/yaml@1.9.7':
|
||||
resolution: {integrity: sha512-8WMXRDD1D+wCohjfslHDgICd2JtMATZU8CkhH8LVJqcJs6dyYj5TGptzP8wApbmEullGBSsCEzzap73DQ1HJaA==}
|
||||
deprecated: This is a stub types definition. yaml provides its own type definitions, so you do not need this installed.
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.46.4':
|
||||
resolution: {integrity: sha512-R48VhmTJqplNyDxCyqqVkFSZIx1qX6PzwqgcXn1olLrzxcSBDlOsbtcnQuQhNtnNiJ4Xe5gREI1foajYaYU2Vg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
@@ -5448,8 +5400,8 @@ packages:
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
napcat-types@0.0.3:
|
||||
resolution: {integrity: sha512-YZVBvtIw7N2TRck+JcVAoZJRqcoKf9PbKhHggZ/EcQzTkqGLgu8iIgMfQnCYscgXRglYBPexpb78piaEwlVcjQ==}
|
||||
napcat-types@0.0.10:
|
||||
resolution: {integrity: sha512-Y3EIdDm6rDJjdDqorQMbfyTVpyZvRRPa95yPmIl1LCTbmZMkfJw2YLQHvAcItEpTxUEW1Pve1ipNfmHBVmZL8Q==}
|
||||
|
||||
napcat.protobuf@1.1.4:
|
||||
resolution: {integrity: sha512-z7XtLSBJ/PxmYb0VD/w+eYr/X3LyGz+SZ2QejFTOczwt6zWNxy2yV1mTMTvJoc3BWkI3ESVFRxkuT6+pj1tb1Q==}
|
||||
@@ -9836,13 +9788,6 @@ snapshots:
|
||||
|
||||
'@types/event-source-polyfill@1.0.5': {}
|
||||
|
||||
'@types/express-serve-static-core@4.19.8':
|
||||
dependencies:
|
||||
'@types/node': 22.19.1
|
||||
'@types/qs': 6.14.0
|
||||
'@types/range-parser': 1.2.7
|
||||
'@types/send': 1.2.1
|
||||
|
||||
'@types/express-serve-static-core@5.1.0':
|
||||
dependencies:
|
||||
'@types/node': 22.19.1
|
||||
@@ -9850,13 +9795,6 @@ snapshots:
|
||||
'@types/range-parser': 1.2.7
|
||||
'@types/send': 1.2.1
|
||||
|
||||
'@types/express@4.17.25':
|
||||
dependencies:
|
||||
'@types/body-parser': 1.19.6
|
||||
'@types/express-serve-static-core': 4.19.8
|
||||
'@types/qs': 6.14.0
|
||||
'@types/serve-static': 1.15.10
|
||||
|
||||
'@types/express@5.0.5':
|
||||
dependencies:
|
||||
'@types/body-parser': 1.19.6
|
||||
@@ -9876,10 +9814,6 @@ snapshots:
|
||||
|
||||
'@types/http-errors@2.0.5': {}
|
||||
|
||||
'@types/ip@1.1.3':
|
||||
dependencies:
|
||||
'@types/node': 22.19.1
|
||||
|
||||
'@types/js-cookie@3.0.6': {}
|
||||
|
||||
'@types/json-schema@7.0.15': {}
|
||||
@@ -9961,18 +9895,10 @@ snapshots:
|
||||
|
||||
'@types/use-sync-external-store@0.0.6': {}
|
||||
|
||||
'@types/winston@2.4.4':
|
||||
dependencies:
|
||||
winston: 3.18.3
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
dependencies:
|
||||
'@types/node': 22.19.1
|
||||
|
||||
'@types/yaml@1.9.7':
|
||||
dependencies:
|
||||
yaml: 2.8.2
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.46.4(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@eslint-community/regexpp': 4.12.2
|
||||
@@ -12819,16 +12745,10 @@ snapshots:
|
||||
|
||||
nanoid@3.3.11: {}
|
||||
|
||||
napcat-types@0.0.3:
|
||||
napcat-types@0.0.10:
|
||||
dependencies:
|
||||
'@types/cors': 2.8.19
|
||||
'@types/express': 4.17.25
|
||||
'@types/ip': 1.1.3
|
||||
'@types/multer': 1.4.13
|
||||
'@sinclair/typebox': 0.34.41
|
||||
'@types/node': 22.19.1
|
||||
'@types/winston': 2.4.4
|
||||
'@types/ws': 8.18.1
|
||||
'@types/yaml': 1.9.7
|
||||
|
||||
napcat.protobuf@1.1.4:
|
||||
dependencies:
|
||||
@@ -14621,7 +14541,8 @@ snapshots:
|
||||
|
||||
yallist@4.0.0: {}
|
||||
|
||||
yaml@2.8.2: {}
|
||||
yaml@2.8.2:
|
||||
optional: true
|
||||
|
||||
yargs-parser@20.2.9: {}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user