mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-02-28 07:40:27 +00:00
Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b7ca1a08f | ||
|
|
57f3c4dd31 | ||
|
|
5b20ebb7b0 | ||
|
|
3a3eaeec7c | ||
|
|
b0cc7b6ee5 | ||
|
|
e5108c0427 | ||
|
|
927797f3d5 | ||
|
|
72e01f8c84 | ||
|
|
c38b98a0c4 | ||
|
|
05d27e86ce | ||
|
|
40409a3841 | ||
|
|
65bae6b57a | ||
|
|
0b6afb66d9 | ||
|
|
52be000fdd | ||
|
|
55ce5bcfd3 | ||
|
|
29888cb38b | ||
|
|
6ea4c9ec65 | ||
|
|
4bec3aa597 | ||
|
|
38c320d2c9 | ||
|
|
76cbd8a1c1 | ||
|
|
0779628be5 | ||
|
|
34ca919c4d | ||
|
|
b1b357347b | ||
|
|
129d63f66e | ||
|
|
699b46acbd | ||
|
|
7f05aee11d | ||
|
|
542036f46e | ||
|
|
b958e9e803 | ||
|
|
73fcfb5900 | ||
|
|
adabc4da46 | ||
|
|
bf073b544b | ||
|
|
a71219062a | ||
|
|
001fe01ace | ||
|
|
0aa0c44634 | ||
|
|
93126e514e | ||
|
|
1ae10ae0c6 | ||
|
|
4b693bf6e2 | ||
|
|
574c257591 | ||
|
|
d680328762 | ||
|
|
d711cdecaf | ||
|
|
c5f1792009 | ||
|
|
a5e705e6a4 | ||
|
|
007f1db339 | ||
|
|
008fb39f8f |
2
.github/prompt/default.md
vendored
2
.github/prompt/default.md
vendored
@@ -2,7 +2,7 @@
|
||||
[使用文档](https://napneko.github.io/)
|
||||
|
||||
## Windows 一键包
|
||||
我们为提供了的轻量化一键部署方案
|
||||
我们提供了轻量化的一键部署方案
|
||||
相对于普通需要安装QQ的方案,下面已内置QQ和Napcat 阅读使用文档参考
|
||||
|
||||
你可以下载
|
||||
|
||||
2
.github/prompt/release_note_prompt.txt
vendored
2
.github/prompt/release_note_prompt.txt
vendored
@@ -31,7 +31,7 @@
|
||||
[使用文档](https://napneko.github.io/)
|
||||
|
||||
## Windows 一键包
|
||||
我们为提供了的轻量化一键部署方案
|
||||
我们提供了轻量化的一键部署方案
|
||||
相对于普通需要安装QQ的方案,下面已内置QQ和Napcat 阅读使用文档参考
|
||||
|
||||
你可以下载
|
||||
|
||||
2
.github/workflows/auto-release.yml
vendored
2
.github/workflows/auto-release.yml
vendored
@@ -50,7 +50,7 @@ jobs:
|
||||
- name: Copy OpenAPI Schema
|
||||
run: |
|
||||
mkdir -p napcat-docs/src/api/${{ env.version }}
|
||||
cp packages/napcat-schema/openapi.json napcat-docs/src/api/${{ env.version }}/openapi.json
|
||||
cp packages/napcat-schema/dist/openapi.json napcat-docs/src/api/${{ env.version }}/openapi.json
|
||||
echo "OpenAPI schema copied to napcat-docs/src/api/${{ env.version }}/openapi.json"
|
||||
|
||||
- name: Commit and Push
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"name": "napcat-types",
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"types": "./napcat-types/index.d.ts",
|
||||
"files": [
|
||||
"**/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/ws": "^8.5.12",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/winston": "^2.4.4",
|
||||
"@types/yaml": "^1.9.7",
|
||||
"@types/ip": "^1.1.3"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://registry.npmjs.org/",
|
||||
"access": "public",
|
||||
"tag": "latest"
|
||||
}
|
||||
}
|
||||
176
packages/napcat-adapter/index.ts
Normal file
176
packages/napcat-adapter/index.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { InstanceContext, NapCatCore } from 'napcat-core';
|
||||
import { NapCatPathWrapper } from 'napcat-common/src/path';
|
||||
import { NapCatOneBot11Adapter } from 'napcat-onebot';
|
||||
import { NapCatProtocolAdapter } from 'napcat-protocol';
|
||||
|
||||
// 协议适配器类型
|
||||
export type ProtocolAdapterType = 'onebot11' | 'napcat-protocol';
|
||||
|
||||
// 协议适配器接口
|
||||
export interface IProtocolAdapter {
|
||||
readonly name: string;
|
||||
readonly enabled: boolean;
|
||||
init (): Promise<void>;
|
||||
close (): Promise<void>;
|
||||
}
|
||||
|
||||
// 协议适配器包装器
|
||||
class OneBotAdapterWrapper implements IProtocolAdapter {
|
||||
readonly name = 'onebot11';
|
||||
private adapter: NapCatOneBot11Adapter;
|
||||
|
||||
constructor (adapter: NapCatOneBot11Adapter) {
|
||||
this.adapter = adapter;
|
||||
}
|
||||
|
||||
get enabled (): boolean {
|
||||
return true; // OneBot11 默认启用
|
||||
}
|
||||
|
||||
async init (): Promise<void> {
|
||||
await this.adapter.InitOneBot();
|
||||
}
|
||||
|
||||
async close (): Promise<void> {
|
||||
await this.adapter.networkManager.closeAllAdapters();
|
||||
}
|
||||
|
||||
getAdapter (): NapCatOneBot11Adapter {
|
||||
return this.adapter;
|
||||
}
|
||||
}
|
||||
|
||||
// NapCat Protocol 适配器包装器
|
||||
class NapCatProtocolAdapterWrapper implements IProtocolAdapter {
|
||||
readonly name = 'napcat-protocol';
|
||||
private adapter: NapCatProtocolAdapter;
|
||||
|
||||
constructor (adapter: NapCatProtocolAdapter) {
|
||||
this.adapter = adapter;
|
||||
}
|
||||
|
||||
get enabled (): boolean {
|
||||
return this.adapter.isEnabled();
|
||||
}
|
||||
|
||||
async init (): Promise<void> {
|
||||
await this.adapter.initProtocol();
|
||||
}
|
||||
|
||||
async close (): Promise<void> {
|
||||
await this.adapter.close();
|
||||
}
|
||||
|
||||
getAdapter (): NapCatProtocolAdapter {
|
||||
return this.adapter;
|
||||
}
|
||||
}
|
||||
|
||||
// 协议适配器管理器
|
||||
export class NapCatAdapterManager {
|
||||
private core: NapCatCore;
|
||||
private context: InstanceContext;
|
||||
private pathWrapper: NapCatPathWrapper;
|
||||
|
||||
// 协议适配器实例
|
||||
private onebotAdapter: OneBotAdapterWrapper | null = null;
|
||||
private napcatProtocolAdapter: NapCatProtocolAdapterWrapper | null = null;
|
||||
|
||||
// 所有已注册的适配器
|
||||
private adapters: Map<string, IProtocolAdapter> = new Map();
|
||||
|
||||
constructor (core: NapCatCore, context: InstanceContext, pathWrapper: NapCatPathWrapper) {
|
||||
this.core = core;
|
||||
this.context = context;
|
||||
this.pathWrapper = pathWrapper;
|
||||
}
|
||||
|
||||
// 初始化所有协议适配器
|
||||
async initAdapters (): Promise<void> {
|
||||
this.context.logger.log('[AdapterManager] 开始初始化协议适配器...');
|
||||
|
||||
// 初始化 OneBot11 适配器 (默认启用)
|
||||
try {
|
||||
const onebot = new NapCatOneBot11Adapter(this.core, this.context, this.pathWrapper);
|
||||
this.onebotAdapter = new OneBotAdapterWrapper(onebot);
|
||||
this.adapters.set('onebot11', this.onebotAdapter);
|
||||
await this.onebotAdapter.init();
|
||||
this.context.logger.log('[AdapterManager] OneBot11 适配器初始化完成');
|
||||
} catch (e) {
|
||||
this.context.logger.logError('[AdapterManager] OneBot11 适配器初始化失败:', e);
|
||||
}
|
||||
|
||||
// 初始化 NapCat Protocol 适配器 (默认关闭,需要配置启用)
|
||||
try {
|
||||
const napcatProtocol = new NapCatProtocolAdapter(this.core, this.context, this.pathWrapper);
|
||||
this.napcatProtocolAdapter = new NapCatProtocolAdapterWrapper(napcatProtocol);
|
||||
this.adapters.set('napcat-protocol', this.napcatProtocolAdapter);
|
||||
|
||||
if (this.napcatProtocolAdapter.enabled) {
|
||||
await this.napcatProtocolAdapter.init();
|
||||
this.context.logger.log('[AdapterManager] NapCat Protocol 适配器初始化完成');
|
||||
} else {
|
||||
this.context.logger.log('[AdapterManager] NapCat Protocol 适配器未启用,跳过初始化');
|
||||
}
|
||||
} catch (e) {
|
||||
this.context.logger.logError('[AdapterManager] NapCat Protocol 适配器初始化失败:', e);
|
||||
}
|
||||
|
||||
this.context.logger.log(`[AdapterManager] 协议适配器初始化完成,已加载 ${this.adapters.size} 个适配器`);
|
||||
}
|
||||
|
||||
// 获取 OneBot11 适配器
|
||||
getOneBotAdapter (): NapCatOneBot11Adapter | null {
|
||||
return this.onebotAdapter?.getAdapter() ?? null;
|
||||
}
|
||||
|
||||
// 获取 NapCat Protocol 适配器
|
||||
getNapCatProtocolAdapter (): NapCatProtocolAdapter | null {
|
||||
return this.napcatProtocolAdapter?.getAdapter() ?? null;
|
||||
}
|
||||
|
||||
// 获取指定适配器
|
||||
getAdapter (name: ProtocolAdapterType): IProtocolAdapter | undefined {
|
||||
return this.adapters.get(name);
|
||||
}
|
||||
|
||||
// 获取所有已启用的适配器
|
||||
getEnabledAdapters (): IProtocolAdapter[] {
|
||||
return Array.from(this.adapters.values()).filter(adapter => adapter.enabled);
|
||||
}
|
||||
|
||||
// 获取所有适配器
|
||||
getAllAdapters (): IProtocolAdapter[] {
|
||||
return Array.from(this.adapters.values());
|
||||
}
|
||||
|
||||
// 关闭所有适配器
|
||||
async closeAllAdapters (): Promise<void> {
|
||||
this.context.logger.log('[AdapterManager] 开始关闭所有协议适配器...');
|
||||
|
||||
for (const [name, adapter] of this.adapters) {
|
||||
try {
|
||||
await adapter.close();
|
||||
this.context.logger.log(`[AdapterManager] ${name} 适配器已关闭`);
|
||||
} catch (e) {
|
||||
this.context.logger.logError(`[AdapterManager] 关闭 ${name} 适配器失败:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
this.adapters.clear();
|
||||
this.context.logger.log('[AdapterManager] 所有协议适配器已关闭');
|
||||
}
|
||||
|
||||
// 重新加载指定适配器
|
||||
async reloadAdapter (name: ProtocolAdapterType): Promise<void> {
|
||||
const adapter = this.adapters.get(name);
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
await adapter.init();
|
||||
this.context.logger.log(`[AdapterManager] ${name} 适配器已重新加载`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { NapCatOneBot11Adapter } from 'napcat-onebot';
|
||||
export { NapCatProtocolAdapter } from 'napcat-protocol';
|
||||
30
packages/napcat-adapter/package.json
Normal file
30
packages/napcat-adapter/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "napcat-adapter",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit --skipLibCheck -p tsconfig.json"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./index.ts"
|
||||
},
|
||||
"./*": {
|
||||
"import": "./*"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"napcat-core": "workspace:*",
|
||||
"napcat-common": "workspace:*",
|
||||
"napcat-onebot": "workspace:*",
|
||||
"napcat-protocol": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -2,14 +2,14 @@ import * as crypto from 'node:crypto';
|
||||
import { PacketMsg } from '@/napcat-core/packet/message/message';
|
||||
|
||||
interface ForwardMsgJson {
|
||||
app: string
|
||||
app: string;
|
||||
config: ForwardMsgJsonConfig,
|
||||
desc: string,
|
||||
extra: ForwardMsgJsonExtra,
|
||||
meta: ForwardMsgJsonMeta,
|
||||
prompt: string,
|
||||
ver: string,
|
||||
view: string
|
||||
view: string;
|
||||
}
|
||||
|
||||
interface ForwardMsgJsonConfig {
|
||||
@@ -17,7 +17,7 @@ interface ForwardMsgJsonConfig {
|
||||
forward: number,
|
||||
round: number,
|
||||
type: string,
|
||||
width: number
|
||||
width: number;
|
||||
}
|
||||
|
||||
interface ForwardMsgJsonExtra {
|
||||
@@ -26,17 +26,17 @@ interface ForwardMsgJsonExtra {
|
||||
}
|
||||
|
||||
interface ForwardMsgJsonMeta {
|
||||
detail: ForwardMsgJsonMetaDetail
|
||||
detail: ForwardMsgJsonMetaDetail;
|
||||
}
|
||||
|
||||
interface ForwardMsgJsonMetaDetail {
|
||||
news: {
|
||||
text: string
|
||||
text: string;
|
||||
}[],
|
||||
resid: string,
|
||||
source: string,
|
||||
summary: string,
|
||||
uniseq: string
|
||||
uniseq: string;
|
||||
}
|
||||
|
||||
interface ForwardAdaptMsg {
|
||||
@@ -50,8 +50,8 @@ interface ForwardAdaptMsgElement {
|
||||
}
|
||||
|
||||
export class ForwardMsgBuilder {
|
||||
private static build (resId: string, msg: ForwardAdaptMsg[], source?: string, news?: ForwardMsgJsonMetaDetail['news'], summary?: string, prompt?: string): ForwardMsgJson {
|
||||
const id = crypto.randomUUID();
|
||||
private static build (resId: string, msg: ForwardAdaptMsg[], source?: string, news?: ForwardMsgJsonMetaDetail['news'], summary?: string, prompt?: string, uuid?: string): ForwardMsgJson {
|
||||
const id = uuid ?? crypto.randomUUID();
|
||||
const isGroupMsg = msg.some(m => m.isGroupMsg);
|
||||
if (!source) {
|
||||
source = msg.length === 0 ? '聊天记录' : (isGroupMsg ? '群聊的聊天记录' : msg.map(m => m.senderName).filter((v, i, a) => a.indexOf(v) === i).slice(0, 4).join('和') + '的聊天记录');
|
||||
@@ -104,13 +104,19 @@ export class ForwardMsgBuilder {
|
||||
return this.build(resId, []);
|
||||
}
|
||||
|
||||
static fromPacketMsg (resId: string, packetMsg: PacketMsg[], source?: string, news?: ForwardMsgJsonMetaDetail['news'], summary?: string, prompt?: string): ForwardMsgJson {
|
||||
static fromPacketMsg (resId: string, packetMsg: PacketMsg[], source?: string, news?: ForwardMsgJsonMetaDetail['news'], summary?: string, prompt?: string, uuid?: string): ForwardMsgJson {
|
||||
return this.build(resId, packetMsg.map(msg => ({
|
||||
senderName: msg.senderName,
|
||||
isGroupMsg: msg.groupId !== undefined,
|
||||
msg: msg.msg.map(m => ({
|
||||
preview: m.valid ? m.toPreview() : '[该消息类型暂不支持查看]',
|
||||
})),
|
||||
})), source, news, summary, prompt);
|
||||
})),
|
||||
source,
|
||||
news,
|
||||
summary,
|
||||
prompt,
|
||||
uuid,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
140
packages/napcat-core/helper/session-proxy.ts
Normal file
140
packages/napcat-core/helper/session-proxy.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { NodeIQQNTWrapperSession } from '@/napcat-core/wrapper';
|
||||
import { ServiceNamingMapping } from '@/napcat-core/services/index';
|
||||
import { NTEventWrapper } from './event';
|
||||
|
||||
/**
|
||||
* 创建 Service 方法的代理
|
||||
* 拦截所有方法调用,通过 EventWrapper 进行调用
|
||||
*/
|
||||
function createServiceMethodProxy<S extends keyof ServiceNamingMapping>(
|
||||
serviceName: S,
|
||||
originalService: ServiceNamingMapping[S],
|
||||
eventWrapper: NTEventWrapper
|
||||
): ServiceNamingMapping[S] {
|
||||
return new Proxy(originalService as object, {
|
||||
get(target, prop, receiver) {
|
||||
const originalValue = Reflect.get(target, prop, receiver);
|
||||
|
||||
// 如果不是函数,直接返回原始值
|
||||
if (typeof originalValue !== 'function') {
|
||||
return originalValue;
|
||||
}
|
||||
|
||||
const methodName = prop as string;
|
||||
|
||||
// 返回一个包装函数,通过 EventWrapper 调用
|
||||
return function (this: unknown, ...args: unknown[]) {
|
||||
// 构造 EventWrapper 需要的路径格式: ServiceName/MethodName
|
||||
const eventPath = `${serviceName}/${methodName}`;
|
||||
|
||||
// 尝试通过 EventWrapper 调用
|
||||
try {
|
||||
// 使用 callNoListenerEvent 的底层实现逻辑
|
||||
const eventFunc = (eventWrapper as any).createEventFunction(eventPath);
|
||||
if (eventFunc) {
|
||||
return eventFunc(...args);
|
||||
}
|
||||
} catch {
|
||||
// 如果 EventWrapper 调用失败,回退到原始调用
|
||||
}
|
||||
|
||||
// 回退到原始方法调用
|
||||
return originalValue.apply(originalService, args);
|
||||
};
|
||||
},
|
||||
}) as ServiceNamingMapping[S];
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 Session 的双层代理
|
||||
* 第一层:拦截 getXXXService 方法
|
||||
* 第二层:拦截 Service 上的具体方法调用
|
||||
*/
|
||||
export function createSessionProxy(
|
||||
session: NodeIQQNTWrapperSession,
|
||||
eventWrapper: NTEventWrapper
|
||||
): NodeIQQNTWrapperSession {
|
||||
// 缓存已代理的 Service,避免重复创建
|
||||
const serviceProxyCache = new Map<string, unknown>();
|
||||
|
||||
return new Proxy(session, {
|
||||
get(target, prop, receiver) {
|
||||
const propName = prop as string;
|
||||
|
||||
// 检查是否是 getXXXService 方法
|
||||
if (typeof propName === 'string' && propName.startsWith('get') && propName.endsWith('Service')) {
|
||||
// 提取 Service 名称: getMsgService -> NodeIKernelMsgService
|
||||
const servicePart = propName.slice(3); // 移除 'get' 前缀
|
||||
const serviceName = `NodeIKernel${servicePart}` as keyof ServiceNamingMapping;
|
||||
|
||||
// 返回一个函数,该函数返回代理后的 Service
|
||||
return function () {
|
||||
// 检查缓存
|
||||
if (serviceProxyCache.has(serviceName)) {
|
||||
return serviceProxyCache.get(serviceName);
|
||||
}
|
||||
|
||||
// 获取原始 Service
|
||||
const originalGetter = Reflect.get(target, prop, receiver) as () => unknown;
|
||||
const originalService = originalGetter.call(target);
|
||||
|
||||
// 检查是否在 ServiceNamingMapping 中定义
|
||||
if (isKnownService(serviceName)) {
|
||||
// 创建 Service 方法代理
|
||||
const proxiedService = createServiceMethodProxy(
|
||||
serviceName,
|
||||
originalService as ServiceNamingMapping[typeof serviceName],
|
||||
eventWrapper
|
||||
);
|
||||
serviceProxyCache.set(serviceName, proxiedService);
|
||||
return proxiedService;
|
||||
}
|
||||
|
||||
// 未知的 Service,直接返回原始对象
|
||||
serviceProxyCache.set(serviceName, originalService);
|
||||
return originalService;
|
||||
};
|
||||
}
|
||||
|
||||
// 非 getXXXService 方法,直接返回原始值
|
||||
return Reflect.get(target, prop, receiver);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 Service 名称是否在已知的映射中
|
||||
*/
|
||||
function isKnownService(serviceName: string): serviceName is keyof ServiceNamingMapping {
|
||||
const knownServices: string[] = [
|
||||
'NodeIKernelAvatarService',
|
||||
'NodeIKernelBuddyService',
|
||||
'NodeIKernelFileAssistantService',
|
||||
'NodeIKernelGroupService',
|
||||
'NodeIKernelLoginService',
|
||||
'NodeIKernelMsgService',
|
||||
'NodeIKernelOnlineStatusService',
|
||||
'NodeIKernelProfileLikeService',
|
||||
'NodeIKernelProfileService',
|
||||
'NodeIKernelTicketService',
|
||||
'NodeIKernelStorageCleanService',
|
||||
'NodeIKernelRobotService',
|
||||
'NodeIKernelRichMediaService',
|
||||
'NodeIKernelDbToolsService',
|
||||
'NodeIKernelTipOffService',
|
||||
'NodeIKernelSearchService',
|
||||
'NodeIKernelCollectionService',
|
||||
];
|
||||
return knownServices.includes(serviceName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建带有 EventWrapper 集成的 InstanceContext
|
||||
* 这是推荐的使用方式,在创建 context 时自动代理 session
|
||||
*/
|
||||
export function createProxiedSession(
|
||||
session: NodeIQQNTWrapperSession,
|
||||
eventWrapper: NTEventWrapper
|
||||
): NodeIQQNTWrapperSession {
|
||||
return createSessionProxy(session, eventWrapper);
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import { hostname, systemName, systemVersion } from 'napcat-common/src/system';
|
||||
import { NTEventWrapper } from '@/napcat-core/helper/event';
|
||||
import { createSessionProxy } from '@/napcat-core/helper/session-proxy';
|
||||
import { KickedOffLineInfo, RawMessage, SelfInfo, SelfStatusInfo } from '@/napcat-core/types';
|
||||
import { NapCatConfigLoader, NapcatConfigSchema } from '@/napcat-core/helper/config';
|
||||
import os from 'node:os';
|
||||
@@ -44,7 +45,9 @@ export * from './helper/log';
|
||||
export * from './helper/qq-basic-info';
|
||||
export * from './helper/event';
|
||||
export * from './helper/config';
|
||||
export * from './helper/config-base';
|
||||
export * from './helper/proxy-handler';
|
||||
export * from './helper/session-proxy';
|
||||
|
||||
export enum NapCatCoreWorkingEnv {
|
||||
Unknown = 0,
|
||||
@@ -118,9 +121,19 @@ export class NapCatCore {
|
||||
// 通过构造器递过去的 runtime info 应该尽量少
|
||||
constructor (context: InstanceContext, selfInfo: SelfInfo) {
|
||||
this.selfInfo = selfInfo;
|
||||
this.context = context;
|
||||
this.util = this.context.wrapper.NodeQQNTWrapperUtil;
|
||||
// 先用原始 session 创建 eventWrapper
|
||||
this.eventWrapper = new NTEventWrapper(context.session);
|
||||
// 通过环境变量 NAPCAT_SESSION_PROXY 开启 session 代理
|
||||
if (process.env['NAPCAT_SESSION_PROXY'] === '1') {
|
||||
const proxiedSession = createSessionProxy(context.session, this.eventWrapper);
|
||||
this.context = {
|
||||
...context,
|
||||
session: proxiedSession,
|
||||
};
|
||||
} else {
|
||||
this.context = context;
|
||||
}
|
||||
this.util = this.context.wrapper.NodeQQNTWrapperUtil;
|
||||
this.configLoader = new NapCatConfigLoader(this, this.context.pathWrapper.configPath, NapcatConfigSchema);
|
||||
this.apis = {
|
||||
FileApi: new NTQQFileApi(this.context, this),
|
||||
|
||||
@@ -18,6 +18,7 @@ import { OidbPacket } from '@/napcat-core/packet/transformer/base';
|
||||
import { ImageOcrResult } from '@/napcat-core/packet/entities/ocrResult';
|
||||
import { gunzipSync } from 'zlib';
|
||||
import { PacketMsgConverter } from '@/napcat-core/packet/message/converter';
|
||||
import { UploadForwardMsgParams } from '@/napcat-core/packet/transformer/message/UploadForwardMsgV2';
|
||||
|
||||
export class PacketOperationContext {
|
||||
private readonly context: PacketContext;
|
||||
@@ -26,7 +27,7 @@ export class PacketOperationContext {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
async sendPacket<T extends boolean = false>(pkt: OidbPacket, rsp?: T): Promise<T extends true ? Buffer : void> {
|
||||
async sendPacket<T extends boolean = false> (pkt: OidbPacket, rsp?: T): Promise<T extends true ? Buffer : void> {
|
||||
return await this.context.client.sendOidbPacket(pkt, rsp);
|
||||
}
|
||||
|
||||
@@ -224,7 +225,15 @@ export class PacketOperationContext {
|
||||
const res = trans.UploadForwardMsg.parse(resp);
|
||||
return res.result.resId;
|
||||
}
|
||||
|
||||
async UploadForwardMsgV2 (msg: UploadForwardMsgParams[], groupUin: number = 0) {
|
||||
//await this.SendPreprocess(msg, groupUin);
|
||||
// 遍历上传资源
|
||||
await Promise.allSettled(msg.map(async (item) => { return await this.SendPreprocess(item.actionMsg, groupUin); }));
|
||||
const req = trans.UploadForwardMsgV2.build(this.context.napcore.basicInfo.uid, msg, groupUin);
|
||||
const resp = await this.context.client.sendOidbPacket(req, true);
|
||||
const res = trans.UploadForwardMsg.parse(resp);
|
||||
return res.result.resId;
|
||||
}
|
||||
async MoveGroupFile (
|
||||
groupUin: number,
|
||||
fileUUID: string,
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import zlib from 'node:zlib';
|
||||
import * as proto from '@/napcat-core/packet/transformer/proto';
|
||||
import { NapProtoMsg } from 'napcat-protobuf';
|
||||
import { OidbPacket, PacketBufBuilder, PacketTransformer } from '@/napcat-core/packet/transformer/base';
|
||||
import { PacketMsg } from '@/napcat-core/packet/message/message';
|
||||
|
||||
export interface UploadForwardMsgParams {
|
||||
actionCommand: string;
|
||||
actionMsg: PacketMsg[];
|
||||
}
|
||||
class UploadForwardMsgV2 extends PacketTransformer<typeof proto.SendLongMsgResp> {
|
||||
build (selfUid: string, msg: UploadForwardMsgParams[], groupUin: number = 0): OidbPacket {
|
||||
const reqdata = msg.map((item) => ({
|
||||
actionCommand: item.actionCommand,
|
||||
actionData: {
|
||||
msgBody: this.msgBuilder.buildFakeMsg(selfUid, item.actionMsg),
|
||||
}
|
||||
}));
|
||||
const longMsgResultData = new NapProtoMsg(proto.LongMsgResult).encode(
|
||||
{
|
||||
action: reqdata,
|
||||
}
|
||||
);
|
||||
const payload = zlib.gzipSync(Buffer.from(longMsgResultData));
|
||||
const req = new NapProtoMsg(proto.SendLongMsgReq).encode(
|
||||
{
|
||||
info: {
|
||||
type: groupUin === 0 ? 1 : 3,
|
||||
uid: {
|
||||
uid: groupUin === 0 ? selfUid : groupUin.toString(),
|
||||
},
|
||||
groupUin,
|
||||
payload,
|
||||
},
|
||||
settings: {
|
||||
field1: 4, field2: 1, field3: 7, field4: 0,
|
||||
},
|
||||
}
|
||||
);
|
||||
return {
|
||||
cmd: 'trpc.group.long_msg_interface.MsgService.SsoSendLongMsg',
|
||||
data: PacketBufBuilder(req),
|
||||
};
|
||||
}
|
||||
|
||||
parse (data: Buffer) {
|
||||
return new NapProtoMsg(proto.SendLongMsgResp).decode(data);
|
||||
}
|
||||
}
|
||||
|
||||
export default new UploadForwardMsgV2();
|
||||
@@ -2,3 +2,4 @@ export { default as UploadForwardMsg } from './UploadForwardMsg';
|
||||
export { default as FetchGroupMessage } from './FetchGroupMessage';
|
||||
export { default as FetchC2CMessage } from './FetchC2CMessage';
|
||||
export { default as DownloadForwardMsg } from './DownloadForwardMsg';
|
||||
export { default as UploadForwardMsgV2 } from './UploadForwardMsgV2';
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NapCatPathWrapper } from 'napcat-common/src/path';
|
||||
import { InitWebUi, WebUiConfig, webUiRuntimePort } from 'napcat-webui-backend/index';
|
||||
import { NapCatOneBot11Adapter } from 'napcat-onebot/index';
|
||||
import { NapCatAdapterManager } from 'napcat-adapter';
|
||||
import { NativePacketHandler } from 'napcat-core/packet/handler/client';
|
||||
import { FFmpegService } from 'napcat-core/helper/ffmpeg/ffmpeg';
|
||||
import { logSubscription, LogWrapper } from 'napcat-core/helper/log';
|
||||
@@ -79,11 +79,14 @@ export async function NCoreInitFramework (
|
||||
// 启动WebUi
|
||||
WebUiDataRuntime.setWorkingEnv(NapCatCoreWorkingEnv.Framework);
|
||||
InitWebUi(logger, pathWrapper, logSubscription, statusHelperSubscription).then().catch(e => logger.logError(e));
|
||||
// 初始化LLNC的Onebot实现
|
||||
const oneBotAdapter = new NapCatOneBot11Adapter(loaderObject.core, loaderObject.context, pathWrapper);
|
||||
// 注册到 WebUiDataRuntime,供调试功能使用
|
||||
WebUiDataRuntime.setOneBotContext(oneBotAdapter);
|
||||
await oneBotAdapter.InitOneBot();
|
||||
// 使用 NapCatAdapterManager 统一管理协议适配器
|
||||
const adapterManager = new NapCatAdapterManager(loaderObject.core, loaderObject.context, pathWrapper);
|
||||
await adapterManager.initAdapters();
|
||||
// 注册 OneBot 适配器到 WebUiDataRuntime,供调试功能使用
|
||||
const oneBotAdapter = adapterManager.getOneBotAdapter();
|
||||
if (oneBotAdapter) {
|
||||
WebUiDataRuntime.setOneBotContext(oneBotAdapter);
|
||||
}
|
||||
}
|
||||
|
||||
export class NapCatFramework {
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
{
|
||||
"name": "napcat-framework",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"typecheck": "tsc --noEmit --skipLibCheck -p tsconfig.json"
|
||||
"name": "napcat-framework",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"typecheck": "tsc --noEmit --skipLibCheck -p tsconfig.json"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./index.ts"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./index.ts"
|
||||
},
|
||||
"./*": {
|
||||
"import": "./*"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"napcat-core": "workspace:*",
|
||||
"napcat-common": "workspace:*",
|
||||
"napcat-onebot": "workspace:*",
|
||||
"napcat-webui-backend": "workspace:*",
|
||||
"napcat-vite": "workspace:*",
|
||||
"napcat-qrcode": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
"./*": {
|
||||
"import": "./*"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"napcat-core": "workspace:*",
|
||||
"napcat-common": "workspace:*",
|
||||
"napcat-adapter": "workspace:*",
|
||||
"napcat-webui-backend": "workspace:*",
|
||||
"napcat-vite": "workspace:*",
|
||||
"napcat-qrcode": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,8 @@ const FrameworkBaseConfig = () =>
|
||||
'@/napcat-pty': resolve(__dirname, '../napcat-pty'),
|
||||
'@/napcat-webui-backend': resolve(__dirname, '../napcat-webui-backend'),
|
||||
'@/image-size': resolve(__dirname, '../image-size'),
|
||||
'@/napcat-protocol': resolve(__dirname, '../napcat-protocol'),
|
||||
'@/napcat-adapter': resolve(__dirname, '../napcat-adapter'),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
|
||||
@@ -22,7 +22,7 @@ export class CreateFlashTask extends OneBotAction<CreateFlashTaskPayload, any> {
|
||||
override actionName = ActionName.CreateFlashTask;
|
||||
override payloadSchema = CreateFlashTaskPayloadSchema;
|
||||
override returnSchema = Type.Any({ description: '任务创建结果' });
|
||||
override actionSummary = '创建闪照任务';
|
||||
override actionSummary = '创建闪传任务';
|
||||
override actionTags = ['文件扩展'];
|
||||
override payloadExample = {
|
||||
files: 'C:\\test.jpg',
|
||||
|
||||
@@ -12,7 +12,7 @@ export class GetFlashFileList extends OneBotAction<GetFlashFileListPayload, any>
|
||||
override actionName = ActionName.GetFlashFileList;
|
||||
override payloadSchema = GetFlashFileListPayloadSchema;
|
||||
override returnSchema = Type.Any({ description: '文件列表' });
|
||||
override actionSummary = '获取闪照文件列表';
|
||||
override actionSummary = '获取闪传文件列表';
|
||||
override actionTags = ['文件扩展'];
|
||||
override payloadExample = {
|
||||
fileset_id: 'set_123'
|
||||
|
||||
@@ -14,7 +14,7 @@ export class GetFlashFileUrl extends OneBotAction<GetFlashFileUrlPayload, any> {
|
||||
override actionName = ActionName.GetFlashFileUrl;
|
||||
override payloadSchema = GetFlashFileUrlPayloadSchema;
|
||||
override returnSchema = Type.Any({ description: '文件下载链接' });
|
||||
override actionSummary = '获取闪照文件链接';
|
||||
override actionSummary = '获取闪传文件链接';
|
||||
override actionTags = ['文件扩展'];
|
||||
override payloadExample = {
|
||||
fileset_id: 'set_123'
|
||||
|
||||
@@ -15,7 +15,7 @@ export class SendFlashMsg extends OneBotAction<SendFlashMsgPayload, any> {
|
||||
override actionName = ActionName.SendFlashMsg;
|
||||
override payloadSchema = SendFlashMsgPayloadSchema;
|
||||
override returnSchema = Type.Any({ description: '发送结果' });
|
||||
override actionSummary = '发送闪照消息';
|
||||
override actionSummary = '发送闪传消息';
|
||||
override actionTags = ['文件扩展'];
|
||||
override payloadExample = {
|
||||
fileset_id: 'set_123',
|
||||
|
||||
@@ -17,6 +17,7 @@ import { rawMsgWithSendMsg } from 'napcat-core/packet/message/converter';
|
||||
import { Static, Type } from '@sinclair/typebox';
|
||||
import { MsgActionsExamples } from '@/napcat-onebot/action/msg/examples';
|
||||
import { OB11MessageMixTypeSchema } from '@/napcat-onebot/types/message';
|
||||
import { UploadForwardMsgParams } from '@/napcat-core/packet/transformer/message/UploadForwardMsgV2';
|
||||
|
||||
export const SendMsgPayloadSchema = Type.Object({
|
||||
message_type: Type.Optional(Type.Union([Type.Literal('private'), Type.Literal('group')], { description: '消息类型 (private/group)' })),
|
||||
@@ -211,10 +212,14 @@ export class SendMsgBase extends OneBotAction<SendMsgPayload, ReturnDataType> {
|
||||
}, dp: number = 0): Promise<{
|
||||
finallySendElements: SendArkElement,
|
||||
res_id?: string,
|
||||
uuid?: string,
|
||||
packetMsg: PacketMsg[],
|
||||
deleteAfterSentFiles: string[],
|
||||
innerPacketMsg?: Array<{ uuid: string, packetMsg: PacketMsg[]; }>;
|
||||
} | null> {
|
||||
const packetMsg: PacketMsg[] = [];
|
||||
const delFiles: string[] = [];
|
||||
const innerMsg: Array<{ uuid: string, packetMsg: PacketMsg[]; }> = new Array();
|
||||
for (const node of messageNodes) {
|
||||
if (dp >= 3) {
|
||||
this.core.context.logger.logWarn('转发消息深度超过3层,将停止解析!');
|
||||
@@ -232,6 +237,13 @@ export class SendMsgBase extends OneBotAction<SendMsgPayload, ReturnDataType> {
|
||||
}, dp + 1);
|
||||
sendElements = uploadReturnData?.finallySendElements ? [uploadReturnData.finallySendElements] : [];
|
||||
delFiles.push(...(uploadReturnData?.deleteAfterSentFiles || []));
|
||||
if (uploadReturnData?.uuid) {
|
||||
innerMsg.push({ uuid: uploadReturnData.uuid, packetMsg: uploadReturnData.packetMsg });
|
||||
uploadReturnData.innerPacketMsg?.forEach(m => {
|
||||
innerMsg.push({ uuid: m.uuid, packetMsg: m.packetMsg });
|
||||
});
|
||||
}
|
||||
|
||||
} else {
|
||||
const sendElementsCreateReturn = await this.obContext.apis.MsgApi.createSendElements(OB11Data, msgPeer);
|
||||
sendElements = sendElementsCreateReturn.sendElements;
|
||||
@@ -273,8 +285,19 @@ export class SendMsgBase extends OneBotAction<SendMsgPayload, ReturnDataType> {
|
||||
this.core.context.logger.logWarn('handleForwardedNodesPacket 元素为空!');
|
||||
return null;
|
||||
}
|
||||
const resid = await this.core.apis.PacketApi.pkt.operation.UploadForwardMsg(packetMsg, msgPeer.chatType === ChatType.KCHATTYPEGROUP ? +msgPeer.peerUid : 0);
|
||||
const forwardJson = ForwardMsgBuilder.fromPacketMsg(resid, packetMsg, source, news, summary, prompt);
|
||||
const uploadMsgData: UploadForwardMsgParams[] = [{
|
||||
actionCommand: 'MultiMsg',
|
||||
actionMsg: packetMsg,
|
||||
}];
|
||||
innerMsg.forEach(({ uuid, packetMsg: msg }) => {
|
||||
uploadMsgData.push({
|
||||
actionCommand: uuid,
|
||||
actionMsg: msg,
|
||||
});
|
||||
});
|
||||
const resid = await this.core.apis.PacketApi.pkt.operation.UploadForwardMsgV2(uploadMsgData, msgPeer.chatType === ChatType.KCHATTYPEGROUP ? +msgPeer.peerUid : 0);
|
||||
const uuid = crypto.randomUUID();
|
||||
const forwardJson = ForwardMsgBuilder.fromPacketMsg(resid, packetMsg, source, news, summary, prompt, uuid);
|
||||
return {
|
||||
deleteAfterSentFiles: delFiles,
|
||||
finallySendElements: {
|
||||
@@ -285,6 +308,9 @@ export class SendMsgBase extends OneBotAction<SendMsgPayload, ReturnDataType> {
|
||||
},
|
||||
} as SendArkElement,
|
||||
res_id: resid,
|
||||
uuid: uuid,
|
||||
packetMsg: packetMsg,
|
||||
innerPacketMsg: innerMsg,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1288,7 +1288,8 @@ export class OneBotMsgApi {
|
||||
}
|
||||
realUri = await this.handleObfuckName(realUri) ?? realUri;
|
||||
try {
|
||||
const { path, fileName, errMsg, success } = await uriToLocalFile(this.core.NapCatTempPath, realUri);
|
||||
const proxy = this.obContext.configLoader.configData.imageDownloadProxy || undefined;
|
||||
const { path, fileName, errMsg, success } = await uriToLocalFile(this.core.NapCatTempPath, realUri, undefined, undefined, proxy);
|
||||
if (!success) {
|
||||
this.core.context.logger.logError('文件处理失败', errMsg);
|
||||
throw new Error('文件处理失败: ' + errMsg);
|
||||
|
||||
@@ -82,6 +82,7 @@ export const OneBotConfigSchema = Type.Object({
|
||||
musicSignUrl: Type.String({ default: '' }),
|
||||
enableLocalFile2Url: Type.Boolean({ default: false }),
|
||||
parseMultMsg: Type.Boolean({ default: false }),
|
||||
imageDownloadProxy: Type.String({ default: '' }),
|
||||
});
|
||||
|
||||
export type OneBotConfig = Static<typeof OneBotConfigSchema>;
|
||||
|
||||
@@ -9,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);
|
||||
File diff suppressed because it is too large
Load Diff
39
packages/napcat-onebot/network/plugin/config.ts
Normal file
39
packages/napcat-onebot/network/plugin/config.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { PluginConfigItem, PluginConfigSchema } from './types';
|
||||
|
||||
/**
|
||||
* NapCat 插件配置构建器
|
||||
* 提供便捷的配置项创建方法
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
23
packages/napcat-onebot/network/plugin/index.ts
Normal file
23
packages/napcat-onebot/network/plugin/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
// 导出类型
|
||||
export type {
|
||||
PluginPackageJson,
|
||||
PluginConfigItem,
|
||||
PluginConfigSchema,
|
||||
INapCatConfigStatic,
|
||||
NapCatConfigClass,
|
||||
IPluginManager,
|
||||
PluginConfigUIController,
|
||||
PluginLogger,
|
||||
NapCatPluginContext,
|
||||
PluginModule,
|
||||
PluginRuntimeStatus,
|
||||
PluginRuntime,
|
||||
PluginEntry,
|
||||
PluginStatusConfig,
|
||||
} from './types';
|
||||
|
||||
// 导出配置构建器
|
||||
export { NapCatConfig } from './config';
|
||||
|
||||
// 导出加载器
|
||||
export { PluginLoader } from './loader';
|
||||
298
packages/napcat-onebot/network/plugin/loader.ts
Normal file
298
packages/napcat-onebot/network/plugin/loader.ts
Normal file
@@ -0,0 +1,298 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { LogWrapper } from 'napcat-core/helper/log';
|
||||
import {
|
||||
PluginPackageJson,
|
||||
PluginModule,
|
||||
PluginEntry,
|
||||
PluginStatusConfig,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* 插件加载器
|
||||
* 负责扫描、加载和导入插件模块
|
||||
*/
|
||||
export class PluginLoader {
|
||||
constructor (
|
||||
private readonly pluginPath: string,
|
||||
private readonly configPath: string,
|
||||
private readonly logger: LogWrapper
|
||||
) { }
|
||||
|
||||
/**
|
||||
* 加载插件状态配置
|
||||
*/
|
||||
loadPluginStatusConfig (): PluginStatusConfig {
|
||||
if (fs.existsSync(this.configPath)) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(this.configPath, 'utf-8'));
|
||||
} catch (e) {
|
||||
this.logger.logWarn('[PluginLoader] Error parsing plugins.json', e);
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存插件状态配置
|
||||
*/
|
||||
savePluginStatusConfig (config: PluginStatusConfig): void {
|
||||
try {
|
||||
fs.writeFileSync(this.configPath, JSON.stringify(config, null, 2), 'utf-8');
|
||||
} catch (e) {
|
||||
this.logger.logError('[PluginLoader] Error saving plugins.json', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描插件目录,收集所有有效插件条目(异步版本,验证模块有效性)
|
||||
* 只有包含有效 plugin_init 函数的插件才会被收集
|
||||
*/
|
||||
async scanPlugins (): Promise<PluginEntry[]> {
|
||||
const entries: PluginEntry[] = [];
|
||||
|
||||
// 确保插件目录存在
|
||||
if (!fs.existsSync(this.pluginPath)) {
|
||||
this.logger.logWarn(`[PluginLoader] Plugin directory does not exist: ${this.pluginPath}`);
|
||||
fs.mkdirSync(this.pluginPath, { recursive: true });
|
||||
return entries;
|
||||
}
|
||||
|
||||
const items = fs.readdirSync(this.pluginPath, { withFileTypes: true });
|
||||
const statusConfig = this.loadPluginStatusConfig();
|
||||
|
||||
for (const item of items) {
|
||||
if (!item.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const entry = this.scanDirectoryPlugin(item.name, statusConfig);
|
||||
if (!entry) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 如果没有入口文件,跳过
|
||||
if (!entry.entryPath) {
|
||||
this.logger.logWarn(`[PluginLoader] Skipping ${item.name}: no entry file found`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 如果插件被禁用,跳过模块验证,直接添加到列表
|
||||
if (!entry.enable) {
|
||||
entries.push(entry);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 验证模块有效性(仅对启用的插件)
|
||||
const validation = await this.validatePluginEntry(entry.entryPath);
|
||||
if (!validation.valid) {
|
||||
this.logger.logWarn(`[PluginLoader] Skipping ${item.name}: ${validation.error}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
entries.push(entry);
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描单个目录插件
|
||||
*/
|
||||
private scanDirectoryPlugin (dirname: string, statusConfig: PluginStatusConfig): PluginEntry | null {
|
||||
const pluginDir = path.join(this.pluginPath, dirname);
|
||||
|
||||
try {
|
||||
// 尝试读取 package.json
|
||||
let packageJson: PluginPackageJson | undefined;
|
||||
const packageJsonPath = path.join(pluginDir, 'package.json');
|
||||
|
||||
if (fs.existsSync(packageJsonPath)) {
|
||||
try {
|
||||
const packageContent = fs.readFileSync(packageJsonPath, 'utf-8');
|
||||
packageJson = JSON.parse(packageContent);
|
||||
} catch (error) {
|
||||
this.logger.logWarn(`[PluginLoader] Invalid package.json in ${dirname}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// 获取插件 ID(包名或目录名)
|
||||
const pluginId = packageJson?.name || dirname;
|
||||
|
||||
// 确定入口文件
|
||||
const entryFile = this.findEntryFile(pluginDir, packageJson);
|
||||
const entryPath = entryFile ? path.join(pluginDir, entryFile) : undefined;
|
||||
|
||||
// 获取启用状态(默认启用)
|
||||
const enable = statusConfig[pluginId] !== false;
|
||||
|
||||
// 创建插件条目
|
||||
const entry: PluginEntry = {
|
||||
id: pluginId,
|
||||
fileId: dirname,
|
||||
name: packageJson?.name,
|
||||
version: packageJson?.version,
|
||||
description: packageJson?.description,
|
||||
author: packageJson?.author,
|
||||
pluginPath: pluginDir,
|
||||
entryPath,
|
||||
packageJson,
|
||||
enable,
|
||||
loaded: false,
|
||||
runtime: {
|
||||
status: 'unloaded',
|
||||
},
|
||||
};
|
||||
|
||||
// 如果没有入口文件,标记为错误
|
||||
if (!entryPath) {
|
||||
entry.runtime = {
|
||||
status: 'error',
|
||||
error: `No valid entry file found for plugin directory: ${dirname}`,
|
||||
};
|
||||
}
|
||||
|
||||
return entry;
|
||||
} catch (error: any) {
|
||||
// 创建错误条目
|
||||
return {
|
||||
id: dirname, // 使用目录名作为 ID
|
||||
fileId: dirname,
|
||||
pluginPath: path.join(this.pluginPath, dirname),
|
||||
enable: statusConfig[dirname] !== false,
|
||||
loaded: false,
|
||||
runtime: {
|
||||
status: 'error',
|
||||
error: error.message || 'Unknown error during scan',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找插件目录的入口文件
|
||||
*/
|
||||
private findEntryFile (pluginDir: string, packageJson?: PluginPackageJson): string | null {
|
||||
const possibleEntries = [
|
||||
packageJson?.main,
|
||||
'index.mjs',
|
||||
'index.js',
|
||||
'main.mjs',
|
||||
'main.js',
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
for (const entry of possibleEntries) {
|
||||
const entryPath = path.join(pluginDir, entry);
|
||||
if (fs.existsSync(entryPath) && fs.statSync(entryPath).isFile()) {
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 动态导入模块
|
||||
*/
|
||||
async importModule (filePath: string): Promise<any> {
|
||||
const fileUrl = `file://${filePath.replace(/\\/g, '/')}`;
|
||||
const fileUrlWithQuery = `${fileUrl}?t=${Date.now()}`;
|
||||
return await import(fileUrlWithQuery);
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载插件模块
|
||||
*/
|
||||
async loadPluginModule (entry: PluginEntry): Promise<PluginModule | null> {
|
||||
if (!entry.entryPath) {
|
||||
entry.runtime = {
|
||||
status: 'error',
|
||||
error: 'No entry path specified',
|
||||
};
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const module = await this.importModule(entry.entryPath);
|
||||
|
||||
if (!this.isValidPluginModule(module)) {
|
||||
entry.runtime = {
|
||||
status: 'error',
|
||||
error: 'Invalid plugin module: missing plugin_init function',
|
||||
};
|
||||
return null;
|
||||
}
|
||||
|
||||
return module;
|
||||
} catch (error: any) {
|
||||
entry.runtime = {
|
||||
status: 'error',
|
||||
error: error.message || 'Failed to import module',
|
||||
};
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查模块是否为有效的插件模块
|
||||
*/
|
||||
isValidPluginModule (module: any): module is PluginModule {
|
||||
return module && typeof module.plugin_init === 'function';
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证插件入口文件是否包含有效的 plugin_init 函数
|
||||
* 用于扫描阶段快速验证
|
||||
*/
|
||||
async validatePluginEntry (entryPath: string): Promise<{ valid: boolean; error?: string; }> {
|
||||
try {
|
||||
const module = await this.importModule(entryPath);
|
||||
if (this.isValidPluginModule(module)) {
|
||||
return { valid: true };
|
||||
}
|
||||
return { valid: false, error: 'Missing plugin_init function' };
|
||||
} catch (error: any) {
|
||||
return { valid: false, error: error.message || 'Failed to import module' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新扫描单个插件
|
||||
*/
|
||||
rescanPlugin (dirname: string): PluginEntry | null {
|
||||
const statusConfig = this.loadPluginStatusConfig();
|
||||
return this.scanDirectoryPlugin(dirname, statusConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 ID 查找插件目录名
|
||||
*/
|
||||
findPluginDirById (pluginId: string): string | null {
|
||||
if (!fs.existsSync(this.pluginPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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 === pluginId) {
|
||||
return item.name;
|
||||
}
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
// 如果目录名就是 ID
|
||||
if (item.name === pluginId) {
|
||||
return item.name;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
513
packages/napcat-onebot/network/plugin/manager.ts
Normal file
513
packages/napcat-onebot/network/plugin/manager.ts
Normal file
@@ -0,0 +1,513 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { ActionMap } from '@/napcat-onebot/action';
|
||||
import { NapCatCore } from 'napcat-core';
|
||||
import { NapCatOneBot11Adapter, OB11Message } from '@/napcat-onebot/index';
|
||||
import { OB11EmitEventContent, OB11NetworkReloadType } from '@/napcat-onebot/network/index';
|
||||
import { IOB11NetworkAdapter } from '@/napcat-onebot/network/adapter';
|
||||
import { PluginConfig } from '@/napcat-onebot/config/config';
|
||||
import { NapCatConfig } from './config';
|
||||
import { PluginLoader } from './loader';
|
||||
import { PluginRouterRegistryImpl } from './router-registry';
|
||||
import {
|
||||
PluginEntry,
|
||||
PluginLogger,
|
||||
PluginStatusConfig,
|
||||
NapCatPluginContext,
|
||||
IPluginManager,
|
||||
} from './types';
|
||||
|
||||
export class OB11PluginManager extends IOB11NetworkAdapter<PluginConfig> implements IPluginManager {
|
||||
private readonly pluginPath: string;
|
||||
private readonly configPath: string;
|
||||
private readonly loader: PluginLoader;
|
||||
|
||||
/** 插件注册表: ID -> 插件条目 */
|
||||
private plugins: Map<string, PluginEntry> = new Map();
|
||||
|
||||
/** 插件路由注册表: pluginId -> PluginRouterRegistry */
|
||||
private pluginRouters: Map<string, PluginRouterRegistryImpl> = new Map();
|
||||
|
||||
declare config: PluginConfig;
|
||||
public NapCatConfig = NapCatConfig;
|
||||
|
||||
override get isActive (): boolean {
|
||||
return this.isEnable && this.getLoadedPlugins().length > 0;
|
||||
}
|
||||
|
||||
constructor (
|
||||
name: string,
|
||||
core: NapCatCore,
|
||||
obContext: NapCatOneBot11Adapter,
|
||||
actions: ActionMap
|
||||
) {
|
||||
const config = {
|
||||
name,
|
||||
messagePostFormat: 'array',
|
||||
reportSelfMessage: true,
|
||||
enable: true,
|
||||
debug: true,
|
||||
};
|
||||
super(name, config, core, obContext, actions);
|
||||
this.pluginPath = this.core.context.pathWrapper.pluginPath;
|
||||
this.configPath = path.join(this.core.context.pathWrapper.configPath, 'plugins.json');
|
||||
this.loader = new PluginLoader(this.pluginPath, this.configPath, this.logger);
|
||||
}
|
||||
|
||||
// ==================== 插件状态配置 ====================
|
||||
|
||||
public getPluginConfig (): PluginStatusConfig {
|
||||
return this.loader.loadPluginStatusConfig();
|
||||
}
|
||||
|
||||
private savePluginConfig (config: PluginStatusConfig): void {
|
||||
this.loader.savePluginStatusConfig(config);
|
||||
}
|
||||
|
||||
// ==================== 插件扫描与加载 ====================
|
||||
|
||||
/**
|
||||
* 扫描并加载所有插件
|
||||
*/
|
||||
private async scanAndLoadPlugins (): Promise<void> {
|
||||
// 扫描所有插件目录
|
||||
const entries = await this.loader.scanPlugins();
|
||||
|
||||
// 清空现有注册表
|
||||
this.plugins.clear();
|
||||
|
||||
// 注册所有插件条目
|
||||
for (const entry of entries) {
|
||||
this.plugins.set(entry.id, entry);
|
||||
}
|
||||
|
||||
this.logger.log(`[PluginManager] Scanned ${this.plugins.size} plugins`);
|
||||
|
||||
// 加载启用的插件
|
||||
for (const entry of this.plugins.values()) {
|
||||
if (entry.enable && entry.runtime.status !== 'error') {
|
||||
await this.loadPlugin(entry);
|
||||
}
|
||||
}
|
||||
|
||||
const loadedCount = this.getLoadedPlugins().length;
|
||||
this.logger.log(`[PluginManager] Loaded ${loadedCount} plugins`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载单个插件
|
||||
*/
|
||||
private async loadPlugin (entry: PluginEntry): Promise<boolean> {
|
||||
if (entry.loaded) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (entry.runtime.status === 'error') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 加载模块
|
||||
const module = await this.loader.loadPluginModule(entry);
|
||||
if (!module) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 创建上下文
|
||||
const context = this.createPluginContext(entry);
|
||||
|
||||
// 初始化插件
|
||||
try {
|
||||
await module.plugin_init(context);
|
||||
|
||||
entry.loaded = true;
|
||||
entry.runtime = {
|
||||
status: 'loaded',
|
||||
module,
|
||||
context,
|
||||
};
|
||||
|
||||
this.logger.log(`[PluginManager] Initialized plugin: ${entry.id}${entry.version ? ` v${entry.version}` : ''}`);
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
entry.loaded = false;
|
||||
entry.runtime = {
|
||||
status: 'error',
|
||||
error: error.message || 'Initialization failed',
|
||||
};
|
||||
|
||||
this.logger.logError(`[PluginManager] Error initializing plugin ${entry.id}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 卸载单个插件
|
||||
*/
|
||||
private async unloadPlugin (entry: PluginEntry): Promise<void> {
|
||||
if (!entry.loaded || entry.runtime.status !== 'loaded') {
|
||||
return;
|
||||
}
|
||||
|
||||
const { module, context } = entry.runtime;
|
||||
|
||||
// 调用清理方法
|
||||
if (module && context && typeof module.plugin_cleanup === 'function') {
|
||||
try {
|
||||
await module.plugin_cleanup(context);
|
||||
this.logger.log(`[PluginManager] Cleaned up plugin: ${entry.id}`);
|
||||
} catch (error) {
|
||||
this.logger.logError(`[PluginManager] Error cleaning up plugin ${entry.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// 重置状态
|
||||
entry.loaded = false;
|
||||
entry.runtime = {
|
||||
status: 'unloaded',
|
||||
};
|
||||
|
||||
this.logger.log(`[PluginManager] Unloaded plugin: ${entry.id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建插件上下文
|
||||
*/
|
||||
private createPluginContext (entry: PluginEntry): NapCatPluginContext {
|
||||
const dataPath = path.join(entry.pluginPath, 'data');
|
||||
const configPath = path.join(dataPath, 'config.json');
|
||||
|
||||
// 创建插件专用日志器
|
||||
const pluginPrefix = `[Plugin: ${entry.id}]`;
|
||||
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),
|
||||
};
|
||||
|
||||
// 创建或获取插件路由注册器
|
||||
let router = this.pluginRouters.get(entry.id);
|
||||
if (!router) {
|
||||
router = new PluginRouterRegistryImpl(entry.id, entry.pluginPath);
|
||||
this.pluginRouters.set(entry.id, router);
|
||||
}
|
||||
|
||||
return {
|
||||
core: this.core,
|
||||
oneBot: this.obContext,
|
||||
actions: this.actions,
|
||||
pluginName: entry.id,
|
||||
pluginPath: entry.pluginPath,
|
||||
dataPath,
|
||||
configPath,
|
||||
NapCatConfig,
|
||||
adapterName: this.name,
|
||||
pluginManager: this,
|
||||
logger: pluginLogger,
|
||||
router,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== 公共 API ====================
|
||||
|
||||
/**
|
||||
* 获取插件目录路径
|
||||
*/
|
||||
public getPluginPath (): string {
|
||||
return this.pluginPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有插件条目
|
||||
*/
|
||||
public getAllPlugins (): PluginEntry[] {
|
||||
return Array.from(this.plugins.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已加载的插件列表
|
||||
*/
|
||||
public getLoadedPlugins (): PluginEntry[] {
|
||||
return Array.from(this.plugins.values()).filter(p => p.loaded);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 ID 获取插件信息
|
||||
*/
|
||||
public getPluginInfo (pluginId: string): PluginEntry | undefined {
|
||||
return this.plugins.get(pluginId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置插件状态(启用/禁用)
|
||||
*/
|
||||
public async setPluginStatus (pluginId: string, enable: boolean): Promise<void> {
|
||||
const config = this.getPluginConfig();
|
||||
config[pluginId] = enable;
|
||||
this.savePluginConfig(config);
|
||||
|
||||
const entry = this.plugins.get(pluginId);
|
||||
if (entry) {
|
||||
entry.enable = enable;
|
||||
|
||||
if (enable && !entry.loaded) {
|
||||
// 启用插件
|
||||
await this.loadPlugin(entry);
|
||||
} else if (!enable && entry.loaded) {
|
||||
// 禁用插件
|
||||
await this.unloadPlugin(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 ID 加载插件
|
||||
*/
|
||||
public async loadPluginById (pluginId: string): Promise<boolean> {
|
||||
let entry = this.plugins.get(pluginId);
|
||||
|
||||
if (!entry) {
|
||||
// 尝试查找并扫描
|
||||
const dirname = this.loader.findPluginDirById(pluginId);
|
||||
if (!dirname) {
|
||||
this.logger.logWarn(`[PluginManager] Plugin ${pluginId} not found in filesystem`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const newEntry = this.loader.rescanPlugin(dirname);
|
||||
if (!newEntry) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.plugins.set(newEntry.id, newEntry);
|
||||
entry = newEntry;
|
||||
}
|
||||
|
||||
return await this.loadPlugin(entry);
|
||||
}
|
||||
|
||||
/**
|
||||
* 卸载插件(仅从内存卸载)
|
||||
*/
|
||||
public async unregisterPlugin (pluginId: string): Promise<void> {
|
||||
const entry = this.plugins.get(pluginId);
|
||||
if (entry) {
|
||||
await this.unloadPlugin(entry);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 卸载并删除插件
|
||||
*/
|
||||
public async uninstallPlugin (pluginId: string, cleanData: boolean = false): Promise<void> {
|
||||
const entry = this.plugins.get(pluginId);
|
||||
if (!entry) {
|
||||
throw new Error(`Plugin ${pluginId} not found`);
|
||||
}
|
||||
|
||||
const pluginPath = entry.pluginPath;
|
||||
const dataPath = path.join(pluginPath, 'data');
|
||||
|
||||
// 先卸载插件
|
||||
await this.unloadPlugin(entry);
|
||||
|
||||
// 从注册表移除
|
||||
this.plugins.delete(pluginId);
|
||||
|
||||
// 删除插件目录
|
||||
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 (pluginId: string): Promise<boolean> {
|
||||
const entry = this.plugins.get(pluginId);
|
||||
if (!entry) {
|
||||
this.logger.logWarn(`[PluginManager] Plugin ${pluginId} not found`);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// 卸载插件
|
||||
await this.unloadPlugin(entry);
|
||||
|
||||
// 重新扫描
|
||||
const newEntry = this.loader.rescanPlugin(entry.fileId);
|
||||
if (!newEntry) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 更新注册表
|
||||
this.plugins.set(newEntry.id, newEntry);
|
||||
|
||||
// 重新加载
|
||||
if (newEntry.enable) {
|
||||
await this.loadPlugin(newEntry);
|
||||
}
|
||||
|
||||
this.logger.log(`[PluginManager] Plugin ${pluginId} reloaded successfully`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.logger.logError(`[PluginManager] Error reloading plugin ${pluginId}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载目录插件(用于新安装的插件)
|
||||
*/
|
||||
public async loadDirectoryPlugin (dirname: string): Promise<void> {
|
||||
const entry = this.loader.rescanPlugin(dirname);
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否已存在
|
||||
if (this.plugins.has(entry.id)) {
|
||||
this.logger.logWarn(`[PluginManager] Plugin ${entry.id} already exists`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.plugins.set(entry.id, entry);
|
||||
|
||||
if (entry.enable && entry.runtime.status !== 'error') {
|
||||
await this.loadPlugin(entry);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件数据目录路径
|
||||
*/
|
||||
public getPluginDataPath (pluginId: string): string {
|
||||
const entry = this.plugins.get(pluginId);
|
||||
if (!entry) {
|
||||
throw new Error(`Plugin ${pluginId} not found`);
|
||||
}
|
||||
return path.join(entry.pluginPath, 'data');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件配置文件路径
|
||||
*/
|
||||
public getPluginConfigPath (pluginId: string): string {
|
||||
return path.join(this.getPluginDataPath(pluginId), 'config.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件路由注册器
|
||||
*/
|
||||
public getPluginRouter (pluginId: string): PluginRouterRegistryImpl | undefined {
|
||||
return this.pluginRouters.get(pluginId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有插件路由注册器
|
||||
*/
|
||||
public getAllPluginRouters (): Map<string, PluginRouterRegistryImpl> {
|
||||
return this.pluginRouters;
|
||||
}
|
||||
|
||||
// ==================== 事件处理 ====================
|
||||
|
||||
async onEvent<T extends OB11EmitEventContent> (event: T): Promise<void> {
|
||||
if (!this.isEnable) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await Promise.allSettled(
|
||||
this.getLoadedPlugins().map((entry) =>
|
||||
this.callPluginEventHandler(entry, event)
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.logError('[PluginManager] Error handling event:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用插件的事件处理方法
|
||||
*/
|
||||
private async callPluginEventHandler (
|
||||
entry: PluginEntry,
|
||||
event: OB11EmitEventContent
|
||||
): Promise<void> {
|
||||
if (entry.runtime.status !== 'loaded' || !entry.runtime.module || !entry.runtime.context) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { module, context } = entry.runtime;
|
||||
|
||||
try {
|
||||
// 优先使用 plugin_onevent 方法
|
||||
if (typeof module.plugin_onevent === 'function') {
|
||||
await module.plugin_onevent(context, event);
|
||||
}
|
||||
|
||||
// 如果是消息事件并且插件有 plugin_onmessage 方法,也调用
|
||||
if (
|
||||
(event as any).message_type &&
|
||||
typeof module.plugin_onmessage === 'function'
|
||||
) {
|
||||
await module.plugin_onmessage(context, event as OB11Message);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.logError(`[PluginManager] Error calling plugin ${entry.id} event handler:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 生命周期 ====================
|
||||
|
||||
async open (): Promise<void> {
|
||||
if (this.isEnable) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.log('[PluginManager] Opening plugin manager...');
|
||||
this.isEnable = true;
|
||||
|
||||
// 扫描并加载所有插件
|
||||
await this.scanAndLoadPlugins();
|
||||
|
||||
this.logger.log(`[PluginManager] Plugin manager opened with ${this.getLoadedPlugins().length} plugins loaded`);
|
||||
}
|
||||
|
||||
async close (): Promise<void> {
|
||||
if (!this.isEnable) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.log('[PluginManager] Closing plugin manager...');
|
||||
this.isEnable = false;
|
||||
|
||||
// 卸载所有已加载的插件
|
||||
for (const entry of this.plugins.values()) {
|
||||
if (entry.loaded) {
|
||||
await this.unloadPlugin(entry);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log('[PluginManager] Plugin manager closed');
|
||||
}
|
||||
|
||||
async reload (): Promise<OB11NetworkReloadType> {
|
||||
this.logger.log('[PluginManager] Reloading plugin manager...');
|
||||
|
||||
// 先关闭然后重新打开
|
||||
await this.close();
|
||||
await this.open();
|
||||
|
||||
this.logger.log('[PluginManager] Plugin manager reloaded');
|
||||
return OB11NetworkReloadType.Normal;
|
||||
}
|
||||
}
|
||||
221
packages/napcat-onebot/network/plugin/router-registry.ts
Normal file
221
packages/napcat-onebot/network/plugin/router-registry.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { Router, static as expressStatic, Request, Response, NextFunction } from 'express';
|
||||
import path from 'path';
|
||||
import {
|
||||
PluginRouterRegistry,
|
||||
PluginRequestHandler,
|
||||
PluginApiRouteDefinition,
|
||||
PluginPageDefinition,
|
||||
PluginHttpRequest,
|
||||
PluginHttpResponse,
|
||||
HttpMethod,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* 包装 Express Request 为 PluginHttpRequest
|
||||
*/
|
||||
function wrapRequest (req: Request): PluginHttpRequest {
|
||||
return {
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
query: req.query as Record<string, string | string[] | undefined>,
|
||||
body: req.body,
|
||||
headers: req.headers as Record<string, string | string[] | undefined>,
|
||||
params: req.params,
|
||||
raw: req,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 包装 Express Response 为 PluginHttpResponse
|
||||
*/
|
||||
function wrapResponse (res: Response): PluginHttpResponse {
|
||||
const wrapped: PluginHttpResponse = {
|
||||
status (code: number) {
|
||||
res.status(code);
|
||||
return wrapped;
|
||||
},
|
||||
json (data: unknown) {
|
||||
res.json(data);
|
||||
},
|
||||
send (data: string | Buffer) {
|
||||
res.send(data);
|
||||
},
|
||||
setHeader (name: string, value: string) {
|
||||
res.setHeader(name, value);
|
||||
return wrapped;
|
||||
},
|
||||
sendFile (filePath: string) {
|
||||
res.sendFile(filePath);
|
||||
},
|
||||
redirect (url: string) {
|
||||
res.redirect(url);
|
||||
},
|
||||
raw: res,
|
||||
};
|
||||
return wrapped;
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件路由注册器实现
|
||||
* 为每个插件创建独立的路由注册器,收集路由定义
|
||||
*/
|
||||
export class PluginRouterRegistryImpl implements PluginRouterRegistry {
|
||||
private apiRoutes: PluginApiRouteDefinition[] = [];
|
||||
private pageDefinitions: PluginPageDefinition[] = [];
|
||||
private staticRoutes: Array<{ urlPath: string; localPath: string; }> = [];
|
||||
|
||||
constructor (
|
||||
private readonly pluginId: string,
|
||||
private readonly pluginPath: string
|
||||
) { }
|
||||
|
||||
// ==================== API 路由注册 ====================
|
||||
|
||||
api (method: HttpMethod, routePath: string, handler: PluginRequestHandler): void {
|
||||
this.apiRoutes.push({ method, path: routePath, handler });
|
||||
}
|
||||
|
||||
get (routePath: string, handler: PluginRequestHandler): void {
|
||||
this.api('get', routePath, handler);
|
||||
}
|
||||
|
||||
post (routePath: string, handler: PluginRequestHandler): void {
|
||||
this.api('post', routePath, handler);
|
||||
}
|
||||
|
||||
put (routePath: string, handler: PluginRequestHandler): void {
|
||||
this.api('put', routePath, handler);
|
||||
}
|
||||
|
||||
delete (routePath: string, handler: PluginRequestHandler): void {
|
||||
this.api('delete', routePath, handler);
|
||||
}
|
||||
|
||||
// ==================== 页面注册 ====================
|
||||
|
||||
page (pageDef: PluginPageDefinition): void {
|
||||
this.pageDefinitions.push(pageDef);
|
||||
}
|
||||
|
||||
pages (pageDefs: PluginPageDefinition[]): void {
|
||||
this.pageDefinitions.push(...pageDefs);
|
||||
}
|
||||
|
||||
// ==================== 静态资源 ====================
|
||||
|
||||
static (urlPath: string, localPath: string): void {
|
||||
// 如果是相对路径,则相对于插件目录
|
||||
const absolutePath = path.isAbsolute(localPath)
|
||||
? localPath
|
||||
: path.join(this.pluginPath, localPath);
|
||||
this.staticRoutes.push({ urlPath, localPath: absolutePath });
|
||||
}
|
||||
|
||||
// ==================== 构建路由 ====================
|
||||
|
||||
/**
|
||||
* 构建 Express Router(用于 API 路由)
|
||||
*/
|
||||
buildApiRouter (): Router {
|
||||
const router = Router();
|
||||
|
||||
// 注册静态文件路由
|
||||
for (const { urlPath, localPath } of this.staticRoutes) {
|
||||
router.use(urlPath, expressStatic(localPath));
|
||||
}
|
||||
|
||||
// 注册 API 路由
|
||||
for (const route of this.apiRoutes) {
|
||||
const handler = this.wrapHandler(route.handler);
|
||||
switch (route.method) {
|
||||
case 'get':
|
||||
router.get(route.path, handler);
|
||||
break;
|
||||
case 'post':
|
||||
router.post(route.path, handler);
|
||||
break;
|
||||
case 'put':
|
||||
router.put(route.path, handler);
|
||||
break;
|
||||
case 'delete':
|
||||
router.delete(route.path, handler);
|
||||
break;
|
||||
case 'patch':
|
||||
router.patch(route.path, handler);
|
||||
break;
|
||||
case 'all':
|
||||
router.all(route.path, handler);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
/**
|
||||
* 包装处理器,添加错误处理和请求/响应包装
|
||||
*/
|
||||
private wrapHandler (handler: PluginRequestHandler): (req: Request, res: Response, next: NextFunction) => void {
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const wrappedReq = wrapRequest(req);
|
||||
const wrappedRes = wrapResponse(res);
|
||||
await handler(wrappedReq, wrappedRes, next);
|
||||
} catch (error: any) {
|
||||
console.error(`[Plugin: ${this.pluginId}] Route error:`, error);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
code: -1,
|
||||
message: `Plugin error: ${error.message || 'Unknown error'}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== 查询方法 ====================
|
||||
|
||||
/**
|
||||
* 检查是否有注册的 API 路由
|
||||
*/
|
||||
hasApiRoutes (): boolean {
|
||||
return this.apiRoutes.length > 0 || this.staticRoutes.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否有注册的页面
|
||||
*/
|
||||
hasPages (): boolean {
|
||||
return this.pageDefinitions.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有注册的页面定义
|
||||
*/
|
||||
getPages (): PluginPageDefinition[] {
|
||||
return [...this.pageDefinitions];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件 ID
|
||||
*/
|
||||
getPluginId (): string {
|
||||
return this.pluginId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件路径
|
||||
*/
|
||||
getPluginPath (): string {
|
||||
return this.pluginPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空路由(用于插件卸载)
|
||||
*/
|
||||
clear (): void {
|
||||
this.apiRoutes = [];
|
||||
this.pageDefinitions = [];
|
||||
this.staticRoutes = [];
|
||||
}
|
||||
}
|
||||
347
packages/napcat-onebot/network/plugin/types.ts
Normal file
347
packages/napcat-onebot/network/plugin/types.ts
Normal file
@@ -0,0 +1,347 @@
|
||||
import { NapCatCore } from 'napcat-core';
|
||||
import { NapCatOneBot11Adapter, OB11Message } from '@/napcat-onebot/index';
|
||||
import { ActionMap } from '@/napcat-onebot/action';
|
||||
import { OB11EmitEventContent } from '@/napcat-onebot/network/index';
|
||||
import { NetworkAdapterConfig } from '@/napcat-onebot/config/config';
|
||||
|
||||
// ==================== 插件包信息 ====================
|
||||
|
||||
export interface PluginPackageJson {
|
||||
name?: string;
|
||||
plugin?: string;
|
||||
version?: string;
|
||||
main?: string;
|
||||
description?: string;
|
||||
author?: string;
|
||||
}
|
||||
|
||||
// ==================== 插件配置 Schema ====================
|
||||
|
||||
export interface PluginConfigItem {
|
||||
key: string;
|
||||
type: 'string' | 'number' | 'boolean' | 'select' | 'multi-select' | 'html' | 'text';
|
||||
label: string;
|
||||
description?: string;
|
||||
default?: unknown;
|
||||
options?: { label: string; value: string | number; }[];
|
||||
placeholder?: string;
|
||||
/** 标记此字段为响应式:值变化时触发 schema 刷新 */
|
||||
reactive?: boolean;
|
||||
/** 是否隐藏此字段 */
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
export type PluginConfigSchema = PluginConfigItem[];
|
||||
|
||||
// ==================== NapCatConfig 静态接口 ====================
|
||||
|
||||
/** NapCatConfig 类的静态方法接口(用于 typeof NapCatConfig) */
|
||||
export interface INapCatConfigStatic {
|
||||
text (key: string, label: string, defaultValue?: string, description?: string, reactive?: boolean): PluginConfigItem;
|
||||
number (key: string, label: string, defaultValue?: number, description?: string, reactive?: boolean): PluginConfigItem;
|
||||
boolean (key: string, label: string, defaultValue?: boolean, description?: string, reactive?: boolean): PluginConfigItem;
|
||||
select (key: string, label: string, options: { label: string; value: string | number; }[], defaultValue?: string | number, description?: string, reactive?: boolean): PluginConfigItem;
|
||||
multiSelect (key: string, label: string, options: { label: string; value: string | number; }[], defaultValue?: (string | number)[], description?: string, reactive?: boolean): PluginConfigItem;
|
||||
html (content: string): PluginConfigItem;
|
||||
plainText (content: string): PluginConfigItem;
|
||||
combine (...items: PluginConfigItem[]): PluginConfigSchema;
|
||||
}
|
||||
|
||||
/** NapCatConfig 类型(包含静态方法) */
|
||||
export type NapCatConfigClass = INapCatConfigStatic;
|
||||
|
||||
// ==================== 插件路由相关类型(包装层,不直接依赖 express) ====================
|
||||
|
||||
/** HTTP 请求对象(包装类型) */
|
||||
export interface PluginHttpRequest {
|
||||
/** 请求路径 */
|
||||
path: string;
|
||||
/** 请求方法 */
|
||||
method: string;
|
||||
/** 查询参数 */
|
||||
query: Record<string, string | string[] | undefined>;
|
||||
/** 请求体 */
|
||||
body: unknown;
|
||||
/** 请求头 */
|
||||
headers: Record<string, string | string[] | undefined>;
|
||||
/** 路由参数 */
|
||||
params: Record<string, string>;
|
||||
/** 原始请求对象(用于高级用法) */
|
||||
raw: unknown;
|
||||
}
|
||||
|
||||
/** HTTP 响应对象(包装类型) */
|
||||
export interface PluginHttpResponse {
|
||||
/** 设置状态码 */
|
||||
status (code: number): PluginHttpResponse;
|
||||
/** 发送 JSON 响应 */
|
||||
json (data: unknown): void;
|
||||
/** 发送文本响应 */
|
||||
send (data: string | Buffer): void;
|
||||
/** 设置响应头 */
|
||||
setHeader (name: string, value: string): PluginHttpResponse;
|
||||
/** 发送文件 */
|
||||
sendFile (filePath: string): void;
|
||||
/** 重定向 */
|
||||
redirect (url: string): void;
|
||||
/** 原始响应对象(用于高级用法) */
|
||||
raw: unknown;
|
||||
}
|
||||
|
||||
/** 下一步函数类型 */
|
||||
export type PluginNextFunction = (err?: unknown) => void;
|
||||
|
||||
/** 插件请求处理器类型 */
|
||||
export type PluginRequestHandler = (
|
||||
req: PluginHttpRequest,
|
||||
res: PluginHttpResponse,
|
||||
next: PluginNextFunction
|
||||
) => void | Promise<void>;
|
||||
|
||||
/** HTTP 方法类型 */
|
||||
export type HttpMethod = 'get' | 'post' | 'put' | 'delete' | 'patch' | 'all';
|
||||
|
||||
/** 插件 API 路由定义 */
|
||||
export interface PluginApiRouteDefinition {
|
||||
/** HTTP 方法 */
|
||||
method: HttpMethod;
|
||||
/** 路由路径(相对于插件路由前缀) */
|
||||
path: string;
|
||||
/** 请求处理器 */
|
||||
handler: PluginRequestHandler;
|
||||
}
|
||||
|
||||
/** 插件页面定义 */
|
||||
export interface PluginPageDefinition {
|
||||
/** 页面路径(用于路由,如 'settings') */
|
||||
path: string;
|
||||
/** 页面标题(显示在 Tab 上) */
|
||||
title: string;
|
||||
/** 页面图标(可选,支持 emoji 或图标名) */
|
||||
icon?: string;
|
||||
/** 页面 HTML 文件路径(相对于插件目录) */
|
||||
htmlFile: string;
|
||||
/** 页面描述 */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** 插件路由注册器 */
|
||||
export interface PluginRouterRegistry {
|
||||
// ==================== API 路由注册 ====================
|
||||
|
||||
/**
|
||||
* 注册单个 API 路由
|
||||
* @param method HTTP 方法
|
||||
* @param path 路由路径
|
||||
* @param handler 请求处理器
|
||||
*/
|
||||
api (method: HttpMethod, path: string, handler: PluginRequestHandler): void;
|
||||
/** 注册 GET API */
|
||||
get (path: string, handler: PluginRequestHandler): void;
|
||||
/** 注册 POST API */
|
||||
post (path: string, handler: PluginRequestHandler): void;
|
||||
/** 注册 PUT API */
|
||||
put (path: string, handler: PluginRequestHandler): void;
|
||||
/** 注册 DELETE API */
|
||||
delete (path: string, handler: PluginRequestHandler): void;
|
||||
|
||||
// ==================== 页面注册 ====================
|
||||
|
||||
/**
|
||||
* 注册插件页面
|
||||
* @param page 页面定义
|
||||
*/
|
||||
page (page: PluginPageDefinition): void;
|
||||
|
||||
/**
|
||||
* 注册多个插件页面
|
||||
* @param pages 页面定义数组
|
||||
*/
|
||||
pages (pages: PluginPageDefinition[]): void;
|
||||
|
||||
// ==================== 静态资源 ====================
|
||||
|
||||
/**
|
||||
* 提供静态文件服务
|
||||
* @param urlPath URL 路径
|
||||
* @param localPath 本地文件夹路径(相对于插件目录或绝对路径)
|
||||
*/
|
||||
static (urlPath: string, localPath: string): void;
|
||||
}
|
||||
|
||||
// ==================== 插件管理器接口 ====================
|
||||
|
||||
/** 插件管理器公共接口 */
|
||||
export interface IPluginManager {
|
||||
readonly config: NetworkAdapterConfig;
|
||||
getPluginPath (): string;
|
||||
getPluginConfig (): PluginStatusConfig;
|
||||
getAllPlugins (): PluginEntry[];
|
||||
getLoadedPlugins (): PluginEntry[];
|
||||
getPluginInfo (pluginId: string): PluginEntry | undefined;
|
||||
setPluginStatus (pluginId: string, enable: boolean): Promise<void>;
|
||||
loadPluginById (pluginId: string): Promise<boolean>;
|
||||
unregisterPlugin (pluginId: string): Promise<void>;
|
||||
uninstallPlugin (pluginId: string, cleanData?: boolean): Promise<void>;
|
||||
reloadPlugin (pluginId: string): Promise<boolean>;
|
||||
loadDirectoryPlugin (dirname: string): Promise<void>;
|
||||
getPluginDataPath (pluginId: string): string;
|
||||
getPluginConfigPath (pluginId: string): string;
|
||||
}
|
||||
|
||||
// ==================== 插件配置 UI 控制器 ====================
|
||||
|
||||
/** 插件配置 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, unknown>;
|
||||
}
|
||||
|
||||
// ==================== 插件日志接口 ====================
|
||||
|
||||
/**
|
||||
* 插件日志接口 - 简化的日志 API
|
||||
*/
|
||||
export interface PluginLogger {
|
||||
/** 普通日志 */
|
||||
log (...args: unknown[]): void;
|
||||
/** 调试日志 */
|
||||
debug (...args: unknown[]): void;
|
||||
/** 信息日志 */
|
||||
info (...args: unknown[]): void;
|
||||
/** 警告日志 */
|
||||
warn (...args: unknown[]): void;
|
||||
/** 错误日志 */
|
||||
error (...args: unknown[]): void;
|
||||
}
|
||||
|
||||
// ==================== 插件上下文 ====================
|
||||
|
||||
export interface NapCatPluginContext {
|
||||
core: NapCatCore;
|
||||
oneBot: NapCatOneBot11Adapter;
|
||||
actions: ActionMap;
|
||||
pluginName: string;
|
||||
pluginPath: string;
|
||||
configPath: string;
|
||||
dataPath: string;
|
||||
/** NapCatConfig 配置构建器 */
|
||||
NapCatConfig: NapCatConfigClass;
|
||||
adapterName: string;
|
||||
/** 插件管理器实例 */
|
||||
pluginManager: IPluginManager;
|
||||
/** 插件日志器 - 自动添加插件名称前缀 */
|
||||
logger: PluginLogger;
|
||||
/**
|
||||
* WebUI 路由注册器
|
||||
* 用于注册插件的 HTTP API 路由,路由将挂载到 /api/Plugin/ext/{pluginId}/
|
||||
*/
|
||||
router: PluginRouterRegistry;
|
||||
}
|
||||
|
||||
// ==================== 插件模块接口 ====================
|
||||
|
||||
export interface PluginModule<T extends OB11EmitEventContent = OB11EmitEventContent, C = unknown> {
|
||||
plugin_init: (ctx: NapCatPluginContext) => void | Promise<void>;
|
||||
plugin_onmessage?: (
|
||||
ctx: NapCatPluginContext,
|
||||
event: OB11Message,
|
||||
) => void | Promise<void>;
|
||||
plugin_onevent?: (
|
||||
ctx: NapCatPluginContext,
|
||||
event: T,
|
||||
) => void | Promise<void>;
|
||||
plugin_cleanup?: (
|
||||
ctx: NapCatPluginContext
|
||||
) => void | Promise<void>;
|
||||
plugin_config_schema?: PluginConfigSchema;
|
||||
plugin_config_ui?: PluginConfigSchema;
|
||||
plugin_get_config?: (ctx: NapCatPluginContext) => C | Promise<C>;
|
||||
plugin_set_config?: (ctx: NapCatPluginContext, config: C) => void | Promise<void>;
|
||||
/**
|
||||
* 配置界面控制器 - 当配置界面打开时调用
|
||||
* 返回清理函数,在界面关闭时调用
|
||||
*/
|
||||
plugin_config_controller?: (
|
||||
ctx: NapCatPluginContext,
|
||||
ui: PluginConfigUIController,
|
||||
initialConfig: Record<string, unknown>
|
||||
) => void | (() => void) | Promise<void | (() => void)>;
|
||||
/**
|
||||
* 响应式字段变化回调 - 当标记为 reactive 的字段值变化时调用
|
||||
*/
|
||||
plugin_on_config_change?: (
|
||||
ctx: NapCatPluginContext,
|
||||
ui: PluginConfigUIController,
|
||||
key: string,
|
||||
value: unknown,
|
||||
currentConfig: Record<string, unknown>
|
||||
) => void | Promise<void>;
|
||||
}
|
||||
|
||||
// ==================== 插件运行时状态 ====================
|
||||
|
||||
export type PluginRuntimeStatus = 'loaded' | 'error' | 'unloaded';
|
||||
|
||||
export interface PluginRuntime {
|
||||
/** 运行时状态 */
|
||||
status: PluginRuntimeStatus;
|
||||
/** 错误信息(当 status 为 'error' 时) */
|
||||
error?: string;
|
||||
/** 插件模块(当 status 为 'loaded' 时) */
|
||||
module?: PluginModule;
|
||||
/** 插件上下文(当 status 为 'loaded' 时) */
|
||||
context?: NapCatPluginContext;
|
||||
}
|
||||
|
||||
// ==================== 插件条目(统一管理所有插件) ====================
|
||||
|
||||
export interface PluginEntry {
|
||||
// ===== 基础信息 =====
|
||||
/** 插件 ID(包名或目录名) */
|
||||
id: string;
|
||||
/** 文件系统目录名 */
|
||||
fileId: string;
|
||||
/** 显示名称 */
|
||||
name?: string;
|
||||
/** 版本号 */
|
||||
version?: string;
|
||||
/** 描述 */
|
||||
description?: string;
|
||||
/** 作者 */
|
||||
author?: string;
|
||||
/** 插件目录路径 */
|
||||
pluginPath: string;
|
||||
/** 入口文件路径 */
|
||||
entryPath?: string;
|
||||
/** package.json 内容 */
|
||||
packageJson?: PluginPackageJson;
|
||||
|
||||
// ===== 状态 =====
|
||||
/** 是否启用(用户配置) */
|
||||
enable: boolean;
|
||||
/** 运行时是否已加载 */
|
||||
loaded: boolean;
|
||||
|
||||
// ===== 运行时 =====
|
||||
/** 运行时信息 */
|
||||
runtime: PluginRuntime;
|
||||
}
|
||||
|
||||
// ==================== 插件状态配置(持久化) ====================
|
||||
|
||||
export interface PluginStatusConfig {
|
||||
[key: string]: boolean; // key: pluginId, value: enabled
|
||||
}
|
||||
@@ -74,7 +74,7 @@ export class OB11WebSocketClientAdapter extends IOB11NetworkAdapter<WebsocketCli
|
||||
let isClosedByError = false;
|
||||
|
||||
this.connection = new WebSocket(this.config.url, {
|
||||
maxPayload: 1024 * 1024 * 1024,
|
||||
maxPayload: 50 * 1024 * 1024, // 50 MB
|
||||
handshakeTimeout: 2000,
|
||||
perMessageDeflate: false,
|
||||
headers: {
|
||||
|
||||
@@ -32,7 +32,7 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
|
||||
this.wsServer = new WebSocketServer({
|
||||
port: this.config.port,
|
||||
host: this.config.host === '0.0.0.0' ? '' : this.config.host,
|
||||
maxPayload: 1024 * 1024 * 1024,
|
||||
maxPayload: 50 * 1024 * 1024, // 50 MB
|
||||
});
|
||||
this.createServer(this.wsServer);
|
||||
}
|
||||
@@ -237,7 +237,7 @@ export class OB11WebSocketServerAdapter extends IOB11NetworkAdapter<WebsocketSer
|
||||
this.wsServer = new WebSocketServer({
|
||||
port: newConfig.port,
|
||||
host: newConfig.host === '0.0.0.0' ? '' : newConfig.host,
|
||||
maxPayload: 1024 * 1024 * 1024,
|
||||
maxPayload: 50 * 1024 * 1024, // 50 MB
|
||||
});
|
||||
this.createServer(this.wsServer);
|
||||
if (newConfig.enable) {
|
||||
|
||||
@@ -1,45 +1,236 @@
|
||||
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', 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);
|
||||
}
|
||||
|
||||
// ==================== 注册 WebUI 路由示例 ====================
|
||||
|
||||
// 注册静态资源目录(webui 目录下的文件可通过 /api/Plugin/ext/{pluginId}/static/ 访问)
|
||||
ctx.router.static('/static', 'webui');
|
||||
|
||||
// 注册 API 路由
|
||||
ctx.router.get('/status', (_req, res) => {
|
||||
const uptime = Date.now() - startTime;
|
||||
res.json({
|
||||
code: 0,
|
||||
data: {
|
||||
pluginName: ctx.pluginName,
|
||||
uptime,
|
||||
uptimeFormatted: formatUptime(uptime),
|
||||
config: currentConfig,
|
||||
platform: process.platform,
|
||||
arch: process.arch
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ctx.router.get('/config', (_req, res) => {
|
||||
res.json({
|
||||
code: 0,
|
||||
data: currentConfig
|
||||
});
|
||||
});
|
||||
|
||||
ctx.router.post('/config', (req, res) => {
|
||||
try {
|
||||
const newConfig = req.body as Partial<BuiltinPluginConfig>;
|
||||
Object.assign(currentConfig, newConfig);
|
||||
// 保存配置
|
||||
const configDir = path.dirname(ctx.configPath);
|
||||
if (!fs.existsSync(configDir)) {
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(ctx.configPath, JSON.stringify(currentConfig, null, 2), 'utf-8');
|
||||
res.json({ code: 0, message: 'Config saved successfully' });
|
||||
} catch (e: any) {
|
||||
res.status(500).json({ code: -1, message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 注册扩展页面
|
||||
ctx.router.page({
|
||||
path: 'dashboard',
|
||||
title: '插件仪表盘',
|
||||
icon: '📊',
|
||||
htmlFile: 'webui/dashboard.html',
|
||||
description: '查看内置插件的运行状态和配置'
|
||||
});
|
||||
|
||||
logger.info('WebUI 路由已注册: /api/Plugin/ext/' + ctx.pluginName);
|
||||
};
|
||||
|
||||
export const plugin_get_config: PluginModule['plugin_get_config'] = async () => {
|
||||
return currentConfig;
|
||||
};
|
||||
|
||||
export const plugin_set_config: PluginModule['plugin_set_config'] = async (ctx, config) => {
|
||||
currentConfig = config as BuiltinPluginConfig;
|
||||
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 +241,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 +263,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 +279,7 @@ async function sendMessage (event: OB11Message, message: string, adapter: string
|
||||
try {
|
||||
await actions.call('send_msg', params, adapter, config);
|
||||
} catch (error) {
|
||||
console.error('[Plugin: builtin] 发送消息失败:', error);
|
||||
logger?.error('发送消息失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "napcat-plugin-builtin",
|
||||
"plugin": "内置插件",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"main": "index.mjs",
|
||||
"description": "NapCat 内置插件",
|
||||
"author": "NapNeko",
|
||||
"dependencies": {
|
||||
"napcat-types": "0.0.3"
|
||||
"napcat-types": "0.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.1"
|
||||
|
||||
@@ -14,8 +14,10 @@ function copyToShellPlugin () {
|
||||
writeBundle () {
|
||||
try {
|
||||
const sourceDir = resolve(__dirname, 'dist');
|
||||
const targetDir = resolve(__dirname, '../napcat-shell/dist/plugins/builtin');
|
||||
const targetDir = resolve(__dirname, '../napcat-shell/dist/plugins/napcat-plugin-builtin');
|
||||
const packageJsonSource = resolve(__dirname, 'package.json');
|
||||
const webuiSourceDir = resolve(__dirname, 'webui');
|
||||
const webuiTargetDir = resolve(targetDir, 'webui');
|
||||
|
||||
// 确保目标目录存在
|
||||
if (!fs.existsSync(targetDir)) {
|
||||
@@ -44,6 +46,12 @@ function copyToShellPlugin () {
|
||||
copiedCount++;
|
||||
}
|
||||
|
||||
// 拷贝 webui 目录
|
||||
if (fs.existsSync(webuiSourceDir)) {
|
||||
copyDirRecursive(webuiSourceDir, webuiTargetDir);
|
||||
console.log(`[copy-to-shell] Copied webui directory to ${webuiTargetDir}`);
|
||||
}
|
||||
|
||||
console.log(`[copy-to-shell] Successfully copied ${copiedCount} file(s) to ${targetDir}`);
|
||||
} catch (error) {
|
||||
console.error('[copy-to-shell] Failed to copy files:', error);
|
||||
@@ -53,6 +61,26 @@ function copyToShellPlugin () {
|
||||
};
|
||||
}
|
||||
|
||||
// 递归复制目录
|
||||
function copyDirRecursive (src: string, dest: string) {
|
||||
if (!fs.existsSync(dest)) {
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
}
|
||||
|
||||
const entries = fs.readdirSync(src, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const srcPath = resolve(src, entry.name);
|
||||
const destPath = resolve(dest, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
copyDirRecursive(srcPath, destPath);
|
||||
} else {
|
||||
fs.copyFileSync(srcPath, destPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
conditions: ['node', 'default'],
|
||||
|
||||
414
packages/napcat-plugin-builtin/webui/dashboard.html
Normal file
414
packages/napcat-plugin-builtin/webui/dashboard.html
Normal file
@@ -0,0 +1,414 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>内置插件仪表盘</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg-primary: rgba(255, 255, 255, 0.4);
|
||||
--bg-secondary: rgba(255, 255, 255, 0.6);
|
||||
--bg-card: rgba(255, 255, 255, 0.5);
|
||||
--bg-item: rgba(0, 0, 0, 0.03);
|
||||
--text-primary: #1a1a1a;
|
||||
--text-secondary: #666;
|
||||
--text-muted: #999;
|
||||
--border-color: rgba(0, 0, 0, 0.06);
|
||||
--accent-color: #52525b;
|
||||
--accent-light: rgba(82, 82, 91, 0.1);
|
||||
--success-color: #17c964;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg-primary: rgba(0, 0, 0, 0.2);
|
||||
--bg-secondary: rgba(0, 0, 0, 0.3);
|
||||
--bg-card: rgba(255, 255, 255, 0.05);
|
||||
--bg-item: rgba(255, 255, 255, 0.05);
|
||||
--text-primary: #f5f5f5;
|
||||
--text-secondary: #a1a1a1;
|
||||
--text-muted: #666;
|
||||
--border-color: rgba(255, 255, 255, 0.1);
|
||||
--accent-light: rgba(82, 82, 91, 0.25);
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background: transparent;
|
||||
min-height: 100vh;
|
||||
padding: 16px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border-radius: 14px;
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.card-header .icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.card-header h2 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.status-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
background: var(--bg-item);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
border: 1px solid var(--border-color);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.status-item:hover {
|
||||
background: var(--accent-light);
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.status-item .label {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.status-item .value {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-color);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.status-item .value.success {
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.config-list {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.config-list li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 6px;
|
||||
background: var(--bg-item);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.config-list li:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.config-list .key {
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.config-list .value {
|
||||
color: var(--accent-color);
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
font-size: 12px;
|
||||
background: var(--accent-light);
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
max-width: 60%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
opacity: 0.9;
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 30px;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.loading::after {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid var(--accent-color);
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin-left: 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
background: rgba(243, 18, 96, 0.1);
|
||||
color: #f31260;
|
||||
padding: 12px 16px;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
border: 1px solid rgba(243, 18, 96, 0.2);
|
||||
}
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
margin-top: 16px;
|
||||
padding: 8px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2>NapCat 内置插件仪表盘</h2>
|
||||
</div>
|
||||
|
||||
<div id="content">
|
||||
<div class="loading">加载中</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2>当前配置</h2>
|
||||
</div>
|
||||
<div id="config-content">
|
||||
<div class="loading">加载中</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2>静态资源测试</h2>
|
||||
</div>
|
||||
<div id="static-content">
|
||||
<p style="color: var(--text-secondary); font-size: 13px; margin-bottom: 12px;">
|
||||
测试插件静态资源服务是否正常工作
|
||||
</p>
|
||||
<div class="actions" style="margin-top: 0;">
|
||||
<button class="btn btn-primary" onclick="testStaticResource()">
|
||||
获取 test.txt
|
||||
</button>
|
||||
</div>
|
||||
<div id="static-result" style="margin-top: 12px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>NapCat Builtin Plugin - WebUI 扩展页面演示</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 从 URL 参数获取 webui_token
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const webuiToken = urlParams.get('webui_token') || '';
|
||||
|
||||
// 插件自行管理 API 调用
|
||||
const apiBase = '/api/Plugin/ext/napcat-plugin-builtin';
|
||||
|
||||
// 封装 fetch,自动携带认证
|
||||
async function authFetch (url, options = {}) {
|
||||
const headers = options.headers || {};
|
||||
if (webuiToken) {
|
||||
headers['Authorization'] = `Bearer ${webuiToken}`;
|
||||
}
|
||||
return fetch(url, { ...options, headers });
|
||||
}
|
||||
|
||||
async function fetchStatus () {
|
||||
try {
|
||||
const response = await authFetch(`${apiBase}/status`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.code === 0) {
|
||||
renderStatus(result.data);
|
||||
} else {
|
||||
showError('获取状态失败: ' + result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError('请求失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchConfig () {
|
||||
try {
|
||||
const response = await authFetch(`${apiBase}/config`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.code === 0) {
|
||||
renderConfig(result.data);
|
||||
} else {
|
||||
showError('获取配置失败: ' + result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError('请求失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function renderStatus (data) {
|
||||
const content = document.getElementById('content');
|
||||
content.innerHTML = `
|
||||
<div class="status-grid">
|
||||
<div class="status-item">
|
||||
<div class="label">插件名称</div>
|
||||
<div class="value">${data.pluginName}</div>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<div class="label">运行时间</div>
|
||||
<div class="value success">${data.uptimeFormatted}</div>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<div class="label">运行平台</div>
|
||||
<div class="value">${data.platform}</div>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<div class="label">系统架构</div>
|
||||
<div class="value">${data.arch}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary" onclick="refresh()">
|
||||
刷新状态
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderConfig (config) {
|
||||
const content = document.getElementById('config-content');
|
||||
const items = Object.entries(config)
|
||||
.map(([key, value]) => `
|
||||
<li>
|
||||
<span class="key">${key}</span>
|
||||
<span class="value">${JSON.stringify(value)}</span>
|
||||
</li>
|
||||
`)
|
||||
.join('');
|
||||
|
||||
content.innerHTML = `
|
||||
<ul class="config-list">
|
||||
${items || '<li><span class="key">暂无配置</span></li>'}
|
||||
</ul>
|
||||
`;
|
||||
}
|
||||
|
||||
function showError (message) {
|
||||
const content = document.getElementById('content');
|
||||
content.innerHTML = `<div class="error">${message}</div>`;
|
||||
}
|
||||
|
||||
function refresh () {
|
||||
document.getElementById('content').innerHTML = '<div class="loading">加载中</div>';
|
||||
fetchStatus();
|
||||
fetchConfig();
|
||||
}
|
||||
|
||||
// 初始化
|
||||
refresh();
|
||||
|
||||
// 每 30 秒自动刷新
|
||||
setInterval(refresh, 30000);
|
||||
|
||||
// 测试静态资源
|
||||
async function testStaticResource () {
|
||||
const resultDiv = document.getElementById('static-result');
|
||||
resultDiv.innerHTML = '<div class="loading">加载中</div>';
|
||||
|
||||
try {
|
||||
const response = await authFetch(`${apiBase}/static/test.txt`);
|
||||
if (response.ok) {
|
||||
const text = await response.text();
|
||||
resultDiv.innerHTML = `
|
||||
<div style="background: var(--bg-item); border: 1px solid var(--border-color); border-radius: 8px; padding: 12px;">
|
||||
<div style="font-size: 11px; color: var(--success-color); margin-bottom: 8px;">静态资源访问成功</div>
|
||||
<pre style="font-family: Monaco, Consolas, monospace; font-size: 12px; color: var(--text-primary); white-space: pre-wrap; margin: 0;">${text}</pre>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
resultDiv.innerHTML = `<div class="error">请求失败: ${response.status} ${response.statusText}</div>`;
|
||||
}
|
||||
} catch (error) {
|
||||
resultDiv.innerHTML = `<div class="error">请求失败: ${error.message}</div>`;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
6
packages/napcat-plugin-builtin/webui/test.txt
Normal file
6
packages/napcat-plugin-builtin/webui/test.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
Hello from NapCat Builtin Plugin!
|
||||
|
||||
这是一个静态资源测试文件。
|
||||
如果你能看到这段文字,说明插件的静态资源服务正常工作。
|
||||
|
||||
时间戳: 2026-01-30
|
||||
@@ -1,3 +0,0 @@
|
||||
# Example Plugin
|
||||
## Install
|
||||
安装只需要将dist产物 `index.mjs` 目录放入 napcat根目录下`plugins/example`,如果没有这个目录请创建。
|
||||
@@ -1,22 +0,0 @@
|
||||
import type { createActionMap } from 'napcat-types/dist/napcat-onebot/action/index.js';
|
||||
import { EventType } from 'napcat-types/dist/napcat-onebot/event/index.js';
|
||||
import type { PluginModule } from 'napcat-types/dist/napcat-onebot/network/plugin-manger';
|
||||
|
||||
/**
|
||||
* 导入 napcat 包时候不使用 @/napcat...,直接使用 napcat...
|
||||
* 因为 @/napcat... 会导致打包时包含整个 napcat 包,而不是只包含需要的部分
|
||||
*/
|
||||
|
||||
// action 作为参数传递时请用这个
|
||||
let actionMap: ReturnType<typeof createActionMap> | undefined = undefined;
|
||||
|
||||
const plugin_init: PluginModule['plugin_init'] = async (_core, _obContext, _actions, _instance) => {
|
||||
console.log('[Plugin: example] 插件已初始化');
|
||||
actionMap = _actions;
|
||||
};
|
||||
const plugin_onmessage: PluginModule['plugin_onmessage'] = async (adapter, _core, _obCtx, event, actions, instance) => {
|
||||
if (event.post_type === EventType.MESSAGE && event.raw_message.includes('ping')) {
|
||||
await actions.get('send_group_msg')?.handle({ group_id: String(event.group_id), message: 'pong' }, adapter, instance.config);
|
||||
}
|
||||
};
|
||||
export { plugin_init, plugin_onmessage, actionMap };
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"name": "napcat-plugin",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"main": "index.mjs",
|
||||
"description": "一个高级的 NapCat 插件示例",
|
||||
"dependencies": {
|
||||
"napcat-types": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.1"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build"
|
||||
}
|
||||
}
|
||||
@@ -1,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()],
|
||||
});
|
||||
51
packages/napcat-protocol/action/index.ts
Normal file
51
packages/napcat-protocol/action/index.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { NapCatCore } from 'napcat-core';
|
||||
import { NapCatProtocolResponse } from '@/napcat-protocol/types';
|
||||
|
||||
// 前向声明类型,避免循环依赖
|
||||
import type { NapCatProtocolAdapter } from '@/napcat-protocol/index';
|
||||
|
||||
// Action 基类
|
||||
export abstract class BaseAction<PayloadType = unknown, ReturnType = unknown> {
|
||||
abstract actionName: string;
|
||||
protected core: NapCatCore;
|
||||
protected adapter: NapCatProtocolAdapter;
|
||||
|
||||
constructor (adapter: NapCatProtocolAdapter, core: NapCatCore) {
|
||||
this.adapter = adapter;
|
||||
this.core = core;
|
||||
}
|
||||
|
||||
protected abstract _handle (payload: PayloadType): Promise<ReturnType>;
|
||||
|
||||
async handle (payload: PayloadType): Promise<NapCatProtocolResponse<ReturnType>> {
|
||||
try {
|
||||
const result = await this._handle(payload);
|
||||
return {
|
||||
status: 'ok',
|
||||
retcode: 0,
|
||||
data: result,
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
status: 'failed',
|
||||
retcode: -1,
|
||||
data: null,
|
||||
message: e instanceof Error ? e.message : String(e),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Action 映射类型
|
||||
export type ActionMap = Map<string, BaseAction<unknown, unknown>>;
|
||||
|
||||
// 创建 Action 映射
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export function createActionMap (_adapter: NapCatProtocolAdapter, _core: NapCatCore): ActionMap {
|
||||
const actionMap = new Map<string, BaseAction<unknown, unknown>>();
|
||||
|
||||
// 这里可以注册各种 Action
|
||||
// 例如: actionMap.set('send_msg', new SendMsgAction(adapter, core));
|
||||
|
||||
return actionMap;
|
||||
}
|
||||
49
packages/napcat-protocol/api/index.ts
Normal file
49
packages/napcat-protocol/api/index.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { NapCatCore } from 'napcat-core';
|
||||
import { NapCatProtocolAdapter } from '@/napcat-protocol/index';
|
||||
|
||||
// NapCat Protocol API 基类
|
||||
export abstract class NapCatProtocolApiBase {
|
||||
protected adapter: NapCatProtocolAdapter;
|
||||
protected core: NapCatCore;
|
||||
|
||||
constructor (adapter: NapCatProtocolAdapter, core: NapCatCore) {
|
||||
this.adapter = adapter;
|
||||
this.core = core;
|
||||
}
|
||||
}
|
||||
|
||||
// 消息 API
|
||||
export class NapCatProtocolMsgApi extends NapCatProtocolApiBase {
|
||||
constructor (adapter: NapCatProtocolAdapter, core: NapCatCore) {
|
||||
super(adapter, core);
|
||||
}
|
||||
|
||||
// 消息相关 API 方法可以在这里实现
|
||||
}
|
||||
|
||||
// 用户 API
|
||||
export class NapCatProtocolUserApi extends NapCatProtocolApiBase {
|
||||
constructor (adapter: NapCatProtocolAdapter, core: NapCatCore) {
|
||||
super(adapter, core);
|
||||
}
|
||||
|
||||
// 用户相关 API 方法可以在这里实现
|
||||
}
|
||||
|
||||
// 群组 API
|
||||
export class NapCatProtocolGroupApi extends NapCatProtocolApiBase {
|
||||
constructor (adapter: NapCatProtocolAdapter, core: NapCatCore) {
|
||||
super(adapter, core);
|
||||
}
|
||||
|
||||
// 群组相关 API 方法可以在这里实现
|
||||
}
|
||||
|
||||
// 好友 API
|
||||
export class NapCatProtocolFriendApi extends NapCatProtocolApiBase {
|
||||
constructor (adapter: NapCatProtocolAdapter, core: NapCatCore) {
|
||||
super(adapter, core);
|
||||
}
|
||||
|
||||
// 好友相关 API 方法可以在这里实现
|
||||
}
|
||||
66
packages/napcat-protocol/config/config.ts
Normal file
66
packages/napcat-protocol/config/config.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { Type, Static } from '@sinclair/typebox';
|
||||
import Ajv from 'ajv';
|
||||
|
||||
// WebSocket 服务端配置
|
||||
const WebsocketServerConfigSchema = Type.Object({
|
||||
name: Type.String({ default: 'napcat-ws-server' }),
|
||||
enable: Type.Boolean({ default: false }),
|
||||
host: Type.String({ default: '127.0.0.1' }),
|
||||
port: Type.Number({ default: 6700 }),
|
||||
token: Type.String({ default: '' }),
|
||||
heartInterval: Type.Number({ default: 30000 }),
|
||||
debug: Type.Boolean({ default: false }),
|
||||
});
|
||||
|
||||
// WebSocket 客户端配置
|
||||
const WebsocketClientConfigSchema = Type.Object({
|
||||
name: Type.String({ default: 'napcat-ws-client' }),
|
||||
enable: Type.Boolean({ default: false }),
|
||||
url: Type.String({ default: 'ws://localhost:6701' }),
|
||||
token: Type.String({ default: '' }),
|
||||
reconnectInterval: Type.Number({ default: 5000 }),
|
||||
heartInterval: Type.Number({ default: 30000 }),
|
||||
debug: Type.Boolean({ default: false }),
|
||||
});
|
||||
|
||||
// HTTP 服务端配置
|
||||
const HttpServerConfigSchema = Type.Object({
|
||||
name: Type.String({ default: 'napcat-http-server' }),
|
||||
enable: Type.Boolean({ default: false }),
|
||||
host: Type.String({ default: '127.0.0.1' }),
|
||||
port: Type.Number({ default: 6702 }),
|
||||
token: Type.String({ default: '' }),
|
||||
enableCors: Type.Boolean({ default: true }),
|
||||
debug: Type.Boolean({ default: false }),
|
||||
});
|
||||
|
||||
// 网络配置
|
||||
const NetworkConfigSchema = Type.Object({
|
||||
httpServers: Type.Array(HttpServerConfigSchema, { default: [] }),
|
||||
websocketServers: Type.Array(WebsocketServerConfigSchema, { default: [] }),
|
||||
websocketClients: Type.Array(WebsocketClientConfigSchema, { default: [] }),
|
||||
}, { default: {} });
|
||||
|
||||
// NapCat Protocol 主配置 - 默认关闭
|
||||
export const NapCatProtocolConfigSchema = Type.Object({
|
||||
enable: Type.Boolean({ default: false }), // 默认关闭
|
||||
network: NetworkConfigSchema,
|
||||
});
|
||||
|
||||
export type NapCatProtocolConfig = Static<typeof NapCatProtocolConfigSchema>;
|
||||
export type HttpServerConfig = Static<typeof HttpServerConfigSchema>;
|
||||
export type WebsocketServerConfig = Static<typeof WebsocketServerConfigSchema>;
|
||||
export type WebsocketClientConfig = Static<typeof WebsocketClientConfigSchema>;
|
||||
|
||||
export type NetworkAdapterConfig = HttpServerConfig | WebsocketServerConfig | WebsocketClientConfig;
|
||||
export type NetworkConfigKey = keyof NapCatProtocolConfig['network'];
|
||||
|
||||
export function loadConfig (config: Partial<NapCatProtocolConfig>): NapCatProtocolConfig {
|
||||
const ajv = new Ajv({ useDefaults: true, coerceTypes: true });
|
||||
const validate = ajv.compile(NapCatProtocolConfigSchema);
|
||||
const valid = validate(config);
|
||||
if (!valid) {
|
||||
throw new Error(ajv.errorsText(validate.errors));
|
||||
}
|
||||
return config as NapCatProtocolConfig;
|
||||
}
|
||||
11
packages/napcat-protocol/config/index.ts
Normal file
11
packages/napcat-protocol/config/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { ConfigBase } from 'napcat-core';
|
||||
import { NapCatCore } from 'napcat-core';
|
||||
import { NapCatProtocolConfig, NapCatProtocolConfigSchema } from './config';
|
||||
|
||||
export class NapCatProtocolConfigLoader extends ConfigBase<NapCatProtocolConfig> {
|
||||
constructor (core: NapCatCore, configPath: string) {
|
||||
super('napcat_protocol', core, configPath, NapCatProtocolConfigSchema);
|
||||
}
|
||||
}
|
||||
|
||||
export * from './config';
|
||||
66
packages/napcat-protocol/event/NapCatProtocolEvent.ts
Normal file
66
packages/napcat-protocol/event/NapCatProtocolEvent.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { NapCatCore } from 'napcat-core';
|
||||
|
||||
// NapCat Protocol 事件基类
|
||||
export abstract class NapCatProtocolEvent {
|
||||
protected core: NapCatCore;
|
||||
public time: number;
|
||||
public self_id: number;
|
||||
public post_type: string;
|
||||
|
||||
constructor (core: NapCatCore) {
|
||||
this.core = core;
|
||||
this.time = Math.floor(Date.now() / 1000);
|
||||
this.self_id = parseInt(core.selfInfo.uin);
|
||||
this.post_type = 'event';
|
||||
}
|
||||
|
||||
abstract toJSON (): Record<string, unknown>;
|
||||
}
|
||||
|
||||
// 消息事件基类
|
||||
export abstract class NapCatProtocolMessageEvent extends NapCatProtocolEvent {
|
||||
public message_type: 'private' | 'group';
|
||||
public message_id: number;
|
||||
public user_id: number;
|
||||
|
||||
constructor (core: NapCatCore, messageType: 'private' | 'group', messageId: number, userId: number) {
|
||||
super(core);
|
||||
this.post_type = 'message';
|
||||
this.message_type = messageType;
|
||||
this.message_id = messageId;
|
||||
this.user_id = userId;
|
||||
}
|
||||
}
|
||||
|
||||
// 通知事件基类
|
||||
export abstract class NapCatProtocolNoticeEvent extends NapCatProtocolEvent {
|
||||
public notice_type: string;
|
||||
|
||||
constructor (core: NapCatCore, noticeType: string) {
|
||||
super(core);
|
||||
this.post_type = 'notice';
|
||||
this.notice_type = noticeType;
|
||||
}
|
||||
}
|
||||
|
||||
// 请求事件基类
|
||||
export abstract class NapCatProtocolRequestEvent extends NapCatProtocolEvent {
|
||||
public request_type: string;
|
||||
|
||||
constructor (core: NapCatCore, requestType: string) {
|
||||
super(core);
|
||||
this.post_type = 'request';
|
||||
this.request_type = requestType;
|
||||
}
|
||||
}
|
||||
|
||||
// 元事件基类
|
||||
export abstract class NapCatProtocolMetaEvent extends NapCatProtocolEvent {
|
||||
public meta_event_type: string;
|
||||
|
||||
constructor (core: NapCatCore, metaEventType: string) {
|
||||
super(core);
|
||||
this.post_type = 'meta_event';
|
||||
this.meta_event_type = metaEventType;
|
||||
}
|
||||
}
|
||||
1
packages/napcat-protocol/event/index.ts
Normal file
1
packages/napcat-protocol/event/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './NapCatProtocolEvent';
|
||||
104
packages/napcat-protocol/index.ts
Normal file
104
packages/napcat-protocol/index.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import {
|
||||
InstanceContext,
|
||||
NapCatCore,
|
||||
} from 'napcat-core';
|
||||
import { NapCatProtocolConfigLoader, NapCatProtocolConfig } from '@/napcat-protocol/config';
|
||||
import { NapCatPathWrapper } from 'napcat-common/src/path';
|
||||
import {
|
||||
NapCatProtocolNetworkManager,
|
||||
} from '@/napcat-protocol/network';
|
||||
import {
|
||||
NapCatProtocolMsgApi,
|
||||
NapCatProtocolUserApi,
|
||||
NapCatProtocolGroupApi,
|
||||
NapCatProtocolFriendApi,
|
||||
} from '@/napcat-protocol/api';
|
||||
import { ActionMap, createActionMap } from '@/napcat-protocol/action';
|
||||
|
||||
interface ApiListType {
|
||||
MsgApi: NapCatProtocolMsgApi;
|
||||
UserApi: NapCatProtocolUserApi;
|
||||
GroupApi: NapCatProtocolGroupApi;
|
||||
FriendApi: NapCatProtocolFriendApi;
|
||||
}
|
||||
|
||||
// NapCat Protocol 适配器 - NapCat 私有 Bot 协议实现
|
||||
export class NapCatProtocolAdapter {
|
||||
readonly core: NapCatCore;
|
||||
readonly context: InstanceContext;
|
||||
|
||||
configLoader: NapCatProtocolConfigLoader;
|
||||
public apis: ApiListType;
|
||||
networkManager: NapCatProtocolNetworkManager;
|
||||
actions: ActionMap;
|
||||
|
||||
constructor (core: NapCatCore, context: InstanceContext, pathWrapper: NapCatPathWrapper) {
|
||||
this.core = core;
|
||||
this.context = context;
|
||||
this.configLoader = new NapCatProtocolConfigLoader(core, pathWrapper.configPath);
|
||||
this.apis = {
|
||||
MsgApi: new NapCatProtocolMsgApi(this, core),
|
||||
UserApi: new NapCatProtocolUserApi(this, core),
|
||||
GroupApi: new NapCatProtocolGroupApi(this, core),
|
||||
FriendApi: new NapCatProtocolFriendApi(this, core),
|
||||
} as const;
|
||||
this.actions = createActionMap(this, core);
|
||||
this.networkManager = new NapCatProtocolNetworkManager();
|
||||
}
|
||||
|
||||
// 检查协议是否启用
|
||||
isEnabled (): boolean {
|
||||
return this.configLoader.configData.enable;
|
||||
}
|
||||
|
||||
async createProtocolLog (config: NapCatProtocolConfig) {
|
||||
let log = '[NapCat Protocol] 配置加载\n';
|
||||
log += `协议状态: ${config.enable ? '已启用' : '已禁用'}\n`;
|
||||
|
||||
if (config.enable) {
|
||||
for (const key of config.network.httpServers) {
|
||||
log += `HTTP服务: ${key.host}:${key.port}, : ${key.enable ? '已启动' : '未启动'}\n`;
|
||||
}
|
||||
for (const key of config.network.websocketServers) {
|
||||
log += `WebSocket服务: ${key.host}:${key.port}, : ${key.enable ? '已启动' : '未启动'}\n`;
|
||||
}
|
||||
for (const key of config.network.websocketClients) {
|
||||
log += `WebSocket客户端: ${key.url}, : ${key.enable ? '已启动' : '未启动'}\n`;
|
||||
}
|
||||
}
|
||||
return log;
|
||||
}
|
||||
|
||||
async initProtocol () {
|
||||
const config = this.configLoader.configData;
|
||||
|
||||
// 如果协议未启用,直接返回
|
||||
if (!config.enable) {
|
||||
this.context.logger.log('[NapCat Protocol] 协议未启用,跳过初始化');
|
||||
return;
|
||||
}
|
||||
|
||||
const selfInfo = this.core.selfInfo;
|
||||
const serviceInfo = await this.createProtocolLog(config);
|
||||
this.context.logger.log(`[Notice] ${serviceInfo}`);
|
||||
|
||||
// 注册网络适配器
|
||||
// 这里可以根据配置注册不同的网络适配器
|
||||
// 例如: WebSocket Server, WebSocket Client, HTTP Server 等
|
||||
|
||||
await this.networkManager.openAllAdapters();
|
||||
|
||||
this.context.logger.log(`[NapCat Protocol] 初始化完成,Bot: ${selfInfo.uin}`);
|
||||
}
|
||||
|
||||
async close () {
|
||||
await this.networkManager.closeAllAdapters();
|
||||
this.context.logger.log('[NapCat Protocol] 已关闭所有网络适配器');
|
||||
}
|
||||
}
|
||||
|
||||
export * from './types/index';
|
||||
export * from './api/index';
|
||||
export * from './event/index';
|
||||
export * from './config/index';
|
||||
export * from './network/index';
|
||||
37
packages/napcat-protocol/network/adapter.ts
Normal file
37
packages/napcat-protocol/network/adapter.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { NetworkAdapterConfig } from '@/napcat-protocol/config/config';
|
||||
import { LogWrapper } from 'napcat-core/helper/log';
|
||||
import { NapCatCore } from 'napcat-core';
|
||||
import { NapCatProtocolAdapter } from '@/napcat-protocol/index';
|
||||
import { ActionMap } from '@/napcat-protocol/action';
|
||||
import { NapCatProtocolEmitEventContent, NapCatProtocolNetworkReloadType } from '@/napcat-protocol/network/index';
|
||||
|
||||
export abstract class INapCatProtocolNetworkAdapter<CT extends NetworkAdapterConfig> {
|
||||
name: string;
|
||||
isEnable: boolean = false;
|
||||
config: CT;
|
||||
readonly logger: LogWrapper;
|
||||
readonly core: NapCatCore;
|
||||
readonly protocolContext: NapCatProtocolAdapter;
|
||||
readonly actions: ActionMap;
|
||||
|
||||
constructor (name: string, config: CT, core: NapCatCore, protocolContext: NapCatProtocolAdapter, actions: ActionMap) {
|
||||
this.name = name;
|
||||
this.config = structuredClone(config);
|
||||
this.core = core;
|
||||
this.protocolContext = protocolContext;
|
||||
this.actions = actions;
|
||||
this.logger = core.context.logger;
|
||||
}
|
||||
|
||||
abstract onEvent<T extends NapCatProtocolEmitEventContent> (event: T): Promise<void>;
|
||||
|
||||
abstract open (): void | Promise<void>;
|
||||
|
||||
abstract close (): void | Promise<void>;
|
||||
|
||||
abstract reload (config: unknown): NapCatProtocolNetworkReloadType | Promise<NapCatProtocolNetworkReloadType>;
|
||||
|
||||
get isActive (): boolean {
|
||||
return this.isEnable;
|
||||
}
|
||||
}
|
||||
112
packages/napcat-protocol/network/index.ts
Normal file
112
packages/napcat-protocol/network/index.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { NapCatProtocolEvent } from '@/napcat-protocol/event/NapCatProtocolEvent';
|
||||
import { NapCatProtocolMessage } from '@/napcat-protocol/types';
|
||||
import { NetworkAdapterConfig } from '@/napcat-protocol/config/config';
|
||||
import { INapCatProtocolNetworkAdapter } from '@/napcat-protocol/network/adapter';
|
||||
|
||||
export type NapCatProtocolEmitEventContent = NapCatProtocolEvent | NapCatProtocolMessage;
|
||||
|
||||
export enum NapCatProtocolNetworkReloadType {
|
||||
Normal = 0,
|
||||
ConfigChange = 1,
|
||||
NetWorkReload = 2,
|
||||
NetWorkClose = 3,
|
||||
NetWorkOpen = 4,
|
||||
}
|
||||
|
||||
export class NapCatProtocolNetworkManager {
|
||||
adapters: Map<string, INapCatProtocolNetworkAdapter<NetworkAdapterConfig>> = new Map();
|
||||
|
||||
async openAllAdapters () {
|
||||
return Promise.all(Array.from(this.adapters.values()).map(adapter => adapter.open()));
|
||||
}
|
||||
|
||||
async emitEvent (event: NapCatProtocolEmitEventContent) {
|
||||
return Promise.all(Array.from(this.adapters.values()).map(async adapter => {
|
||||
if (adapter.isActive) {
|
||||
return await adapter.onEvent(event);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
async emitEvents (events: NapCatProtocolEmitEventContent[]) {
|
||||
return Promise.all(events.map(event => this.emitEvent(event)));
|
||||
}
|
||||
|
||||
async emitEventByName (names: string[], event: NapCatProtocolEmitEventContent) {
|
||||
return Promise.all(names.map(async name => {
|
||||
const adapter = this.adapters.get(name);
|
||||
if (adapter && adapter.isActive) {
|
||||
return await adapter.onEvent(event);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
async emitEventByNames (map: Map<string, NapCatProtocolEmitEventContent>) {
|
||||
return Promise.all(Array.from(map.entries()).map(async ([name, event]) => {
|
||||
const adapter = this.adapters.get(name);
|
||||
if (adapter && adapter.isActive) {
|
||||
return await adapter.onEvent(event);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
registerAdapter<CT extends NetworkAdapterConfig> (adapter: INapCatProtocolNetworkAdapter<CT>) {
|
||||
this.adapters.set(adapter.name, adapter);
|
||||
}
|
||||
|
||||
async registerAdapterAndOpen<CT extends NetworkAdapterConfig> (adapter: INapCatProtocolNetworkAdapter<CT>) {
|
||||
this.registerAdapter(adapter);
|
||||
await adapter.open();
|
||||
}
|
||||
|
||||
async closeSomeAdapters<CT extends NetworkAdapterConfig> (adaptersToClose: INapCatProtocolNetworkAdapter<CT>[]) {
|
||||
for (const adapter of adaptersToClose) {
|
||||
this.adapters.delete(adapter.name);
|
||||
await adapter.close();
|
||||
}
|
||||
}
|
||||
|
||||
async closeSomeAdapterWhenOpen<CT extends NetworkAdapterConfig> (adaptersToClose: INapCatProtocolNetworkAdapter<CT>[]) {
|
||||
for (const adapter of adaptersToClose) {
|
||||
this.adapters.delete(adapter.name);
|
||||
if (adapter.isEnable) {
|
||||
await adapter.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
findSomeAdapter (name: string) {
|
||||
return this.adapters.get(name);
|
||||
}
|
||||
|
||||
async closeAdapterByPredicate (closeFilter: (adapter: INapCatProtocolNetworkAdapter<NetworkAdapterConfig>) => boolean) {
|
||||
const adaptersToClose = Array.from(this.adapters.values()).filter(closeFilter);
|
||||
await this.closeSomeAdapters(adaptersToClose);
|
||||
}
|
||||
|
||||
async closeAllAdapters () {
|
||||
await Promise.all(Array.from(this.adapters.values()).map(adapter => adapter.close()));
|
||||
this.adapters.clear();
|
||||
}
|
||||
|
||||
async reloadAdapter<T> (name: string, config: T) {
|
||||
const adapter = this.adapters.get(name);
|
||||
if (adapter) {
|
||||
await adapter.reload(config);
|
||||
}
|
||||
}
|
||||
|
||||
async reloadSomeAdapters<T> (configMap: Map<string, T>) {
|
||||
await Promise.all(Array.from(configMap.entries()).map(([name, config]) => this.reloadAdapter(name, config)));
|
||||
}
|
||||
|
||||
hasActiveAdapters (): boolean {
|
||||
return Array.from(this.adapters.values()).some(adapter => adapter.isActive);
|
||||
}
|
||||
|
||||
async getAllConfig () {
|
||||
return Array.from(this.adapters.values()).map(adapter => adapter.config);
|
||||
}
|
||||
}
|
||||
|
||||
export * from './adapter';
|
||||
36
packages/napcat-protocol/package.json
Normal file
36
packages/napcat-protocol/package.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "napcat-protocol",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit --skipLibCheck -p tsconfig.json"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./index.ts"
|
||||
},
|
||||
"./*": {
|
||||
"import": "./*"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"ajv": "^8.13.0",
|
||||
"@sinclair/typebox": "^0.34.38",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^5.0.0",
|
||||
"ws": "^8.18.3",
|
||||
"json5": "^2.2.3",
|
||||
"napcat-core": "workspace:*",
|
||||
"napcat-common": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^22.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
11
packages/napcat-protocol/tsconfig.json
Normal file
11
packages/napcat-protocol/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": [
|
||||
"*.ts",
|
||||
"**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
70
packages/napcat-protocol/types/index.ts
Normal file
70
packages/napcat-protocol/types/index.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
// NapCat Protocol 消息类型定义
|
||||
|
||||
export interface NapCatProtocolMessage {
|
||||
post_type: 'message' | 'notice' | 'request' | 'meta_event';
|
||||
time: number;
|
||||
self_id: number;
|
||||
message_type?: 'private' | 'group';
|
||||
sub_type?: string;
|
||||
message_id?: number;
|
||||
user_id?: number;
|
||||
group_id?: number;
|
||||
message?: NapCatProtocolMessageSegment[] | string;
|
||||
raw_message?: string;
|
||||
sender?: NapCatProtocolSender;
|
||||
}
|
||||
|
||||
export interface NapCatProtocolMessageSegment {
|
||||
type: string;
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface NapCatProtocolSender {
|
||||
user_id: number;
|
||||
nickname: string;
|
||||
card?: string;
|
||||
sex?: 'male' | 'female' | 'unknown';
|
||||
age?: number;
|
||||
area?: string;
|
||||
level?: string;
|
||||
role?: 'owner' | 'admin' | 'member';
|
||||
title?: string;
|
||||
}
|
||||
|
||||
// API 请求类型
|
||||
export interface NapCatProtocolRequest {
|
||||
action: string;
|
||||
params?: Record<string, unknown>;
|
||||
echo?: string | number;
|
||||
}
|
||||
|
||||
// API 响应类型
|
||||
export interface NapCatProtocolResponse<T = unknown> {
|
||||
status: 'ok' | 'failed';
|
||||
retcode: number;
|
||||
data: T | null;
|
||||
message?: string;
|
||||
echo?: string | number;
|
||||
}
|
||||
|
||||
// 心跳事件
|
||||
export interface NapCatProtocolHeartbeat {
|
||||
post_type: 'meta_event';
|
||||
meta_event_type: 'heartbeat';
|
||||
time: number;
|
||||
self_id: number;
|
||||
status: {
|
||||
online: boolean;
|
||||
good: boolean;
|
||||
};
|
||||
interval: number;
|
||||
}
|
||||
|
||||
// 生命周期事件
|
||||
export interface NapCatProtocolLifecycle {
|
||||
post_type: 'meta_event';
|
||||
meta_event_type: 'lifecycle';
|
||||
time: number;
|
||||
self_id: number;
|
||||
sub_type: 'connect' | 'enable' | 'disable';
|
||||
}
|
||||
@@ -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']
|
||||
},
|
||||
|
||||
@@ -15,12 +15,11 @@ export default defineConfig({
|
||||
resolve: {
|
||||
conditions: ['node', 'default'],
|
||||
alias: {
|
||||
'@/napcat-core': resolve(__dirname, '../napcat-core'),
|
||||
'@/napcat-common': resolve(__dirname, '../napcat-common'),
|
||||
'@/napcat-onebot': resolve(__dirname, '../napcat-onebot'),
|
||||
'@/napcat-pty': resolve(__dirname, '../napcat-pty'),
|
||||
'@/napcat-common': resolve(__dirname, '../napcat-common'),
|
||||
'@/napcat-schema': resolve(__dirname, './src'),
|
||||
'@/napcat-core': resolve(__dirname, '../napcat-core'),
|
||||
'@/napcat-webui-backend': resolve(__dirname, '../napcat-webui-backend'),
|
||||
'@/image-size': resolve(__dirname, '../image-size'),
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
|
||||
@@ -22,7 +22,7 @@ import fs from 'fs';
|
||||
import os from 'os';
|
||||
import { LoginListItem, NodeIKernelLoginService } from 'napcat-core/services';
|
||||
import qrcode from 'napcat-qrcode/lib/main';
|
||||
import { NapCatOneBot11Adapter } from 'napcat-onebot/index';
|
||||
import { NapCatAdapterManager } from 'napcat-adapter';
|
||||
import { InitWebUi } from 'napcat-webui-backend/index';
|
||||
import { WebUiDataRuntime } from 'napcat-webui-backend/src/helper/Data';
|
||||
import { napCatVersion } from 'napcat-common/src/version';
|
||||
@@ -475,10 +475,14 @@ export class NapCatShell {
|
||||
this.core.event.on('KickedOffLine', (tips: string) => {
|
||||
WebUiDataRuntime.setQQLoginError(tips);
|
||||
});
|
||||
const oneBotAdapter = new NapCatOneBot11Adapter(this.core, this.context, this.context.pathWrapper);
|
||||
// 注册到 WebUiDataRuntime,供调试功能使用
|
||||
WebUiDataRuntime.setOneBotContext(oneBotAdapter);
|
||||
oneBotAdapter.InitOneBot()
|
||||
.catch(e => this.context.logger.logError('初始化OneBot失败', e));
|
||||
// 使用 NapCatAdapterManager 统一管理协议适配器
|
||||
const adapterManager = new NapCatAdapterManager(this.core, this.context, this.context.pathWrapper);
|
||||
await adapterManager.initAdapters()
|
||||
.catch(e => this.context.logger.logError('初始化协议适配器失败', e));
|
||||
// 注册 OneBot 适配器到 WebUiDataRuntime,供调试功能使用
|
||||
const oneBotAdapter = adapterManager.getOneBotAdapter();
|
||||
if (oneBotAdapter) {
|
||||
WebUiDataRuntime.setOneBotContext(oneBotAdapter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,11 @@ let isElectron = false;
|
||||
let isRestarting = false;
|
||||
let isShuttingDown = false;
|
||||
|
||||
// 进程崩溃保护:记录最近的异常退出时间戳
|
||||
const recentCrashTimestamps: number[] = [];
|
||||
const CRASH_TIME_WINDOW = 10000; // 10秒时间窗口
|
||||
const MAX_CRASHES_IN_WINDOW = 3; // 最大崩溃次数
|
||||
|
||||
/**
|
||||
* 获取进程类型名称(用于日志)
|
||||
*/
|
||||
@@ -217,7 +222,23 @@ async function startWorker (passQuickLogin: boolean = true, secretKey?: string,
|
||||
}
|
||||
// 如果不是由于主动重启或关闭引起的退出,尝试自动重新拉起
|
||||
if (!isRestarting && !isShuttingDown) {
|
||||
logger.logWarn(`[NapCat] [${processType}] Worker进程意外退出,正在尝试重新拉起...`);
|
||||
const now = Date.now();
|
||||
|
||||
// 清理超出时间窗口的崩溃记录
|
||||
while (recentCrashTimestamps.length > 0 && now - recentCrashTimestamps[0]! > CRASH_TIME_WINDOW) {
|
||||
recentCrashTimestamps.shift();
|
||||
}
|
||||
|
||||
// 记录本次崩溃
|
||||
recentCrashTimestamps.push(now);
|
||||
|
||||
// 检查是否超过崩溃阈值
|
||||
if (recentCrashTimestamps.length >= MAX_CRASHES_IN_WINDOW) {
|
||||
logger.logError(`[NapCat] [${processType}] Worker进程在 ${CRASH_TIME_WINDOW / 1000} 秒内异常退出 ${MAX_CRASHES_IN_WINDOW} 次,主进程退出`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
logger.logWarn(`[NapCat] [${processType}] Worker进程意外退出 (${recentCrashTimestamps.length}/${MAX_CRASHES_IN_WINDOW}),正在尝试重新拉起...`);
|
||||
startWorker(true).catch(e => {
|
||||
logger.logError(`[NapCat] [${processType}] 重新拉起Worker进程失败:`, e);
|
||||
});
|
||||
|
||||
@@ -1,34 +1,34 @@
|
||||
{
|
||||
"name": "napcat-shell",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"build:dev": "vite build --mode development",
|
||||
"typecheck": "tsc --noEmit --skipLibCheck -p tsconfig.json"
|
||||
"name": "napcat-shell",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"build:dev": "vite build --mode development",
|
||||
"typecheck": "tsc --noEmit --skipLibCheck -p tsconfig.json"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./index.ts"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./index.ts"
|
||||
},
|
||||
"./*": {
|
||||
"import": "./*"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"napcat-core": "workspace:*",
|
||||
"napcat-common": "workspace:*",
|
||||
"napcat-onebot": "workspace:*",
|
||||
"napcat-webui-backend": "workspace:*",
|
||||
"napcat-qrcode": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.1",
|
||||
"napcat-vite": "workspace:*"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
"./*": {
|
||||
"import": "./*"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"napcat-core": "workspace:*",
|
||||
"napcat-common": "workspace:*",
|
||||
"napcat-adapter": "workspace:*",
|
||||
"napcat-webui-backend": "workspace:*",
|
||||
"napcat-qrcode": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.1",
|
||||
"napcat-vite": "workspace:*"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,7 @@ const ShellBaseConfig = (source_map: boolean = false) =>
|
||||
'@/napcat-pty': resolve(__dirname, '../napcat-pty'),
|
||||
'@/napcat-webui-backend': resolve(__dirname, '../napcat-webui-backend'),
|
||||
'@/image-size': resolve(__dirname, '../image-size'),
|
||||
'@/napcat-protocol': resolve(__dirname, '../napcat-protocol'),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/// <reference path="./external-shims.d.ts" />
|
||||
// 聚合导出核心库的所有内容(包括枚举、类和类型)
|
||||
export * from '../napcat-core/index';
|
||||
|
||||
|
||||
@@ -14,13 +14,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/ws": "^8.5.12",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/winston": "^2.4.4",
|
||||
"@types/yaml": "^1.9.7",
|
||||
"@types/ip": "^1.1.3",
|
||||
"@sinclair/typebox": "^0.34.38"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "napcat-types",
|
||||
"version": "0.0.4",
|
||||
"version": "0.0.14",
|
||||
"private": false,
|
||||
"type": "module",
|
||||
"types": "./napcat-types/index.d.ts",
|
||||
@@ -9,13 +9,6 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/ws": "^8.5.12",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/winston": "^2.4.4",
|
||||
"@types/yaml": "^1.9.7",
|
||||
"@types/ip": "^1.1.3",
|
||||
"@sinclair/typebox": "^0.34.38"
|
||||
},
|
||||
"publishConfig": {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// 复制 cp README.md dist/ && cp package.public.json dist/package.json && cp external-shims.d.ts dist/
|
||||
// 复制 cp README.md dist/ && cp package.public.json dist/package.json
|
||||
import { copyFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
@@ -10,8 +10,4 @@ await copyFile(
|
||||
await copyFile(
|
||||
join(__dirname, 'README.md'),
|
||||
join(__dirname, 'dist', 'README.md')
|
||||
);
|
||||
await copyFile(
|
||||
join(__dirname, 'external-shims.d.ts'),
|
||||
join(__dirname, 'dist', 'external-shims.d.ts')
|
||||
);
|
||||
@@ -5,6 +5,111 @@ import { fileURLToPath } from 'node:url';
|
||||
const __dirname = fileURLToPath(new URL('../', import.meta.url));
|
||||
const distDir = join(__dirname, 'dist');
|
||||
|
||||
// 允许保留的包(白名单)
|
||||
const ALLOWED_PACKAGES = [
|
||||
'@sinclair/typebox',
|
||||
'node:', // node: 前缀的内置模块
|
||||
];
|
||||
|
||||
// 外部包类型到 any 的映射
|
||||
const EXTERNAL_TYPE_REPLACEMENTS = {
|
||||
// winston
|
||||
'winston.Logger': 'any',
|
||||
'winston.transport': 'any',
|
||||
// express
|
||||
'express.Express': 'any',
|
||||
'express.Application': 'any',
|
||||
'express.Router': 'any',
|
||||
'Express': 'any',
|
||||
'Request': 'any',
|
||||
'Response': 'any',
|
||||
'NextFunction': 'any',
|
||||
// ws
|
||||
'WebSocket': 'any',
|
||||
'WebSocketServer': 'any',
|
||||
'RawData': 'any',
|
||||
// ajv
|
||||
'Ajv': 'any',
|
||||
'AnySchema': 'any',
|
||||
'ValidateFunction': 'any',
|
||||
'ValidateFunction<T>': 'any',
|
||||
// inversify
|
||||
'Container': 'any',
|
||||
// async-mutex
|
||||
'Mutex': 'any',
|
||||
'Semaphore': 'any',
|
||||
// napcat-protobuf
|
||||
'NapProtoDecodeStructType': 'any',
|
||||
'NapProtoEncodeStructType': 'any',
|
||||
'NapProtoDecodeStructType<T>': 'any',
|
||||
'NapProtoEncodeStructType<T>': 'any',
|
||||
};
|
||||
|
||||
function isAllowedImport (importPath) {
|
||||
return ALLOWED_PACKAGES.some(pkg => importPath.startsWith(pkg));
|
||||
}
|
||||
|
||||
function removeExternalImports (content) {
|
||||
const lines = content.split('\n');
|
||||
const resultLines = [];
|
||||
|
||||
for (const line of lines) {
|
||||
// 匹配 import 语句
|
||||
const importMatch = line.match(/^import\s+.*\s+from\s+['"]([^'"]+)['"]/);
|
||||
if (importMatch) {
|
||||
const importPath = importMatch[1];
|
||||
// 如果是相对路径或白名单包,保留
|
||||
if (importPath.startsWith('.') || importPath.startsWith('/') || isAllowedImport(importPath)) {
|
||||
resultLines.push(line);
|
||||
}
|
||||
// 否则移除该 import
|
||||
continue;
|
||||
}
|
||||
resultLines.push(line);
|
||||
}
|
||||
|
||||
return resultLines.join('\n');
|
||||
}
|
||||
|
||||
function replaceExternalTypes (content) {
|
||||
let result = content;
|
||||
|
||||
// 替换带泛型的类型(先处理复杂的)
|
||||
result = result.replace(/NapProtoDecodeStructType<[^>]+>/g, 'any');
|
||||
result = result.replace(/NapProtoEncodeStructType<[^>]+>/g, 'any');
|
||||
result = result.replace(/ValidateFunction<[^>]+>/g, 'any');
|
||||
|
||||
// 替换 winston.Logger 等带命名空间的类型
|
||||
result = result.replace(/winston\.Logger/g, 'any');
|
||||
result = result.replace(/winston\.transport/g, 'any');
|
||||
result = result.replace(/express\.Express/g, 'any');
|
||||
result = result.replace(/express\.Application/g, 'any');
|
||||
result = result.replace(/express\.Router/g, 'any');
|
||||
|
||||
// 替换独立的类型名(需要小心不要替换变量名)
|
||||
// 使用类型上下文的模式匹配
|
||||
const typeContextPatterns = [
|
||||
// : Type
|
||||
/:\s*(WebSocket|WebSocketServer|RawData|Ajv|AnySchema|ValidateFunction|Container|Mutex|Semaphore|NapProtoDecodeStructType|NapProtoEncodeStructType|Express|Request|Response|NextFunction)(?=\s*[;,)\]\}|&]|$)/g,
|
||||
// <Type>
|
||||
/<(WebSocket|WebSocketServer|RawData|Ajv|AnySchema|ValidateFunction|Container|Mutex|Semaphore|NapProtoDecodeStructType|NapProtoEncodeStructType|Express|Request|Response|NextFunction)>/g,
|
||||
// Type[]
|
||||
/(WebSocket|WebSocketServer|RawData|Ajv|AnySchema|ValidateFunction|Container|Mutex|Semaphore|NapProtoDecodeStructType|NapProtoEncodeStructType|Express|Request|Response|NextFunction)\[\]/g,
|
||||
// extends Type
|
||||
/extends\s+(WebSocket|WebSocketServer|RawData|Ajv|AnySchema|ValidateFunction|Container|Mutex|Semaphore|NapProtoDecodeStructType|NapProtoEncodeStructType|Express|Request|Response|NextFunction)(?=\s*[{,])/g,
|
||||
// implements Type
|
||||
/implements\s+(WebSocket|WebSocketServer|RawData|Ajv|AnySchema|ValidateFunction|Container|Mutex|Semaphore|NapProtoDecodeStructType|NapProtoEncodeStructType|Express|Request|Response|NextFunction)(?=\s*[{,])/g,
|
||||
];
|
||||
|
||||
for (const pattern of typeContextPatterns) {
|
||||
result = result.replace(pattern, (match, typeName) => {
|
||||
return match.replace(typeName, 'any');
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function traverseDirectory (dir) {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
|
||||
@@ -23,7 +128,13 @@ async function processFile (filePath) {
|
||||
// Read file content
|
||||
let content = await readFile(filePath, 'utf-8');
|
||||
|
||||
// Replace "export declare enum" with "export enum"
|
||||
// 1. 移除外部包的 import
|
||||
content = removeExternalImports(content);
|
||||
|
||||
// 2. 替换外部类型为 any
|
||||
content = replaceExternalTypes(content);
|
||||
|
||||
// 3. Replace "export declare enum" with "export enum"
|
||||
content = content.replace(/export declare enum/g, 'export enum');
|
||||
|
||||
// Write back the modified content
|
||||
@@ -33,7 +144,7 @@ async function processFile (filePath) {
|
||||
const newPath = filePath.replace(/\.d\.ts$/, '.ts');
|
||||
await rename(filePath, newPath);
|
||||
|
||||
console.log(`Processed: ${basename(filePath)} -> ${basename(newPath)}`);
|
||||
//console.log(`Processed: ${basename(filePath)} -> ${basename(newPath)}`);
|
||||
}
|
||||
|
||||
console.log('Starting post-build processing...');
|
||||
|
||||
@@ -39,9 +39,6 @@
|
||||
"../napcat-onebot/**/*.ts",
|
||||
"../napcat-common/**/*.ts"
|
||||
],
|
||||
"files": [
|
||||
"./external-shims.d.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
|
||||
230
packages/napcat-webui-backend/src/api/Mirror.ts
Normal file
230
packages/napcat-webui-backend/src/api/Mirror.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import { RequestHandler } from 'express';
|
||||
import { sendSuccess, sendError } from '@/napcat-webui-backend/src/utils/response';
|
||||
import {
|
||||
GITHUB_FILE_MIRRORS,
|
||||
GITHUB_RAW_MIRRORS,
|
||||
buildMirrorUrl,
|
||||
getMirrorConfig,
|
||||
setCustomMirror,
|
||||
clearMirrorCache
|
||||
} from 'napcat-common/src/mirror';
|
||||
import https from 'https';
|
||||
import http from 'http';
|
||||
|
||||
export interface MirrorTestResult {
|
||||
mirror: string;
|
||||
latency: number;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试单个镜像的延迟
|
||||
*/
|
||||
async function testMirrorLatency (mirror: string, testUrl: string, timeout: number = 5000): Promise<MirrorTestResult> {
|
||||
const url = mirror ? buildMirrorUrl(testUrl, mirror) : testUrl;
|
||||
const start = Date.now();
|
||||
|
||||
return new Promise<MirrorTestResult>((resolve) => {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
const isHttps = urlObj.protocol === 'https:';
|
||||
const client = isHttps ? https : http;
|
||||
|
||||
const req = client.request({
|
||||
hostname: urlObj.hostname,
|
||||
port: urlObj.port || (isHttps ? 443 : 80),
|
||||
path: urlObj.pathname + urlObj.search,
|
||||
method: 'HEAD',
|
||||
timeout,
|
||||
headers: {
|
||||
'User-Agent': 'NapCat-Mirror-Test',
|
||||
},
|
||||
}, (res) => {
|
||||
const statusCode = res.statusCode || 0;
|
||||
const isValid = statusCode >= 200 && statusCode < 400;
|
||||
resolve({
|
||||
mirror: mirror || 'https://github.com',
|
||||
latency: Date.now() - start,
|
||||
success: isValid,
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (e) => {
|
||||
resolve({
|
||||
mirror: mirror || 'https://github.com',
|
||||
latency: Date.now() - start,
|
||||
success: false,
|
||||
error: e.message,
|
||||
});
|
||||
});
|
||||
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
resolve({
|
||||
mirror: mirror || 'https://github.com',
|
||||
latency: timeout,
|
||||
success: false,
|
||||
error: 'Timeout',
|
||||
});
|
||||
});
|
||||
|
||||
req.end();
|
||||
} catch (e: any) {
|
||||
resolve({
|
||||
mirror: mirror || 'https://github.com',
|
||||
latency: Date.now() - start,
|
||||
success: false,
|
||||
error: e.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有可用的镜像列表
|
||||
*/
|
||||
export const GetMirrorListHandler: RequestHandler = async (_req, res) => {
|
||||
try {
|
||||
const config = getMirrorConfig();
|
||||
return sendSuccess(res, {
|
||||
fileMirrors: GITHUB_FILE_MIRRORS.filter(m => m),
|
||||
rawMirrors: GITHUB_RAW_MIRRORS,
|
||||
customMirror: config.customMirror,
|
||||
timeout: config.timeout,
|
||||
});
|
||||
} catch (e: any) {
|
||||
return sendError(res, e.message);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 设置自定义镜像
|
||||
*/
|
||||
export const SetCustomMirrorHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
const { mirror } = req.body;
|
||||
setCustomMirror(mirror || '');
|
||||
clearMirrorCache();
|
||||
return sendSuccess(res, { message: 'Mirror set successfully' });
|
||||
} catch (e: any) {
|
||||
return sendError(res, e.message);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* SSE 实时测速所有镜像
|
||||
*/
|
||||
export const TestMirrorsSSEHandler: RequestHandler = async (req, res) => {
|
||||
const { type = 'file' } = req.query;
|
||||
|
||||
// 设置 SSE 响应头
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.flushHeaders();
|
||||
|
||||
const sendProgress = (data: any) => {
|
||||
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
||||
};
|
||||
|
||||
try {
|
||||
// 选择镜像列表
|
||||
let mirrors: string[];
|
||||
let testUrl: string;
|
||||
|
||||
if (type === 'raw') {
|
||||
mirrors = GITHUB_RAW_MIRRORS;
|
||||
testUrl = 'https://raw.githubusercontent.com/NapNeko/NapCatQQ/main/README.md';
|
||||
} else {
|
||||
mirrors = GITHUB_FILE_MIRRORS.filter(m => m);
|
||||
testUrl = 'https://github.com/NapNeko/NapCatQQ/releases/latest';
|
||||
}
|
||||
|
||||
// 添加原始 URL 测试
|
||||
if (!mirrors.includes('')) {
|
||||
mirrors = ['', ...mirrors];
|
||||
}
|
||||
|
||||
sendProgress({
|
||||
type: 'start',
|
||||
total: mirrors.length,
|
||||
message: `开始测试 ${mirrors.length} 个镜像源...`,
|
||||
});
|
||||
|
||||
const results: MirrorTestResult[] = [];
|
||||
const timeout = 5000;
|
||||
|
||||
// 逐个测试并实时推送结果
|
||||
for (let i = 0; i < mirrors.length; i++) {
|
||||
const mirror = mirrors[i] ?? '';
|
||||
const displayName = mirror || 'https://github.com (原始)';
|
||||
|
||||
sendProgress({
|
||||
type: 'testing',
|
||||
index: i,
|
||||
total: mirrors.length,
|
||||
mirror: displayName,
|
||||
message: `正在测试: ${displayName}`,
|
||||
});
|
||||
|
||||
const result = await testMirrorLatency(mirror, testUrl, timeout);
|
||||
results.push(result);
|
||||
|
||||
sendProgress({
|
||||
type: 'result',
|
||||
index: i,
|
||||
total: mirrors.length,
|
||||
result: {
|
||||
...result,
|
||||
mirror: result.mirror || 'https://github.com (原始)',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 按延迟排序
|
||||
const sortedResults = results
|
||||
.filter(r => r.success)
|
||||
.sort((a, b) => a.latency - b.latency);
|
||||
|
||||
const failedResults = results.filter(r => !r.success);
|
||||
|
||||
sendProgress({
|
||||
type: 'complete',
|
||||
results: sortedResults,
|
||||
failed: failedResults,
|
||||
fastest: sortedResults[0] || null,
|
||||
message: `测试完成!${sortedResults.length} 个可用,${failedResults.length} 个失败`,
|
||||
});
|
||||
|
||||
res.end();
|
||||
} catch (e: any) {
|
||||
sendProgress({
|
||||
type: 'error',
|
||||
error: e.message,
|
||||
});
|
||||
res.end();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 快速测试单个镜像
|
||||
*/
|
||||
export const TestSingleMirrorHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
const { mirror, type = 'file' } = req.body;
|
||||
|
||||
let testUrl: string;
|
||||
if (type === 'raw') {
|
||||
testUrl = 'https://raw.githubusercontent.com/NapNeko/NapCatQQ/main/README.md';
|
||||
} else {
|
||||
testUrl = 'https://github.com/NapNeko/NapCatQQ/releases/latest';
|
||||
}
|
||||
|
||||
const result = await testMirrorLatency(mirror || '', testUrl, 5000);
|
||||
|
||||
return sendSuccess(res, result);
|
||||
} catch (e: any) {
|
||||
return sendError(res, e.message);
|
||||
}
|
||||
};
|
||||
@@ -3,8 +3,10 @@ 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';
|
||||
import compressing from 'compressing';
|
||||
|
||||
// Helper to get the plugin manager adapter
|
||||
const getPluginManager = (): OB11PluginMangerAdapter | null => {
|
||||
@@ -13,170 +15,145 @@ 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) {
|
||||
// 返回成功但带特殊标记
|
||||
return sendSuccess(res, { plugins: [], pluginManagerNotFound: true });
|
||||
return sendSuccess(res, { plugins: [], pluginManagerNotFound: true, extensionPages: [] });
|
||||
}
|
||||
|
||||
// 辅助函数:根据文件名/路径生成唯一ID(作为配置键)
|
||||
const getPluginId = (fsName: string, isFile: boolean): string => {
|
||||
if (isFile) {
|
||||
return path.parse(fsName).name;
|
||||
}
|
||||
return fsName;
|
||||
};
|
||||
const loadedPlugins = pluginManager.getAllPlugins();
|
||||
const AllPlugins: Array<{
|
||||
name: string;
|
||||
id: string;
|
||||
version: string;
|
||||
description: string;
|
||||
author: string;
|
||||
status: string;
|
||||
hasConfig: boolean;
|
||||
hasPages: boolean;
|
||||
}> = new Array();
|
||||
|
||||
const loadedPlugins = pluginManager.getLoadedPlugins();
|
||||
const loadedPluginMap = new Map<string, any>(); // Map ID -> Loaded Info
|
||||
// 收集所有插件的扩展页面
|
||||
const extensionPages: Array<{
|
||||
pluginId: string;
|
||||
pluginName: string;
|
||||
path: string;
|
||||
title: string;
|
||||
icon?: string;
|
||||
description?: string;
|
||||
}> = [];
|
||||
|
||||
// 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);
|
||||
// 根据插件状态确定 status
|
||||
let status: string;
|
||||
if (!p.enable) {
|
||||
status = 'disabled';
|
||||
} else if (p.loaded) {
|
||||
status = 'active';
|
||||
} else {
|
||||
status = 'stopped'; // 启用但未加载(可能加载失败)
|
||||
}
|
||||
|
||||
loadedPluginMap.set(id, {
|
||||
name: p.packageJson?.name || p.name, // 优先使用 package.json 的 name
|
||||
id: id,
|
||||
// 检查插件是否有注册页面
|
||||
const pluginRouter = pluginManager.getPluginRouter(p.id);
|
||||
const hasPages = pluginRouter?.hasPages() ?? false;
|
||||
|
||||
AllPlugins.push({
|
||||
name: p.packageJson?.plugin || p.name || '', // 优先显示 package.json 的 plugin 字段
|
||||
id: p.id, // 包名,用于 API 操作
|
||||
version: p.version || '0.0.0',
|
||||
description: p.packageJson?.description || '',
|
||||
author: p.packageJson?.author || '',
|
||||
status: 'active',
|
||||
filename: fsName, // 真实文件/目录名
|
||||
loadedName: p.name // 运行时注册的名称,用于重载/卸载
|
||||
status,
|
||||
hasConfig: !!(p.runtime.module?.plugin_config_schema || p.runtime.module?.plugin_config_ui),
|
||||
hasPages
|
||||
});
|
||||
}
|
||||
|
||||
const pluginPath = pluginManager.getPluginPath();
|
||||
const pluginConfig = pluginManager.getPluginConfig();
|
||||
const allPlugins: any[] = [];
|
||||
|
||||
// 2. 扫描文件系统,合并状态
|
||||
if (fs.existsSync(pluginPath)) {
|
||||
const items = fs.readdirSync(pluginPath, { withFileTypes: true });
|
||||
|
||||
for (const item of items) {
|
||||
let id = '';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const isActiveConfig = pluginConfig[id] !== false; // 默认为 true
|
||||
|
||||
if (loadedPluginMap.has(id)) {
|
||||
// 已加载,使用加载的信息
|
||||
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,
|
||||
version,
|
||||
description,
|
||||
author,
|
||||
// 如果配置是 false,则为 disabled;否则是 stopped (应启动但未启动)
|
||||
status: isActiveConfig ? 'stopped' : 'disabled',
|
||||
filename: item.name
|
||||
// 收集插件的扩展页面
|
||||
if (hasPages && pluginRouter) {
|
||||
const pages = pluginRouter.getPages();
|
||||
for (const page of pages) {
|
||||
extensionPages.push({
|
||||
pluginId: p.id,
|
||||
pluginName: p.packageJson?.plugin || p.name || p.id,
|
||||
path: page.path,
|
||||
title: page.title,
|
||||
icon: page.icon,
|
||||
description: page.description
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
return sendSuccess(res, { plugins: AllPlugins, pluginManagerNotFound: false, extensionPages });
|
||||
};
|
||||
|
||||
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);
|
||||
// 设置插件状态(需要 await,因为内部会加载/卸载插件)
|
||||
await 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 plugin = pluginManager.getPluginInfo(id);
|
||||
if (!plugin || !plugin.loaded) {
|
||||
return sendError(res, 'Plugin load failed: ' + 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 +163,434 @@ 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: unknown = {};
|
||||
if (plugin.runtime.module?.plugin_get_config && plugin.runtime.context) {
|
||||
try {
|
||||
config = await plugin.runtime.module?.plugin_get_config(plugin.runtime.context);
|
||||
} catch (e) { }
|
||||
} else {
|
||||
// Default behavior: read from default config path
|
||||
try {
|
||||
const configPath = plugin.runtime.context?.configPath || pluginManager.getPluginConfigPath(id);
|
||||
if (fs.existsSync(configPath)) {
|
||||
config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||
}
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
// 获取静态 schema
|
||||
const schema = plugin.runtime.module?.plugin_config_schema || plugin.runtime.module?.plugin_config_ui || [];
|
||||
|
||||
// 检查是否支持动态控制
|
||||
const supportReactive = !!(plugin.runtime.module?.plugin_config_controller || plugin.runtime.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.runtime.module?.plugin_config_controller && plugin.runtime.context) {
|
||||
try {
|
||||
const result = await plugin.runtime.module.plugin_config_controller(
|
||||
plugin.runtime.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.runtime.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 {
|
||||
if (plugin.runtime.context) {
|
||||
await plugin.runtime.module.plugin_on_config_change(
|
||||
plugin.runtime.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.runtime.module?.plugin_set_config && plugin.runtime.context) {
|
||||
try {
|
||||
await plugin.runtime.module.plugin_set_config(plugin.runtime.context, config);
|
||||
return sendSuccess(res, { message: 'Config updated' });
|
||||
} catch (e: any) {
|
||||
return sendError(res, 'Error updating config: ' + e.message);
|
||||
}
|
||||
} else if (plugin.runtime.module?.plugin_config_schema || plugin.runtime.module?.plugin_config_ui || plugin.runtime.module?.plugin_config_controller) {
|
||||
// Default behavior: write to default config path
|
||||
try {
|
||||
const configPath = plugin.runtime.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');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 导入本地插件包(支持 .zip 文件)
|
||||
*/
|
||||
export const ImportLocalPluginHandler: RequestHandler = async (req, res) => {
|
||||
const pluginManager = getPluginManager();
|
||||
if (!pluginManager) {
|
||||
return sendError(res, 'Plugin Manager not found');
|
||||
}
|
||||
|
||||
// multer 会将文件信息放在 req.file 中
|
||||
const file = req.file;
|
||||
if (!file) {
|
||||
return sendError(res, 'No file uploaded');
|
||||
}
|
||||
|
||||
const PLUGINS_DIR = webUiPathWrapper.pluginPath;
|
||||
|
||||
// 确保插件目录存在
|
||||
if (!fs.existsSync(PLUGINS_DIR)) {
|
||||
fs.mkdirSync(PLUGINS_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
const tempZipPath = file.path;
|
||||
|
||||
try {
|
||||
// 创建临时解压目录
|
||||
const tempExtractDir = path.join(PLUGINS_DIR, `_temp_extract_${Date.now()}`);
|
||||
fs.mkdirSync(tempExtractDir, { recursive: true });
|
||||
|
||||
// 解压到临时目录
|
||||
await compressing.zip.uncompress(tempZipPath, tempExtractDir);
|
||||
|
||||
// 检查解压后的内容
|
||||
const extractedItems = fs.readdirSync(tempExtractDir);
|
||||
|
||||
let pluginSourceDir: string;
|
||||
let pluginId: string;
|
||||
|
||||
// 判断解压结构:可能是直接的插件文件,或者包含一个子目录
|
||||
const hasPackageJson = extractedItems.includes('package.json');
|
||||
const hasIndexFile = extractedItems.some(item =>
|
||||
['index.js', 'index.mjs', 'main.js', 'main.mjs'].includes(item)
|
||||
);
|
||||
|
||||
if (hasPackageJson || hasIndexFile) {
|
||||
// 直接是插件文件
|
||||
pluginSourceDir = tempExtractDir;
|
||||
|
||||
// 尝试从 package.json 获取插件 ID
|
||||
const packageJsonPath = path.join(tempExtractDir, 'package.json');
|
||||
if (fs.existsSync(packageJsonPath)) {
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
|
||||
pluginId = pkg.name || path.basename(file.originalname, '.zip');
|
||||
} catch {
|
||||
pluginId = path.basename(file.originalname, '.zip');
|
||||
}
|
||||
} else {
|
||||
pluginId = path.basename(file.originalname, '.zip');
|
||||
}
|
||||
} else if (extractedItems.length === 1 && fs.statSync(path.join(tempExtractDir, extractedItems[0]!)).isDirectory()) {
|
||||
// 包含一个子目录
|
||||
const subDir = extractedItems[0]!;
|
||||
pluginSourceDir = path.join(tempExtractDir, subDir);
|
||||
|
||||
// 尝试从子目录的 package.json 获取插件 ID
|
||||
const packageJsonPath = path.join(pluginSourceDir, 'package.json');
|
||||
if (fs.existsSync(packageJsonPath)) {
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
|
||||
pluginId = pkg.name || subDir;
|
||||
} catch {
|
||||
pluginId = subDir;
|
||||
}
|
||||
} else {
|
||||
pluginId = subDir;
|
||||
}
|
||||
} else {
|
||||
// 清理临时文件
|
||||
fs.rmSync(tempExtractDir, { recursive: true, force: true });
|
||||
fs.unlinkSync(tempZipPath);
|
||||
return sendError(res, 'Invalid plugin package structure');
|
||||
}
|
||||
|
||||
// 目标插件目录
|
||||
const targetPluginDir = path.join(PLUGINS_DIR, pluginId);
|
||||
|
||||
// 如果目标目录已存在,先删除
|
||||
if (fs.existsSync(targetPluginDir)) {
|
||||
// 先卸载已存在的插件
|
||||
const existingPlugin = pluginManager.getPluginInfo(pluginId);
|
||||
if (existingPlugin && existingPlugin.loaded) {
|
||||
await pluginManager.unregisterPlugin(pluginId);
|
||||
}
|
||||
fs.rmSync(targetPluginDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// 移动插件文件到目标目录
|
||||
if (pluginSourceDir === tempExtractDir) {
|
||||
// 直接重命名临时目录
|
||||
fs.renameSync(tempExtractDir, targetPluginDir);
|
||||
} else {
|
||||
// 移动子目录内容
|
||||
fs.renameSync(pluginSourceDir, targetPluginDir);
|
||||
// 清理临时目录
|
||||
fs.rmSync(tempExtractDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// 删除上传的临时文件
|
||||
if (fs.existsSync(tempZipPath)) {
|
||||
fs.unlinkSync(tempZipPath);
|
||||
}
|
||||
|
||||
// 加载插件
|
||||
const loaded = await pluginManager.loadPluginById(pluginId);
|
||||
|
||||
if (loaded) {
|
||||
return sendSuccess(res, {
|
||||
message: 'Plugin imported and loaded successfully',
|
||||
pluginId,
|
||||
installPath: targetPluginDir,
|
||||
});
|
||||
} else {
|
||||
return sendSuccess(res, {
|
||||
message: 'Plugin imported but failed to load (check plugin structure)',
|
||||
pluginId,
|
||||
installPath: targetPluginDir,
|
||||
});
|
||||
}
|
||||
} catch (e: any) {
|
||||
// 清理临时文件
|
||||
if (fs.existsSync(tempZipPath)) {
|
||||
fs.unlinkSync(tempZipPath);
|
||||
}
|
||||
return sendError(res, 'Failed to import plugin: ' + e.message);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { RequestHandler } from 'express';
|
||||
import { WebUiConfig } from '@/napcat-webui-backend/index';
|
||||
import { WebUiConfig, webUiPathWrapper } from '@/napcat-webui-backend/index';
|
||||
import { sendError, sendSuccess } from '@/napcat-webui-backend/src/utils/response';
|
||||
import { isEmpty } from '@/napcat-webui-backend/src/utils/check';
|
||||
import { existsSync, promises as fsProm } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
// 获取WebUI基础配置
|
||||
export const GetWebUIConfigHandler: RequestHandler = async (_, res) => {
|
||||
@@ -158,3 +160,86 @@ export const UpdateWebUIConfigHandler: RequestHandler = async (req, res) => {
|
||||
return sendError(res, `更新WebUI配置失败: ${msg}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取SSL证书状态
|
||||
export const GetSSLStatusHandler: RequestHandler = async (_, res) => {
|
||||
try {
|
||||
const certPath = join(webUiPathWrapper.configPath, 'cert.pem');
|
||||
const keyPath = join(webUiPathWrapper.configPath, 'key.pem');
|
||||
|
||||
const certExists = existsSync(certPath);
|
||||
const keyExists = existsSync(keyPath);
|
||||
|
||||
let certContent = '';
|
||||
let keyContent = '';
|
||||
|
||||
if (certExists) {
|
||||
certContent = await fsProm.readFile(certPath, 'utf-8');
|
||||
}
|
||||
if (keyExists) {
|
||||
keyContent = await fsProm.readFile(keyPath, 'utf-8');
|
||||
}
|
||||
|
||||
return sendSuccess(res, {
|
||||
enabled: certExists && keyExists,
|
||||
certExists,
|
||||
keyExists,
|
||||
certContent,
|
||||
keyContent,
|
||||
});
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
return sendError(res, `获取SSL状态失败: ${msg}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 保存SSL证书(通过文本内容)
|
||||
export const UploadSSLCertHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
const { cert, key } = req.body;
|
||||
|
||||
if (isEmpty(cert) || isEmpty(key)) {
|
||||
return sendError(res, 'cert和key内容不能为空');
|
||||
}
|
||||
|
||||
// 简单验证证书格式
|
||||
if (!cert.includes('-----BEGIN CERTIFICATE-----') || !cert.includes('-----END CERTIFICATE-----')) {
|
||||
return sendError(res, 'cert格式不正确,应为PEM格式的证书');
|
||||
}
|
||||
|
||||
if (!key.includes('-----BEGIN') || !key.includes('KEY-----')) {
|
||||
return sendError(res, 'key格式不正确,应为PEM格式的私钥');
|
||||
}
|
||||
|
||||
const certPath = join(webUiPathWrapper.configPath, 'cert.pem');
|
||||
const keyPath = join(webUiPathWrapper.configPath, 'key.pem');
|
||||
|
||||
await fsProm.writeFile(certPath, cert, 'utf-8');
|
||||
await fsProm.writeFile(keyPath, key, 'utf-8');
|
||||
|
||||
return sendSuccess(res, { message: 'SSL证书保存成功,重启后生效' });
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
return sendError(res, `保存SSL证书失败: ${msg}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 删除SSL证书
|
||||
export const DeleteSSLCertHandler: RequestHandler = async (_, res) => {
|
||||
try {
|
||||
const certPath = join(webUiPathWrapper.configPath, 'cert.pem');
|
||||
const keyPath = join(webUiPathWrapper.configPath, 'key.pem');
|
||||
|
||||
if (existsSync(certPath)) {
|
||||
await fsProm.unlink(certPath);
|
||||
}
|
||||
if (existsSync(keyPath)) {
|
||||
await fsProm.unlink(keyPath);
|
||||
}
|
||||
|
||||
return sendSuccess(res, { message: 'SSL证书已删除,重启后生效' });
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
return sendError(res, `删除SSL证书失败: ${msg}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -12,7 +12,7 @@ import { getRandomToken } from '../utils/url';
|
||||
// 限制尝试端口的次数,避免死循环
|
||||
// 定义配置的类型
|
||||
const WebUiConfigSchema = Type.Object({
|
||||
host: Type.String({ default: '0.0.0.0' }),
|
||||
host: Type.String({ default: '::' }),
|
||||
port: Type.Number({ default: 6099 }),
|
||||
token: Type.String({ default: getRandomToken(12) }),
|
||||
loginRate: Type.Number({ default: 10 }),
|
||||
|
||||
@@ -16,10 +16,7 @@ export async function auth (req: Request, res: Response, next: NextFunction) {
|
||||
req.url === '/auth/passkey/verify-authentication') {
|
||||
return next();
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 判断是否有Authorization头
|
||||
let hash: string | undefined;
|
||||
if (req.headers?.authorization) {
|
||||
// 切割参数以获取token
|
||||
const authorization = req.headers.authorization.split(' ');
|
||||
@@ -28,8 +25,14 @@ export async function auth (req: Request, res: Response, next: NextFunction) {
|
||||
return sendError(res, 'Unauthorized');
|
||||
}
|
||||
// 获取token
|
||||
const hash = authorization[1];
|
||||
if (!hash) return sendError(res, 'Unauthorized');
|
||||
hash = authorization[1];
|
||||
} else if (req.query['webui_token'] && typeof req.query['webui_token'] === 'string') {
|
||||
// 支持通过query参数传递token
|
||||
hash = req.query['webui_token'];
|
||||
}
|
||||
// 判断是否有Authorization头
|
||||
if (hash) {
|
||||
//if (!hash) return sendError(res, 'Unauthorized');
|
||||
// 解析token
|
||||
let Credential: WebUiCredentialJson;
|
||||
try {
|
||||
|
||||
@@ -82,6 +82,7 @@ export const OneBotConfigSchema = Type.Object({
|
||||
musicSignUrl: Type.String({ default: '' }),
|
||||
enableLocalFile2Url: Type.Boolean({ default: false }),
|
||||
parseMultMsg: Type.Boolean({ default: false }),
|
||||
imageDownloadProxy: Type.String({ default: '' }),
|
||||
});
|
||||
|
||||
export type OneBotConfig = Static<typeof OneBotConfigSchema>;
|
||||
|
||||
23
packages/napcat-webui-backend/src/router/Mirror.ts
Normal file
23
packages/napcat-webui-backend/src/router/Mirror.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Router } from 'express';
|
||||
import {
|
||||
GetMirrorListHandler,
|
||||
SetCustomMirrorHandler,
|
||||
TestMirrorsSSEHandler,
|
||||
TestSingleMirrorHandler
|
||||
} from '@/napcat-webui-backend/src/api/Mirror';
|
||||
|
||||
const router: Router = Router();
|
||||
|
||||
// 获取镜像列表
|
||||
router.get('/List', GetMirrorListHandler);
|
||||
|
||||
// 设置自定义镜像
|
||||
router.post('/SetCustom', SetCustomMirrorHandler);
|
||||
|
||||
// SSE 实时测速
|
||||
router.get('/Test/SSE', TestMirrorsSSEHandler);
|
||||
|
||||
// 测试单个镜像
|
||||
router.post('/Test', TestSingleMirrorHandler);
|
||||
|
||||
export { router as MirrorRouter };
|
||||
@@ -1,13 +1,73 @@
|
||||
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 multer from 'multer';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import {
|
||||
GetPluginListHandler,
|
||||
SetPluginStatusHandler,
|
||||
UninstallPluginHandler,
|
||||
GetPluginConfigHandler,
|
||||
SetPluginConfigHandler,
|
||||
RegisterPluginManagerHandler,
|
||||
PluginConfigSSEHandler,
|
||||
PluginConfigChangeHandler,
|
||||
ImportLocalPluginHandler
|
||||
} from '@/napcat-webui-backend/src/api/Plugin';
|
||||
import {
|
||||
GetPluginStoreListHandler,
|
||||
GetPluginStoreDetailHandler,
|
||||
InstallPluginFromStoreHandler,
|
||||
InstallPluginFromStoreSSEHandler
|
||||
} from '@/napcat-webui-backend/src/api/PluginStore';
|
||||
import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
|
||||
import { NapCatOneBot11Adapter } from '@/napcat-onebot/index';
|
||||
import { OB11PluginMangerAdapter } from '@/napcat-onebot/network/plugin-manger';
|
||||
|
||||
// 配置 multer 用于文件上传
|
||||
const uploadDir = path.join(os.tmpdir(), 'napcat-plugin-uploads');
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
}
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: (_req, _file, cb) => {
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
filename: (_req, file, cb) => {
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||
cb(null, uniqueSuffix + '-' + file.originalname);
|
||||
}
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: {
|
||||
fileSize: 50 * 1024 * 1024, // 50MB 限制
|
||||
},
|
||||
fileFilter: (_req, file, cb) => {
|
||||
// 只允许 .zip 文件
|
||||
if (file.mimetype === 'application/zip' ||
|
||||
file.mimetype === 'application/x-zip-compressed' ||
|
||||
file.originalname.endsWith('.zip')) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Only .zip files are allowed'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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.post('/Import', upload.single('plugin'), ImportLocalPluginHandler);
|
||||
|
||||
// 插件商店相关路由
|
||||
router.get('/Store/List', GetPluginStoreListHandler);
|
||||
@@ -15,4 +75,92 @@ router.get('/Store/Detail/:id', GetPluginStoreDetailHandler);
|
||||
router.post('/Store/Install', InstallPluginFromStoreHandler);
|
||||
router.get('/Store/Install/SSE', InstallPluginFromStoreSSEHandler);
|
||||
|
||||
// 插件扩展路由 - 动态挂载插件注册的 API 路由
|
||||
router.use('/ext/:pluginId', (req, res, next): void => {
|
||||
const { pluginId } = req.params;
|
||||
|
||||
if (!pluginId) {
|
||||
res.status(400).json({ code: -1, message: 'Plugin ID is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取插件管理器
|
||||
const ob11 = WebUiDataRuntime.getOneBotContext() as NapCatOneBot11Adapter;
|
||||
if (!ob11) {
|
||||
res.status(503).json({ code: -1, message: 'OneBot context not available' });
|
||||
return;
|
||||
}
|
||||
|
||||
const pluginManager = ob11.networkManager.findSomeAdapter('plugin_manager') as OB11PluginMangerAdapter;
|
||||
if (!pluginManager) {
|
||||
res.status(503).json({ code: -1, message: 'Plugin manager not available' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取插件路由
|
||||
const routerRegistry = pluginManager.getPluginRouter(pluginId);
|
||||
if (!routerRegistry || !routerRegistry.hasApiRoutes()) {
|
||||
res.status(404).json({ code: -1, message: `Plugin '${pluginId}' has no registered API routes` });
|
||||
return;
|
||||
}
|
||||
|
||||
// 构建并执行插件路由
|
||||
const pluginRouter = routerRegistry.buildApiRouter();
|
||||
pluginRouter(req, res, next);
|
||||
});
|
||||
|
||||
// 插件页面路由 - 服务插件注册的 HTML 页面
|
||||
router.get('/page/:pluginId/:pagePath', (req, res): void => {
|
||||
const { pluginId, pagePath } = req.params;
|
||||
|
||||
if (!pluginId) {
|
||||
res.status(400).json({ code: -1, message: 'Plugin ID is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取插件管理器
|
||||
const ob11 = WebUiDataRuntime.getOneBotContext() as NapCatOneBot11Adapter;
|
||||
if (!ob11) {
|
||||
res.status(503).json({ code: -1, message: 'OneBot context not available' });
|
||||
return;
|
||||
}
|
||||
|
||||
const pluginManager = ob11.networkManager.findSomeAdapter('plugin_manager') as OB11PluginMangerAdapter;
|
||||
if (!pluginManager) {
|
||||
res.status(503).json({ code: -1, message: 'Plugin manager not available' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取插件路由
|
||||
const routerRegistry = pluginManager.getPluginRouter(pluginId);
|
||||
if (!routerRegistry || !routerRegistry.hasPages()) {
|
||||
res.status(404).json({ code: -1, message: `Plugin '${pluginId}' has no registered pages` });
|
||||
return;
|
||||
}
|
||||
|
||||
// 查找匹配的页面
|
||||
const pages = routerRegistry.getPages();
|
||||
const page = pages.find(p => p.path === '/' + pagePath || p.path === pagePath);
|
||||
if (!page) {
|
||||
res.status(404).json({ code: -1, message: `Page '${pagePath}' not found in plugin '${pluginId}'` });
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取插件路径
|
||||
const pluginPath = routerRegistry.getPluginPath();
|
||||
if (!pluginPath) {
|
||||
res.status(500).json({ code: -1, message: 'Plugin path not available' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 构建 HTML 文件路径并发送
|
||||
const htmlFilePath = path.join(pluginPath, page.htmlFile);
|
||||
if (!fs.existsSync(htmlFilePath)) {
|
||||
res.status(404).json({ code: -1, message: `HTML file not found: ${page.htmlFile}` });
|
||||
return;
|
||||
}
|
||||
|
||||
res.sendFile(htmlFilePath);
|
||||
});
|
||||
|
||||
export { router as PluginRouter };
|
||||
|
||||
@@ -5,6 +5,9 @@ import {
|
||||
UpdateDisableWebUIHandler,
|
||||
UpdateWebUIConfigHandler,
|
||||
GetClientIPHandler,
|
||||
GetSSLStatusHandler,
|
||||
UploadSSLCertHandler,
|
||||
DeleteSSLCertHandler,
|
||||
} from '@/napcat-webui-backend/src/api/WebUIConfig';
|
||||
|
||||
const router: Router = Router();
|
||||
@@ -24,4 +27,13 @@ router.post('/UpdateDisableWebUI', UpdateDisableWebUIHandler);
|
||||
// 获取当前客户端IP
|
||||
router.get('/GetClientIP', GetClientIPHandler);
|
||||
|
||||
// 获取SSL证书状态
|
||||
router.get('/GetSSLStatus', GetSSLStatusHandler);
|
||||
|
||||
// 上传SSL证书
|
||||
router.post('/UploadSSLCert', UploadSSLCertHandler);
|
||||
|
||||
// 删除SSL证书
|
||||
router.post('/DeleteSSLCert', DeleteSSLCertHandler);
|
||||
|
||||
export { router as WebUIConfigRouter };
|
||||
|
||||
@@ -18,6 +18,7 @@ import { UpdateNapCatRouter } from './UpdateNapCat';
|
||||
import DebugRouter from '@/napcat-webui-backend/src/api/Debug';
|
||||
import { ProcessRouter } from './Process';
|
||||
import { PluginRouter } from './Plugin';
|
||||
import { MirrorRouter } from './Mirror';
|
||||
|
||||
const router: Router = Router();
|
||||
|
||||
@@ -50,5 +51,7 @@ router.use('/Debug', DebugRouter);
|
||||
router.use('/Process', ProcessRouter);
|
||||
// router:插件管理相关路由
|
||||
router.use('/Plugin', PluginRouter);
|
||||
// router:镜像管理相关路由
|
||||
router.use('/Mirror', MirrorRouter);
|
||||
|
||||
export { router as ALLRouter };
|
||||
|
||||
Binary file not shown.
@@ -27,6 +27,7 @@ const NetworkPage = lazy(() => import('@/pages/dashboard/network'));
|
||||
const TerminalPage = lazy(() => import('@/pages/dashboard/terminal'));
|
||||
const PluginPage = lazy(() => import('@/pages/dashboard/plugin'));
|
||||
const PluginStorePage = lazy(() => import('@/pages/dashboard/plugin_store'));
|
||||
const ExtensionPage = lazy(() => import('@/pages/dashboard/extension'));
|
||||
|
||||
function App () {
|
||||
return (
|
||||
@@ -80,6 +81,7 @@ function AppRoutes () {
|
||||
<Route path='terminal' element={<TerminalPage />} />
|
||||
<Route path='plugins' element={<PluginPage />} />
|
||||
<Route path='plugin_store' element={<PluginStorePage />} />
|
||||
<Route path='extension' element={<ExtensionPage />} />
|
||||
<Route path='about' element={<AboutPage />} />
|
||||
</Route>
|
||||
<Route path='/qq_login' element={<QQLoginPage />} />
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -180,7 +180,7 @@ const GenericForm = <T extends keyof NetworkConfigType> ({
|
||||
export default GenericForm;
|
||||
export function random_token (length: number) {
|
||||
const chars =
|
||||
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789@#$%^&*()-_=+[]{}|;:,.<>?';
|
||||
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.~';
|
||||
let result = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -127,6 +127,20 @@ const SchemaContainer: React.FC<{
|
||||
};
|
||||
|
||||
const RenderSchema: React.FC<{ schema: ParsedSchema; }> = ({ schema }) => {
|
||||
// 处理循环引用和截断的情况,直接显示提示而不继续递归
|
||||
if (schema.isCircularRef || schema.isTruncated) {
|
||||
return (
|
||||
<div className='mb-2 flex items-center gap-1 pl-5'>
|
||||
{schema.name && (
|
||||
<span className='text-default-400'>{schema.name}</span>
|
||||
)}
|
||||
<Chip size='sm' color='default' variant='flat'>
|
||||
{schema.description || '...'}
|
||||
</Chip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (schema.type === 'object') {
|
||||
return (
|
||||
<SchemaContainer schema={schema}>
|
||||
|
||||
@@ -8,18 +8,22 @@ import { Switch } from '@heroui/switch';
|
||||
import { Pagination } from '@heroui/pagination';
|
||||
import { Tabs, Tab } from '@heroui/tabs';
|
||||
import { Input } from '@heroui/input';
|
||||
import { Button } from '@heroui/button';
|
||||
import { useLocalStorage, useDebounce } from '@uidotdev/usehooks';
|
||||
import { useRequest } from 'ahooks';
|
||||
import clsx from 'clsx';
|
||||
import { FaCircleInfo, FaQq } from 'react-icons/fa6';
|
||||
import { IoLogoChrome, IoLogoOctocat, IoSearch } from 'react-icons/io5';
|
||||
import { IoMdFlash, IoMdCheckmark, IoMdSettings } from 'react-icons/io';
|
||||
import { RiMacFill } from 'react-icons/ri';
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
import key from '@/const/key';
|
||||
import WebUIManager from '@/controllers/webui_manager';
|
||||
import MirrorManager from '@/controllers/mirror_manager';
|
||||
import useDialog from '@/hooks/use-dialog';
|
||||
import Modal from '@/components/modal';
|
||||
import MirrorSelectorModal from '@/components/mirror_selector_modal';
|
||||
import { hasNewVersion, compareVersion } from '@/utils/version';
|
||||
|
||||
|
||||
@@ -304,17 +308,54 @@ const VersionSelectDialogContent: React.FC<VersionSelectDialogProps> = ({
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const debouncedSearch = useDebounce(searchQuery, 300);
|
||||
|
||||
// 镜像相关状态
|
||||
const [selectedMirror, setSelectedMirror] = useState<string | undefined>(undefined);
|
||||
const { data: mirrorsData } = useRequest(WebUIManager.getMirrors, {
|
||||
cacheKey: 'napcat-mirrors',
|
||||
staleTime: 60 * 60 * 1000,
|
||||
});
|
||||
const mirrors = mirrorsData?.mirrors || [];
|
||||
const [mirrorLatency, setMirrorLatency] = useState<number | null>(null);
|
||||
const [mirrorTesting, setMirrorTesting] = useState(false);
|
||||
const [mirrorModalOpen, setMirrorModalOpen] = useState(false);
|
||||
|
||||
const pageSize = 15;
|
||||
// 测试当前镜像速度
|
||||
const testCurrentMirror = async () => {
|
||||
setMirrorTesting(true);
|
||||
try {
|
||||
const result = await MirrorManager.testSingleMirror(selectedMirror || '', 'file');
|
||||
if (result.success) {
|
||||
setMirrorLatency(result.latency);
|
||||
} else {
|
||||
setMirrorLatency(null);
|
||||
}
|
||||
} catch (e) {
|
||||
setMirrorLatency(null);
|
||||
} finally {
|
||||
setMirrorTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatLatency = (latency: number) => {
|
||||
if (latency >= 5000) return '>5s';
|
||||
if (latency >= 1000) return `${(latency / 1000).toFixed(1)}s`;
|
||||
return `${latency}ms`;
|
||||
};
|
||||
|
||||
const getLatencyColor = (latency: number | null): 'success' | 'warning' | 'danger' | 'default' => {
|
||||
if (latency === null) return 'default';
|
||||
if (latency < 300) return 'success';
|
||||
if (latency < 1000) return 'warning';
|
||||
return 'danger';
|
||||
};
|
||||
|
||||
const getMirrorDisplayName = () => {
|
||||
if (!selectedMirror) return '自动选择';
|
||||
try {
|
||||
return new URL(selectedMirror).hostname;
|
||||
} catch {
|
||||
return selectedMirror;
|
||||
}
|
||||
};
|
||||
|
||||
// 获取所有可用版本(带分页、过滤和搜索)
|
||||
// 懒加载:根据 activeTab 只获取对应类型的版本
|
||||
const pageSize = 15;
|
||||
const { data: releasesData, loading: releasesLoading, error: releasesError } = useRequest(
|
||||
() => WebUIManager.getAllReleases({
|
||||
page: currentPage,
|
||||
@@ -502,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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
LuZap,
|
||||
LuPackage,
|
||||
LuStore,
|
||||
LuPuzzle,
|
||||
} from 'react-icons/lu';
|
||||
|
||||
export type SiteConfig = typeof siteConfig;
|
||||
@@ -36,11 +37,6 @@ export const siteConfig = {
|
||||
icon: <LuSignal className='w-5 h-5' />,
|
||||
href: '/network',
|
||||
},
|
||||
{
|
||||
label: '其他配置',
|
||||
icon: <LuSettings className='w-5 h-5' />,
|
||||
href: '/config',
|
||||
},
|
||||
{
|
||||
label: '猫猫日志',
|
||||
icon: <LuFileText className='w-5 h-5' />,
|
||||
@@ -71,11 +67,21 @@ export const siteConfig = {
|
||||
icon: <LuStore className='w-5 h-5' />,
|
||||
href: '/plugin_store',
|
||||
},
|
||||
{
|
||||
label: '扩展页面',
|
||||
icon: <LuPuzzle className='w-5 h-5' />,
|
||||
href: '/extension',
|
||||
},
|
||||
{
|
||||
label: '系统终端',
|
||||
icon: <LuTerminal className='w-5 h-5' />,
|
||||
href: '/terminal',
|
||||
},
|
||||
{
|
||||
label: '系统配置',
|
||||
icon: <LuSettings className='w-5 h-5' />,
|
||||
href: '/config',
|
||||
},
|
||||
{
|
||||
label: '关于我们',
|
||||
icon: <LuInfo className='w-5 h-5' />,
|
||||
|
||||
109
packages/napcat-webui-frontend/src/controllers/mirror_manager.ts
Normal file
109
packages/napcat-webui-frontend/src/controllers/mirror_manager.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { EventSourcePolyfill } from 'event-source-polyfill';
|
||||
import { serverRequest } from '@/utils/request';
|
||||
import key from '@/const/key';
|
||||
|
||||
export interface MirrorTestResult {
|
||||
mirror: string;
|
||||
latency: number;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface MirrorListResponse {
|
||||
fileMirrors: string[];
|
||||
rawMirrors: string[];
|
||||
customMirror?: string;
|
||||
timeout: number;
|
||||
}
|
||||
|
||||
export default class MirrorManager {
|
||||
/**
|
||||
* 获取镜像列表
|
||||
*/
|
||||
public static async getMirrorList (): Promise<MirrorListResponse> {
|
||||
const { data } = await serverRequest.get<ServerResponse<MirrorListResponse>>('/Mirror/List');
|
||||
return data.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置自定义镜像
|
||||
*/
|
||||
public static async setCustomMirror (mirror: string): Promise<void> {
|
||||
await serverRequest.post('/Mirror/SetCustom', { mirror });
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试单个镜像
|
||||
*/
|
||||
public static async testSingleMirror (mirror: string, type: 'file' | 'raw' = 'file'): Promise<MirrorTestResult> {
|
||||
const { data } = await serverRequest.post<ServerResponse<MirrorTestResult>>('/Mirror/Test', { mirror, type });
|
||||
return data.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* SSE 实时测速所有镜像
|
||||
*/
|
||||
public static testMirrorsSSE (
|
||||
type: 'file' | 'raw' = 'file',
|
||||
callbacks: {
|
||||
onStart?: (data: { total: number; message: string; }) => void;
|
||||
onTesting?: (data: { index: number; total: number; mirror: string; message: string; }) => void;
|
||||
onResult?: (data: { index: number; total: number; result: MirrorTestResult; }) => void;
|
||||
onComplete?: (data: { results: MirrorTestResult[]; failed: MirrorTestResult[]; fastest: MirrorTestResult | null; message: string; }) => void;
|
||||
onError?: (error: string) => void;
|
||||
}
|
||||
): EventSourcePolyfill {
|
||||
const token = localStorage.getItem(key.token);
|
||||
if (!token) {
|
||||
throw new Error('未登录');
|
||||
}
|
||||
const _token = JSON.parse(token);
|
||||
|
||||
const eventSource = new EventSourcePolyfill(
|
||||
`/api/Mirror/Test/SSE?type=${type}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${_token}`,
|
||||
Accept: 'text/event-stream',
|
||||
},
|
||||
withCredentials: true,
|
||||
}
|
||||
);
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
switch (data.type) {
|
||||
case 'start':
|
||||
callbacks.onStart?.(data);
|
||||
break;
|
||||
case 'testing':
|
||||
callbacks.onTesting?.(data);
|
||||
break;
|
||||
case 'result':
|
||||
callbacks.onResult?.(data);
|
||||
break;
|
||||
case 'complete':
|
||||
callbacks.onComplete?.(data);
|
||||
eventSource.close();
|
||||
break;
|
||||
case 'error':
|
||||
callbacks.onError?.(data.error);
|
||||
eventSource.close();
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse SSE message:', e);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
console.error('SSE连接出错:', error);
|
||||
callbacks.onError?.('连接中断');
|
||||
eventSource.close();
|
||||
};
|
||||
|
||||
return eventSource;
|
||||
}
|
||||
}
|
||||
@@ -1,59 +1,229 @@
|
||||
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;
|
||||
/** 是否有扩展页面 */
|
||||
hasPages?: boolean;
|
||||
}
|
||||
|
||||
/** 扩展页面信息 */
|
||||
export interface ExtensionPageItem {
|
||||
/** 插件 ID */
|
||||
pluginId: string;
|
||||
/** 插件名称 */
|
||||
pluginName: string;
|
||||
/** 页面路径 */
|
||||
path: string;
|
||||
/** 页面标题 */
|
||||
title: string;
|
||||
/** 页面图标 */
|
||||
icon?: string;
|
||||
/** 页面描述 */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** 插件列表响应 */
|
||||
export interface PluginListResponse {
|
||||
plugins: PluginItem[];
|
||||
pluginManagerNotFound: boolean;
|
||||
extensionPages: ExtensionPageItem[];
|
||||
}
|
||||
|
||||
/** 插件配置项定义 */
|
||||
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 () {
|
||||
/**
|
||||
* 导入本地插件包
|
||||
* @param file 插件 zip 文件
|
||||
*/
|
||||
public static async importLocalPlugin (file: File): Promise<{ message: string; pluginId: string; installPath: string; }> {
|
||||
const formData = new FormData();
|
||||
formData.append('plugin', file);
|
||||
|
||||
const { data } = await serverRequest.post<ServerResponse<{ message: string; pluginId: string; installPath: string; }>>(
|
||||
'/Plugin/Import',
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
timeout: 60000, // 60秒超时
|
||||
}
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
// ==================== 插件商店 ====================
|
||||
|
||||
/**
|
||||
* 获取插件商店列表
|
||||
*/
|
||||
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()}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,6 +281,35 @@ export default class WebUIManager {
|
||||
return data.data;
|
||||
}
|
||||
|
||||
// 获取SSL证书状态
|
||||
public static async getSSLStatus () {
|
||||
const { data } = await serverRequest.get<ServerResponse<{
|
||||
enabled: boolean;
|
||||
certExists: boolean;
|
||||
keyExists: boolean;
|
||||
certContent: string;
|
||||
keyContent: string;
|
||||
}>>('/WebUIConfig/GetSSLStatus');
|
||||
return data.data;
|
||||
}
|
||||
|
||||
// 保存SSL证书
|
||||
public static async saveSSLCert (cert: string, key: string) {
|
||||
const { data } = await serverRequest.post<ServerResponse<{ message: string; }>>(
|
||||
'/WebUIConfig/UploadSSLCert',
|
||||
{ cert, key }
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
// 删除SSL证书
|
||||
public static async deleteSSLCert () {
|
||||
const { data } = await serverRequest.post<ServerResponse<{ message: string; }>>(
|
||||
'/WebUIConfig/DeleteSSLCert'
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
// Passkey相关方法
|
||||
public static async generatePasskeyRegistrationOptions () {
|
||||
const { data } = await serverRequest.post<ServerResponse<any>>(
|
||||
|
||||
@@ -10,6 +10,7 @@ import ChangePasswordCard from './change_password';
|
||||
import LoginConfigCard from './login';
|
||||
import OneBotConfigCard from './onebot';
|
||||
import ServerConfigCard from './server';
|
||||
import SSLConfigCard from './ssl';
|
||||
import ThemeConfigCard from './theme';
|
||||
import WebUIConfigCard from './webui';
|
||||
|
||||
@@ -81,6 +82,11 @@ export default function ConfigPage () {
|
||||
<ServerConfigCard />
|
||||
</ConfigPageItem>
|
||||
</Tab>
|
||||
<Tab title='SSL配置' key='ssl'>
|
||||
<ConfigPageItem size='sm'>
|
||||
<SSLConfigCard />
|
||||
</ConfigPageItem>
|
||||
</Tab>
|
||||
<Tab title='WebUI配置' key='webui'>
|
||||
<ConfigPageItem>
|
||||
<WebUIConfigCard />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useRequest } from 'ahooks';
|
||||
import { useEffect, useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { Button } from '@heroui/button';
|
||||
import { Textarea } from '@heroui/input';
|
||||
|
||||
import PageLoading from '@/components/page_loading';
|
||||
|
||||
import WebUIManager from '@/controllers/webui_manager';
|
||||
|
||||
const SSLConfigCard = () => {
|
||||
const {
|
||||
data: sslData,
|
||||
loading: sslLoading,
|
||||
refreshAsync: refreshSSL,
|
||||
} = useRequest(WebUIManager.getSSLStatus);
|
||||
|
||||
const [sslCert, setSslCert] = useState('');
|
||||
const [sslKey, setSslKey] = useState('');
|
||||
const [sslSaving, setSslSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (sslData) {
|
||||
setSslCert(sslData.certContent || '');
|
||||
setSslKey(sslData.keyContent || '');
|
||||
}
|
||||
}, [sslData]);
|
||||
|
||||
const handleSaveSSL = async () => {
|
||||
if (!sslCert.trim() || !sslKey.trim()) {
|
||||
toast.error('证书和私钥内容不能为空');
|
||||
return;
|
||||
}
|
||||
setSslSaving(true);
|
||||
try {
|
||||
const result = await WebUIManager.saveSSLCert(sslCert, sslKey);
|
||||
toast.success(result.message || 'SSL证书保存成功');
|
||||
await refreshSSL();
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
toast.error(`保存SSL证书失败: ${msg}`);
|
||||
} finally {
|
||||
setSslSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteSSL = async () => {
|
||||
setSslSaving(true);
|
||||
try {
|
||||
const result = await WebUIManager.deleteSSLCert();
|
||||
toast.success(result.message || 'SSL证书已删除');
|
||||
setSslCert('');
|
||||
setSslKey('');
|
||||
await refreshSSL();
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
toast.error(`删除SSL证书失败: ${msg}`);
|
||||
} finally {
|
||||
setSslSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefresh = async () => {
|
||||
try {
|
||||
await refreshSSL();
|
||||
toast.success('刷新成功');
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
toast.error(`刷新失败: ${msg}`);
|
||||
}
|
||||
};
|
||||
|
||||
if (sslLoading) return <PageLoading loading />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>SSL配置 - NapCat WebUI</title>
|
||||
<div className='flex flex-col gap-4'>
|
||||
<div className='flex flex-col gap-3'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='flex-shrink-0 w-full font-bold text-default-600 dark:text-default-400 px-1'>SSL/HTTPS 配置</div>
|
||||
{sslData?.enabled && (
|
||||
<span className='px-2 py-0.5 text-xs bg-success-100 text-success-700 dark:bg-success-900/30 dark:text-success-400 rounded-full whitespace-nowrap'>
|
||||
已启用
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className='text-sm text-default-500 px-1'>
|
||||
配置SSL证书后重启即可启用HTTPS。将证书(cert.pem)和私钥(key.pem)的内容粘贴到下方文本框中。
|
||||
</p>
|
||||
<div className='p-3 bg-warning-50 dark:bg-warning-900/20 rounded-lg border border-warning-200 dark:border-warning-800'>
|
||||
<p className='text-sm text-warning-700 dark:text-warning-400'>
|
||||
<strong>注意:</strong>保存证书后需要重启服务才能生效。删除证书后同样需要重启才能切换回HTTP模式。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-4'>
|
||||
<Textarea
|
||||
label='证书内容 (cert.pem)'
|
||||
placeholder={'-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----'}
|
||||
value={sslCert}
|
||||
onValueChange={setSslCert}
|
||||
minRows={6}
|
||||
maxRows={12}
|
||||
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 font-mono text-sm',
|
||||
}}
|
||||
/>
|
||||
<Textarea
|
||||
label='私钥内容 (key.pem)'
|
||||
placeholder={'-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----'}
|
||||
value={sslKey}
|
||||
onValueChange={setSslKey}
|
||||
minRows={6}
|
||||
maxRows={12}
|
||||
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 font-mono text-sm',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex gap-2 justify-end'>
|
||||
<Button
|
||||
variant='flat'
|
||||
isLoading={sslSaving || sslLoading}
|
||||
onPress={handleRefresh}
|
||||
size='sm'
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
{sslData?.enabled && (
|
||||
<Button
|
||||
color='danger'
|
||||
variant='flat'
|
||||
isLoading={sslSaving || sslLoading}
|
||||
onPress={handleDeleteSSL}
|
||||
size='sm'
|
||||
>
|
||||
删除SSL证书
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
color='primary'
|
||||
isLoading={sslSaving || sslLoading}
|
||||
onPress={handleSaveSSL}
|
||||
size='sm'
|
||||
>
|
||||
保存SSL证书
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SSLConfigCard;
|
||||
@@ -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);
|
||||
// 确保请求参数可见
|
||||
|
||||
164
packages/napcat-webui-frontend/src/pages/dashboard/extension.tsx
Normal file
164
packages/napcat-webui-frontend/src/pages/dashboard/extension.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import { Tab, Tabs } from '@heroui/tabs';
|
||||
import { Button } from '@heroui/button';
|
||||
import { Spinner } from '@heroui/spinner';
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { IoMdRefresh } from 'react-icons/io';
|
||||
import { MdExtension } from 'react-icons/md';
|
||||
|
||||
import PageLoading from '@/components/page_loading';
|
||||
import pluginManager from '@/controllers/plugin_manager';
|
||||
|
||||
interface ExtensionPage {
|
||||
pluginId: string;
|
||||
pluginName: string;
|
||||
path: string;
|
||||
title: string;
|
||||
icon?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export default function ExtensionPage () {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [extensionPages, setExtensionPages] = useState<ExtensionPage[]>([]);
|
||||
const [selectedTab, setSelectedTab] = useState<string>('');
|
||||
const [iframeLoading, setIframeLoading] = useState(false);
|
||||
|
||||
const fetchExtensionPages = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await pluginManager.getPluginList();
|
||||
if (result.pluginManagerNotFound) {
|
||||
setExtensionPages([]);
|
||||
} else {
|
||||
setExtensionPages(result.extensionPages || []);
|
||||
// 默认选中第一个
|
||||
if (result.extensionPages?.length > 0 && !selectedTab) {
|
||||
setSelectedTab(`${result.extensionPages[0].pluginId}:${result.extensionPages[0].path}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
toast.error(`获取扩展页面失败: ${msg}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const refresh = async () => {
|
||||
await fetchExtensionPages();
|
||||
};
|
||||
|
||||
// 生成 tabs
|
||||
const tabs = useMemo(() => {
|
||||
return extensionPages.map(page => ({
|
||||
key: `${page.pluginId}:${page.path}`,
|
||||
title: page.title,
|
||||
pluginId: page.pluginId,
|
||||
pluginName: page.pluginName,
|
||||
path: page.path,
|
||||
icon: page.icon,
|
||||
description: page.description,
|
||||
}));
|
||||
}, [extensionPages]);
|
||||
|
||||
// 获取当前选中页面的 iframe URL
|
||||
const currentPageUrl = useMemo(() => {
|
||||
if (!selectedTab) return '';
|
||||
const [pluginId, ...pathParts] = selectedTab.split(':');
|
||||
const path = pathParts.join(':').replace(/^\//, '');
|
||||
// 获取认证 token
|
||||
const token = localStorage.getItem('token') || '';
|
||||
return `/api/Plugin/page/${pluginId}/${path}?webui_token=${token}`;
|
||||
}, [selectedTab]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchExtensionPages();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentPageUrl) {
|
||||
setIframeLoading(true);
|
||||
}
|
||||
}, [currentPageUrl]);
|
||||
|
||||
const handleIframeLoad = () => {
|
||||
setIframeLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>扩展页面 - NapCat WebUI</title>
|
||||
<div className='p-2 md:p-4 relative h-full flex flex-col'>
|
||||
<PageLoading loading={loading} />
|
||||
|
||||
<div className='flex mb-4 items-center gap-4'>
|
||||
<div className='flex items-center gap-2 text-default-600'>
|
||||
<MdExtension size={24} />
|
||||
<span className='text-lg font-medium'>插件扩展页面</span>
|
||||
</div>
|
||||
<Button
|
||||
isIconOnly
|
||||
className='bg-default-100/50 hover:bg-default-200/50 text-default-700 backdrop-blur-md'
|
||||
radius='full'
|
||||
onPress={refresh}
|
||||
>
|
||||
<IoMdRefresh size={24} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{extensionPages.length === 0 && !loading
|
||||
? (
|
||||
<div className='flex-1 flex flex-col items-center justify-center text-default-400'>
|
||||
<MdExtension size={64} className='mb-4 opacity-50' />
|
||||
<p className='text-lg'>暂无插件扩展页面</p>
|
||||
<p className='text-sm mt-2'>插件可以通过注册页面来扩展 WebUI 功能</p>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className='flex-1 flex flex-col min-h-0'>
|
||||
<Tabs
|
||||
aria-label='Extension Pages'
|
||||
className='max-w-full'
|
||||
selectedKey={selectedTab}
|
||||
onSelectionChange={(key) => setSelectedTab(key as string)}
|
||||
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',
|
||||
panel: 'flex-1 min-h-0 p-0',
|
||||
}}
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<Tab
|
||||
key={tab.key}
|
||||
title={
|
||||
<div className='flex items-center gap-2'>
|
||||
{tab.icon && <span>{tab.icon}</span>}
|
||||
<span>{tab.title}</span>
|
||||
<span className='text-xs text-default-400'>({tab.pluginName})</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className='relative w-full h-[calc(100vh-220px)] bg-white/40 dark:bg-black/20 backdrop-blur-md rounded-lg overflow-hidden'>
|
||||
{iframeLoading && (
|
||||
<div className='absolute inset-0 flex items-center justify-center bg-default-100/50 z-10'>
|
||||
<Spinner size='lg' />
|
||||
</div>
|
||||
)}
|
||||
<iframe
|
||||
src={currentPageUrl}
|
||||
className='w-full h-full border-0'
|
||||
onLoad={handleIframeLoad}
|
||||
title={tab.title}
|
||||
sandbox='allow-scripts allow-same-origin allow-forms allow-popups'
|
||||
/>
|
||||
</div>
|
||||
</Tab>
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { IoMdRefresh } from 'react-icons/io';
|
||||
import { FiUpload } from 'react-icons/fi';
|
||||
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 +17,21 @@ export default function PluginPage () {
|
||||
const [pluginManagerNotFound, setPluginManagerNotFound] = useState(false);
|
||||
const dialog = useDialog();
|
||||
|
||||
const { isOpen, onOpen, onOpenChange } = useDisclosure();
|
||||
const [currentPluginId, setCurrentPluginId] = useState<string>('');
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
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 +44,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 +63,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 +103,77 @@ export default function PluginPage () {
|
||||
});
|
||||
};
|
||||
|
||||
const handleConfig = (plugin: PluginItem) => {
|
||||
setCurrentPluginId(plugin.id);
|
||||
onOpen();
|
||||
};
|
||||
|
||||
const handleImportClick = () => {
|
||||
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);
|
||||
// 注册成功后打开文件选择器
|
||||
fileInputRef.current?.click();
|
||||
} catch (e: any) {
|
||||
toast.error('注册失败: ' + e.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// 重置 input,允许重复选择同一文件
|
||||
e.target.value = '';
|
||||
|
||||
if (!file.name.endsWith('.zip')) {
|
||||
toast.error('请选择 .zip 格式的插件包');
|
||||
return;
|
||||
}
|
||||
|
||||
const loadingToast = toast.loading('正在导入插件...');
|
||||
try {
|
||||
const result = await PluginManager.importLocalPlugin(file);
|
||||
toast.success(result.message, { id: loadingToast });
|
||||
loadPlugins();
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || '导入失败', { id: loadingToast });
|
||||
}
|
||||
};
|
||||
|
||||
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
|
||||
@@ -99,6 +184,21 @@ export default function PluginPage () {
|
||||
>
|
||||
<IoMdRefresh size={24} />
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-primary-100/50 hover:bg-primary-200/50 text-primary-700 backdrop-blur-md"
|
||||
radius='full'
|
||||
startContent={<FiUpload size={18} />}
|
||||
onPress={handleImportClick}
|
||||
>
|
||||
导入插件
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".zip"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{pluginManagerNotFound ? (
|
||||
@@ -117,11 +217,20 @@ 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.status !== 'active') {
|
||||
toast.error('未启用插件,无法配置插件');
|
||||
} else if (plugin.hasConfig) {
|
||||
handleConfig(plugin);
|
||||
} else {
|
||||
toast.error('此插件没有配置哦');
|
||||
}
|
||||
}}
|
||||
hasConfig={true}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from '@heroui/modal';
|
||||
import { Button } from '@heroui/button';
|
||||
import { Input } from '@heroui/input';
|
||||
import { Select, SelectItem } from '@heroui/select';
|
||||
import { Switch } from '@heroui/switch';
|
||||
import { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { EventSourcePolyfill } from 'event-source-polyfill';
|
||||
import PluginManager, { PluginConfigSchemaItem } from '@/controllers/plugin_manager';
|
||||
import key from '@/const/key';
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onOpenChange: () => void;
|
||||
/** 插件包名 (id) */
|
||||
pluginId: string;
|
||||
}
|
||||
|
||||
/** Schema 更新事件类型 */
|
||||
interface SchemaUpdateEvent {
|
||||
type: 'full' | 'updateField' | 'removeField' | 'addField' | 'showField' | 'hideField';
|
||||
schema?: PluginConfigSchemaItem[];
|
||||
key?: string;
|
||||
field?: Partial<PluginConfigSchemaItem>;
|
||||
afterKey?: string;
|
||||
}
|
||||
|
||||
export default function PluginConfigModal ({ isOpen, onOpenChange, pluginId }: Props) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [schema, setSchema] = useState<PluginConfigSchemaItem[]>([]);
|
||||
const [config, setConfig] = useState<Record<string, unknown>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [supportReactive, setSupportReactive] = useState(false);
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
const [connected, setConnected] = useState(false);
|
||||
|
||||
// SSE 连接引用
|
||||
const eventSourceRef = useRef<EventSourcePolyfill | null>(null);
|
||||
// 当前配置引用(用于 SSE 回调)
|
||||
const configRef = useRef<Record<string, unknown>>({});
|
||||
|
||||
// 同步 config 到 ref
|
||||
useEffect(() => {
|
||||
configRef.current = config;
|
||||
}, [config]);
|
||||
|
||||
/** 处理 schema 更新事件 */
|
||||
const handleSchemaUpdate = useCallback((event: SchemaUpdateEvent) => {
|
||||
switch (event.type) {
|
||||
case 'full':
|
||||
if (event.schema) {
|
||||
setSchema(event.schema);
|
||||
}
|
||||
break;
|
||||
case 'updateField':
|
||||
if (event.key && event.field) {
|
||||
setSchema(prev => prev.map(item =>
|
||||
item.key === event.key ? { ...item, ...event.field } : item
|
||||
));
|
||||
}
|
||||
break;
|
||||
case 'removeField':
|
||||
if (event.key) {
|
||||
setSchema(prev => prev.filter(item => item.key !== event.key));
|
||||
}
|
||||
break;
|
||||
case 'addField':
|
||||
if (event.field) {
|
||||
setSchema(prev => {
|
||||
const newField = event.field as PluginConfigSchemaItem;
|
||||
// 检查字段是否已存在,如果存在则更新
|
||||
const existingIndex = prev.findIndex(item => item.key === newField.key);
|
||||
if (existingIndex !== -1) {
|
||||
// 字段已存在,更新它
|
||||
const newSchema = [...prev];
|
||||
newSchema[existingIndex] = { ...newSchema[existingIndex], ...newField };
|
||||
return newSchema;
|
||||
}
|
||||
// 字段不存在,添加新字段
|
||||
if (event.afterKey) {
|
||||
const index = prev.findIndex(item => item.key === event.afterKey);
|
||||
if (index !== -1) {
|
||||
const newSchema = [...prev];
|
||||
newSchema.splice(index + 1, 0, newField);
|
||||
return newSchema;
|
||||
}
|
||||
}
|
||||
return [...prev, newField];
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'showField':
|
||||
if (event.key) {
|
||||
setSchema(prev => prev.map(item =>
|
||||
item.key === event.key ? { ...item, hidden: false } : item
|
||||
));
|
||||
}
|
||||
break;
|
||||
case 'hideField':
|
||||
if (event.key) {
|
||||
setSchema(prev => prev.map(item =>
|
||||
item.key === event.key ? { ...item, hidden: true } : item
|
||||
));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/** 建立 SSE 连接 */
|
||||
const connectSSE = useCallback((initialConfig: Record<string, unknown>) => {
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
}
|
||||
|
||||
const token = localStorage.getItem(key.token);
|
||||
if (!token) {
|
||||
console.warn('未登录,无法建立 SSE 连接');
|
||||
return;
|
||||
}
|
||||
const _token = JSON.parse(token);
|
||||
|
||||
const url = PluginManager.getConfigSSEUrl(pluginId, initialConfig);
|
||||
const es = new EventSourcePolyfill(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${_token}`,
|
||||
Accept: 'text/event-stream',
|
||||
},
|
||||
withCredentials: true,
|
||||
});
|
||||
eventSourceRef.current = es;
|
||||
|
||||
es.addEventListener('connected', (e) => {
|
||||
const data = JSON.parse((e as MessageEvent).data);
|
||||
setSessionId(data.sessionId);
|
||||
setConnected(true);
|
||||
});
|
||||
|
||||
es.addEventListener('schema', (e) => {
|
||||
const data = JSON.parse((e as MessageEvent).data);
|
||||
handleSchemaUpdate(data);
|
||||
});
|
||||
|
||||
es.addEventListener('error', (e) => {
|
||||
try {
|
||||
const data = JSON.parse((e as MessageEvent).data);
|
||||
toast.error('插件错误: ' + data.message);
|
||||
} catch {
|
||||
// SSE 连接错误
|
||||
setConnected(false);
|
||||
}
|
||||
});
|
||||
|
||||
es.onerror = () => {
|
||||
setConnected(false);
|
||||
};
|
||||
}, [pluginId, handleSchemaUpdate]);
|
||||
|
||||
/** 关闭 SSE 连接 */
|
||||
const disconnectSSE = useCallback(() => {
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
setSessionId(null);
|
||||
setConnected(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && pluginId) {
|
||||
loadConfig();
|
||||
}
|
||||
return () => {
|
||||
disconnectSSE();
|
||||
};
|
||||
}, [isOpen, pluginId, disconnectSSE]);
|
||||
|
||||
/** 初始加载配置 */
|
||||
const loadConfig = async () => {
|
||||
setLoading(true);
|
||||
setSchema([]);
|
||||
setConfig({});
|
||||
setSupportReactive(false);
|
||||
disconnectSSE();
|
||||
|
||||
try {
|
||||
const data = await PluginManager.getPluginConfig(pluginId);
|
||||
setSchema(data.schema || []);
|
||||
setConfig(data.config || {});
|
||||
setSupportReactive(!!data.supportReactive);
|
||||
|
||||
// 如果支持响应式,建立 SSE 连接
|
||||
if (data.supportReactive) {
|
||||
connectSSE(data.config || {});
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast.error('加载配置失败: ' + e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await PluginManager.setPluginConfig(pluginId, config);
|
||||
toast.success('Configuration saved');
|
||||
onOpenChange();
|
||||
} catch (e: any) {
|
||||
toast.error('Save failed: ' + e.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** 更新配置 */
|
||||
const updateConfig = useCallback((key: string, value: any) => {
|
||||
setConfig((prev) => {
|
||||
const newConfig = { ...prev, [key]: value };
|
||||
|
||||
// 如果是响应式字段且已连接 SSE,通知后端
|
||||
const field = schema.find(item => item.key === key);
|
||||
if (field?.reactive && sessionId && connected) {
|
||||
PluginManager.notifyConfigChange(pluginId, sessionId, key, value, newConfig)
|
||||
.catch(e => console.error('通知配置变化失败:', e));
|
||||
}
|
||||
|
||||
return newConfig;
|
||||
});
|
||||
}, [schema, sessionId, connected, pluginId]);
|
||||
|
||||
const renderField = (item: PluginConfigSchemaItem) => {
|
||||
const value = config[item.key] ?? item.default;
|
||||
|
||||
switch (item.type) {
|
||||
case 'string':
|
||||
return (
|
||||
<Input
|
||||
key={item.key}
|
||||
label={item.label}
|
||||
placeholder={item.placeholder || item.description}
|
||||
value={value || ''}
|
||||
onValueChange={(val) => updateConfig(item.key, val)}
|
||||
description={item.description}
|
||||
className="mb-4"
|
||||
/>
|
||||
);
|
||||
case 'number':
|
||||
return (
|
||||
<Input
|
||||
key={item.key}
|
||||
type="number"
|
||||
label={item.label}
|
||||
placeholder={item.placeholder || item.description}
|
||||
value={String(value ?? 0)}
|
||||
onValueChange={(val) => updateConfig(item.key, Number(val))}
|
||||
description={item.description}
|
||||
className="mb-4"
|
||||
/>
|
||||
);
|
||||
case 'boolean':
|
||||
return (
|
||||
<div key={item.key} className="flex justify-between items-center mb-4 p-2 bg-default-100 rounded-lg">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-small">{item.label}</span>
|
||||
{item.description && <span className="text-tiny text-default-500">{item.description}</span>}
|
||||
</div>
|
||||
<Switch
|
||||
isSelected={!!value}
|
||||
onValueChange={(val) => updateConfig(item.key, val)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
case 'select': {
|
||||
const selectedValue = value !== undefined ? String(value) : undefined;
|
||||
const options = item.options || [];
|
||||
return (
|
||||
<Select
|
||||
key={item.key}
|
||||
label={item.label}
|
||||
placeholder={item.placeholder || 'Select an option'}
|
||||
selectedKeys={selectedValue ? [selectedValue] : []}
|
||||
onSelectionChange={(keys) => {
|
||||
const val = Array.from(keys)[0];
|
||||
const opt = options.find(o => String(o.value) === val);
|
||||
updateConfig(item.key, opt ? opt.value : val);
|
||||
}}
|
||||
description={item.description}
|
||||
className="mb-4"
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<SelectItem key={String(opt.value)} textValue={opt.label}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
case 'multi-select': {
|
||||
const selectedKeys = Array.isArray(value) ? value.map(String) : [];
|
||||
const options = item.options || [];
|
||||
return (
|
||||
<Select
|
||||
key={item.key}
|
||||
label={item.label}
|
||||
placeholder={item.placeholder || 'Select options'}
|
||||
selectionMode="multiple"
|
||||
selectedKeys={new Set(selectedKeys)}
|
||||
onSelectionChange={(keys) => {
|
||||
const selected = Array.from(keys).map(k => {
|
||||
const opt = options.find(o => String(o.value) === k);
|
||||
return opt ? opt.value : k;
|
||||
});
|
||||
updateConfig(item.key, selected);
|
||||
}}
|
||||
description={item.description}
|
||||
className="mb-4"
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<SelectItem key={String(opt.value)} textValue={opt.label}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
case 'html':
|
||||
return (
|
||||
<div key={item.key} className="mb-4">
|
||||
{item.label && <h4 className="text-small font-bold mb-1">{item.label}</h4>}
|
||||
<div dangerouslySetInnerHTML={{ __html: item.default || '' }} className="prose dark:prose-invert max-w-none" />
|
||||
{item.description && <p className="text-tiny text-default-500 mt-1">{item.description}</p>}
|
||||
</div>
|
||||
);
|
||||
case 'text':
|
||||
return (
|
||||
<div key={item.key} className="mb-4">
|
||||
{item.label && <h4 className="text-small font-bold mb-1">{item.label}</h4>}
|
||||
<div className="whitespace-pre-wrap text-default-700">{item.default || ''}</div>
|
||||
{item.description && <p className="text-tiny text-default-500 mt-1">{item.description}</p>}
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange} size="2xl" scrollBehavior="inside">
|
||||
<ModalContent>
|
||||
{(onClose) => (
|
||||
<>
|
||||
<ModalHeader className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
插件配置: {pluginId}
|
||||
{supportReactive && (
|
||||
<span className={`text-tiny px-2 py-0.5 rounded ${connected ? 'bg-success-100 text-success-600' : 'bg-warning-100 text-warning-600'}`}>
|
||||
{connected ? '已连接' : '未连接'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
{loading ? (
|
||||
<div className="flex justify-center p-8">Loading configuration...</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{schema.length === 0 ? (
|
||||
<div className="text-center text-default-500">No configuration schema available.</div>
|
||||
) : (
|
||||
schema.filter(item => !item.hidden).map(renderField)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color="danger" variant="light" onPress={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
<Button color="primary" onPress={handleSave} isLoading={saving}>
|
||||
Save
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { Input } from '@heroui/input';
|
||||
import { Select, SelectItem } from '@heroui/select';
|
||||
import { Tab, Tabs } from '@heroui/tabs';
|
||||
import { Card, CardBody } from '@heroui/card';
|
||||
import { Tooltip } from '@heroui/tooltip';
|
||||
import { Spinner } from '@heroui/spinner';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { IoMdRefresh, IoMdSearch } from 'react-icons/io';
|
||||
import { IoMdRefresh, IoMdSearch, IoMdSettings } from 'react-icons/io';
|
||||
import clsx from 'clsx';
|
||||
import { useRequest } from 'ahooks';
|
||||
import { EventSourcePolyfill } from 'event-source-polyfill';
|
||||
|
||||
import PageLoading from '@/components/page_loading';
|
||||
import PluginStoreCard from '@/components/display_card/plugin_store_card';
|
||||
import PluginManager from '@/controllers/plugin_manager';
|
||||
import WebUIManager from '@/controllers/webui_manager';
|
||||
import PluginStoreCard, { InstallStatus } from '@/components/display_card/plugin_store_card';
|
||||
import PluginManager, { PluginItem } from '@/controllers/plugin_manager';
|
||||
import MirrorSelectorModal from '@/components/mirror_selector_modal';
|
||||
import { PluginStoreItem } from '@/types/plugin-store';
|
||||
import useDialog from '@/hooks/use-dialog';
|
||||
import key from '@/const/key';
|
||||
@@ -35,23 +35,32 @@ const EmptySection: React.FC<EmptySectionProps> = ({ isEmpty }) => {
|
||||
|
||||
export default function PluginStorePage () {
|
||||
const [plugins, setPlugins] = useState<PluginStoreItem[]>([]);
|
||||
const [installedPlugins, setInstalledPlugins] = useState<PluginItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [activeTab, setActiveTab] = useState<string>('all');
|
||||
const [pluginManagerNotFound, setPluginManagerNotFound] = useState(false);
|
||||
const dialog = useDialog();
|
||||
|
||||
// 获取镜像列表
|
||||
const { data: mirrorsData } = useRequest(WebUIManager.getMirrors, {
|
||||
cacheKey: 'napcat-mirrors',
|
||||
staleTime: 60 * 60 * 1000,
|
||||
});
|
||||
const mirrors = mirrorsData?.mirrors || [];
|
||||
// 商店列表源相关状态
|
||||
const [storeSourceModalOpen, setStoreSourceModalOpen] = useState(false);
|
||||
const [currentStoreSource, setCurrentStoreSource] = useState<string | undefined>(undefined);
|
||||
|
||||
// 下载镜像弹窗状态(安装时使用)
|
||||
const [downloadMirrorModalOpen, setDownloadMirrorModalOpen] = useState(false);
|
||||
const [pendingInstallPlugin, setPendingInstallPlugin] = useState<PluginStoreItem | null>(null);
|
||||
const [selectedDownloadMirror, setSelectedDownloadMirror] = useState<string | undefined>(undefined);
|
||||
|
||||
const loadPlugins = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await PluginManager.getPluginStoreList();
|
||||
setPlugins(data.plugins);
|
||||
|
||||
// 检查插件管理器是否已加载
|
||||
const listResult = await PluginManager.getPluginList();
|
||||
setPluginManagerNotFound(listResult.pluginManagerNotFound);
|
||||
setInstalledPlugins(listResult.plugins || []);
|
||||
} catch (e: any) {
|
||||
toast.error(e.message);
|
||||
} finally {
|
||||
@@ -61,7 +70,7 @@ export default function PluginStorePage () {
|
||||
|
||||
useEffect(() => {
|
||||
loadPlugins();
|
||||
}, []);
|
||||
}, [currentStoreSource]);
|
||||
|
||||
// 按标签分类和搜索
|
||||
const categorizedPlugins = useMemo(() => {
|
||||
@@ -90,6 +99,23 @@ export default function PluginStorePage () {
|
||||
return categories;
|
||||
}, [plugins, searchQuery]);
|
||||
|
||||
// 获取插件的安装状态和已安装版本
|
||||
const getPluginInstallInfo = (plugin: PluginStoreItem): { status: InstallStatus; installedVersion?: string; } => {
|
||||
// 通过 id (包名) 或 name 匹配已安装的插件
|
||||
const installed = installedPlugins.find(p => p.id === plugin.id);
|
||||
|
||||
if (!installed) {
|
||||
return { status: 'not-installed' };
|
||||
}
|
||||
|
||||
// 使用不等于判断:版本不同就显示更新
|
||||
if (installed.version !== plugin.version) {
|
||||
return { status: 'update-available', installedVersion: installed.version };
|
||||
}
|
||||
|
||||
return { status: 'installed', installedVersion: installed.version };
|
||||
};
|
||||
|
||||
const tabs = useMemo(() => {
|
||||
return [
|
||||
{ key: 'all', title: '全部', count: categorizedPlugins.all?.length || 0 },
|
||||
@@ -101,60 +127,9 @@ export default function PluginStorePage () {
|
||||
}, [categorizedPlugins]);
|
||||
|
||||
const handleInstall = async (plugin: PluginStoreItem) => {
|
||||
// 检测是否是 GitHub 下载链接
|
||||
const githubPattern = /^https:\/\/github\.com\//;
|
||||
const isGitHubUrl = githubPattern.test(plugin.downloadUrl);
|
||||
|
||||
// 如果是 GitHub 链接,弹出镜像选择对话框
|
||||
if (isGitHubUrl) {
|
||||
let selectedMirror: string | undefined = undefined;
|
||||
|
||||
dialog.confirm({
|
||||
title: '安装插件',
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm mb-2">
|
||||
插件名称: <span className="font-semibold">{plugin.name}</span>
|
||||
</p>
|
||||
<p className="text-sm mb-2">
|
||||
版本: <span className="font-semibold">v{plugin.version}</span>
|
||||
</p>
|
||||
<p className="text-sm text-default-500 mb-4">
|
||||
{plugin.description}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">选择下载镜像源</label>
|
||||
<Select
|
||||
placeholder="自动选择 (默认)"
|
||||
defaultSelectedKeys={['default']}
|
||||
onSelectionChange={(keys) => {
|
||||
const m = Array.from(keys)[0] as string;
|
||||
selectedMirror = m === 'default' ? undefined : m;
|
||||
}}
|
||||
size="sm"
|
||||
aria-label="选择镜像源"
|
||||
>
|
||||
{['default', ...mirrors].map(m => (
|
||||
<SelectItem key={m} textValue={m === 'default' ? '自动选择' : m}>
|
||||
{m === 'default' ? '自动选择 (默认)' : m}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
confirmText: '开始安装',
|
||||
cancelText: '取消',
|
||||
onConfirm: async () => {
|
||||
await installPluginWithSSE(plugin.id, selectedMirror);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// 非 GitHub 链接,直接安装
|
||||
await installPluginWithSSE(plugin.id);
|
||||
}
|
||||
// 弹窗选择下载镜像
|
||||
setPendingInstallPlugin(plugin);
|
||||
setDownloadMirrorModalOpen(true);
|
||||
};
|
||||
|
||||
const installPluginWithSSE = async (pluginId: string, mirror?: string) => {
|
||||
@@ -195,6 +170,35 @@ export default function PluginStorePage () {
|
||||
} else if (data.success) {
|
||||
toast.success('插件安装成功!', { id: loadingToast });
|
||||
eventSource.close();
|
||||
// 刷新插件列表
|
||||
loadPlugins();
|
||||
// 安装成功后检查插件管理器状态
|
||||
if (pluginManagerNotFound) {
|
||||
dialog.confirm({
|
||||
title: '插件管理器未加载',
|
||||
content: (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-default-600">
|
||||
插件已安装成功,但插件管理器尚未加载。
|
||||
</p>
|
||||
<p className="text-sm text-default-600">
|
||||
是否立即注册插件管理器?注册后插件才能正常运行。
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
confirmText: '注册插件管理器',
|
||||
cancelText: '稍后再说',
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await PluginManager.registerPluginManager();
|
||||
toast.success('插件管理器注册成功');
|
||||
setPluginManagerNotFound(false);
|
||||
} catch (e: any) {
|
||||
toast.error('注册失败: ' + e.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
} else if (data.message) {
|
||||
toast.loading(data.message, { id: loadingToast });
|
||||
}
|
||||
@@ -213,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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user