mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-03-01 08:10:25 +00:00
feat: 插件系统引入npm
This commit is contained in:
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user