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:
@@ -281,15 +281,48 @@ class CommandService:
|
|||||||
permissions=["framework.tui.control"],
|
permissions=["framework.tui.control"],
|
||||||
source="internal"
|
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)} 个命令")
|
logger.info(f"内置命令注册完成,共注册 {len(self.commands)} 个命令")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"注册内置命令时出错: {str(e)}", exc_info=True)
|
logger.error(f"注册内置命令时出错: {str(e)}", exc_info=True)
|
||||||
raise
|
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:
|
async def _cmd_netdiag(self, *args) -> str:
|
||||||
"""网络诊断命令"""
|
"""网络诊断命令"""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user