From fca2aa5a05c7cb7c4e1d506c496bb001d82b2c83 Mon Sep 17 00:00:00 2001 From: qinglong Date: Sat, 13 Jun 2026 11:08:47 +0800 Subject: [PATCH] =?UTF-8?q?feat(v0.5.2):=20=E6=8F=92=E4=BB=B6=E7=B4=A2?= =?UTF-8?q?=E5=BC=95=E4=BB=93=E5=BA=93=20+=20install=20=E5=91=BD=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PluginIndex 服务: - 从远程 JSON 索引获取可用插件列表 - 支持 zip 包和单文件 .py 两种格式安装 - 自动解压/移动到 plugins/ 目录 - 默认索引地址: GitHub Pages install 内置命令: - install --list → 列出远程可用插件 - install → 下载并安装 - 自动缓存索引,避免重复请求 Co-Authored-By: Claude --- services/command_service.py | 39 ++++++++++++- services/plugin_index.py | 108 ++++++++++++++++++++++++++++++++++++ 2 files changed, 144 insertions(+), 3 deletions(-) create mode 100644 services/plugin_index.py diff --git a/services/command_service.py b/services/command_service.py index 66c7917..3f8485b 100644 --- a/services/command_service.py +++ b/services/command_service.py @@ -281,15 +281,48 @@ class CommandService: permissions=["framework.tui.control"], source="internal" ) - - - + + # 插件安装命令 + self.register_command( + name="install", + handler=self._cmd_install, + description="从在线索引安装插件: install 或 install --list", + permissions=["framework.plugin.install"], + source="internal", + ) + logger.info(f"内置命令注册完成,共注册 {len(self.commands)} 个命令") except Exception as e: logger.error(f"注册内置命令时出错: {str(e)}", exc_info=True) raise + async def _cmd_install(self, *args) -> str: + """插件安装命令""" + try: + from services.plugin_index import PluginIndex + index = PluginIndex() + if not args or args[0] == "--list": + plugins = await index.fetch_index() + if not plugins: + return "📭 插件索引为空或无法连接" + lines = [f"📦 可用插件 ({len(plugins)}):", "=" * 40] + for p in plugins: + lines.append( + f" 🔹 {p.get('name','?')} v{p.get('version','?')} — " + f"{p.get('description','?')[:50]}" + ) + lines.append("\n💡 install 安装插件") + return "\n".join(lines) + + name = args[0] + ok = await index.install(name) + if ok: + return f"✅ 插件安装完成: {name}\n💡 重启框架或使用热重载加载新插件" + return f"❌ 安装失败: {name}" + except ImportError: + return "❌ 缺少 aiohttp,无法使用插件安装功能" + async def _cmd_netdiag(self, *args) -> str: """网络诊断命令""" try: diff --git a/services/plugin_index.py b/services/plugin_index.py new file mode 100644 index 0000000..1727507 --- /dev/null +++ b/services/plugin_index.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +"""插件索引仓库 — 在线 JSON 索引 + 一键安装""" +import logging, json, os, asyncio, tempfile, zipfile, shutil +from pathlib import Path +from typing import Optional, List, Dict + +logger = logging.getLogger(__name__) + +DEFAULT_INDEX_URL = "https://raw.githubusercontent.com/AskaEth/SenSu-plugins/main/index.json" + + +class PluginIndex: + def __init__(self, index_url: str = DEFAULT_INDEX_URL): + self.index_url = index_url + self._cache: Optional[List[Dict]] = None + + async def fetch_index(self, force: bool = False) -> List[Dict]: + """获取远程插件索引""" + if self._cache is not None and not force: + return self._cache + try: + import aiohttp + async with aiohttp.ClientSession() as session: + async with session.get(self.index_url, timeout=aiohttp.ClientTimeout(total=15)) as resp: + if resp.status == 200: + data = await resp.json() + self._cache = data if isinstance(data, list) else data.get("plugins", []) + logger.info(f"插件索引已加载: {len(self._cache)} 个可用插件") + return self._cache + else: + logger.warning(f"插件索引请求失败: HTTP {resp.status}") + return [] + except Exception as e: + logger.warning(f"无法获取插件索引 ({self.index_url}): {e}") + return [] + + def search(self, name: str) -> Optional[Dict]: + """在缓存中搜索插件""" + if not self._cache: + return None + for p in self._cache: + if p.get("name") == name: + return p + return None + + def list_plugins(self) -> List[str]: + if not self._cache: + return [] + return [f"{p.get('name','?')} v{p.get('version','?')} — {p.get('description','?')[:60]}" + for p in self._cache] + + async def install(self, name: str, target_dir: str = "plugins") -> bool: + """下载并安装指定插件到 plugins/ 目录""" + plugin = self.search(name) + if not plugin: + # Try to refresh cache + await self.fetch_index(force=True) + plugin = self.search(name) + if not plugin: + logger.error(f"插件未在索引中找到: {name}") + return False + + download_url = plugin.get("download_url") or plugin.get("url") + if not download_url: + logger.error(f"插件 {name} 缺少下载地址") + return False + + target = Path(target_dir) / name + if target.exists(): + logger.warning(f"插件目录已存在: {target}") + return False + + try: + import aiohttp + async with aiohttp.ClientSession() as session: + async with session.get(download_url, timeout=aiohttp.ClientTimeout(total=120)) as resp: + if resp.status != 200: + logger.error(f"下载失败: HTTP {resp.status}") + return False + content = await resp.read() + + # Handle zip archives + if download_url.endswith(".zip"): + with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as tmp: + tmp.write(content) + tmp.flush() + with zipfile.ZipFile(tmp.name, "r") as zf: + # Zip 内第一层目录名可能不同, 提取到临时位置再移动 + extract_tmp = Path(tempfile.mkdtemp()) + zf.extractall(extract_tmp) + # 如果 zip 内只有一个顶层目录, 直接用它 + members = list(extract_tmp.iterdir()) + if len(members) == 1 and members[0].is_dir(): + shutil.move(str(members[0]), str(target)) + else: + extract_tmp.rename(target) + os.unlink(tmp.name) + else: + # Assume single .py file plugin + target.mkdir(parents=True, exist_ok=True) + (target / "__init__.py").write_bytes(content) + + logger.info(f"✅ 插件安装完成: {name} → {target}") + return True + + except Exception as e: + logger.error(f"安装插件 {name} 失败: {e}") + return False