feat(v0.5.2): 插件索引仓库 + install 命令

PluginIndex 服务:
- 从远程 JSON 索引获取可用插件列表
- 支持 zip 包和单文件 .py 两种格式安装
- 自动解压/移动到 plugins/ 目录
- 默认索引地址: GitHub Pages

install 内置命令:
- install --list → 列出远程可用插件
- install <name> → 下载并安装
- 自动缓存索引,避免重复请求

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
qinglong
2026-06-13 11:08:47 +08:00
parent 7ce495de2a
commit fca2aa5a05
2 changed files with 144 additions and 3 deletions
+36 -3
View File
@@ -281,15 +281,48 @@ class CommandService:
permissions=["framework.tui.control"],
source="internal"
)
# 插件安装命令
self.register_command(
name="install",
handler=self._cmd_install,
description="从在线索引安装插件: install <name> 或 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 <name> 安装插件")
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:
+108
View File
@@ -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