mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-02-27 23:30:24 +00:00
Compare commits
16 Commits
v4.17.4
...
feat-plugi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67691dd58a | ||
|
|
5fec649425 | ||
|
|
052e7fa2b3 | ||
|
|
04e425d17a | ||
|
|
cbe0506577 | ||
|
|
32ec097f51 | ||
|
|
53f27ea9e2 | ||
|
|
41d94cd5e2 | ||
|
|
285d352bc8 | ||
|
|
a3b3836b8a | ||
|
|
b9f61cc0ee | ||
|
|
9998207346 | ||
|
|
4f47af233f | ||
|
|
6aadc2402d | ||
|
|
eb937b29e4 | ||
|
|
5edafeed3e |
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -10,7 +10,7 @@ permissions: write-all
|
||||
|
||||
env:
|
||||
OPENROUTER_API_URL: https://91vip.futureppo.top/v1/chat/completions
|
||||
OPENROUTER_MODEL: "glm-4.7"
|
||||
OPENROUTER_MODEL: "deepseek-v3.2-chat"
|
||||
RELEASE_NAME: "NapCat"
|
||||
|
||||
jobs:
|
||||
|
||||
314
packages/napcat-common/src/npm-registry.ts
Normal file
314
packages/napcat-common/src/npm-registry.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* npm 注册表工具模块
|
||||
* 提供从 npm registry 获取包信息和下载 tarball 的能力
|
||||
* 适用于 Electron 环境,不依赖系统安装的 npm CLI
|
||||
*
|
||||
* 设计目标:
|
||||
* - 通过 HTTP API 直接与 npm registry 交互
|
||||
* - 支持多个 registry 镜像源
|
||||
* - 下载 tarball 并解压到指定目录(与现有 zip 安装流程一致)
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { pipeline } from 'stream/promises';
|
||||
import { createWriteStream } from 'fs';
|
||||
|
||||
// ============== npm Registry 镜像源 ==============
|
||||
|
||||
/**
|
||||
* npm Registry 镜像列表
|
||||
* 按优先级排序,优先使用国内镜像
|
||||
*/
|
||||
export const NPM_REGISTRY_MIRRORS = [
|
||||
'https://registry.npmmirror.com', // 淘宝镜像(国内首选)
|
||||
'https://registry.npmjs.org', // 官方源
|
||||
];
|
||||
|
||||
// ============== 类型定义 ==============
|
||||
|
||||
/** npm 包的简要版本信息 */
|
||||
export interface NpmPackageVersionInfo {
|
||||
name: string;
|
||||
version: string;
|
||||
description?: string;
|
||||
author?: string | { name: string; email?: string; url?: string };
|
||||
homepage?: string;
|
||||
repository?: string | { type: string; url: string };
|
||||
keywords?: string[];
|
||||
dist: {
|
||||
tarball: string;
|
||||
shasum?: string;
|
||||
integrity?: string;
|
||||
fileCount?: number;
|
||||
unpackedSize?: number;
|
||||
};
|
||||
/** 插件扩展字段 */
|
||||
napcat?: {
|
||||
tags?: string[];
|
||||
minVersion?: string;
|
||||
displayName?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/** npm 包的完整元数据(简化版) */
|
||||
export interface NpmPackageMetadata {
|
||||
name: string;
|
||||
description?: string;
|
||||
'dist-tags': Record<string, string>;
|
||||
versions: Record<string, NpmPackageVersionInfo>;
|
||||
time?: Record<string, string>;
|
||||
readme?: string;
|
||||
homepage?: string;
|
||||
repository?: string | { type: string; url: string };
|
||||
author?: string | { name: string; email?: string; url?: string };
|
||||
keywords?: string[];
|
||||
}
|
||||
|
||||
// ============== 缓存 ==============
|
||||
|
||||
interface MetadataCache {
|
||||
data: NpmPackageMetadata;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
const metadataCache: Map<string, MetadataCache> = new Map();
|
||||
const METADATA_CACHE_TTL = 5 * 60 * 1000; // 5 分钟
|
||||
|
||||
// ============== 核心功能 ==============
|
||||
|
||||
/**
|
||||
* 从 npm registry 获取包的元数据
|
||||
* @param packageName 包名(如 "napcat-plugin-example")
|
||||
* @param registry 指定的 registry URL(可选)
|
||||
* @param forceRefresh 强制跳过缓存
|
||||
*/
|
||||
export async function fetchNpmPackageMetadata (
|
||||
packageName: string,
|
||||
registry?: string,
|
||||
forceRefresh: boolean = false
|
||||
): Promise<NpmPackageMetadata> {
|
||||
// 检查缓存
|
||||
const cacheKey = `${registry || 'auto'}:${packageName}`;
|
||||
if (!forceRefresh) {
|
||||
const cached = metadataCache.get(cacheKey);
|
||||
if (cached && (Date.now() - cached.timestamp) < METADATA_CACHE_TTL) {
|
||||
return cached.data;
|
||||
}
|
||||
}
|
||||
|
||||
const registries = registry ? [registry] : NPM_REGISTRY_MIRRORS;
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const reg of registries) {
|
||||
try {
|
||||
const url = `${reg.replace(/\/$/, '')}/${encodeURIComponent(packageName)}`;
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'User-Agent': 'NapCat-PluginManager',
|
||||
},
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
|
||||
if (response.status === 404) {
|
||||
throw new Error(`包 "${packageName}" 在 npm 上不存在`);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json() as NpmPackageMetadata;
|
||||
|
||||
// 更新缓存
|
||||
metadataCache.set(cacheKey, { data, timestamp: Date.now() });
|
||||
|
||||
return data;
|
||||
} catch (e: any) {
|
||||
errors.push(`${reg}: ${e.message}`);
|
||||
// 如果是 404,直接抛出,不再尝试其他镜像
|
||||
if (e.message.includes('不存在')) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`获取 npm 包 "${packageName}" 信息失败:\n${errors.join('\n')}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取包的最新版本信息
|
||||
*/
|
||||
export async function fetchNpmLatestVersion (
|
||||
packageName: string,
|
||||
registry?: string
|
||||
): Promise<NpmPackageVersionInfo> {
|
||||
const metadata = await fetchNpmPackageMetadata(packageName, registry);
|
||||
const latestTag = metadata['dist-tags']?.['latest'];
|
||||
|
||||
if (!latestTag) {
|
||||
throw new Error(`包 "${packageName}" 没有 latest 标签`);
|
||||
}
|
||||
|
||||
const versionInfo = metadata.versions[latestTag];
|
||||
if (!versionInfo) {
|
||||
throw new Error(`包 "${packageName}" 的 latest 版本 (${latestTag}) 信息不存在`);
|
||||
}
|
||||
|
||||
return versionInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取包的指定版本信息
|
||||
*/
|
||||
export async function fetchNpmVersionInfo (
|
||||
packageName: string,
|
||||
version: string,
|
||||
registry?: string
|
||||
): Promise<NpmPackageVersionInfo> {
|
||||
const metadata = await fetchNpmPackageMetadata(packageName, registry);
|
||||
const versionInfo = metadata.versions[version];
|
||||
|
||||
if (!versionInfo) {
|
||||
const availableVersions = Object.keys(metadata.versions);
|
||||
throw new Error(
|
||||
`版本 "${version}" 不存在。可用版本: ${availableVersions.slice(-5).join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
return versionInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载 npm tarball 文件
|
||||
* @param tarballUrl tarball 下载地址
|
||||
* @param destPath 保存路径
|
||||
* @param registry 用于替换 tarball URL 中的 registry 域名(镜像加速)
|
||||
* @param onProgress 进度回调
|
||||
* @param timeout 超时时间(毫秒)
|
||||
*/
|
||||
export async function downloadNpmTarball (
|
||||
tarballUrl: string,
|
||||
destPath: string,
|
||||
registry?: string,
|
||||
onProgress?: (percent: number, downloaded: number, total: number, speed: number) => void,
|
||||
timeout: number = 120000
|
||||
): Promise<void> {
|
||||
// 如果指定了 registry,替换 tarball URL 中的域名
|
||||
let downloadUrl = tarballUrl;
|
||||
if (registry) {
|
||||
// tarball URL 通常是 https://registry.npmjs.org/package/-/package-1.0.0.tgz
|
||||
// 替换为 https://registry.npmmirror.com/package/-/package-1.0.0.tgz
|
||||
try {
|
||||
const tarballUrlObj = new URL(tarballUrl);
|
||||
const registryUrlObj = new URL(registry);
|
||||
tarballUrlObj.hostname = registryUrlObj.hostname;
|
||||
tarballUrlObj.protocol = registryUrlObj.protocol;
|
||||
if (registryUrlObj.port) {
|
||||
tarballUrlObj.port = registryUrlObj.port;
|
||||
}
|
||||
downloadUrl = tarballUrlObj.toString();
|
||||
} catch {
|
||||
// URL 解析失败,使用原始 URL
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// 确保目标目录存在
|
||||
const destDir = path.dirname(destPath);
|
||||
if (!fs.existsSync(destDir)) {
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
}
|
||||
|
||||
if (onProgress) {
|
||||
onProgress(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
const response = await fetch(downloadUrl, {
|
||||
headers: {
|
||||
'User-Agent': 'NapCat-PluginManager',
|
||||
},
|
||||
signal: AbortSignal.timeout(timeout),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error('Response body is null');
|
||||
}
|
||||
|
||||
const totalLength = Number(response.headers.get('content-length')) || 0;
|
||||
let downloaded = 0;
|
||||
let lastTime = Date.now();
|
||||
let lastDownloaded = 0;
|
||||
|
||||
// 进度监控
|
||||
// eslint-disable-next-line @stylistic/generator-star-spacing
|
||||
const progressMonitor = async function* (source: any) {
|
||||
for await (const chunk of source) {
|
||||
downloaded += chunk.length;
|
||||
const now = Date.now();
|
||||
const elapsed = now - lastTime;
|
||||
|
||||
if (elapsed >= 500 || (totalLength && downloaded === totalLength)) {
|
||||
const percent = totalLength ? Math.round((downloaded / totalLength) * 100) : 0;
|
||||
const speed = (downloaded - lastDownloaded) / (elapsed / 1000);
|
||||
|
||||
if (onProgress) {
|
||||
onProgress(percent, downloaded, totalLength, speed);
|
||||
}
|
||||
|
||||
lastTime = now;
|
||||
lastDownloaded = downloaded;
|
||||
}
|
||||
|
||||
yield chunk;
|
||||
}
|
||||
};
|
||||
|
||||
const fileStream = createWriteStream(destPath);
|
||||
await pipeline(progressMonitor(response.body), fileStream);
|
||||
} catch (e: any) {
|
||||
if (fs.existsSync(destPath)) {
|
||||
fs.unlinkSync(destPath);
|
||||
}
|
||||
throw new Error(`下载 npm 包失败: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 npm 包的元数据中提取作者名
|
||||
*/
|
||||
export function extractAuthorName (
|
||||
author?: string | { name: string; email?: string; url?: string }
|
||||
): string {
|
||||
if (!author) return 'unknown';
|
||||
if (typeof author === 'string') return author;
|
||||
return author.name || 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 npm 包的元数据中提取 homepage
|
||||
*/
|
||||
export function extractHomepage (
|
||||
homepage?: string,
|
||||
repository?: string | { type: string; url: string }
|
||||
): string | undefined {
|
||||
if (homepage) return homepage;
|
||||
if (!repository) return undefined;
|
||||
if (typeof repository === 'string') return repository;
|
||||
// 转换 git+https://github.com/xxx/yyy.git → https://github.com/xxx/yyy
|
||||
return repository.url
|
||||
?.replace(/^git\+/, '')
|
||||
?.replace(/\.git$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除 npm 元数据缓存
|
||||
*/
|
||||
export function clearNpmMetadataCache (): void {
|
||||
metadataCache.clear();
|
||||
}
|
||||
10
packages/napcat-core/external/napcat.json
vendored
10
packages/napcat-core/external/napcat.json
vendored
@@ -5,5 +5,13 @@
|
||||
"consoleLogLevel": "info",
|
||||
"packetBackend": "auto",
|
||||
"packetServer": "",
|
||||
"o3HookMode": 0
|
||||
"o3HookMode": 1,
|
||||
"bypass": {
|
||||
"hook": false,
|
||||
"window": false,
|
||||
"module": false,
|
||||
"process": false,
|
||||
"container": false,
|
||||
"js": false
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,19 @@
|
||||
import { ConfigBase } from '@/napcat-core/helper/config-base';
|
||||
import { NapCatCore } from '@/napcat-core/index';
|
||||
import { Type, Static } from '@sinclair/typebox';
|
||||
import { AnySchema } from 'ajv';
|
||||
import Ajv, { AnySchema } from 'ajv';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import json5 from 'json5';
|
||||
|
||||
export const BypassOptionsSchema = Type.Object({
|
||||
hook: Type.Boolean({ default: true }),
|
||||
window: Type.Boolean({ default: true }),
|
||||
module: Type.Boolean({ default: true }),
|
||||
process: Type.Boolean({ default: true }),
|
||||
container: Type.Boolean({ default: true }),
|
||||
js: Type.Boolean({ default: true }),
|
||||
});
|
||||
|
||||
export const NapcatConfigSchema = Type.Object({
|
||||
fileLog: Type.Boolean({ default: false }),
|
||||
@@ -11,10 +23,31 @@ export const NapcatConfigSchema = Type.Object({
|
||||
packetBackend: Type.String({ default: 'auto' }),
|
||||
packetServer: Type.String({ default: '' }),
|
||||
o3HookMode: Type.Number({ default: 0 }),
|
||||
bypass: Type.Optional(BypassOptionsSchema),
|
||||
});
|
||||
|
||||
export type NapcatConfig = Static<typeof NapcatConfigSchema>;
|
||||
|
||||
/**
|
||||
* 从指定配置目录读取 napcat.json,按 NapcatConfigSchema 校验并填充默认值
|
||||
* 用于登录前(无 NapCatCore 实例时)的早期配置读取
|
||||
*/
|
||||
export function loadNapcatConfig (configPath: string): NapcatConfig {
|
||||
const ajv = new Ajv({ useDefaults: true, coerceTypes: true });
|
||||
const validate = ajv.compile<NapcatConfig>(NapcatConfigSchema);
|
||||
let data: Record<string, unknown> = {};
|
||||
try {
|
||||
const configFile = path.join(configPath, 'napcat.json');
|
||||
if (fs.existsSync(configFile)) {
|
||||
data = json5.parse(fs.readFileSync(configFile, 'utf-8'));
|
||||
}
|
||||
} catch {
|
||||
// 读取失败时使用 schema 默认值
|
||||
}
|
||||
validate(data);
|
||||
return data as NapcatConfig;
|
||||
}
|
||||
|
||||
export class NapCatConfigLoader extends ConfigBase<NapcatConfig> {
|
||||
constructor (core: NapCatCore, configPath: string, schema: AnySchema) {
|
||||
super('napcat', core, configPath, schema);
|
||||
|
||||
@@ -194,7 +194,7 @@ export class NativePacketHandler {
|
||||
}
|
||||
}
|
||||
|
||||
async init (version: string): Promise<boolean> {
|
||||
async init (version: string, o3HookMode: boolean = false): Promise<boolean> {
|
||||
const version_arch = version + '-' + process.arch;
|
||||
try {
|
||||
if (!this.loaded) {
|
||||
@@ -215,7 +215,7 @@ export class NativePacketHandler {
|
||||
|
||||
this.MoeHooExport.exports.initHook?.(send, recv, (type: PacketType, uin: string, cmd: string, seq: number, hex_data: string) => {
|
||||
this.emitPacket(type, uin, cmd, seq, hex_data);
|
||||
}, true);
|
||||
}, o3HookMode);
|
||||
this.logger.log('[PacketHandler] 初始化成功');
|
||||
return true;
|
||||
} catch (error) {
|
||||
|
||||
@@ -4,10 +4,19 @@ import fs from 'fs';
|
||||
import { constants } from 'node:os';
|
||||
import { LogWrapper } from '../../helper/log';
|
||||
|
||||
export interface BypassOptions {
|
||||
hook?: boolean;
|
||||
window?: boolean;
|
||||
module?: boolean;
|
||||
process?: boolean;
|
||||
container?: boolean;
|
||||
js?: boolean;
|
||||
}
|
||||
|
||||
export interface Napi2NativeExportType {
|
||||
initHook?: (send: string, recv: string) => boolean;
|
||||
setVerbose?: (verbose: boolean) => void; // 默认关闭日志
|
||||
enableAllBypasses?: () => void;
|
||||
enableAllBypasses?: (options?: BypassOptions) => boolean;
|
||||
}
|
||||
|
||||
export class Napi2NativeLoader {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { InitWebUi, WebUiConfig, webUiRuntimePort } from 'napcat-webui-backend/i
|
||||
import { NapCatAdapterManager } from 'napcat-adapter';
|
||||
import { NativePacketHandler } from 'napcat-core/packet/handler/client';
|
||||
import { Napi2NativeLoader } from 'napcat-core/packet/handler/napi2nativeLoader';
|
||||
import { loadNapcatConfig } from '@/napcat-core/helper/config';
|
||||
import { FFmpegService } from 'napcat-core/helper/ffmpeg/ffmpeg';
|
||||
import { logSubscription, LogWrapper } from 'napcat-core/helper/log';
|
||||
import { QQBasicInfoWrapper } from '@/napcat-core/helper/qq-basic-info';
|
||||
@@ -42,19 +43,22 @@ export async function NCoreInitFramework (
|
||||
const wrapper = loadQQWrapper(basicInfoWrapper.QQMainPath, basicInfoWrapper.getFullQQVersion());
|
||||
const nativePacketHandler = new NativePacketHandler({ logger }); // 初始化 NativePacketHandler 用于后续使用
|
||||
const napi2nativeLoader = new Napi2NativeLoader({ logger }); // 初始化 Napi2NativeLoader 用于后续使用
|
||||
const napcatConfig = loadNapcatConfig(pathWrapper.configPath);
|
||||
//console.log('[NapCat] [Napi2NativeLoader]', napi2nativeLoader.nativeExports.enableAllBypasses?.());
|
||||
if (process.env['NAPCAT_DISABLE_BYPASS'] !== '1') {
|
||||
const bypassEnabled = napi2nativeLoader.nativeExports.enableAllBypasses?.();
|
||||
const bypassOptions = napcatConfig.bypass ?? {};
|
||||
const bypassEnabled = napi2nativeLoader.nativeExports.enableAllBypasses?.(bypassOptions);
|
||||
if (bypassEnabled) {
|
||||
logger.log('[NapCat] Napi2NativeLoader: 已启用Bypass');
|
||||
}
|
||||
logger.log('[NapCat] Napi2NativeLoader: Framework模式Bypass配置:', bypassOptions);
|
||||
} else {
|
||||
logger.log('[NapCat] Napi2NativeLoader: Bypass已通过环境变量禁用');
|
||||
}
|
||||
// nativePacketHandler.onAll((packet) => {
|
||||
// console.log('[Packet]', packet.uin, packet.cmd, packet.hex_data);
|
||||
// });
|
||||
await nativePacketHandler.init(basicInfoWrapper.getFullQQVersion());
|
||||
await nativePacketHandler.init(basicInfoWrapper.getFullQQVersion(), napcatConfig.o3HookMode === 1 ? true : false);
|
||||
// 在 init 之后注册监听器
|
||||
|
||||
// 初始化 FFmpeg 服务
|
||||
|
||||
@@ -22,7 +22,8 @@
|
||||
"napcat-adapter": "workspace:*",
|
||||
"napcat-webui-backend": "workspace:*",
|
||||
"napcat-vite": "workspace:*",
|
||||
"napcat-qrcode": "workspace:*"
|
||||
"napcat-qrcode": "workspace:*",
|
||||
"json5": "^2.2.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.1"
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -196,9 +196,14 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> i
|
||||
* 创建插件上下文
|
||||
*/
|
||||
private createPluginContext (entry: PluginEntry): NapCatPluginContext {
|
||||
const dataPath = path.join(entry.pluginPath, 'data');
|
||||
const dataPath = path.join(this.core.context.pathWrapper.configPath, 'plugins', entry.id);
|
||||
const configPath = path.join(dataPath, 'config.json');
|
||||
|
||||
// 确保插件配置目录存在
|
||||
if (!fs.existsSync(dataPath)) {
|
||||
fs.mkdirSync(dataPath, { recursive: true });
|
||||
}
|
||||
|
||||
// 创建插件专用日志器
|
||||
const pluginPrefix = `[Plugin: ${entry.id}]`;
|
||||
const coreLogger = this.logger;
|
||||
@@ -358,7 +363,7 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> i
|
||||
}
|
||||
|
||||
const pluginPath = entry.pluginPath;
|
||||
const dataPath = path.join(pluginPath, 'data');
|
||||
const dataPath = path.join(this.core.context.pathWrapper.configPath, 'plugins', pluginId);
|
||||
|
||||
if (entry.loaded) {
|
||||
await this.unloadPlugin(entry);
|
||||
@@ -372,7 +377,7 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> i
|
||||
fs.rmSync(pluginPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// 清理数据
|
||||
// 清理插件配置数据
|
||||
if (cleanData && fs.existsSync(dataPath)) {
|
||||
fs.rmSync(dataPath, { recursive: true, force: true });
|
||||
}
|
||||
@@ -440,11 +445,7 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter<PluginConfig> i
|
||||
* 获取插件数据目录路径
|
||||
*/
|
||||
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');
|
||||
return path.join(this.core.context.pathWrapper.configPath, 'plugins', pluginId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -173,9 +173,14 @@ export class OB11PluginManager extends IOB11NetworkAdapter<PluginConfig> impleme
|
||||
* 创建插件上下文
|
||||
*/
|
||||
private createPluginContext (entry: PluginEntry): NapCatPluginContext {
|
||||
const dataPath = path.join(entry.pluginPath, 'data');
|
||||
const dataPath = path.join(this.core.context.pathWrapper.configPath, 'plugins', entry.id);
|
||||
const configPath = path.join(dataPath, 'config.json');
|
||||
|
||||
// 确保插件配置目录存在
|
||||
if (!fs.existsSync(dataPath)) {
|
||||
fs.mkdirSync(dataPath, { recursive: true });
|
||||
}
|
||||
|
||||
// 创建插件专用日志器
|
||||
const pluginPrefix = `[Plugin: ${entry.id}]`;
|
||||
const coreLogger = this.logger;
|
||||
@@ -323,7 +328,7 @@ export class OB11PluginManager extends IOB11NetworkAdapter<PluginConfig> impleme
|
||||
}
|
||||
|
||||
const pluginPath = entry.pluginPath;
|
||||
const dataPath = path.join(pluginPath, 'data');
|
||||
const dataPath = path.join(this.core.context.pathWrapper.configPath, 'plugins', pluginId);
|
||||
|
||||
// 先卸载插件
|
||||
await this.unloadPlugin(entry);
|
||||
@@ -336,7 +341,7 @@ export class OB11PluginManager extends IOB11NetworkAdapter<PluginConfig> impleme
|
||||
fs.rmSync(pluginPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// 清理数据
|
||||
// 清理插件配置数据
|
||||
if (cleanData && fs.existsSync(dataPath)) {
|
||||
fs.rmSync(dataPath, { recursive: true, force: true });
|
||||
}
|
||||
@@ -404,11 +409,7 @@ export class OB11PluginManager extends IOB11NetworkAdapter<PluginConfig> impleme
|
||||
* 获取插件数据目录路径
|
||||
*/
|
||||
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');
|
||||
return path.join(this.core.context.pathWrapper.configPath, 'plugins', pluginId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -31,12 +31,14 @@ import { sleep } from 'napcat-common/src/helper';
|
||||
import { FFmpegService } from '@/napcat-core/helper/ffmpeg/ffmpeg';
|
||||
import { NativePacketHandler } from 'napcat-core/packet/handler/client';
|
||||
import { Napi2NativeLoader } from 'napcat-core/packet/handler/napi2nativeLoader';
|
||||
import { loadNapcatConfig } from '@/napcat-core/helper/config';
|
||||
import { logSubscription, LogWrapper } from '@/napcat-core/helper/log';
|
||||
import { proxiedListenerOf } from '@/napcat-core/helper/proxy-handler';
|
||||
import { QQBasicInfoWrapper } from '@/napcat-core/helper/qq-basic-info';
|
||||
import { statusHelperSubscription } from '@/napcat-core/helper/status';
|
||||
import { applyPendingUpdates } from '@/napcat-webui-backend/src/api/UpdateNapCat';
|
||||
import { connectToNamedPipe } from './pipe';
|
||||
|
||||
// NapCat Shell App ES 入口文件
|
||||
async function handleUncaughtExceptions (logger: LogWrapper) {
|
||||
process.on('uncaughtException', (err) => {
|
||||
@@ -392,7 +394,6 @@ export async function NCoreInitShell () {
|
||||
const basicInfoWrapper = new QQBasicInfoWrapper({ logger });
|
||||
const nativePacketHandler = new NativePacketHandler({ logger });
|
||||
const napi2nativeLoader = new Napi2NativeLoader({ logger });
|
||||
await nativePacketHandler.init(basicInfoWrapper.getFullQQVersion());
|
||||
|
||||
// 初始化 FFmpeg 服务
|
||||
await FFmpegService.init(pathWrapper.binaryPath, logger);
|
||||
@@ -401,10 +402,17 @@ export async function NCoreInitShell () {
|
||||
await connectToNamedPipe(logger).catch(e => logger.logError('命名管道连接失败', e));
|
||||
}
|
||||
const wrapper = loadQQWrapper(basicInfoWrapper.QQMainPath, basicInfoWrapper.getFullQQVersion());
|
||||
|
||||
// wrapper.node 加载后再初始化 hook,按 schema 读取配置
|
||||
const napcatConfig = loadNapcatConfig(pathWrapper.configPath);
|
||||
await nativePacketHandler.init(basicInfoWrapper.getFullQQVersion(), napcatConfig.o3HookMode === 1 ? true : false);
|
||||
if (process.env['NAPCAT_ENABLE_VERBOSE_LOG'] === '1') {
|
||||
napi2nativeLoader.nativeExports.setVerbose?.(true);
|
||||
}
|
||||
// wrapper.node 加载后立刻启用 Bypass(可通过环境变量禁用)
|
||||
if (process.env['NAPCAT_DISABLE_BYPASS'] !== '1') {
|
||||
const bypassEnabled = napi2nativeLoader.nativeExports.enableAllBypasses?.();
|
||||
const bypassOptions = napcatConfig.bypass ?? {};
|
||||
logger.logDebug('[NapCat] Bypass 配置:', bypassOptions);
|
||||
const bypassEnabled = napi2nativeLoader.nativeExports.enableAllBypasses?.(bypassOptions);
|
||||
if (bypassEnabled) {
|
||||
logger.log('[NapCat] Napi2NativeLoader: 已启用Bypass');
|
||||
}
|
||||
@@ -461,6 +469,13 @@ export async function NCoreInitShell () {
|
||||
o3Service.reportAmgomWeather('login', 'a1', [dataTimestape, '0', '0']);
|
||||
|
||||
const selfInfo = await handleLogin(loginService, logger, pathWrapper, quickLoginUin, historyLoginList);
|
||||
|
||||
// 登录成功后通知 Master 进程(用于切换崩溃重试策略)
|
||||
if (typeof process.send === 'function') {
|
||||
process.send({ type: 'login-success' });
|
||||
logger.log('[NapCat] 已通知主进程登录成功');
|
||||
}
|
||||
|
||||
const amgomDataPiece = 'eb1fd6ac257461580dc7438eb099f23aae04ca679f4d88f53072dc56e3bb1129';
|
||||
o3Service.setAmgomDataPiece(basicInfoWrapper.QQVersionAppid, new Uint8Array(Buffer.from(amgomDataPiece, 'hex')));
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ const ENV = {
|
||||
|
||||
// Worker 消息类型
|
||||
interface WorkerMessage {
|
||||
type: 'restart' | 'restart-prepare' | 'shutdown';
|
||||
type: 'restart' | 'restart-prepare' | 'shutdown' | 'login-success';
|
||||
secretKey?: string;
|
||||
port?: number;
|
||||
}
|
||||
@@ -65,6 +65,7 @@ const recentCrashTimestamps: number[] = [];
|
||||
const CRASH_TIME_WINDOW = 10000; // 10秒时间窗口
|
||||
const MAX_CRASHES_IN_WINDOW = 3; // 最大崩溃次数
|
||||
|
||||
|
||||
/**
|
||||
* 获取进程类型名称(用于日志)
|
||||
*/
|
||||
@@ -275,6 +276,8 @@ async function startWorker (passQuickLogin: boolean = true, secretKey?: string,
|
||||
restartWorker(message.secretKey, message.port).catch(e => {
|
||||
logger.logError(`[NapCat] [${processType}] 重启Worker进程失败:`, e);
|
||||
});
|
||||
} else if (message.type === 'login-success') {
|
||||
logger.log(`[NapCat] [${processType}] Worker进程已登录成功,切换到正常重试策略`);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -297,13 +300,13 @@ async function startWorker (passQuickLogin: boolean = true, secretKey?: string,
|
||||
// 记录本次崩溃
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"napcat-qrcode": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"json5": "^2.2.3",
|
||||
"@types/node": "^22.0.1",
|
||||
"napcat-vite": "workspace:*"
|
||||
},
|
||||
|
||||
90
packages/napcat-webui-backend/src/api/NapCatConfig.ts
Normal file
90
packages/napcat-webui-backend/src/api/NapCatConfig.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { RequestHandler } from 'express';
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { webUiPathWrapper } from '@/napcat-webui-backend/index';
|
||||
import { sendError, sendSuccess } from '@/napcat-webui-backend/src/utils/response';
|
||||
import json5 from 'json5';
|
||||
|
||||
import Ajv from 'ajv';
|
||||
import { NapcatConfigSchema } from '@/napcat-core/helper/config';
|
||||
|
||||
// 动态获取 NapCat 配置默认值
|
||||
function getDefaultNapcatConfig (): Record<string, unknown> {
|
||||
const ajv = new Ajv({ useDefaults: true, coerceTypes: true });
|
||||
const validate = ajv.compile(NapcatConfigSchema);
|
||||
const data = {};
|
||||
validate(data);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 napcat 配置文件路径
|
||||
*/
|
||||
function getNapcatConfigPath (): string {
|
||||
return resolve(webUiPathWrapper.configPath, './napcat.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取 napcat 配置
|
||||
*/
|
||||
function readNapcatConfig (): Record<string, unknown> {
|
||||
const configPath = getNapcatConfigPath();
|
||||
try {
|
||||
if (existsSync(configPath)) {
|
||||
const content = readFileSync(configPath, 'utf-8');
|
||||
return { ...getDefaultNapcatConfig(), ...json5.parse(content) };
|
||||
}
|
||||
} catch (_e) {
|
||||
// 读取失败,使用默认值
|
||||
}
|
||||
return { ...getDefaultNapcatConfig() };
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入 napcat 配置
|
||||
*/
|
||||
function writeNapcatConfig (config: Record<string, unknown>): void {
|
||||
const configPath = resolve(webUiPathWrapper.configPath, './napcat.json');
|
||||
mkdirSync(webUiPathWrapper.configPath, { recursive: true });
|
||||
writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
// 获取 NapCat 配置
|
||||
export const NapCatGetConfigHandler: RequestHandler = (_, res) => {
|
||||
try {
|
||||
const config = readNapcatConfig();
|
||||
return sendSuccess(res, config);
|
||||
} catch (e) {
|
||||
return sendError(res, 'Config Get Error: ' + (e as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
// 设置 NapCat 配置
|
||||
export const NapCatSetConfigHandler: RequestHandler = (req, res) => {
|
||||
try {
|
||||
const newConfig = req.body;
|
||||
if (!newConfig || typeof newConfig !== 'object') {
|
||||
return sendError(res, 'config is empty or invalid');
|
||||
}
|
||||
|
||||
// 读取当前配置并合并
|
||||
const currentConfig = readNapcatConfig();
|
||||
const mergedConfig = { ...currentConfig, ...newConfig };
|
||||
|
||||
// 验证 bypass 字段
|
||||
if (mergedConfig.bypass && typeof mergedConfig.bypass === 'object') {
|
||||
const bypass = mergedConfig.bypass as Record<string, unknown>;
|
||||
const validKeys = ['hook', 'window', 'module', 'process', 'container', 'js'];
|
||||
for (const key of validKeys) {
|
||||
if (key in bypass && typeof bypass[key] !== 'boolean') {
|
||||
return sendError(res, `bypass.${key} must be boolean`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
writeNapcatConfig(mergedConfig);
|
||||
return sendSuccess(res, null);
|
||||
} catch (e) {
|
||||
return sendError(res, 'Config Set Error: ' + (e as Error).message);
|
||||
}
|
||||
};
|
||||
@@ -1,12 +1,21 @@
|
||||
import { RequestHandler } from 'express';
|
||||
import { sendError, sendSuccess } from '@/napcat-webui-backend/src/utils/response';
|
||||
import { PluginStoreList } from '@/napcat-webui-backend/src/types/PluginStore';
|
||||
import { PluginStoreList, PluginStoreItem } from '@/napcat-webui-backend/src/types/PluginStore';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { pipeline } from 'stream/promises';
|
||||
import { createWriteStream } from 'fs';
|
||||
import compressing from 'compressing';
|
||||
import { findAvailableDownloadUrl, GITHUB_RAW_MIRRORS } from 'napcat-common/src/mirror';
|
||||
import {
|
||||
fetchNpmPackageMetadata,
|
||||
fetchNpmLatestVersion,
|
||||
fetchNpmVersionInfo,
|
||||
downloadNpmTarball,
|
||||
extractAuthorName,
|
||||
extractHomepage,
|
||||
NPM_REGISTRY_MIRRORS,
|
||||
} from 'napcat-common/src/npm-registry';
|
||||
import { webUiPathWrapper } from '@/napcat-webui-backend/index';
|
||||
import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
|
||||
import { NapCatOneBot11Adapter } from '@/napcat-onebot/index';
|
||||
@@ -287,6 +296,92 @@ async function extractPlugin (zipPath: string, pluginId: string): Promise<void>
|
||||
console.log('[extractPlugin] Extracted files:', files);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解压 npm tarball (.tgz) 到指定目录
|
||||
* npm tarball 解压后通常有一个 "package/" 前缀目录,需要去掉
|
||||
*/
|
||||
async function extractNpmTarball (tgzPath: string, pluginId: string): Promise<void> {
|
||||
const safeId = validatePluginId(pluginId);
|
||||
const PLUGINS_DIR = getPluginsDir();
|
||||
const pluginDir = path.join(PLUGINS_DIR, safeId);
|
||||
const dataDir = path.join(pluginDir, 'data');
|
||||
const tempDataDir = path.join(PLUGINS_DIR, `${safeId}.data.backup`);
|
||||
const tempExtractDir = path.join(PLUGINS_DIR, `${safeId}.npm.temp`);
|
||||
|
||||
console.log(`[extractNpmTarball] pluginId: ${safeId}, tgz: ${tgzPath}`);
|
||||
|
||||
if (!fs.existsSync(PLUGINS_DIR)) {
|
||||
fs.mkdirSync(PLUGINS_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// 备份 data 目录
|
||||
let hasDataBackup = false;
|
||||
if (fs.existsSync(pluginDir)) {
|
||||
if (fs.existsSync(dataDir)) {
|
||||
if (fs.existsSync(tempDataDir)) {
|
||||
fs.rmSync(tempDataDir, { recursive: true, force: true });
|
||||
}
|
||||
fs.renameSync(dataDir, tempDataDir);
|
||||
hasDataBackup = true;
|
||||
}
|
||||
fs.rmSync(pluginDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// 创建临时解压目录
|
||||
if (fs.existsSync(tempExtractDir)) {
|
||||
fs.rmSync(tempExtractDir, { recursive: true, force: true });
|
||||
}
|
||||
fs.mkdirSync(tempExtractDir, { recursive: true });
|
||||
|
||||
try {
|
||||
// 解压 tgz(npm tarball 格式)
|
||||
await compressing.tgz.uncompress(tgzPath, tempExtractDir);
|
||||
|
||||
// npm tarball 解压后通常有 "package/" 目录
|
||||
const extractedItems = fs.readdirSync(tempExtractDir);
|
||||
let sourceDir = tempExtractDir;
|
||||
|
||||
if (extractedItems.length === 1 && extractedItems[0]) {
|
||||
const singleDir = path.join(tempExtractDir, extractedItems[0]);
|
||||
if (fs.statSync(singleDir).isDirectory()) {
|
||||
sourceDir = singleDir;
|
||||
}
|
||||
}
|
||||
|
||||
// 移动到目标目录
|
||||
fs.renameSync(sourceDir, pluginDir);
|
||||
|
||||
// 清理临时目录
|
||||
if (sourceDir !== tempExtractDir && fs.existsSync(tempExtractDir)) {
|
||||
fs.rmSync(tempExtractDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// 恢复 data 目录
|
||||
if (hasDataBackup && fs.existsSync(tempDataDir)) {
|
||||
if (fs.existsSync(dataDir)) {
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
fs.renameSync(tempDataDir, dataDir);
|
||||
}
|
||||
|
||||
console.log(`[extractNpmTarball] Extracted npm package to: ${pluginDir}`);
|
||||
} catch (e) {
|
||||
if (fs.existsSync(tempExtractDir)) {
|
||||
fs.rmSync(tempExtractDir, { recursive: true, force: true });
|
||||
}
|
||||
if (hasDataBackup && fs.existsSync(tempDataDir)) {
|
||||
if (!fs.existsSync(pluginDir)) {
|
||||
fs.mkdirSync(pluginDir, { recursive: true });
|
||||
}
|
||||
if (fs.existsSync(dataDir)) {
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
fs.renameSync(tempDataDir, dataDir);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件商店列表
|
||||
*/
|
||||
@@ -322,10 +417,11 @@ export const GetPluginStoreDetailHandler: RequestHandler = async (req, res) => {
|
||||
|
||||
/**
|
||||
* 安装插件(从商店)- 普通 POST 接口
|
||||
* 支持 npm 和 github 两种来源
|
||||
*/
|
||||
export const InstallPluginFromStoreHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
const { id: rawId, mirror } = req.body;
|
||||
const { id: rawId, mirror, registry } = req.body;
|
||||
|
||||
if (!rawId) {
|
||||
return sendError(res, 'Plugin ID is required');
|
||||
@@ -350,41 +446,62 @@ export const InstallPluginFromStoreHandler: RequestHandler = async (req, res) =>
|
||||
}
|
||||
}
|
||||
|
||||
// 下载插件
|
||||
const PLUGINS_DIR = getPluginsDir();
|
||||
const tempZipPath = path.join(PLUGINS_DIR, `${id}.temp.zip`);
|
||||
const isNpmSource = plugin.source === 'npm' && plugin.npmPackage;
|
||||
|
||||
try {
|
||||
await downloadFile(plugin.downloadUrl, tempZipPath, mirror, undefined, 300000);
|
||||
if (isNpmSource) {
|
||||
// npm 安装流程
|
||||
const tempTgzPath = path.join(PLUGINS_DIR, `${id}.temp.tgz`);
|
||||
try {
|
||||
const versionInfo = await fetchNpmLatestVersion(plugin.npmPackage!, registry);
|
||||
await downloadNpmTarball(versionInfo.dist.tarball, tempTgzPath, registry, undefined, 300000);
|
||||
await extractNpmTarball(tempTgzPath, id);
|
||||
fs.unlinkSync(tempTgzPath);
|
||||
|
||||
// 解压插件
|
||||
await extractPlugin(tempZipPath, id);
|
||||
|
||||
// 删除临时文件
|
||||
fs.unlinkSync(tempZipPath);
|
||||
|
||||
// 如果 pluginManager 存在,立即注册或重载插件
|
||||
const pluginManager = getPluginManager();
|
||||
if (pluginManager) {
|
||||
// 如果插件已存在,则重载以刷新版本信息;否则注册新插件
|
||||
if (pluginManager.getPluginInfo(id)) {
|
||||
await pluginManager.reloadPlugin(id);
|
||||
} else {
|
||||
await pluginManager.loadPluginById(id);
|
||||
const pluginManager = getPluginManager();
|
||||
if (pluginManager) {
|
||||
if (pluginManager.getPluginInfo(id)) {
|
||||
await pluginManager.reloadPlugin(id);
|
||||
} else {
|
||||
await pluginManager.loadPluginById(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sendSuccess(res, {
|
||||
message: 'Plugin installed successfully',
|
||||
plugin,
|
||||
installPath: path.join(PLUGINS_DIR, id),
|
||||
});
|
||||
} catch (downloadError: any) {
|
||||
// 清理临时文件
|
||||
if (fs.existsSync(tempZipPath)) {
|
||||
fs.unlinkSync(tempZipPath);
|
||||
return sendSuccess(res, {
|
||||
message: 'Plugin installed successfully (npm)',
|
||||
plugin,
|
||||
installPath: path.join(PLUGINS_DIR, id),
|
||||
});
|
||||
} catch (e: any) {
|
||||
if (fs.existsSync(tempTgzPath)) fs.unlinkSync(tempTgzPath);
|
||||
throw e;
|
||||
}
|
||||
} else {
|
||||
// GitHub 安装流程(向后兼容)
|
||||
const tempZipPath = path.join(PLUGINS_DIR, `${id}.temp.zip`);
|
||||
try {
|
||||
await downloadFile(plugin.downloadUrl, tempZipPath, mirror, undefined, 300000);
|
||||
await extractPlugin(tempZipPath, id);
|
||||
fs.unlinkSync(tempZipPath);
|
||||
|
||||
const pluginManager = getPluginManager();
|
||||
if (pluginManager) {
|
||||
if (pluginManager.getPluginInfo(id)) {
|
||||
await pluginManager.reloadPlugin(id);
|
||||
} else {
|
||||
await pluginManager.loadPluginById(id);
|
||||
}
|
||||
}
|
||||
|
||||
return sendSuccess(res, {
|
||||
message: 'Plugin installed successfully',
|
||||
plugin,
|
||||
installPath: path.join(PLUGINS_DIR, id),
|
||||
});
|
||||
} catch (downloadError: any) {
|
||||
if (fs.existsSync(tempZipPath)) fs.unlinkSync(tempZipPath);
|
||||
throw downloadError;
|
||||
}
|
||||
throw downloadError;
|
||||
}
|
||||
} catch (e: any) {
|
||||
return sendError(res, 'Failed to install plugin: ' + e.message);
|
||||
@@ -393,9 +510,10 @@ export const InstallPluginFromStoreHandler: RequestHandler = async (req, res) =>
|
||||
|
||||
/**
|
||||
* 安装插件(从商店)- SSE 版本,实时推送进度
|
||||
* 支持 npm 和 github 两种来源
|
||||
*/
|
||||
export const InstallPluginFromStoreSSEHandler: RequestHandler = async (req, res) => {
|
||||
const { id: rawId, mirror } = req.query;
|
||||
const { id: rawId, mirror, registry } = req.query;
|
||||
|
||||
if (!rawId || typeof rawId !== 'string') {
|
||||
res.status(400).json({ error: 'Plugin ID is required' });
|
||||
@@ -447,71 +565,145 @@ export const InstallPluginFromStoreSSEHandler: RequestHandler = async (req, res)
|
||||
}
|
||||
|
||||
sendProgress(`找到插件: ${plugin.name} v${plugin.version}`, 20);
|
||||
sendProgress(`下载地址: ${plugin.downloadUrl}`, 25);
|
||||
|
||||
if (mirror && typeof mirror === 'string') {
|
||||
sendProgress(`使用镜像: ${mirror}`, 28);
|
||||
}
|
||||
|
||||
// 下载插件
|
||||
const isNpmSource = plugin.source === 'npm' && plugin.npmPackage;
|
||||
const PLUGINS_DIR = getPluginsDir();
|
||||
const tempZipPath = path.join(PLUGINS_DIR, `${id}.temp.zip`);
|
||||
|
||||
try {
|
||||
sendProgress('正在下载插件...', 30);
|
||||
await downloadFile(plugin.downloadUrl, tempZipPath, mirror as string | undefined, (percent, downloaded, total, speed) => {
|
||||
const overallProgress = 30 + Math.round(percent * 0.5);
|
||||
const downloadedMb = (downloaded / 1024 / 1024).toFixed(1);
|
||||
const totalMb = total ? (total / 1024 / 1024).toFixed(1) : '?';
|
||||
const speedMb = (speed / 1024 / 1024).toFixed(2);
|
||||
const eta = (total > 0 && speed > 0) ? Math.round((total - downloaded) / speed) : -1;
|
||||
if (isNpmSource) {
|
||||
// ========== npm 安装流程 ==========
|
||||
const npmRegistry = (registry && typeof registry === 'string') ? registry : undefined;
|
||||
sendProgress(`来源: npm (${plugin.npmPackage})`, 25);
|
||||
|
||||
sendProgress(`正在下载插件... ${percent}%`, overallProgress, {
|
||||
downloaded,
|
||||
total,
|
||||
speed,
|
||||
eta,
|
||||
downloadedStr: `${downloadedMb}MB`,
|
||||
totalStr: `${totalMb}MB`,
|
||||
speedStr: `${speedMb}MB/s`,
|
||||
});
|
||||
}, 300000);
|
||||
if (npmRegistry) {
|
||||
sendProgress(`使用 npm 镜像: ${npmRegistry}`, 28);
|
||||
}
|
||||
|
||||
sendProgress('下载完成,正在解压...', 85);
|
||||
await extractPlugin(tempZipPath, id);
|
||||
const tempTgzPath = path.join(PLUGINS_DIR, `${id}.temp.tgz`);
|
||||
|
||||
sendProgress('解压完成,正在清理...', 95);
|
||||
fs.unlinkSync(tempZipPath);
|
||||
try {
|
||||
sendProgress('正在从 npm 获取版本信息...', 30);
|
||||
const versionInfo = await fetchNpmLatestVersion(plugin.npmPackage!, npmRegistry);
|
||||
sendProgress(`tarball: ${versionInfo.dist.tarball}`, 35);
|
||||
|
||||
// 如果 pluginManager 存在,立即注册或重载插件
|
||||
const pluginManager = getPluginManager();
|
||||
if (pluginManager) {
|
||||
// 如果插件已存在,则重载以刷新版本信息;否则注册新插件
|
||||
if (pluginManager.getPluginInfo(id)) {
|
||||
sendProgress('正在刷新插件信息...', 95);
|
||||
await pluginManager.reloadPlugin(id);
|
||||
} else {
|
||||
sendProgress('正在注册插件...', 95);
|
||||
await pluginManager.loadPluginById(id);
|
||||
sendProgress('正在下载插件包...', 40);
|
||||
await downloadNpmTarball(
|
||||
versionInfo.dist.tarball,
|
||||
tempTgzPath,
|
||||
npmRegistry,
|
||||
(percent, downloaded, total, speed) => {
|
||||
const overallProgress = 40 + Math.round(percent * 0.4);
|
||||
const downloadedMb = (downloaded / 1024 / 1024).toFixed(1);
|
||||
const totalMb = total ? (total / 1024 / 1024).toFixed(1) : '?';
|
||||
const speedMb = (speed / 1024 / 1024).toFixed(2);
|
||||
const eta = (total > 0 && speed > 0) ? Math.round((total - downloaded) / speed) : -1;
|
||||
|
||||
sendProgress(`正在下载插件... ${percent}%`, overallProgress, {
|
||||
downloaded,
|
||||
total,
|
||||
speed,
|
||||
eta,
|
||||
downloadedStr: `${downloadedMb}MB`,
|
||||
totalStr: `${totalMb}MB`,
|
||||
speedStr: `${speedMb}MB/s`,
|
||||
});
|
||||
},
|
||||
300000,
|
||||
);
|
||||
|
||||
sendProgress('下载完成,正在解压 npm 包...', 85);
|
||||
await extractNpmTarball(tempTgzPath, id);
|
||||
|
||||
sendProgress('解压完成,正在清理...', 95);
|
||||
fs.unlinkSync(tempTgzPath);
|
||||
|
||||
// 注册到 pluginManager
|
||||
const pluginManager = getPluginManager();
|
||||
if (pluginManager) {
|
||||
if (pluginManager.getPluginInfo(id)) {
|
||||
sendProgress('正在刷新插件信息...', 95);
|
||||
await pluginManager.reloadPlugin(id);
|
||||
} else {
|
||||
sendProgress('正在注册插件...', 95);
|
||||
await pluginManager.loadPluginById(id);
|
||||
}
|
||||
}
|
||||
|
||||
sendProgress('安装成功!', 100);
|
||||
res.write(`data: ${JSON.stringify({
|
||||
success: true,
|
||||
message: 'Plugin installed successfully (npm)',
|
||||
plugin,
|
||||
installPath: path.join(PLUGINS_DIR, id),
|
||||
})}\n\n`);
|
||||
res.end();
|
||||
} catch (downloadError: any) {
|
||||
if (fs.existsSync(tempTgzPath)) fs.unlinkSync(tempTgzPath);
|
||||
sendProgress(`错误: ${downloadError.message}`, 0);
|
||||
res.write(`data: ${JSON.stringify({ error: downloadError.message })}\n\n`);
|
||||
res.end();
|
||||
}
|
||||
} else {
|
||||
// ========== GitHub 安装流程(向后兼容)==========
|
||||
sendProgress(`来源: GitHub`, 25);
|
||||
sendProgress(`下载地址: ${plugin.downloadUrl}`, 25);
|
||||
|
||||
if (mirror && typeof mirror === 'string') {
|
||||
sendProgress(`使用镜像: ${mirror}`, 28);
|
||||
}
|
||||
|
||||
sendProgress('安装成功!', 100);
|
||||
res.write(`data: ${JSON.stringify({
|
||||
success: true,
|
||||
message: 'Plugin installed successfully',
|
||||
plugin,
|
||||
installPath: path.join(PLUGINS_DIR, id),
|
||||
})}\n\n`);
|
||||
res.end();
|
||||
} catch (downloadError: any) {
|
||||
// 清理临时文件
|
||||
if (fs.existsSync(tempZipPath)) {
|
||||
const tempZipPath = path.join(PLUGINS_DIR, `${id}.temp.zip`);
|
||||
|
||||
try {
|
||||
sendProgress('正在下载插件...', 30);
|
||||
await downloadFile(plugin.downloadUrl, tempZipPath, mirror as string | undefined, (percent, downloaded, total, speed) => {
|
||||
const overallProgress = 30 + Math.round(percent * 0.5);
|
||||
const downloadedMb = (downloaded / 1024 / 1024).toFixed(1);
|
||||
const totalMb = total ? (total / 1024 / 1024).toFixed(1) : '?';
|
||||
const speedMb = (speed / 1024 / 1024).toFixed(2);
|
||||
const eta = (total > 0 && speed > 0) ? Math.round((total - downloaded) / speed) : -1;
|
||||
|
||||
sendProgress(`正在下载插件... ${percent}%`, overallProgress, {
|
||||
downloaded,
|
||||
total,
|
||||
speed,
|
||||
eta,
|
||||
downloadedStr: `${downloadedMb}MB`,
|
||||
totalStr: `${totalMb}MB`,
|
||||
speedStr: `${speedMb}MB/s`,
|
||||
});
|
||||
}, 300000);
|
||||
|
||||
sendProgress('下载完成,正在解压...', 85);
|
||||
await extractPlugin(tempZipPath, id);
|
||||
|
||||
sendProgress('解压完成,正在清理...', 95);
|
||||
fs.unlinkSync(tempZipPath);
|
||||
|
||||
const pluginManager = getPluginManager();
|
||||
if (pluginManager) {
|
||||
if (pluginManager.getPluginInfo(id)) {
|
||||
sendProgress('正在刷新插件信息...', 95);
|
||||
await pluginManager.reloadPlugin(id);
|
||||
} else {
|
||||
sendProgress('正在注册插件...', 95);
|
||||
await pluginManager.loadPluginById(id);
|
||||
}
|
||||
}
|
||||
|
||||
sendProgress('安装成功!', 100);
|
||||
res.write(`data: ${JSON.stringify({
|
||||
success: true,
|
||||
message: 'Plugin installed successfully',
|
||||
plugin,
|
||||
installPath: path.join(PLUGINS_DIR, id),
|
||||
})}\n\n`);
|
||||
res.end();
|
||||
} catch (downloadError: any) {
|
||||
if (fs.existsSync(tempZipPath)) fs.unlinkSync(tempZipPath);
|
||||
sendProgress(`错误: ${downloadError.message}`, 0);
|
||||
res.write(`data: ${JSON.stringify({ error: downloadError.message })}\n\n`);
|
||||
res.end();
|
||||
}
|
||||
sendProgress(`错误: ${downloadError.message}`, 0);
|
||||
res.write(`data: ${JSON.stringify({ error: downloadError.message })}\n\n`);
|
||||
res.end();
|
||||
}
|
||||
} catch (e: any) {
|
||||
sendProgress(`错误: ${e.message}`, 0);
|
||||
@@ -519,3 +711,277 @@ export const InstallPluginFromStoreSSEHandler: RequestHandler = async (req, res)
|
||||
res.end();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 从 npm 直接安装插件(不依赖商店索引)
|
||||
* 通过 npm 包名直接安装
|
||||
*/
|
||||
export const InstallPluginFromNpmHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
const { packageName, version, registry } = req.body;
|
||||
|
||||
if (!packageName || typeof packageName !== 'string') {
|
||||
return sendError(res, 'npm 包名不能为空');
|
||||
}
|
||||
|
||||
// 验证包名格式(npm 包名规则)
|
||||
if (!/^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/.test(packageName)) {
|
||||
return sendError(res, '无效的 npm 包名格式');
|
||||
}
|
||||
|
||||
const PLUGINS_DIR = getPluginsDir();
|
||||
const tempTgzPath = path.join(PLUGINS_DIR, `${packageName.replace(/\//g, '-')}.temp.tgz`);
|
||||
|
||||
try {
|
||||
// 获取版本信息
|
||||
let versionInfo;
|
||||
if (version) {
|
||||
versionInfo = await fetchNpmVersionInfo(packageName, version, registry);
|
||||
} else {
|
||||
versionInfo = await fetchNpmLatestVersion(packageName, registry);
|
||||
}
|
||||
|
||||
const pluginId = versionInfo.name;
|
||||
|
||||
// 检查是否已安装相同版本
|
||||
const pm = getPluginManager();
|
||||
if (pm) {
|
||||
const installedInfo = pm.getPluginInfo(pluginId);
|
||||
if (installedInfo && installedInfo.version === versionInfo.version) {
|
||||
return sendError(res, '该插件已安装且版本相同,无需重复安装');
|
||||
}
|
||||
}
|
||||
|
||||
// 下载并解压
|
||||
await downloadNpmTarball(versionInfo.dist.tarball, tempTgzPath, registry, undefined, 300000);
|
||||
await extractNpmTarball(tempTgzPath, pluginId);
|
||||
fs.unlinkSync(tempTgzPath);
|
||||
|
||||
// 注册
|
||||
const pluginManager = getPluginManager();
|
||||
if (pluginManager) {
|
||||
if (pluginManager.getPluginInfo(pluginId)) {
|
||||
await pluginManager.reloadPlugin(pluginId);
|
||||
} else {
|
||||
await pluginManager.loadPluginById(pluginId);
|
||||
}
|
||||
}
|
||||
|
||||
return sendSuccess(res, {
|
||||
message: 'Plugin installed successfully from npm',
|
||||
pluginId,
|
||||
version: versionInfo.version,
|
||||
installPath: path.join(PLUGINS_DIR, pluginId),
|
||||
});
|
||||
} catch (e: any) {
|
||||
if (fs.existsSync(tempTgzPath)) fs.unlinkSync(tempTgzPath);
|
||||
throw e;
|
||||
}
|
||||
} catch (e: any) {
|
||||
return sendError(res, '从 npm 安装插件失败: ' + e.message);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 从 npm 直接安装插件 - SSE 版本
|
||||
*/
|
||||
export const InstallPluginFromNpmSSEHandler: RequestHandler = async (req, res) => {
|
||||
const { packageName, version, registry } = req.query;
|
||||
|
||||
if (!packageName || typeof packageName !== 'string') {
|
||||
res.status(400).json({ error: 'npm 包名不能为空' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/.test(packageName)) {
|
||||
res.status(400).json({ error: '无效的 npm 包名格式' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 设置 SSE 响应头
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.flushHeaders();
|
||||
|
||||
const sendProgress = (message: string, progress?: number, detail?: any) => {
|
||||
res.write(`data: ${JSON.stringify({ message, progress, ...detail })}\n\n`);
|
||||
};
|
||||
|
||||
const PLUGINS_DIR = getPluginsDir();
|
||||
const npmRegistry = (registry && typeof registry === 'string') ? registry : undefined;
|
||||
const tempTgzPath = path.join(PLUGINS_DIR, `${packageName.replace(/\//g, '-')}.temp.tgz`);
|
||||
|
||||
try {
|
||||
sendProgress('正在从 npm 获取包信息...', 10);
|
||||
|
||||
let versionInfo;
|
||||
if (version && typeof version === 'string') {
|
||||
versionInfo = await fetchNpmVersionInfo(packageName, version, npmRegistry);
|
||||
} else {
|
||||
versionInfo = await fetchNpmLatestVersion(packageName, npmRegistry);
|
||||
}
|
||||
|
||||
const pluginId = versionInfo.name;
|
||||
sendProgress(`找到包: ${pluginId} v${versionInfo.version}`, 20);
|
||||
|
||||
// 检查版本
|
||||
const pm = getPluginManager();
|
||||
if (pm) {
|
||||
const installedInfo = pm.getPluginInfo(pluginId);
|
||||
if (installedInfo && installedInfo.version === versionInfo.version) {
|
||||
sendProgress('错误: 该插件已安装且版本相同', 0);
|
||||
res.write(`data: ${JSON.stringify({ error: '该插件已安装且版本相同,无需重复安装' })}\n\n`);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
sendProgress(`tarball: ${versionInfo.dist.tarball}`, 25);
|
||||
if (npmRegistry) {
|
||||
sendProgress(`使用 npm 镜像: ${npmRegistry}`, 28);
|
||||
}
|
||||
|
||||
sendProgress('正在下载插件包...', 30);
|
||||
await downloadNpmTarball(
|
||||
versionInfo.dist.tarball,
|
||||
tempTgzPath,
|
||||
npmRegistry,
|
||||
(percent, downloaded, total, speed) => {
|
||||
const overallProgress = 30 + Math.round(percent * 0.5);
|
||||
const downloadedMb = (downloaded / 1024 / 1024).toFixed(1);
|
||||
const totalMb = total ? (total / 1024 / 1024).toFixed(1) : '?';
|
||||
const speedMb = (speed / 1024 / 1024).toFixed(2);
|
||||
const eta = (total > 0 && speed > 0) ? Math.round((total - downloaded) / speed) : -1;
|
||||
|
||||
sendProgress(`正在下载... ${percent}%`, overallProgress, {
|
||||
downloaded, total, speed, eta,
|
||||
downloadedStr: `${downloadedMb}MB`,
|
||||
totalStr: `${totalMb}MB`,
|
||||
speedStr: `${speedMb}MB/s`,
|
||||
});
|
||||
},
|
||||
300000,
|
||||
);
|
||||
|
||||
sendProgress('下载完成,正在解压...', 85);
|
||||
await extractNpmTarball(tempTgzPath, pluginId);
|
||||
|
||||
sendProgress('解压完成,正在清理...', 95);
|
||||
fs.unlinkSync(tempTgzPath);
|
||||
|
||||
const pluginManager = getPluginManager();
|
||||
if (pluginManager) {
|
||||
if (pluginManager.getPluginInfo(pluginId)) {
|
||||
sendProgress('正在刷新插件信息...', 95);
|
||||
await pluginManager.reloadPlugin(pluginId);
|
||||
} else {
|
||||
sendProgress('正在注册插件...', 95);
|
||||
await pluginManager.loadPluginById(pluginId);
|
||||
}
|
||||
}
|
||||
|
||||
sendProgress('安装成功!', 100);
|
||||
res.write(`data: ${JSON.stringify({
|
||||
success: true,
|
||||
message: 'Plugin installed successfully from npm',
|
||||
pluginId,
|
||||
version: versionInfo.version,
|
||||
installPath: path.join(PLUGINS_DIR, pluginId),
|
||||
})}\n\n`);
|
||||
res.end();
|
||||
} catch (e: any) {
|
||||
if (fs.existsSync(tempTgzPath)) fs.unlinkSync(tempTgzPath);
|
||||
sendProgress(`错误: ${e.message}`, 0);
|
||||
res.write(`data: ${JSON.stringify({ error: e.message })}\n\n`);
|
||||
res.end();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 搜索 npm 上的 NapCat 插件
|
||||
* 使用 npm search API 搜索带有特定关键字的包
|
||||
*/
|
||||
export const SearchNpmPluginsHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
const keyword = (req.query['keyword'] as string) || 'napcat-plugin';
|
||||
const registry = (req.query['registry'] as string) || NPM_REGISTRY_MIRRORS[0];
|
||||
const from = parseInt(req.query['from'] as string) || 0;
|
||||
const size = Math.min(parseInt(req.query['size'] as string) || 20, 50);
|
||||
|
||||
// npm search API: /-/v1/search?text=keyword
|
||||
const searchUrl = `${registry?.replace(/\/$/, '')}/-/v1/search?text=${encodeURIComponent(keyword)}&from=${from}&size=${size}`;
|
||||
|
||||
const response = await fetch(searchUrl, {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'User-Agent': 'NapCat-PluginManager',
|
||||
},
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const searchResult = await response.json() as any;
|
||||
|
||||
// 转换为 PluginStoreItem 格式
|
||||
const plugins: PluginStoreItem[] = (searchResult.objects || []).map((obj: any) => {
|
||||
const pkg = obj.package;
|
||||
return {
|
||||
id: pkg.name,
|
||||
name: pkg.napcat?.displayName || pkg.name,
|
||||
version: pkg.version,
|
||||
description: pkg.description || '',
|
||||
author: extractAuthorName(pkg.author || (pkg.publisher ? pkg.publisher.username : undefined)),
|
||||
homepage: extractHomepage(pkg.links?.homepage, pkg.links?.repository),
|
||||
downloadUrl: '', // npm 源不需要 downloadUrl
|
||||
tags: pkg.keywords || [],
|
||||
source: 'npm' as const,
|
||||
npmPackage: pkg.name,
|
||||
};
|
||||
});
|
||||
|
||||
return sendSuccess(res, {
|
||||
total: searchResult.total || 0,
|
||||
plugins,
|
||||
});
|
||||
} catch (e: any) {
|
||||
return sendError(res, '搜索 npm 插件失败: ' + e.message);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取 npm 包详情(版本列表、README 等)
|
||||
*/
|
||||
export const GetNpmPluginDetailHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
const packageName = req.params['packageName'];
|
||||
const registry = req.query['registry'] as string | undefined;
|
||||
|
||||
if (!packageName) {
|
||||
return sendError(res, '包名不能为空');
|
||||
}
|
||||
|
||||
const metadata = await fetchNpmPackageMetadata(packageName, registry);
|
||||
const latestVersion = metadata['dist-tags']?.['latest'] || '';
|
||||
const latestInfo = latestVersion ? metadata.versions[latestVersion] : null;
|
||||
|
||||
return sendSuccess(res, {
|
||||
name: metadata.name,
|
||||
description: metadata.description || '',
|
||||
latestVersion,
|
||||
author: extractAuthorName(metadata.author),
|
||||
homepage: extractHomepage(metadata.homepage, metadata.repository),
|
||||
readme: metadata.readme || '',
|
||||
versions: Object.keys(metadata.versions).reverse().slice(0, 20),
|
||||
keywords: metadata.keywords || [],
|
||||
tarball: latestInfo?.dist?.tarball || '',
|
||||
unpackedSize: latestInfo?.dist?.unpackedSize,
|
||||
napcat: latestInfo?.napcat,
|
||||
});
|
||||
} catch (e: any) {
|
||||
return sendError(res, '获取 npm 包详情失败: ' + e.message);
|
||||
}
|
||||
};
|
||||
|
||||
12
packages/napcat-webui-backend/src/router/NapCatConfig.ts
Normal file
12
packages/napcat-webui-backend/src/router/NapCatConfig.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Router } from 'express';
|
||||
|
||||
import { NapCatGetConfigHandler, NapCatSetConfigHandler } from '@/napcat-webui-backend/src/api/NapCatConfig';
|
||||
|
||||
const router: Router = Router();
|
||||
|
||||
// router:获取 NapCat 配置
|
||||
router.get('/GetConfig', NapCatGetConfigHandler);
|
||||
// router:设置 NapCat 配置
|
||||
router.post('/SetConfig', NapCatSetConfigHandler);
|
||||
|
||||
export { router as NapCatConfigRouter };
|
||||
@@ -18,7 +18,11 @@ import {
|
||||
GetPluginStoreListHandler,
|
||||
GetPluginStoreDetailHandler,
|
||||
InstallPluginFromStoreHandler,
|
||||
InstallPluginFromStoreSSEHandler
|
||||
InstallPluginFromStoreSSEHandler,
|
||||
InstallPluginFromNpmHandler,
|
||||
InstallPluginFromNpmSSEHandler,
|
||||
SearchNpmPluginsHandler,
|
||||
GetNpmPluginDetailHandler,
|
||||
} from '@/napcat-webui-backend/src/api/PluginStore';
|
||||
import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
|
||||
import { NapCatOneBot11Adapter } from '@/napcat-onebot/index';
|
||||
@@ -75,6 +79,12 @@ router.get('/Store/Detail/:id', GetPluginStoreDetailHandler);
|
||||
router.post('/Store/Install', InstallPluginFromStoreHandler);
|
||||
router.get('/Store/Install/SSE', InstallPluginFromStoreSSEHandler);
|
||||
|
||||
// npm 插件安装相关路由
|
||||
router.post('/Npm/Install', InstallPluginFromNpmHandler);
|
||||
router.get('/Npm/Install/SSE', InstallPluginFromNpmSSEHandler);
|
||||
router.get('/Npm/Search', SearchNpmPluginsHandler);
|
||||
router.get('/Npm/Detail/:packageName', GetNpmPluginDetailHandler);
|
||||
|
||||
// 插件扩展路由 - 动态挂载插件注册的 API 路由
|
||||
router.use('/ext/:pluginId', (req, res, next): void => {
|
||||
const { pluginId } = req.params;
|
||||
|
||||
@@ -19,6 +19,7 @@ import DebugRouter from '@/napcat-webui-backend/src/api/Debug';
|
||||
import { ProcessRouter } from './Process';
|
||||
import { PluginRouter } from './Plugin';
|
||||
import { MirrorRouter } from './Mirror';
|
||||
import { NapCatConfigRouter } from './NapCatConfig';
|
||||
|
||||
const router: Router = Router();
|
||||
|
||||
@@ -53,5 +54,7 @@ router.use('/Process', ProcessRouter);
|
||||
router.use('/Plugin', PluginRouter);
|
||||
// router:镜像管理相关路由
|
||||
router.use('/Mirror', MirrorRouter);
|
||||
// router:NapCat配置相关路由
|
||||
router.use('/NapCatConfig', NapCatConfigRouter);
|
||||
|
||||
export { router as ALLRouter };
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
// 插件商店相关类型定义
|
||||
|
||||
/** 插件来源类型 */
|
||||
export type PluginSourceType = 'npm' | 'github';
|
||||
|
||||
export interface PluginStoreItem {
|
||||
id: string; // 插件唯一标识
|
||||
name: string; // 插件名称
|
||||
@@ -7,9 +10,13 @@ export interface PluginStoreItem {
|
||||
description: string; // 插件描述
|
||||
author: string; // 作者
|
||||
homepage?: string; // 主页链接
|
||||
downloadUrl: string; // 下载地址
|
||||
downloadUrl: string; // 下载地址(GitHub 模式兼容)
|
||||
tags?: string[]; // 标签
|
||||
minVersion?: string; // 最低版本要求
|
||||
/** 插件来源类型,默认 'github' 保持向后兼容 */
|
||||
source?: PluginSourceType;
|
||||
/** npm 包名(当 source 为 'npm' 时使用) */
|
||||
npmPackage?: string;
|
||||
}
|
||||
|
||||
export interface PluginStoreList {
|
||||
|
||||
@@ -54,7 +54,7 @@ const AddButton: React.FC<AddButtonProps> = (props) => {
|
||||
textValue='title'
|
||||
>
|
||||
<div className='flex items-center gap-2 justify-center'>
|
||||
<div className='w-5 h-5 -ml-3'>
|
||||
<div className='w-5 h-5 -ml-3 flex items-center justify-center'>
|
||||
<PlusIcon />
|
||||
</div>
|
||||
<div className='text-primary-400'>新建网络配置</div>
|
||||
|
||||
@@ -185,6 +185,19 @@ const PluginStoreCard: React.FC<PluginStoreCardProps> = ({
|
||||
v{version}
|
||||
</Chip>
|
||||
|
||||
{/* 来源标识 */}
|
||||
{data.source === 'npm' && (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='flat'
|
||||
color='danger'
|
||||
className='h-5 text-xs font-semibold px-0.5'
|
||||
classNames={{ content: 'px-1' }}
|
||||
>
|
||||
npm
|
||||
</Chip>
|
||||
)}
|
||||
|
||||
{/* Tags with proper truncation and hover */}
|
||||
{tags?.slice(0, 2).map((tag) => (
|
||||
<Chip
|
||||
|
||||
@@ -164,16 +164,49 @@ export default class PluginManager {
|
||||
/**
|
||||
* 从商店安装插件
|
||||
* @param id 插件 ID
|
||||
* @param mirror 镜像源
|
||||
* @param mirror 镜像源(GitHub 模式)
|
||||
* @param registry npm 镜像源(npm 模式)
|
||||
*/
|
||||
public static async installPluginFromStore (id: string, mirror?: string): Promise<void> {
|
||||
public static async installPluginFromStore (id: string, mirror?: string, registry?: string): Promise<void> {
|
||||
await serverRequest.post<ServerResponse<void>>(
|
||||
'/Plugin/Store/Install',
|
||||
{ id, mirror },
|
||||
{ id, mirror, registry },
|
||||
{ timeout: 300000 } // 5分钟超时
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== npm 插件安装 ====================
|
||||
|
||||
/** npm 搜索结果 */
|
||||
public static async searchNpmPlugins (
|
||||
keyword: string = 'napcat-plugin',
|
||||
registry?: string,
|
||||
from: number = 0,
|
||||
size: number = 20,
|
||||
): Promise<{ total: number; plugins: PluginStoreItem[]; }> {
|
||||
const params: Record<string, string> = { keyword, from: String(from), size: String(size) };
|
||||
if (registry) params['registry'] = registry;
|
||||
const { data } = await serverRequest.get<ServerResponse<{ total: number; plugins: PluginStoreItem[]; }>>('/Plugin/Npm/Search', { params });
|
||||
return data.data;
|
||||
}
|
||||
|
||||
/** 获取 npm 包详情 */
|
||||
public static async getNpmPluginDetail (packageName: string, registry?: string): Promise<any> {
|
||||
const params: Record<string, string> = {};
|
||||
if (registry) params['registry'] = registry;
|
||||
const { data } = await serverRequest.get<ServerResponse<any>>(`/Plugin/Npm/Detail/${encodeURIComponent(packageName)}`, { params });
|
||||
return data.data;
|
||||
}
|
||||
|
||||
/** 从 npm 直接安装插件 */
|
||||
public static async installPluginFromNpm (packageName: string, version?: string, registry?: string): Promise<void> {
|
||||
await serverRequest.post<ServerResponse<void>>(
|
||||
'/Plugin/Npm/Install',
|
||||
{ packageName, version, registry },
|
||||
{ timeout: 300000 },
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== 插件配置 ====================
|
||||
|
||||
/**
|
||||
|
||||
@@ -178,5 +178,23 @@ export default class QQManager {
|
||||
public static async resetLinuxDeviceID () {
|
||||
await serverRequest.post<ServerResponse<null>>('/QQLogin/ResetLinuxDeviceID');
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// NapCat 配置管理
|
||||
// ============================================================
|
||||
|
||||
public static async getNapCatConfig () {
|
||||
const { data } = await serverRequest.get<ServerResponse<NapCatConfig>>(
|
||||
'/NapCatConfig/GetConfig'
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
public static async setNapCatConfig (config: Partial<NapCatConfig>) {
|
||||
await serverRequest.post<ServerResponse<null>>(
|
||||
'/NapCatConfig/SetConfig',
|
||||
config
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
import SaveButtons from '@/components/button/save_buttons';
|
||||
import PageLoading from '@/components/page_loading';
|
||||
import SwitchCard from '@/components/switch_card';
|
||||
|
||||
import QQManager from '@/controllers/qq_manager';
|
||||
|
||||
interface BypassFormData {
|
||||
hook: boolean;
|
||||
window: boolean;
|
||||
module: boolean;
|
||||
process: boolean;
|
||||
container: boolean;
|
||||
js: boolean;
|
||||
o3HookMode: boolean;
|
||||
}
|
||||
|
||||
|
||||
const BypassConfigCard = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { isSubmitting },
|
||||
setValue,
|
||||
} = useForm<BypassFormData>();
|
||||
|
||||
const loadConfig = async (showTip = false) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const config = await QQManager.getNapCatConfig();
|
||||
const bypass = config.bypass ?? {} as Partial<BypassOptions>;
|
||||
setValue('hook', bypass.hook ?? false);
|
||||
setValue('window', bypass.window ?? false);
|
||||
setValue('module', bypass.module ?? false);
|
||||
setValue('process', bypass.process ?? false);
|
||||
setValue('container', bypass.container ?? false);
|
||||
setValue('js', bypass.js ?? false);
|
||||
setValue('o3HookMode', config.o3HookMode === 1);
|
||||
if (showTip) toast.success('刷新成功');
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
toast.error(`获取配置失败: ${msg}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = handleSubmit(async (data) => {
|
||||
try {
|
||||
const { o3HookMode, ...bypass } = data;
|
||||
await QQManager.setNapCatConfig({ bypass, o3HookMode: o3HookMode ? 1 : 0 });
|
||||
toast.success('保存成功,重启后生效');
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
toast.error(`保存失败: ${msg}`);
|
||||
}
|
||||
});
|
||||
|
||||
const onReset = () => {
|
||||
loadConfig();
|
||||
};
|
||||
|
||||
const onRefresh = async () => {
|
||||
await loadConfig(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig();
|
||||
}, []);
|
||||
|
||||
if (loading) return <PageLoading loading />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>反检测配置 - NapCat WebUI</title>
|
||||
<div className='flex flex-col gap-1 mb-2'>
|
||||
<h3 className='text-lg font-semibold text-default-700'>反检测开关配置</h3>
|
||||
<p className='text-sm text-default-500'>
|
||||
控制 Napi2Native 模块的各项反检测功能,修改后需重启生效。
|
||||
</p>
|
||||
</div>
|
||||
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3'>
|
||||
<Controller
|
||||
control={control}
|
||||
name='hook'
|
||||
render={({ field }) => (
|
||||
<SwitchCard
|
||||
{...field}
|
||||
label='Hook'
|
||||
description='hook特征隐藏'
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name='window'
|
||||
render={({ field }) => (
|
||||
<SwitchCard
|
||||
{...field}
|
||||
label='Window'
|
||||
description='窗口伪造'
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name='module'
|
||||
render={({ field }) => (
|
||||
<SwitchCard
|
||||
{...field}
|
||||
label='Module'
|
||||
description='加载模块隐藏'
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name='process'
|
||||
render={({ field }) => (
|
||||
<SwitchCard
|
||||
{...field}
|
||||
label='Process'
|
||||
description='进程反检测'
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name='container'
|
||||
render={({ field }) => (
|
||||
<SwitchCard
|
||||
{...field}
|
||||
label='Container'
|
||||
description='容器反检测'
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name='js'
|
||||
render={({ field }) => (
|
||||
<SwitchCard
|
||||
{...field}
|
||||
label='JS'
|
||||
description='JS反检测'
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name='o3HookMode'
|
||||
render={({ field }) => (
|
||||
<SwitchCard
|
||||
{...field}
|
||||
label='o3HookMode'
|
||||
description='O3 Hook 模式'
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<SaveButtons
|
||||
onSubmit={onSubmit}
|
||||
reset={onReset}
|
||||
isSubmitting={isSubmitting}
|
||||
refresh={onRefresh}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default BypassConfigCard;
|
||||
@@ -14,6 +14,7 @@ import SSLConfigCard from './ssl';
|
||||
import ThemeConfigCard from './theme';
|
||||
import WebUIConfigCard from './webui';
|
||||
import BackupConfigCard from './backup';
|
||||
import BypassConfigCard from './bypass';
|
||||
|
||||
export interface ConfigPageProps {
|
||||
children?: React.ReactNode;
|
||||
@@ -114,6 +115,11 @@ export default function ConfigPage () {
|
||||
<BackupConfigCard />
|
||||
</ConfigPageItem>
|
||||
</Tab>
|
||||
<Tab title='反检测' key='bypass'>
|
||||
<ConfigPageItem>
|
||||
<BypassConfigCard />
|
||||
</ConfigPageItem>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -131,7 +131,7 @@ const WebUIConfigCard = () => {
|
||||
isLoading={isLoadingOptions}
|
||||
className='w-fit'
|
||||
>
|
||||
{!isLoadingOptions && '📥'}
|
||||
{!isLoadingOptions}
|
||||
准备选项
|
||||
</Button>
|
||||
<Button
|
||||
@@ -225,12 +225,12 @@ const WebUIConfigCard = () => {
|
||||
disabled={!registrationOptions}
|
||||
className='w-fit'
|
||||
>
|
||||
🔐 注册Passkey
|
||||
注册Passkey
|
||||
</Button>
|
||||
</div>
|
||||
{registrationOptions && (
|
||||
<div className='text-xs text-green-600'>
|
||||
✅ 注册选项已准备就绪,可以开始注册
|
||||
注册选项已准备就绪,可以开始注册
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -115,39 +115,42 @@ export default function ExtensionPage () {
|
||||
</Button>
|
||||
</div>
|
||||
{extensionPages.length > 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: 'hidden',
|
||||
}}
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<Tab
|
||||
key={tab.key}
|
||||
title={
|
||||
<div className='flex items-center gap-2'>
|
||||
{tab.icon && <span>{tab.icon}</span>}
|
||||
<span
|
||||
className='cursor-pointer hover:underline truncate max-w-[6rem] md:max-w-none'
|
||||
title={`插件:${tab.pluginName}\n点击在新窗口打开`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openInNewWindow(tab.pluginId, tab.path);
|
||||
}}
|
||||
>
|
||||
{tab.title}
|
||||
</span>
|
||||
<span className='text-xs text-default-400 hidden md:inline'>({tab.pluginName})</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Tabs>
|
||||
<div className='max-w-full overflow-x-auto overflow-y-hidden pb-1 -mb-1'>
|
||||
<Tabs
|
||||
aria-label='Extension Pages'
|
||||
className='min-w-max'
|
||||
selectedKey={selectedTab}
|
||||
onSelectionChange={(key) => setSelectedTab(key as string)}
|
||||
classNames={{
|
||||
tabList: 'bg-white/40 dark:bg-black/20 backdrop-blur-md flex-nowrap',
|
||||
cursor: 'bg-white/80 dark:bg-white/10 backdrop-blur-md shadow-sm',
|
||||
panel: 'hidden',
|
||||
}}
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<Tab
|
||||
key={tab.key}
|
||||
className='shrink-0'
|
||||
title={
|
||||
<div className='flex items-center gap-2'>
|
||||
{tab.icon && <span>{tab.icon}</span>}
|
||||
<span
|
||||
className='cursor-pointer hover:underline truncate max-w-[6rem] md:max-w-none shrink-0'
|
||||
title={`插件:${tab.pluginName}\n点击在新窗口打开`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openInNewWindow(tab.pluginId, tab.path);
|
||||
}}
|
||||
>
|
||||
{tab.title}
|
||||
</span>
|
||||
<span className='text-xs text-default-400 hidden md:inline shrink-0'>({tab.pluginName})</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -61,30 +61,28 @@ export default function PluginPage () {
|
||||
|
||||
const handleUninstall = async (plugin: PluginItem) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
let cleanData = false;
|
||||
dialog.confirm({
|
||||
title: '卸载插件',
|
||||
content: (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p>确定要卸载插件「{plugin.name}」吗? 此操作不可恢复。</p>
|
||||
<p className="text-small text-default-500">如果插件创建了数据文件,是否一并删除?</p>
|
||||
<p className="text-base text-default-800">确定要卸载插件「<span className="font-semibold text-danger">{plugin.name}</span>」吗? 此操作不可恢复。</p>
|
||||
<div className="mt-2 bg-default-100 dark:bg-default-50/10 p-3 rounded-lg flex flex-col gap-1">
|
||||
<label className="flex items-center gap-2 cursor-pointer w-fit">
|
||||
<input
|
||||
type="checkbox"
|
||||
onChange={(e) => { cleanData = e.target.checked; }}
|
||||
className="w-4 h-4 cursor-pointer accent-danger"
|
||||
/>
|
||||
<span className="text-small font-medium text-default-700">同时删除其配置文件</span>
|
||||
</label>
|
||||
<p className="text-xs text-default-500 pl-6 break-all w-full">配置目录: config/plugins/{plugin.id}</p>
|
||||
</div>
|
||||
</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?
|
||||
confirmText: '确定卸载',
|
||||
cancelText: '取消',
|
||||
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.id, cleanData);
|
||||
|
||||
@@ -374,10 +374,10 @@ export default function PluginConfigModal ({ isOpen, onOpenChange, pluginId }: P
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color="danger" variant="light" onPress={onClose}>
|
||||
Close
|
||||
关闭
|
||||
</Button>
|
||||
<Button color="primary" onPress={handleSave} isLoading={saving}>
|
||||
Save
|
||||
保存
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
|
||||
@@ -6,11 +6,13 @@ import { Tooltip } from '@heroui/tooltip';
|
||||
import { Spinner } from '@heroui/spinner';
|
||||
import { IoMdCheckmarkCircle, IoMdOpen, IoMdDownload } from 'react-icons/io';
|
||||
import { MdUpdate } from 'react-icons/md';
|
||||
import { FaNpm } from 'react-icons/fa';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
import { PluginStoreItem } from '@/types/plugin-store';
|
||||
import { InstallStatus } from '@/components/display_card/plugin_store_card';
|
||||
import TailwindMarkdown from '@/components/tailwind_markdown';
|
||||
import PluginManagerController from '@/controllers/plugin_manager';
|
||||
|
||||
interface PluginDetailModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -124,12 +126,15 @@ export default function PluginDetailModal ({
|
||||
const [readmeLoading, setReadmeLoading] = useState(false);
|
||||
const [readmeError, setReadmeError] = useState(false);
|
||||
|
||||
// 判断插件来源
|
||||
const isNpmSource = plugin?.source === 'npm';
|
||||
|
||||
// 获取 GitHub 仓库信息(需要在 hooks 之前计算)
|
||||
const githubRepo = plugin ? extractGitHubRepo(plugin.homepage) : null;
|
||||
|
||||
// 当模态框打开且有 GitHub 链接时,获取 README
|
||||
// 当模态框打开时,获取 README(npm 或 GitHub)
|
||||
useEffect(() => {
|
||||
if (!isOpen || !githubRepo) {
|
||||
if (!isOpen || !plugin) {
|
||||
setReadme('');
|
||||
setReadmeError(false);
|
||||
return;
|
||||
@@ -139,9 +144,22 @@ export default function PluginDetailModal ({
|
||||
setReadmeLoading(true);
|
||||
setReadmeError(false);
|
||||
try {
|
||||
const content = await fetchGitHubReadme(githubRepo.owner, githubRepo.repo);
|
||||
// 清理 HTML 标签后再设置
|
||||
setReadme(cleanReadmeHtml(content));
|
||||
if (isNpmSource && plugin.npmPackage) {
|
||||
// npm 来源:从后端获取 npm 包详情中的 README
|
||||
const detail = await PluginManagerController.getNpmPluginDetail(plugin.npmPackage);
|
||||
if (detail?.readme) {
|
||||
setReadme(cleanReadmeHtml(detail.readme));
|
||||
} else {
|
||||
setReadmeError(true);
|
||||
}
|
||||
} else if (githubRepo) {
|
||||
// GitHub 来源:从 GitHub API 获取 README
|
||||
const content = await fetchGitHubReadme(githubRepo.owner, githubRepo.repo);
|
||||
setReadme(cleanReadmeHtml(content));
|
||||
} else {
|
||||
// 无可用的 README 来源
|
||||
setReadme('');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch README:', error);
|
||||
setReadmeError(true);
|
||||
@@ -151,11 +169,11 @@ export default function PluginDetailModal ({
|
||||
};
|
||||
|
||||
loadReadme();
|
||||
}, [isOpen, githubRepo?.owner, githubRepo?.repo]);
|
||||
}, [isOpen, plugin?.id, isNpmSource, plugin?.npmPackage, githubRepo?.owner, githubRepo?.repo]);
|
||||
|
||||
if (!plugin) return null;
|
||||
|
||||
const { name, version, author, description, tags, homepage, downloadUrl, minVersion } = plugin;
|
||||
const { name, version, author, description, tags, homepage, downloadUrl, minVersion, npmPackage } = plugin;
|
||||
const avatarUrl = getAuthorAvatar(homepage, downloadUrl) || `https://avatar.vercel.sh/${encodeURIComponent(name)}`;
|
||||
|
||||
return (
|
||||
@@ -213,6 +231,16 @@ export default function PluginDetailModal ({
|
||||
<Chip size='sm' color='primary' variant='flat'>
|
||||
v{version}
|
||||
</Chip>
|
||||
{isNpmSource && (
|
||||
<Chip
|
||||
size='sm'
|
||||
color='danger'
|
||||
variant='flat'
|
||||
startContent={<FaNpm size={14} />}
|
||||
>
|
||||
npm
|
||||
</Chip>
|
||||
)}
|
||||
{tags?.map((tag) => (
|
||||
<Chip
|
||||
key={tag}
|
||||
@@ -281,6 +309,23 @@ export default function PluginDetailModal ({
|
||||
<span className='text-default-500'>插件 ID:</span>
|
||||
<span className='font-mono text-xs text-default-900'>{plugin.id}</span>
|
||||
</div>
|
||||
{npmPackage && (
|
||||
<div className='flex justify-between items-center'>
|
||||
<span className='text-default-500'>npm 包名:</span>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='flat'
|
||||
color='danger'
|
||||
as='a'
|
||||
href={`https://www.npmjs.com/package/${npmPackage}`}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
startContent={<FaNpm size={14} />}
|
||||
>
|
||||
{npmPackage}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{downloadUrl && (
|
||||
<div className='flex justify-between items-center'>
|
||||
<span className='text-default-500'>下载地址:</span>
|
||||
@@ -301,8 +346,8 @@ export default function PluginDetailModal ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* GitHub README 显示 */}
|
||||
{githubRepo && (
|
||||
{/* README 显示(支持 npm 和 GitHub) */}
|
||||
{(githubRepo || isNpmSource) && (
|
||||
<>
|
||||
<div className='mt-2'>
|
||||
<h3 className='text-sm font-semibold text-default-700 mb-3'>详情</h3>
|
||||
@@ -316,17 +361,19 @@ export default function PluginDetailModal ({
|
||||
<p className='text-sm text-default-500 mb-3'>
|
||||
无法加载 README
|
||||
</p>
|
||||
<Button
|
||||
color='primary'
|
||||
variant='flat'
|
||||
as='a'
|
||||
href={homepage}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
startContent={<IoMdOpen />}
|
||||
>
|
||||
在 GitHub 查看
|
||||
</Button>
|
||||
{homepage && (
|
||||
<Button
|
||||
color='primary'
|
||||
variant='flat'
|
||||
as='a'
|
||||
href={homepage}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
startContent={<IoMdOpen />}
|
||||
>
|
||||
{isNpmSource ? '在 npm 查看' : '在 GitHub 查看'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!readmeLoading && !readmeError && readme && (
|
||||
|
||||
@@ -3,9 +3,11 @@ import { Input } from '@heroui/input';
|
||||
import { Tab, Tabs } from '@heroui/tabs';
|
||||
import { Tooltip } from '@heroui/tooltip';
|
||||
import { Spinner } from '@heroui/spinner';
|
||||
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from '@heroui/modal';
|
||||
import { useEffect, useMemo, useState, useRef } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { IoMdRefresh, IoMdSearch, IoMdSettings } from 'react-icons/io';
|
||||
import { MdOutlineGetApp } from 'react-icons/md';
|
||||
import clsx from 'clsx';
|
||||
import { EventSourcePolyfill } from 'event-source-polyfill';
|
||||
import { useLocalStorage } from '@uidotdev/usehooks';
|
||||
@@ -82,6 +84,13 @@ export default function PluginStorePage () {
|
||||
const [pendingInstallPlugin, setPendingInstallPlugin] = useState<PluginStoreItem | null>(null);
|
||||
const [selectedDownloadMirror, setSelectedDownloadMirror] = useState<string | undefined>(undefined);
|
||||
|
||||
// npm 注册表镜像弹窗状态
|
||||
const [npmRegistryModalOpen, setNpmRegistryModalOpen] = useState(false);
|
||||
const [selectedNpmRegistry, setSelectedNpmRegistry] = useState<string | undefined>(undefined);
|
||||
|
||||
// npm 直接安装弹窗状态
|
||||
const [npmInstallModalOpen, setNpmInstallModalOpen] = useState(false);
|
||||
|
||||
// 插件详情弹窗状态
|
||||
const [detailModalOpen, setDetailModalOpen] = useState(false);
|
||||
const [selectedPlugin, setSelectedPlugin] = useState<PluginStoreItem | null>(null);
|
||||
@@ -179,12 +188,19 @@ export default function PluginStorePage () {
|
||||
}, [categorizedPlugins]);
|
||||
|
||||
const handleInstall = async (plugin: PluginStoreItem) => {
|
||||
// 弹窗选择下载镜像
|
||||
setPendingInstallPlugin(plugin);
|
||||
setDownloadMirrorModalOpen(true);
|
||||
const isNpmSource = plugin.source === 'npm' && plugin.npmPackage;
|
||||
if (isNpmSource) {
|
||||
// npm 源 → 选择 npm registry 镜像
|
||||
setPendingInstallPlugin(plugin);
|
||||
setNpmRegistryModalOpen(true);
|
||||
} else {
|
||||
// GitHub 源(默认/向后兼容)→ 选择 GitHub 下载镜像
|
||||
setPendingInstallPlugin(plugin);
|
||||
setDownloadMirrorModalOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const installPluginWithSSE = async (pluginId: string, mirror?: string) => {
|
||||
const installPluginWithSSE = async (pluginId: string, mirror?: string, registry?: string) => {
|
||||
const loadingToast = toast.loading('正在准备安装...');
|
||||
|
||||
try {
|
||||
@@ -200,6 +216,9 @@ export default function PluginStorePage () {
|
||||
if (mirror) {
|
||||
params.append('mirror', mirror);
|
||||
}
|
||||
if (registry) {
|
||||
params.append('registry', registry);
|
||||
}
|
||||
|
||||
const eventSource = new EventSourcePolyfill(
|
||||
`/api/Plugin/Store/Install/SSE?${params.toString()}`,
|
||||
@@ -288,6 +307,74 @@ export default function PluginStorePage () {
|
||||
}
|
||||
};
|
||||
|
||||
const installNpmPackageWithSSE = async (packageName: string, registry?: string) => {
|
||||
const loadingToast = toast.loading('正在从 npm 安装...');
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem(key.token);
|
||||
if (!token) {
|
||||
toast.error('未登录,请先登录', { id: loadingToast });
|
||||
return;
|
||||
}
|
||||
const _token = JSON.parse(token);
|
||||
|
||||
const params = new URLSearchParams({ packageName });
|
||||
if (registry) params.append('registry', registry);
|
||||
|
||||
const eventSource = new EventSourcePolyfill(
|
||||
`/api/Plugin/Npm/Install/SSE?${params.toString()}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${_token}`,
|
||||
Accept: 'text/event-stream',
|
||||
},
|
||||
withCredentials: true,
|
||||
}
|
||||
);
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.error) {
|
||||
toast.error(`安装失败: ${data.error}`, { id: loadingToast });
|
||||
setInstallProgress(prev => ({ ...prev, show: false }));
|
||||
eventSource.close();
|
||||
} else if (data.success) {
|
||||
toast.success('从 npm 安装成功!', { id: loadingToast });
|
||||
setInstallProgress(prev => ({ ...prev, show: false }));
|
||||
eventSource.close();
|
||||
loadPlugins();
|
||||
} else if (data.message) {
|
||||
if (typeof data.progress === 'number' && data.progress >= 0 && data.progress <= 100) {
|
||||
setInstallProgress((prev) => ({
|
||||
...prev,
|
||||
show: true,
|
||||
message: data.message,
|
||||
progress: data.progress,
|
||||
speedStr: data.speedStr || (data.message.includes('下载') ? prev.speedStr : undefined),
|
||||
eta: data.eta !== undefined ? data.eta : (data.message.includes('下载') ? prev.eta : undefined),
|
||||
downloadedStr: data.downloadedStr || (data.message.includes('下载') ? prev.downloadedStr : undefined),
|
||||
totalStr: data.totalStr || (data.message.includes('下载') ? prev.totalStr : undefined),
|
||||
}));
|
||||
} else {
|
||||
toast.loading(data.message, { id: loadingToast });
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse SSE message:', e);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = () => {
|
||||
toast.error('连接中断,安装失败', { id: loadingToast });
|
||||
setInstallProgress(prev => ({ ...prev, show: false }));
|
||||
eventSource.close();
|
||||
};
|
||||
} catch (error: any) {
|
||||
toast.error(`安装失败: ${error.message || '未知错误'}`, { id: loadingToast });
|
||||
}
|
||||
};
|
||||
|
||||
const getStoreSourceDisplayName = () => {
|
||||
if (!currentStoreSource) return '默认源';
|
||||
try {
|
||||
@@ -329,6 +416,18 @@ export default function PluginStorePage () {
|
||||
<IoMdRefresh size={20} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content='从 npm 包名安装插件'>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='flat'
|
||||
className='bg-default-100/50 hover:bg-default-200/50 text-default-700 backdrop-blur-md'
|
||||
radius='full'
|
||||
startContent={<MdOutlineGetApp size={18} />}
|
||||
onPress={() => setNpmInstallModalOpen(true)}
|
||||
>
|
||||
npm 安装
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* 顶栏搜索框与列表源 */}
|
||||
@@ -428,7 +527,7 @@ export default function PluginStorePage () {
|
||||
type='raw'
|
||||
/>
|
||||
|
||||
{/* 下载镜像选择弹窗 */}
|
||||
{/* 下载镜像选择弹窗(GitHub 源插件使用) */}
|
||||
<MirrorSelectorModal
|
||||
isOpen={downloadMirrorModalOpen}
|
||||
onClose={() => {
|
||||
@@ -440,7 +539,7 @@ export default function PluginStorePage () {
|
||||
// 选择后立即开始安装
|
||||
if (pendingInstallPlugin) {
|
||||
setDownloadMirrorModalOpen(false);
|
||||
installPluginWithSSE(pendingInstallPlugin.id, mirror);
|
||||
installPluginWithSSE(pendingInstallPlugin.id, mirror, undefined);
|
||||
setPendingInstallPlugin(null);
|
||||
}
|
||||
}}
|
||||
@@ -448,6 +547,24 @@ export default function PluginStorePage () {
|
||||
type='file'
|
||||
/>
|
||||
|
||||
{/* npm Registry 选择弹窗(npm 源插件使用) */}
|
||||
<NpmRegistrySelectorModal
|
||||
isOpen={npmRegistryModalOpen}
|
||||
onClose={() => {
|
||||
setNpmRegistryModalOpen(false);
|
||||
setPendingInstallPlugin(null);
|
||||
}}
|
||||
onSelect={(registry) => {
|
||||
setSelectedNpmRegistry(registry);
|
||||
if (pendingInstallPlugin) {
|
||||
setNpmRegistryModalOpen(false);
|
||||
installPluginWithSSE(pendingInstallPlugin.id, undefined, registry);
|
||||
setPendingInstallPlugin(null);
|
||||
}
|
||||
}}
|
||||
currentRegistry={selectedNpmRegistry}
|
||||
/>
|
||||
|
||||
{/* 插件详情弹窗 */}
|
||||
<PluginDetailModal
|
||||
isOpen={detailModalOpen}
|
||||
@@ -470,6 +587,17 @@ export default function PluginStorePage () {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* npm 直接安装弹窗 */}
|
||||
<NpmDirectInstallModal
|
||||
isOpen={npmInstallModalOpen}
|
||||
onClose={() => setNpmInstallModalOpen(false)}
|
||||
onInstall={(packageName, registry) => {
|
||||
setNpmInstallModalOpen(false);
|
||||
// 使用 SSE 安装 npm 包
|
||||
installNpmPackageWithSSE(packageName, registry);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 插件下载进度条全局居中样式 */}
|
||||
{installProgress.show && (
|
||||
<div className='fixed inset-0 flex items-center justify-center z-[9999] animate-in fade-in duration-300'>
|
||||
@@ -529,3 +657,249 @@ export default function PluginStorePage () {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ============== npm Registry 选择弹窗 ==============
|
||||
|
||||
const NPM_REGISTRIES = [
|
||||
{ label: '淘宝镜像(推荐)', value: 'https://registry.npmmirror.com', recommended: true },
|
||||
{ label: 'npm 官方', value: 'https://registry.npmjs.org' },
|
||||
];
|
||||
|
||||
interface NpmRegistrySelectorModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (registry: string | undefined) => void;
|
||||
currentRegistry?: string;
|
||||
}
|
||||
|
||||
function NpmRegistrySelectorModal ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSelect,
|
||||
currentRegistry,
|
||||
}: NpmRegistrySelectorModalProps) {
|
||||
const [selected, setSelected] = useState<string>(currentRegistry || NPM_REGISTRIES[0]?.value || '');
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size='md'
|
||||
classNames={{
|
||||
backdrop: 'z-[200]',
|
||||
wrapper: 'z-[200]',
|
||||
}}
|
||||
>
|
||||
<ModalContent>
|
||||
<ModalHeader>选择 npm 镜像源</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className='flex flex-col gap-2'>
|
||||
{NPM_REGISTRIES.map((reg) => (
|
||||
<div
|
||||
key={reg.value}
|
||||
className={clsx(
|
||||
'flex items-center justify-between p-3 rounded-lg cursor-pointer transition-all',
|
||||
'bg-content1 hover:bg-content2 border-2',
|
||||
selected === reg.value ? 'border-primary' : 'border-transparent',
|
||||
)}
|
||||
onClick={() => setSelected(reg.value)}
|
||||
>
|
||||
<div>
|
||||
<p className='font-medium'>{reg.label}</p>
|
||||
<p className='text-xs text-default-500'>{reg.value}</p>
|
||||
</div>
|
||||
{reg.recommended && (
|
||||
<span className='text-xs bg-primary/10 text-primary px-2 py-0.5 rounded-full'>推荐</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant='light' onPress={onClose}>取消</Button>
|
||||
<Button color='primary' onPress={() => { onSelect(selected); onClose(); }}>
|
||||
确认并安装
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ============== npm 直接安装弹窗 ==============
|
||||
|
||||
interface NpmDirectInstallModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onInstall: (packageName: string, registry?: string) => void;
|
||||
}
|
||||
|
||||
function NpmDirectInstallModal ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onInstall,
|
||||
}: NpmDirectInstallModalProps) {
|
||||
const [packageName, setPackageName] = useState('');
|
||||
const [registry, setRegistry] = useState(NPM_REGISTRIES[0]?.value || '');
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<PluginStoreItem[]>([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<string>('search');
|
||||
|
||||
const handleInstall = () => {
|
||||
if (!packageName.trim()) {
|
||||
toast.error('请输入 npm 包名');
|
||||
return;
|
||||
}
|
||||
onInstall(packageName.trim(), registry);
|
||||
setPackageName('');
|
||||
};
|
||||
|
||||
const handleSearch = async () => {
|
||||
const keyword = searchKeyword.trim() || 'napcat-plugin';
|
||||
setSearching(true);
|
||||
try {
|
||||
const result = await PluginManager.searchNpmPlugins(keyword, registry);
|
||||
setSearchResults(result.plugins || []);
|
||||
if (result.plugins.length === 0) {
|
||||
toast('未找到相关插件', { icon: '🔍' });
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error('搜索失败: ' + (error?.message || '未知错误'));
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size='2xl'
|
||||
scrollBehavior='inside'
|
||||
classNames={{
|
||||
backdrop: 'z-[200]',
|
||||
wrapper: 'z-[200]',
|
||||
}}
|
||||
>
|
||||
<ModalContent>
|
||||
<ModalHeader>从 npm 安装插件</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className='flex flex-col gap-4'>
|
||||
{/* npm 镜像源选择 */}
|
||||
<div className='flex flex-col gap-2'>
|
||||
<p className='text-sm font-medium'>npm 镜像源</p>
|
||||
<div className='flex gap-2'>
|
||||
{NPM_REGISTRIES.map((reg) => (
|
||||
<div
|
||||
key={reg.value}
|
||||
className={clsx(
|
||||
'flex-1 flex items-center justify-center p-2 rounded-lg cursor-pointer transition-all text-sm',
|
||||
'bg-content1 hover:bg-content2 border-2',
|
||||
registry === reg.value ? 'border-primary' : 'border-transparent',
|
||||
)}
|
||||
onClick={() => setRegistry(reg.value)}
|
||||
>
|
||||
<span>{reg.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 搜索 / 手动输入 切换 */}
|
||||
<Tabs
|
||||
selectedKey={activeTab}
|
||||
onSelectionChange={(key) => setActiveTab(key as string)}
|
||||
variant='underlined'
|
||||
color='primary'
|
||||
>
|
||||
<Tab key='search' title='搜索插件'>
|
||||
<div className='flex flex-col gap-3'>
|
||||
<div className='flex gap-2'>
|
||||
<Input
|
||||
placeholder='搜索 napcat 插件...'
|
||||
value={searchKeyword}
|
||||
onValueChange={setSearchKeyword}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleSearch(); }}
|
||||
startContent={<IoMdSearch className='text-default-400' />}
|
||||
size='sm'
|
||||
/>
|
||||
<Button
|
||||
color='primary'
|
||||
size='sm'
|
||||
onPress={handleSearch}
|
||||
isLoading={searching}
|
||||
className='flex-shrink-0'
|
||||
>
|
||||
搜索
|
||||
</Button>
|
||||
</div>
|
||||
{/* 搜索结果列表 */}
|
||||
{searchResults.length > 0 && (
|
||||
<div className='flex flex-col gap-2 max-h-64 overflow-y-auto'>
|
||||
{searchResults.map((pkg) => (
|
||||
<div
|
||||
key={pkg.id}
|
||||
className='flex items-center justify-between p-3 rounded-lg bg-content1 hover:bg-content2 transition-all'
|
||||
>
|
||||
<div className='flex-1 min-w-0'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='font-medium text-sm truncate'>{pkg.name}</span>
|
||||
<span className='text-xs text-default-400'>v{pkg.version}</span>
|
||||
</div>
|
||||
<p className='text-xs text-default-500 mt-1 truncate'>
|
||||
{pkg.description || '暂无描述'}
|
||||
</p>
|
||||
{pkg.author && (
|
||||
<p className='text-xs text-default-400 mt-0.5'>
|
||||
by {pkg.author}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size='sm'
|
||||
color='primary'
|
||||
variant='flat'
|
||||
onPress={() => {
|
||||
onInstall(pkg.npmPackage || pkg.id, registry);
|
||||
}}
|
||||
className='flex-shrink-0 ml-2'
|
||||
>
|
||||
安装
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tab>
|
||||
<Tab key='manual' title='手动输入'>
|
||||
<div className='flex flex-col gap-3'>
|
||||
<p className='text-sm text-default-500'>
|
||||
输入 npm 包名直接安装插件,适合安装未上架的第三方插件。
|
||||
</p>
|
||||
<Input
|
||||
label='npm 包名'
|
||||
placeholder='例如: napcat-plugin-example'
|
||||
value={packageName}
|
||||
onValueChange={setPackageName}
|
||||
description='输入完整的 npm 包名'
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleInstall(); }}
|
||||
/>
|
||||
</div>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant='light' onPress={onClose}>取消</Button>
|
||||
{activeTab === 'manual' && (
|
||||
<Button color='primary' onPress={handleInstall} isDisabled={!packageName.trim()}>
|
||||
安装
|
||||
</Button>
|
||||
)}
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
19
packages/napcat-webui-frontend/src/types/napcat_conf.d.ts
vendored
Normal file
19
packages/napcat-webui-frontend/src/types/napcat_conf.d.ts
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
interface BypassOptions {
|
||||
hook: boolean;
|
||||
window: boolean;
|
||||
module: boolean;
|
||||
process: boolean;
|
||||
container: boolean;
|
||||
js: boolean;
|
||||
}
|
||||
|
||||
interface NapCatConfig {
|
||||
fileLog: boolean;
|
||||
consoleLog: boolean;
|
||||
fileLogLevel: string;
|
||||
consoleLogLevel: string;
|
||||
packetBackend: string;
|
||||
packetServer: string;
|
||||
o3HookMode: number;
|
||||
bypass?: BypassOptions;
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
// 插件商店相关类型定义
|
||||
|
||||
/** 插件来源类型 */
|
||||
export type PluginSourceType = 'npm' | 'github';
|
||||
|
||||
export interface PluginStoreItem {
|
||||
id: string; // 插件唯一标识
|
||||
name: string; // 插件名称
|
||||
@@ -7,9 +10,13 @@ export interface PluginStoreItem {
|
||||
description: string; // 插件描述
|
||||
author: string; // 作者
|
||||
homepage?: string; // 主页链接
|
||||
downloadUrl: string; // 下载地址
|
||||
downloadUrl: string; // 下载地址(GitHub 模式兼容)
|
||||
tags?: string[]; // 标签
|
||||
minVersion?: string; // 最低版本要求
|
||||
/** 插件来源类型,默认 'github' 保持向后兼容 */
|
||||
source?: PluginSourceType;
|
||||
/** npm 包名(当 source 为 'npm' 时使用) */
|
||||
npmPackage?: string;
|
||||
}
|
||||
|
||||
export interface PluginStoreList {
|
||||
|
||||
45
pnpm-lock.yaml
generated
45
pnpm-lock.yaml
generated
@@ -136,6 +136,9 @@ importers:
|
||||
|
||||
packages/napcat-framework:
|
||||
dependencies:
|
||||
json5:
|
||||
specifier: ^2.2.3
|
||||
version: 2.2.3
|
||||
napcat-adapter:
|
||||
specifier: workspace:*
|
||||
version: link:../napcat-adapter
|
||||
@@ -357,6 +360,9 @@ importers:
|
||||
'@types/node':
|
||||
specifier: ^22.0.1
|
||||
version: 22.19.1
|
||||
json5:
|
||||
specifier: ^2.2.3
|
||||
version: 2.2.3
|
||||
napcat-vite:
|
||||
specifier: workspace:*
|
||||
version: link:../napcat-vite
|
||||
@@ -1907,89 +1913,105 @@ packages:
|
||||
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-arm@1.2.4':
|
||||
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-ppc64@1.2.4':
|
||||
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-riscv64@1.2.4':
|
||||
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-s390x@1.2.4':
|
||||
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-x64@1.2.4':
|
||||
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
|
||||
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
|
||||
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-linux-arm64@0.34.5':
|
||||
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-arm@0.34.5':
|
||||
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-ppc64@0.34.5':
|
||||
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-riscv64@0.34.5':
|
||||
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-s390x@0.34.5':
|
||||
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-x64@0.34.5':
|
||||
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linuxmusl-arm64@0.34.5':
|
||||
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-linuxmusl-x64@0.34.5':
|
||||
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-wasm32@0.34.5':
|
||||
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
|
||||
@@ -2740,56 +2762,67 @@ packages:
|
||||
resolution: {integrity: sha512-EPlb95nUsz6Dd9Qy13fI5kUPXNSljaG9FiJ4YUGU1O/Q77i5DYFW5KR8g1OzTcdZUqQQ1KdDqsTohdFVwCwjqg==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.53.2':
|
||||
resolution: {integrity: sha512-BOmnVW+khAUX+YZvNfa0tGTEMVVEerOxN0pDk2E6N6DsEIa2Ctj48FOMfNDdrwinocKaC7YXUZ1pHlKpnkja/Q==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.53.2':
|
||||
resolution: {integrity: sha512-Xt2byDZ+6OVNuREgBXr4+CZDJtrVso5woFtpKdGPhpTPHcNG7D8YXeQzpNbFRxzTVqJf7kvPMCub/pcGUWgBjA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.53.2':
|
||||
resolution: {integrity: sha512-+LdZSldy/I9N8+klim/Y1HsKbJ3BbInHav5qE9Iy77dtHC/pibw1SR/fXlWyAk0ThnpRKoODwnAuSjqxFRDHUQ==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-loong64-gnu@4.53.2':
|
||||
resolution: {integrity: sha512-8ms8sjmyc1jWJS6WdNSA23rEfdjWB30LH8Wqj0Cqvv7qSHnvw6kgMMXRdop6hkmGPlyYBdRPkjJnj3KCUHV/uQ==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.53.2':
|
||||
resolution: {integrity: sha512-3HRQLUQbpBDMmzoxPJYd3W6vrVHOo2cVW8RUo87Xz0JPJcBLBr5kZ1pGcQAhdZgX9VV7NbGNipah1omKKe23/g==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.53.2':
|
||||
resolution: {integrity: sha512-fMjKi+ojnmIvhk34gZP94vjogXNNUKMEYs+EDaB/5TG/wUkoeua7p7VCHnE6T2Tx+iaghAqQX8teQzcvrYpaQA==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.53.2':
|
||||
resolution: {integrity: sha512-XuGFGU+VwUUV5kLvoAdi0Wz5Xbh2SrjIxCtZj6Wq8MDp4bflb/+ThZsVxokM7n0pcbkEr2h5/pzqzDYI7cCgLQ==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.53.2':
|
||||
resolution: {integrity: sha512-w6yjZF0P+NGzWR3AXWX9zc0DNEGdtvykB03uhonSHMRa+oWA6novflo2WaJr6JZakG2ucsyb+rvhrKac6NIy+w==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.53.2':
|
||||
resolution: {integrity: sha512-yo8d6tdfdeBArzC7T/PnHd7OypfI9cbuZzPnzLJIyKYFhAQ8SvlkKtKBMbXDxe1h03Rcr7u++nFS7tqXz87Gtw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.53.2':
|
||||
resolution: {integrity: sha512-ah59c1YkCxKExPP8O9PwOvs+XRLKwh/mV+3YdKqQ5AMQ0r4M4ZDuOrpWkUaqO7fzAHdINzV9tEVu8vNw48z0lA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-openharmony-arm64@4.53.2':
|
||||
resolution: {integrity: sha512-4VEd19Wmhr+Zy7hbUsFZ6YXEiP48hE//KPLCSVNY5RMGX2/7HZ+QkN55a3atM1C/BZCGIgqN+xrVgtdak2S9+A==}
|
||||
@@ -2864,24 +2897,28 @@ packages:
|
||||
engines: {node: '>=10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@swc/core-linux-arm64-musl@1.15.1':
|
||||
resolution: {integrity: sha512-fKzP9mRQGbhc5QhJPIsqKNNX/jyWrZgBxmo3Nz1SPaepfCUc7RFmtcJQI5q8xAun3XabXjh90wqcY/OVyg2+Kg==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@swc/core-linux-x64-gnu@1.15.1':
|
||||
resolution: {integrity: sha512-ZLjMi138uTJxb+1wzo4cB8mIbJbAsSLWRNeHc1g1pMvkERPWOGlem+LEYkkzaFzCNv1J8aKcL653Vtw8INHQeg==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@swc/core-linux-x64-musl@1.15.1':
|
||||
resolution: {integrity: sha512-jvSI1IdsIYey5kOITzyajjofXOOySVitmLxb45OPUjoNojql4sDojvlW5zoHXXFePdA6qAX4Y6KbzAOV3T3ctA==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@swc/core-win32-arm64-msvc@1.15.1':
|
||||
resolution: {integrity: sha512-X/FcDtNrDdY9r4FcXHt9QxUqC/2FbQdvZobCKHlHe8vTSKhUHOilWl5EBtkFVfsEs4D5/yAri9e3bJbwyBhhBw==}
|
||||
@@ -3246,41 +3283,49 @@ packages:
|
||||
resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-arm64-musl@1.11.1':
|
||||
resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@unrs/resolver-binding-linux-ppc64-gnu@1.11.1':
|
||||
resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-riscv64-gnu@1.11.1':
|
||||
resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-riscv64-musl@1.11.1':
|
||||
resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@unrs/resolver-binding-linux-s390x-gnu@1.11.1':
|
||||
resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-x64-gnu@1.11.1':
|
||||
resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-x64-musl@1.11.1':
|
||||
resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@unrs/resolver-binding-wasm32-wasi@1.11.1':
|
||||
resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
|
||||
|
||||
Reference in New Issue
Block a user