mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-02-04 06:31:13 +00:00
Implement real plugin store backend and install logic
Replaces mock plugin store data with live fetching from a remote source using mirrors and caching. Implements actual plugin download, extraction, and installation logic in the backend. Updates frontend to call the new install API and display installation results, and removes unused icon/rating display from plugin cards.
This commit is contained in:
parent
8b676ed693
commit
24623f18d8
@ -1,126 +1,166 @@
|
|||||||
import { RequestHandler } from 'express';
|
import { RequestHandler } from 'express';
|
||||||
import { sendError, sendSuccess } from '@/napcat-webui-backend/src/utils/response';
|
import { sendError, sendSuccess } from '@/napcat-webui-backend/src/utils/response';
|
||||||
import { PluginStoreList } from '@/napcat-webui-backend/src/types/PluginStore';
|
import { PluginStoreList } 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';
|
||||||
|
|
||||||
// Mock数据 - 模拟远程插件列表
|
// 插件商店源配置
|
||||||
const mockPluginStoreData: PluginStoreList = {
|
const PLUGIN_STORE_SOURCES = [
|
||||||
version: '1.0.0',
|
'https://raw.githubusercontent.com/NapNeko/napcat-plugin-index/main/plugins.v4.json',
|
||||||
updateTime: new Date().toISOString(),
|
];
|
||||||
plugins: [
|
|
||||||
{
|
// 插件目录
|
||||||
id: 'napcat-plugin-example',
|
const PLUGINS_DIR = path.join(process.cwd(), 'plugins');
|
||||||
name: '示例插件',
|
|
||||||
version: '1.0.0',
|
// 确保插件目录存在
|
||||||
description: '这是一个示例插件,展示如何开发NapCat插件',
|
if (!fs.existsSync(PLUGINS_DIR)) {
|
||||||
author: 'NapCat Team',
|
fs.mkdirSync(PLUGINS_DIR, { recursive: true });
|
||||||
homepage: 'https://github.com/NapNeko/NapCatQQ',
|
}
|
||||||
repository: 'https://github.com/NapNeko/NapCatQQ',
|
|
||||||
downloadUrl: 'https://example.com/plugins/napcat-plugin-example-1.0.0.zip',
|
// 插件列表缓存
|
||||||
tags: ['示例', '教程'],
|
let pluginListCache: PluginStoreList | null = null;
|
||||||
screenshots: ['https://picsum.photos/800/600?random=1'],
|
let cacheTimestamp: number = 0;
|
||||||
minNapCatVersion: '1.0.0',
|
const CACHE_TTL = 10 * 60 * 1000; // 10分钟缓存
|
||||||
downloads: 1234,
|
|
||||||
rating: 4.5,
|
/**
|
||||||
createdAt: '2024-01-01T00:00:00Z',
|
* 从多个源获取插件列表,使用镜像系统
|
||||||
updatedAt: '2024-01-20T00:00:00Z',
|
* 带10分钟缓存
|
||||||
},
|
*/
|
||||||
{
|
async function fetchPluginList (): Promise<PluginStoreList> {
|
||||||
id: 'napcat-plugin-auto-reply',
|
// 检查缓存
|
||||||
name: '自动回复插件',
|
const now = Date.now();
|
||||||
version: '2.1.0',
|
if (pluginListCache && (now - cacheTimestamp) < CACHE_TTL) {
|
||||||
description: '支持关键词匹配的自动回复功能,可配置多种回复规则',
|
//console.log('Using cached plugin list');
|
||||||
author: 'Community',
|
return pluginListCache;
|
||||||
homepage: 'https://github.com/example/auto-reply',
|
}
|
||||||
repository: 'https://github.com/example/auto-reply',
|
|
||||||
downloadUrl: 'https://example.com/plugins/napcat-plugin-auto-reply-2.1.0.zip',
|
const errors: string[] = [];
|
||||||
tags: ['自动回复', '消息处理'],
|
|
||||||
minNapCatVersion: '1.0.0',
|
for (const source of PLUGIN_STORE_SOURCES) {
|
||||||
downloads: 5678,
|
// 使用镜像系统的 raw 镜像列表
|
||||||
rating: 4.8,
|
for (const mirror of GITHUB_RAW_MIRRORS) {
|
||||||
createdAt: '2024-01-05T00:00:00Z',
|
try {
|
||||||
updatedAt: '2024-01-22T00:00:00Z',
|
const url = mirror ? `${mirror}/${source.replace('https://raw.githubusercontent.com/', '')}` : source;
|
||||||
},
|
|
||||||
{
|
const response = await fetch(url, {
|
||||||
id: 'napcat-plugin-welcome',
|
headers: {
|
||||||
name: '入群欢迎插件',
|
'User-Agent': 'NapCat-WebUI',
|
||||||
version: '1.2.3',
|
},
|
||||||
description: '新成员入群时自动发送欢迎消息,支持自定义欢迎语',
|
signal: AbortSignal.timeout(10000), // 10秒超时
|
||||||
author: 'Developer',
|
});
|
||||||
homepage: 'https://github.com/example/welcome',
|
|
||||||
repository: 'https://github.com/example/welcome',
|
if (!response.ok) {
|
||||||
downloadUrl: 'https://example.com/plugins/napcat-plugin-welcome-1.2.3.zip',
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||||
tags: ['欢迎', '群管理'],
|
}
|
||||||
minNapCatVersion: '1.0.0',
|
|
||||||
downloads: 3456,
|
const data = await response.json();
|
||||||
rating: 4.3,
|
//console.log(`Successfully fetched plugin list from: ${url}`);
|
||||||
createdAt: '2024-01-10T00:00:00Z',
|
|
||||||
updatedAt: '2024-01-18T00:00:00Z',
|
// 更新缓存
|
||||||
},
|
pluginListCache = data as PluginStoreList;
|
||||||
{
|
cacheTimestamp = now;
|
||||||
id: 'napcat-plugin-music',
|
|
||||||
name: '音乐点歌插件',
|
return pluginListCache;
|
||||||
version: '3.0.1',
|
} catch (e: any) {
|
||||||
description: '支持网易云、QQ音乐等平台的点歌功能',
|
const errorMsg = `Failed to fetch from ${source} via mirror: ${e.message}`;
|
||||||
author: 'Music Lover',
|
console.warn(errorMsg);
|
||||||
homepage: 'https://github.com/example/music',
|
errors.push(errorMsg);
|
||||||
repository: 'https://github.com/example/music',
|
}
|
||||||
downloadUrl: 'https://example.com/plugins/napcat-plugin-music-3.0.1.zip',
|
}
|
||||||
tags: ['音乐', '娱乐'],
|
}
|
||||||
screenshots: ['https://picsum.photos/800/600?random=4', 'https://picsum.photos/800/600?random=5'],
|
|
||||||
minNapCatVersion: '1.1.0',
|
throw new Error(`All plugin sources failed:\n${errors.join('\n')}`);
|
||||||
downloads: 8901,
|
}
|
||||||
rating: 4.9,
|
|
||||||
createdAt: '2023-12-01T00:00:00Z',
|
/**
|
||||||
updatedAt: '2024-01-23T00:00:00Z',
|
* 下载文件,使用镜像系统
|
||||||
},
|
* 自动识别 GitHub Release URL 并使用镜像加速
|
||||||
{
|
*/
|
||||||
id: 'napcat-plugin-admin',
|
async function downloadFile (url: string, destPath: string): Promise<void> {
|
||||||
name: '群管理插件',
|
try {
|
||||||
version: '2.5.0',
|
let downloadUrl: string;
|
||||||
description: '提供踢人、禁言、设置管理员等群管理功能',
|
|
||||||
author: 'Admin Tools',
|
// 判断是否是 GitHub Release URL
|
||||||
homepage: 'https://github.com/example/admin',
|
// 格式: https://github.com/{owner}/{repo}/releases/download/{tag}/{filename}
|
||||||
repository: 'https://github.com/example/admin',
|
const githubReleasePattern = /^https:\/\/github\.com\/[^/]+\/[^/]+\/releases\/download\//;
|
||||||
downloadUrl: 'https://example.com/plugins/napcat-plugin-admin-2.5.0.zip',
|
|
||||||
tags: ['管理', '群管理', '工具'],
|
if (githubReleasePattern.test(url)) {
|
||||||
minNapCatVersion: '1.0.0',
|
// 使用镜像系统查找可用的下载 URL(支持 GitHub Release 镜像)
|
||||||
downloads: 6789,
|
console.log(`Detected GitHub Release URL: ${url}`);
|
||||||
rating: 4.6,
|
downloadUrl = await findAvailableDownloadUrl(url, {
|
||||||
createdAt: '2023-12-15T00:00:00Z',
|
validateContent: true,
|
||||||
updatedAt: '2024-01-21T00:00:00Z',
|
minFileSize: 1024, // 最小 1KB
|
||||||
},
|
timeout: 60000, // 60秒超时
|
||||||
{
|
useFastMirrors: true, // 使用快速镜像列表
|
||||||
id: 'napcat-plugin-image-search',
|
});
|
||||||
name: '以图搜图插件',
|
} else {
|
||||||
version: '1.5.2',
|
// 其他URL直接下载
|
||||||
description: '支持多个搜图引擎,快速找到图片来源',
|
console.log(`Direct download URL: ${url}`);
|
||||||
author: 'Image Hunter',
|
downloadUrl = url;
|
||||||
homepage: 'https://github.com/example/image-search',
|
}
|
||||||
repository: 'https://github.com/example/image-search',
|
|
||||||
downloadUrl: 'https://example.com/plugins/napcat-plugin-image-search-1.5.2.zip',
|
console.log(`Downloading from: ${downloadUrl}`);
|
||||||
tags: ['图片', '搜索', '工具'],
|
|
||||||
minNapCatVersion: '1.0.0',
|
const response = await fetch(downloadUrl, {
|
||||||
downloads: 4567,
|
headers: {
|
||||||
rating: 4.4,
|
'User-Agent': 'NapCat-WebUI',
|
||||||
createdAt: '2024-01-08T00:00:00Z',
|
},
|
||||||
updatedAt: '2024-01-19T00:00:00Z',
|
signal: AbortSignal.timeout(60000),
|
||||||
},
|
});
|
||||||
],
|
|
||||||
};
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.body) {
|
||||||
|
throw new Error('Response body is null');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写入文件
|
||||||
|
const fileStream = createWriteStream(destPath);
|
||||||
|
await pipeline(response.body as any, fileStream);
|
||||||
|
|
||||||
|
console.log(`Successfully downloaded to: ${destPath}`);
|
||||||
|
} catch (e: any) {
|
||||||
|
// 删除可能的不完整文件
|
||||||
|
if (fs.existsSync(destPath)) {
|
||||||
|
fs.unlinkSync(destPath);
|
||||||
|
}
|
||||||
|
throw new Error(`Download failed: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解压插件到指定目录
|
||||||
|
*/
|
||||||
|
async function extractPlugin (zipPath: string, pluginId: string): Promise<void> {
|
||||||
|
const pluginDir = path.join(PLUGINS_DIR, pluginId);
|
||||||
|
|
||||||
|
// 如果目录已存在,先删除
|
||||||
|
if (fs.existsSync(pluginDir)) {
|
||||||
|
fs.rmSync(pluginDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建插件目录
|
||||||
|
fs.mkdirSync(pluginDir, { recursive: true });
|
||||||
|
|
||||||
|
// 解压
|
||||||
|
await compressing.zip.uncompress(zipPath, pluginDir);
|
||||||
|
|
||||||
|
//console.log(`Plugin extracted to: ${pluginDir}`);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取插件商店列表
|
* 获取插件商店列表
|
||||||
* 未来可以从远程URL读取
|
|
||||||
*/
|
*/
|
||||||
export const GetPluginStoreListHandler: RequestHandler = async (_req, res) => {
|
export const GetPluginStoreListHandler: RequestHandler = async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
// TODO: 未来从远程URL读取
|
const data = await fetchPluginList();
|
||||||
// const remoteUrl = 'https://napcat.example.com/plugin-list.json';
|
return sendSuccess(res, data);
|
||||||
// const response = await fetch(remoteUrl);
|
|
||||||
// const data = await response.json();
|
|
||||||
|
|
||||||
// 目前返回Mock数据
|
|
||||||
return sendSuccess(res, mockPluginStoreData);
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
return sendError(res, 'Failed to fetch plugin store list: ' + e.message);
|
return sendError(res, 'Failed to fetch plugin store list: ' + e.message);
|
||||||
}
|
}
|
||||||
@ -132,7 +172,8 @@ export const GetPluginStoreListHandler: RequestHandler = async (_req, res) => {
|
|||||||
export const GetPluginStoreDetailHandler: RequestHandler = async (req, res) => {
|
export const GetPluginStoreDetailHandler: RequestHandler = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const plugin = mockPluginStoreData.plugins.find(p => p.id === id);
|
const data = await fetchPluginList();
|
||||||
|
const plugin = data.plugins.find(p => p.id === id);
|
||||||
|
|
||||||
if (!plugin) {
|
if (!plugin) {
|
||||||
return sendError(res, 'Plugin not found');
|
return sendError(res, 'Plugin not found');
|
||||||
@ -146,7 +187,6 @@ export const GetPluginStoreDetailHandler: RequestHandler = async (req, res) => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 安装插件(从商店)
|
* 安装插件(从商店)
|
||||||
* TODO: 实现实际的下载和安装逻辑
|
|
||||||
*/
|
*/
|
||||||
export const InstallPluginFromStoreHandler: RequestHandler = async (req, res) => {
|
export const InstallPluginFromStoreHandler: RequestHandler = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@ -156,21 +196,38 @@ export const InstallPluginFromStoreHandler: RequestHandler = async (req, res) =>
|
|||||||
return sendError(res, 'Plugin ID is required');
|
return sendError(res, 'Plugin ID is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
const plugin = mockPluginStoreData.plugins.find(p => p.id === id);
|
// 获取插件信息
|
||||||
|
const data = await fetchPluginList();
|
||||||
|
const plugin = data.plugins.find(p => p.id === id);
|
||||||
|
|
||||||
if (!plugin) {
|
if (!plugin) {
|
||||||
return sendError(res, 'Plugin not found in store');
|
return sendError(res, 'Plugin not found in store');
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: 实现实际的下载和安装逻辑
|
// 下载插件
|
||||||
// 1. 下载插件文件
|
const tempZipPath = path.join(PLUGINS_DIR, `${id}.temp.zip`);
|
||||||
// 2. 解压到插件目录
|
|
||||||
// 3. 加载插件
|
|
||||||
|
|
||||||
return sendSuccess(res, {
|
try {
|
||||||
message: 'Plugin installation started',
|
await downloadFile(plugin.downloadUrl, tempZipPath);
|
||||||
plugin: plugin
|
|
||||||
});
|
// 解压插件
|
||||||
|
await extractPlugin(tempZipPath, id);
|
||||||
|
|
||||||
|
// 删除临时文件
|
||||||
|
fs.unlinkSync(tempZipPath);
|
||||||
|
|
||||||
|
return sendSuccess(res, {
|
||||||
|
message: 'Plugin installed successfully',
|
||||||
|
plugin: plugin,
|
||||||
|
installPath: path.join(PLUGINS_DIR, id),
|
||||||
|
});
|
||||||
|
} catch (downloadError: any) {
|
||||||
|
// 清理临时文件
|
||||||
|
if (fs.existsSync(tempZipPath)) {
|
||||||
|
fs.unlinkSync(tempZipPath);
|
||||||
|
}
|
||||||
|
throw downloadError;
|
||||||
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
return sendError(res, 'Failed to install plugin: ' + e.message);
|
return sendError(res, 'Failed to install plugin: ' + e.message);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { Button } from '@heroui/button';
|
import { Button } from '@heroui/button';
|
||||||
import { Chip } from '@heroui/chip';
|
import { Chip } from '@heroui/chip';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { IoMdStar, IoMdDownload } from 'react-icons/io';
|
import { IoMdDownload } from 'react-icons/io';
|
||||||
|
|
||||||
import DisplayCardContainer from './container';
|
import DisplayCardContainer from './container';
|
||||||
import { PluginStoreItem } from '@/types/plugin-store';
|
import { PluginStoreItem } from '@/types/plugin-store';
|
||||||
@ -15,7 +15,7 @@ const PluginStoreCard: React.FC<PluginStoreCardProps> = ({
|
|||||||
data,
|
data,
|
||||||
onInstall,
|
onInstall,
|
||||||
}) => {
|
}) => {
|
||||||
const { name, version, author, description, tags, rating, icon } = data;
|
const { name, version, author, description, tags, id } = data;
|
||||||
const [processing, setProcessing] = useState(false);
|
const [processing, setProcessing] = useState(false);
|
||||||
|
|
||||||
const handleInstall = () => {
|
const handleInstall = () => {
|
||||||
@ -37,17 +37,7 @@ const PluginStoreCard: React.FC<PluginStoreCardProps> = ({
|
|||||||
v{version}
|
v{version}
|
||||||
</Chip>
|
</Chip>
|
||||||
}
|
}
|
||||||
enableSwitch={
|
enableSwitch={undefined}
|
||||||
icon ? (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<img
|
|
||||||
src={icon}
|
|
||||||
alt={name}
|
|
||||||
className="w-10 h-10 rounded-lg object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : undefined
|
|
||||||
}
|
|
||||||
action={
|
action={
|
||||||
<Button
|
<Button
|
||||||
fullWidth
|
fullWidth
|
||||||
@ -88,14 +78,13 @@ const PluginStoreCard: React.FC<PluginStoreCardProps> = ({
|
|||||||
{description || '暂无描述'}
|
{description || '暂无描述'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{rating && (
|
{id && (
|
||||||
<div className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors'>
|
<div className='flex flex-col gap-1 p-3 bg-default-100/50 dark:bg-white/10 rounded-xl border border-transparent hover:border-default-200 transition-colors'>
|
||||||
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>
|
<span className='text-xs text-default-500 dark:text-white/50 font-medium tracking-wide'>
|
||||||
评分
|
包名
|
||||||
</span>
|
</span>
|
||||||
<div className='flex items-center gap-1 text-sm font-medium text-default-700 dark:text-white/90'>
|
<div className='text-sm font-medium text-default-700 dark:text-white/90 break-words line-clamp-2 h-10 overflow-hidden'>
|
||||||
<IoMdStar className='text-warning' size={16} />
|
{id || '包名'}
|
||||||
<span>{rating.toFixed(1)}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -86,11 +86,14 @@ export default function PluginStorePage () {
|
|||||||
];
|
];
|
||||||
}, [categorizedPlugins]);
|
}, [categorizedPlugins]);
|
||||||
|
|
||||||
const handleInstall = async () => {
|
const handleInstall = async (pluginId: string) => {
|
||||||
toast('该功能尚未完工,敬请期待', {
|
try {
|
||||||
icon: '🚧',
|
await PluginManager.installPluginFromStore(pluginId);
|
||||||
duration: 3000,
|
toast.success('插件安装成功!');
|
||||||
});
|
// 可以选择刷新插件列表或导航到插件管理页面
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(`安装失败: ${error.message || '未知错误'}`);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -145,7 +148,7 @@ export default function PluginStorePage () {
|
|||||||
<PluginStoreCard
|
<PluginStoreCard
|
||||||
key={plugin.id}
|
key={plugin.id}
|
||||||
data={plugin}
|
data={plugin}
|
||||||
onInstall={handleInstall}
|
onInstall={() => handleInstall(plugin.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user