Compare commits

...

29 Commits

Author SHA1 Message Date
手瓜一十雪
29888cb38b Remove undici dependency from lockfile
The undici package and its references have been removed from pnpm-lock.yaml, indicating it is no longer required as a dependency.
2026-01-29 20:58:14 +08:00
手瓜一十雪
6ea4c9ec65 Remove undici dependency and implement proxy download
Replaces the use of the undici library for HTTP downloads with proxy support by implementing a custom httpDownloadWithProxy function using Node.js http and tls modules. The undici dependency is removed from package.json, reducing external dependencies and improving compatibility.
2026-01-29 20:54:48 +08:00
手瓜一十雪
4bec3aa597 Reapply "Add image download proxy support to OneBot"
This reverts commit 38c320d2c9.
2026-01-29 20:40:19 +08:00
手瓜一十雪
38c320d2c9 Revert "Add image download proxy support to OneBot"
This reverts commit 0779628be5.
2026-01-29 20:39:07 +08:00
手瓜一十雪
76cbd8a1c1 Add crash protection for worker process restarts
Implements a mechanism to track recent worker process crashes and prevent excessive restarts. If the worker crashes more than 3 times within 10 seconds, the main process will exit to avoid crash loops.
2026-01-29 20:38:35 +08:00
手瓜一十雪
0779628be5 Add image download proxy support to OneBot
Introduces an 'imageDownloadProxy' config option to OneBot, allowing image downloads via a specified HTTP proxy. Updates the file download logic in napcat-common to use the undici library for proxy support, and propagates the new config through backend, frontend, and type definitions. Also adds undici as a dependency.
2026-01-29 20:32:01 +08:00
手瓜一十雪
34ca919c4d Add reactive plugin config UI with SSE support
Introduces a reactive plugin configuration system with dynamic schema updates via server-sent events (SSE). Adds new fields and controller interfaces to the plugin manager, updates the built-in plugin to demonstrate dynamic config fields, and implements backend and frontend logic for real-time config UI updates. Also updates napcat-types to 0.0.10.
2026-01-29 20:18:34 +08:00
手瓜一十雪
b1b357347b Remove unused installedVersion prop from PluginStoreCard
Some checks are pending
Build NapCat Artifacts / Build-Framework (push) Waiting to run
Build NapCat Artifacts / Build-Shell (push) Waiting to run
The installedVersion prop was declared but not used in PluginStoreCard. This commit removes it to clean up the component's props.
2026-01-29 17:13:30 +08:00
手瓜一十雪
129d63f66e Add mirror management and selection UI
Introduces backend API and router for mirror management, including latency testing and custom mirror setting. Adds frontend components and controllers for mirror selection, speed testing, and integration into system info and plugin store pages, allowing users to select and test download/list mirrors interactively.
2026-01-29 17:11:59 +08:00
手瓜一十雪
699b46acbd Improve plugin status handling and dirname lookup
Enhanced setPluginStatus to support enabling/disabling plugins by both package name and dirname, improving robustness when plugins are not loaded. Also removed redundant directory name matching logic from findDirnameById in the web UI backend.

Register plugin after installation in PluginStore

Adds logic to immediately register a plugin with the plugin manager after installation, both in the standard and SSE install handlers. This ensures newly installed plugins are available without requiring a restart or manual reload.

Refactor plugin path handling in plugin manager

Simplifies plugin directory and data path resolution by using pluginPath from the plugin context instead of fileId. Streamlines plugin uninstall and reload logic, removing redundant file system scans and improving code clarity.

Refactor plugin API to use package id and improve UX

Standardized plugin management APIs and frontend to use 'id' (package name) instead of ambiguous 'name' or 'filename'. Added support for a 'plugin' display field in package.json and improved plugin store UI to show install/update status. Refactored backend and frontend logic for enabling, disabling, uninstalling, and configuring plugins to use consistent identifiers, and enhanced type definitions and documentation for better maintainability.
2026-01-29 16:42:15 +08:00
手瓜一十雪
7f05aee11d Add manual plugin manager registration support
Introduces backend and frontend logic to manually register the plugin manager if not already loaded. Adds a new API endpoint and frontend UI prompt to guide users through registration after plugin installation when necessary.
2026-01-29 15:44:26 +08:00
手瓜一十雪
542036f46e Refactor type build: inline external types, simplify scripts
Removed custom build scripts for copying and inlining types, consolidating all post-build logic into a single enhanced post-build.mjs script. The new script processes .d.ts files, inlines external module types, updates imports, and copies necessary files to dist, eliminating the need for external-shims and simplifying the build process. Updated package.json scripts accordingly.

Refactor type inlining: remove shims, auto-extract types

Removed external-shims.d.ts and its references, replacing manual shims with an automated script that extracts type definitions from node_modules. Updated build scripts, dependencies, and test files to support the new inlining process. The inline-types.mjs script now scans for external imports, generates inline type files, and rewrites imports as import type, eliminating the need for hand-written shims.

Add type inlining script and update build process

Introduced a new script (inline-types.mjs) to inline external type dependencies into the dist directory, updated the build process to use this script, and removed the now-unnecessary external-shims.d.ts from the copy-dist script. Added a test file to verify inlined types, updated dependencies to include ts-morph, and adjusted package.json and pnpm-lock.yaml accordingly.
2026-01-29 15:27:46 +08:00
pohgxz
b958e9e803 修复 OpenAPI 导出的相应接口缺失 stream 字段 2026-01-29 14:14:11 +08:00
冷曦
73fcfb5900 修复下载插件后插件列表显示开启 (#1560)
下载后插件应该为禁用状态,但是前端显示启用状态
2026-01-29 13:12:54 +08:00
手瓜一十雪
adabc4da46 Improve schema parsing and error handling in API debug tools
Some checks are pending
Build NapCat Artifacts / Build-Framework (push) Waiting to run
Build NapCat Artifacts / Build-Shell (push) Waiting to run
Enhances the TypeBox schema parser to better handle deep nesting, circular references, and union truncation, and adds error handling for schema parsing and default value generation in the OneBot API debug UI. Updates the display component to show clear messages for circular or truncated schemas, and improves robustness in HTTP debug command execution. Also synchronizes the ParsedSchema type in the Zod utility for consistency.
2026-01-28 16:07:23 +08:00
手瓜一十雪
bf073b544b Refactor schema ID handling and reduce parse depth
Changed MAX_PARSE_DEPTH from 10 to 6 to limit nesting. Improved schema ID retrieval to only use $id if present, and added a utility to collect all $id values in a schema for better circular reference detection. Updated font file AaCute.woff.
2026-01-28 16:01:43 +08:00
手瓜一十雪
a71219062a Enhance TypeBox schema parsing with circular ref detection
Added detection and handling for circular references and excessive nesting in TypeBox schema parsing and default value generation. Introduced depth limits and a visited set to prevent infinite recursion, and updated the parseTypeBox and generateDefaultFromTypeBox functions accordingly.
2026-01-28 15:59:27 +08:00
手瓜一十雪
001fe01ace Add plugin logger interface and update builtin plugin
Introduces a PluginLogger interface and injects a plugin-specific logger into the plugin context for consistent logging. Refactors the builtin plugin to use the new logger instead of direct console calls. Updates napcat-types to version 0.0.9 in dependencies and lock files.
2026-01-28 15:07:06 +08:00
手瓜一十雪
0aa0c44634 Refactor plugin identification to use package name and dirname
Updated plugin manager and API to distinguish between plugin package name and directory name (dirname) for more robust plugin identification and path resolution. Adjusted context creation, status management, and API handlers to use package name for identification and dirname for filesystem operations. Also replaced console.error with console.log in builtin plugin for consistency.
2026-01-28 15:02:47 +08:00
手瓜一十雪
93126e514e Refactor builtin plugin for improved type safety
Replaced generic 'any' types with 'NetworkAdapterConfig' for better type safety in getVersionInfo and sendMessage functions. Removed redundant comments and improved code clarity. Changed a warning log to a standard log for config load failures.
2026-01-28 14:54:43 +08:00
手瓜一十雪
1ae10ae0c6 Refactor plugin to use context actions and update deps
Refactored the builtin plugin to pass actions and adapterName explicitly from context instead of relying on a global variable. Updated napcat-types dependency to version 0.0.8.
2026-01-28 14:42:44 +08:00
手瓜一十雪
4b693bf6e2 Refactor plugin manager to support only directory plugins
Removed support for single-file plugins in OB11PluginMangerAdapter, simplifying plugin identification to use directory names as unique IDs. Updated related logic in the backend API to align with this change, ensuring consistent plugin management and status handling.
2026-01-28 14:38:11 +08:00
手瓜一十雪
574c257591 Refactor plugin manager to support only directory plugins
Removed support for single-file plugins in OB11PluginMangerAdapter, simplifying plugin identification to use directory names as unique IDs. Updated related logic in the backend API to align with this change, ensuring consistent plugin management and status handling.
2026-01-28 14:18:44 +08:00
手瓜一十雪
d680328762 Add config UI and persistence to builtin plugin
Introduces a configuration UI schema and persistent config storage for the napcat-plugin-builtin. The plugin now loads and saves its configuration, supports dynamic prefix and reply toggling, and updates dependencies to napcat-types v0.0.6.
2026-01-28 14:13:48 +08:00
手瓜一十雪
d711cdecaf Add plugin config management to backend and frontend
Introduces a unified plugin configuration schema and API in the backend, with endpoints for getting and setting plugin config. Updates the frontend to support plugin config modals, including a UI for editing plugin settings. Also adds support for uninstalling plugins with optional data cleanup, and updates dependencies to use napcat-types@0.0.5.
2026-01-28 13:56:40 +08:00
手瓜一十雪
c5f1792009 Add plugin data management API and frontend support
Introduced backend API endpoints for managing plugin configuration data, including listing, reading, saving, and deleting plugin data files. Added a new PluginData API module and registered related routes. Updated the frontend plugin manager controller to support these new API methods and corresponding TypeScript interfaces. Also fixed minor typos in documentation prompts.
2026-01-28 13:39:36 +08:00
手瓜一十雪
a5e705e6a4 Fix typo in Windows deployment instructions
Some checks are pending
Build NapCat Artifacts / Build-Framework (push) Waiting to run
Build NapCat Artifacts / Build-Shell (push) Waiting to run
Corrected a grammatical error in the Windows one-click deployment section in both the default prompt and release note prompt files.
2026-01-27 23:01:16 +08:00
手瓜一十雪
007f1db339 Update OpenAPI schema copy path in release workflow
The workflow now copies openapi.json from the dist directory instead of the package root, ensuring the built schema is used during the release process.
2026-01-27 23:00:14 +08:00
手瓜一十雪
008fb39f8f Reduce WebSocket maxPayload to 50 MB
Lowered the maxPayload limit from 1 GB to 50 MB in both the WebSocket client and server adapters to improve resource management and prevent excessively large payloads.
2026-01-27 22:56:27 +08:00
57 changed files with 3210 additions and 944 deletions

View File

@@ -2,7 +2,7 @@
[使用文档](https://napneko.github.io/)
## Windows 一键包
我们提供了轻量化一键部署方案
我们提供了轻量化一键部署方案
相对于普通需要安装QQ的方案,下面已内置QQ和Napcat 阅读使用文档参考
你可以下载

View File

@@ -31,7 +31,7 @@
[使用文档](https://napneko.github.io/)
## Windows 一键包
我们提供了轻量化一键部署方案
我们提供了轻量化一键部署方案
相对于普通需要安装QQ的方案,下面已内置QQ和Napcat 阅读使用文档参考
你可以下载

View File

@@ -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

View File

@@ -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"
}
}

View File

@@ -1,28 +1,28 @@
{
"name": "napcat-common",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "src/index.ts",
"scripts": {
"typecheck": "tsc --noEmit --skipLibCheck -p tsconfig.json"
"name": "napcat-common",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "src/index.ts",
"scripts": {
"typecheck": "tsc --noEmit --skipLibCheck -p tsconfig.json"
},
"exports": {
".": {
"import": "./src/index.ts"
},
"exports": {
".": {
"import": "./src/index.ts"
},
"./src/*": {
"import": "./src/*"
}
},
"dependencies": {
"ajv": "^8.13.0",
"file-type": "^21.0.0"
},
"devDependencies": {
"@types/node": "^22.0.1"
},
"engines": {
"node": ">=18.0.0"
"./src/*": {
"import": "./src/*"
}
},
"dependencies": {
"ajv": "^8.13.0",
"file-type": "^21.0.0"
},
"devDependencies": {
"@types/node": "^22.0.1"
},
"engines": {
"node": ">=18.0.0"
}
}

View File

@@ -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 };
}

View File

@@ -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',

View File

@@ -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'

View File

@@ -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'

View File

@@ -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',

View File

@@ -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);

View File

@@ -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>;

View File

@@ -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;
}
if (item.isFile()) {
// 处理单文件插件
await this.loadFilePlugin(item.name);
} else if (item.isDirectory()) {
// 处理目录插件
await this.loadDirectoryPlugin(item.name);
// 先读取 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;
}
// 处理目录插件
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,55 +490,23 @@ 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) {
@@ -461,10 +514,9 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> {
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);
}
await this.loadDirectoryPlugin(dirname);
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');
}
}

View File

@@ -9,6 +9,7 @@ import path from 'path';
export interface PluginPackageJson {
name?: string;
plugin?: string;
version?: string;
main?: string;
}
@@ -255,7 +256,7 @@ export class OB11PluginAdapter extends IOB11NetworkAdapter<PluginConfig> {
this.logger.log(`[Plugin Adapter] Unloaded plugin: ${pluginName}`);
}
async onEvent<T extends OB11EmitEventContent>(event: T) {
async onEvent<T extends OB11EmitEventContent> (event: T) {
if (!this.isEnable) {
return;
}
@@ -357,7 +358,7 @@ export class OB11PluginAdapter extends IOB11NetworkAdapter<PluginConfig> {
// 重新加载插件
const isDirectory = fs.statSync(plugin.pluginPath).isDirectory() &&
plugin.pluginPath !== this.pluginPath;
plugin.pluginPath !== this.pluginPath;
if (isDirectory) {
const dirname = path.basename(plugin.pluginPath);

View File

@@ -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: {

View File

@@ -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) {

View File

@@ -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);
}
}

View File

@@ -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"

View File

@@ -1,3 +0,0 @@
# Example Plugin
## Install
安装只需要将dist产物 `index.mjs` 目录放入 napcat根目录下`plugins/example`,如果没有这个目录请创建。

View File

@@ -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 };

View File

@@ -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"
}
}

View File

@@ -1,11 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"include": [
"*.ts",
"**/*.ts"
],
"exclude": [
"node_modules",
"dist"
]
}

View File

@@ -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()],
});

View File

@@ -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']
},

View File

@@ -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);
});

View File

@@ -1,4 +1,3 @@
/// <reference path="./external-shims.d.ts" />
// 聚合导出核心库的所有内容(包括枚举、类和类型)
export * from '../napcat-core/index';

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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')
);

View File

@@ -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...');

View File

@@ -39,9 +39,6 @@
"../napcat-onebot/**/*.ts",
"../napcat-common/**/*.ts"
],
"files": [
"./external-shims.d.ts"
],
"exclude": [
"node_modules",
"dist"

View 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);
}
};

View File

@@ -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');
}
};

View File

@@ -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,

View File

@@ -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>;

View 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 };

View File

@@ -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);

View File

@@ -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 };

View File

@@ -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,32 +41,34 @@ const PluginDisplayCard: React.FC<PluginDisplayCardProps> = ({
<DisplayCardContainer
className='w-full max-w-[420px]'
action={
<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'
size='sm'
variant='flat'
className='flex-1 bg-default-100 dark:bg-default-50 text-default-600 font-medium hover:bg-danger/20 hover:text-danger transition-colors'
startContent={<MdDeleteForever size={16} />}
onPress={handleUninstall}
isDisabled={processing}
>
</Button>
<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-danger/20 hover:text-danger transition-colors'
startContent={<MdDeleteForever size={16} />}
onPress={handleUninstall}
isDisabled={processing}
>
</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={

View File

@@ -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={
<Chip
className="ml-auto"
color="primary"
size="sm"
variant="flat"
>
v{version}
</Chip>
<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
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>
}
>

View File

@@ -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>
);
}

View File

@@ -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 wrappedResponseSchema = Type.Object({
...BaseResponseSchema.properties,
data: data?.response || Type.Any({ description: '数据' })
});
const parsedResponse = parseTypeBox(wrappedResponseSchema);
const parsedResponse = useMemo(() => {
try {
const wrappedResponseSchema = Type.Object({
...BaseResponseSchema.properties,
data: data?.response || Type.Any({ description: '数据' })
});
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 {
setRequestBody(JSON.stringify(generateDefaultFromTypeBox(data?.payload), null, 2));
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>

View File

@@ -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}>

View File

@@ -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,46 +543,78 @@ const VersionSelectDialogContent: React.FC<VersionSelectDialogProps> = ({
<Tab key='action' title='临时版本 (Action)' />
</Tabs>
<div className="flex gap-2">
{/* 搜索框 */}
<Input
placeholder='搜索版本号...'
size='sm'
value={searchQuery}
onValueChange={(value) => {
setSearchQuery(value);
setCurrentPage(1);
setSelectedVersion(null);
}}
startContent={<IoSearch className='text-default-400' />}
isClearable
onClear={() => setSearchQuery('')}
classNames={{
inputWrapper: 'h-9',
base: 'flex-1'
}}
/>
{/* 下载镜像状态卡片 */}
<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>
{/* 镜像选择 */}
<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>
{/* 搜索框 */}
<Input
placeholder='搜索版本号...'
size='sm'
value={searchQuery}
onValueChange={(value) => {
setSearchQuery(value);
setCurrentPage(1);
setSelectedVersion(null);
}}
startContent={<IoSearch className='text-default-400' />}
isClearable
onClear={() => setSearchQuery('')}
classNames={{
inputWrapper: 'h-9',
}}
/>
{/* 版本选择 */}
<div className='space-y-2'>
@@ -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>
);
};

View 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;
}
}

View File

@@ -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()}`;
}
}

View File

@@ -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}

View File

@@ -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);
// 确保请求参数可见

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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,23 +217,55 @@ 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">
<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}
>
<IoMdRefresh size={24} />
</Button>
<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>
{/* 搜索框 */}
@@ -244,35 +280,80 @@ export default function PluginStorePage () {
</div>
{/* 标签页 */}
<Tabs
aria-label="Plugin Store Categories"
className="max-w-full"
selectedKey={activeTab}
onSelectionChange={(key) => setActiveTab(String(key))}
classNames={{
tabList: 'bg-white/40 dark:bg-black/20 backdrop-blur-md',
cursor: 'bg-white/80 dark:bg-white/10 backdrop-blur-md shadow-sm',
}}
>
{tabs.map((tab) => (
<Tab
key={tab.key}
title={`${tab.title} (${tab.count})`}
>
<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) => (
<PluginStoreCard
key={plugin.id}
data={plugin}
onInstall={() => handleInstall(plugin)}
/>
))}
</div>
</Tab>
))}
</Tabs>
<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"
selectedKey={activeTab}
onSelectionChange={(key) => setActiveTab(String(key))}
classNames={{
tabList: 'bg-white/40 dark:bg-black/20 backdrop-blur-md',
cursor: 'bg-white/80 dark:bg-white/10 backdrop-blur-md shadow-sm',
}}
>
{tabs.map((tab) => (
<Tab
key={tab.key}
title={`${tab.title} (${tab.count})`}
>
<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) => {
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"
/>
</>
);
}

View 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: '',
},
};

View File

@@ -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; // 图片下载代理地址
}

View File

@@ -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;
return { name, type: 'array', children: [child], optional, description };
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;

View File

@@ -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
View File

@@ -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: {}