diff --git a/docs/plugins/manifest-guide.md b/docs/plugins/manifest-guide.md new file mode 100644 index 000000000..76068920d --- /dev/null +++ b/docs/plugins/manifest-guide.md @@ -0,0 +1,194 @@ +# 📄 插件Manifest系统指南 + +## 概述 + +MaiBot插件系统现在强制要求每个插件都必须包含一个 `_manifest.json` 文件。这个文件描述了插件的基本信息、依赖关系、组件等重要元数据。 + +## 🔧 Manifest文件结构 + +### 必需字段 + +以下字段是必需的,不能为空: + +```json +{ + "manifest_version": 3, + "name": "插件显示名称", + "version": "1.0.0", + "description": "插件功能描述", + "author": { + "name": "作者名称" + } +} +``` + +### 可选字段 + +以下字段都是可选的,可以根据需要添加: + +```json +{ + "license": "MIT", + "host_application": { + "min_version": "1.0.0", + "max_version": "4.0.0" + }, + "homepage_url": "https://github.com/your-repo", + "repository_url": "https://github.com/your-repo", + "keywords": ["关键词1", "关键词2"], + "categories": ["分类1", "分类2"], + "default_locale": "zh-CN", + "locales_path": "_locales", + "plugin_info": { + "is_built_in": false, + "plugin_type": "general", + "components": [ + { + "type": "action", + "name": "组件名称", + "description": "组件描述" + } + ] + } +} +``` + +## 🛠️ 管理工具 + +### 使用manifest_tool.py + +我们提供了一个命令行工具来帮助管理manifest文件: + +```bash +# 扫描缺少manifest的插件 +python scripts/manifest_tool.py scan src/plugins + +# 为插件创建最小化manifest文件 +python scripts/manifest_tool.py create-minimal src/plugins/my_plugin --name "我的插件" --author "作者" + +# 为插件创建完整manifest模板 +python scripts/manifest_tool.py create-complete src/plugins/my_plugin --name "我的插件" + +# 验证manifest文件 +python scripts/manifest_tool.py validate src/plugins/my_plugin +``` + +### 验证示例 + +验证通过的示例: +``` +✅ Manifest文件验证通过 +``` + +验证失败的示例: +``` +❌ 验证错误: + - 缺少必需字段: name + - 作者信息缺少name字段或为空 +⚠️ 验证警告: + - 建议填写字段: license + - 建议填写字段: keywords +``` + +## 🔄 迁移指南 + +### 对于现有插件 + +1. **检查缺少manifest的插件**: + ```bash + python scripts/manifest_tool.py scan src/plugins + ``` + +2. **为每个插件创建manifest**: + ```bash + python scripts/manifest_tool.py create-minimal src/plugins/your_plugin + ``` + +3. **编辑manifest文件**,填写正确的信息。 + +4. **验证manifest**: + ```bash + python scripts/manifest_tool.py validate src/plugins/your_plugin + ``` + +### 对于新插件 + +创建新插件时,建议的步骤: + +1. **创建插件目录和基本文件** +2. **创建完整manifest模板**: + ```bash + python scripts/manifest_tool.py create-complete src/plugins/new_plugin + ``` +3. **根据实际情况修改manifest文件** +4. **编写插件代码** +5. **验证manifest文件** + +## 📋 字段说明 + +### 基本信息 +- `manifest_version`: manifest格式版本,当前为3 +- `name`: 插件显示名称(必需) +- `version`: 插件版本号(必需) +- `description`: 插件功能描述(必需) +- `author`: 作者信息(必需) + - `name`: 作者名称(必需) + - `url`: 作者主页(可选) + +### 许可和URL +- `license`: 插件许可证(可选,建议填写) +- `homepage_url`: 插件主页(可选) +- `repository_url`: 源码仓库地址(可选) + +### 分类和标签 +- `keywords`: 关键词数组(可选,建议填写) +- `categories`: 分类数组(可选,建议填写) + +### 兼容性 +- `host_application`: 主机应用兼容性(可选) + - `min_version`: 最低兼容版本 + - `max_version`: 最高兼容版本 + +### 国际化 +- `default_locale`: 默认语言(可选) +- `locales_path`: 语言文件目录(可选) + +### 插件特定信息 +- `plugin_info`: 插件详细信息(可选) + - `is_built_in`: 是否为内置插件 + - `plugin_type`: 插件类型 + - `components`: 组件列表 + +## ⚠️ 注意事项 + +1. **强制要求**:所有插件必须包含`_manifest.json`文件,否则无法加载 +2. **编码格式**:manifest文件必须使用UTF-8编码 +3. **JSON格式**:文件必须是有效的JSON格式 +4. **必需字段**:`manifest_version`、`name`、`version`、`description`、`author.name`是必需的 +5. **版本兼容**:当前只支持manifest_version = 3 + +## 🔍 常见问题 + +### Q: 为什么要强制要求manifest文件? +A: Manifest文件提供了插件的标准化元数据,使得插件管理、依赖检查、版本兼容性验证等功能成为可能。 + +### Q: 可以不填写可选字段吗? +A: 可以。所有标记为"可选"的字段都可以不填写,但建议至少填写`license`和`keywords`。 + +### Q: 如何快速为所有插件创建manifest? +A: 可以编写脚本批量处理: +```bash +# 扫描并为每个缺少manifest的插件创建最小化manifest +python scripts/manifest_tool.py scan src/plugins +# 然后手动为每个插件运行create-minimal命令 +``` + +### Q: manifest验证失败怎么办? +A: 根据验证器的错误提示修复相应问题。错误会导致插件加载失败,警告不会。 + +## 📚 参考示例 + +查看内置插件的manifest文件作为参考: +- `src/plugins/built_in/core_actions/_manifest.json` +- `src/plugins/built_in/doubao_pic_plugin/_manifest.json` +- `src/plugins/built_in/tts_plugin/_manifest.json` diff --git a/docs/plugins/manifest-implementation-summary.md b/docs/plugins/manifest-implementation-summary.md new file mode 100644 index 000000000..62993a080 --- /dev/null +++ b/docs/plugins/manifest-implementation-summary.md @@ -0,0 +1,113 @@ +# ✅ MaiBot插件Manifest系统实现完成 + +## 🎉 实现成果 + +### 1. **强制Manifest要求** ✅ +- 修改了`BasePlugin`类,强制要求所有插件必须包含`_manifest.json`文件 +- 插件加载时会验证manifest文件的存在性和格式正确性 +- 缺少manifest或格式错误的插件将无法加载,并显示明确的错误信息 + +### 2. **完善的验证系统** ✅ +- 实现了`ManifestValidator`类,支持完整的manifest格式验证 +- 区分必需字段和可选字段,只有必需字段错误才会导致加载失败 +- 提供详细的验证报告,包括错误和警告信息 + +### 3. **可选字段真正可选** ✅ +- 所有示例中标记为可选的字段都可以不填写 +- 必需字段:`manifest_version`、`name`、`version`、`description`、`author.name` +- 可选字段:`license`、`homepage_url`、`repository_url`、`keywords`、`categories`等 + +### 4. **管理工具** ✅ +- 创建了`scripts/manifest_tool.py`命令行工具 +- 支持创建最小化manifest、完整模板、验证文件、扫描缺失等功能 +- 提供友好的命令行界面和详细的使用说明 + +### 5. **内置插件适配** ✅ +- 为所有内置插件创建了符合规范的manifest文件: + - `core_actions`: 核心动作插件 + - `doubao_pic_plugin`: 豆包图片生成插件 + - `tts_plugin`: 文本转语音插件 + - `vtb_plugin`: VTB虚拟主播插件 + - `mute_plugin`: 静音插件 + - `take_picture_plugin`: 拍照插件 + +### 6. **增强的插件信息显示** ✅ +- 插件管理器现在显示更丰富的插件信息 +- 包括许可证、关键词、分类、版本兼容性等manifest信息 +- 更好的错误报告和故障排除信息 + +### 7. **完整的文档** ✅ +- 创建了详细的manifest系统指南:`docs/plugins/manifest-guide.md` +- 包含字段说明、使用示例、迁移指南、常见问题等 +- 提供了最佳实践和开发建议 + +## 📋 核心特性对比 + +| 特性 | 实现前 | 实现后 | +|------|--------|--------| +| **Manifest要求** | 可选 | **强制要求** | +| **字段验证** | 无 | **完整验证** | +| **可选字段** | 概念模糊 | **真正可选** | +| **错误处理** | 基础 | **详细错误信息** | +| **管理工具** | 无 | **命令行工具** | +| **文档** | 基础 | **完整指南** | + +## 🔧 使用示例 + +### 最小化Manifest示例 +```json +{ + "manifest_version": 3, + "name": "我的插件", + "version": "1.0.0", + "description": "插件描述", + "author": { + "name": "作者名称" + } +} +``` + +### 验证失败示例 +```bash +❌ 插件加载失败: my_plugin - 缺少manifest文件: /path/to/plugin/_manifest.json +❌ 插件加载失败: bad_plugin - manifest验证失败: 缺少必需字段: name +``` + +### 成功加载示例 +```bash +✅ 插件加载成功: core_actions v1.0.0 (5个ACTION) [GPL-v3.0-or-later] 关键词: core, chat, reply... - 系统核心动作插件 +``` + +## 🚀 下一步建议 + +### 1. **插件迁移** +- 使用`manifest_tool.py scan`扫描所有插件目录 +- 为缺少manifest的插件创建文件 +- 逐步完善manifest信息 + +### 2. **开发者指导** +- 在插件开发文档中强调manifest的重要性 +- 提供插件开发模板,包含标准manifest +- 建议在CI/CD中加入manifest验证 + +### 3. **功能增强** +- 考虑添加manifest版本迁移工具 +- 支持从manifest自动生成插件文档 +- 添加插件依赖关系验证 + +### 4. **用户体验** +- 在插件管理界面显示manifest信息 +- 支持按关键词和分类筛选插件 +- 提供插件兼容性检查 + +## ✨ 总结 + +MaiBot插件Manifest系统现已完全实现,提供了: + +- **✅ 强制性要求**:所有插件必须有manifest文件 +- **✅ 灵活性**:可选字段真正可选,最小化配置负担 +- **✅ 可维护性**:完整的验证和错误报告系统 +- **✅ 易用性**:命令行工具和详细文档 +- **✅ 扩展性**:为未来功能扩展奠定基础 + +系统已准备就绪,可以开始全面推广使用!🎉 diff --git a/plugins/hello_world_plugin/_manifest.json b/plugins/hello_world_plugin/_manifest.json new file mode 100644 index 000000000..8b6e0cee0 --- /dev/null +++ b/plugins/hello_world_plugin/_manifest.json @@ -0,0 +1,19 @@ +{ + "manifest_version": 3, + "name": "hello_world_plugin", + "version": "1.0.0", + "description": "我的第一个MaiCore插件,包含问候功能", + "author": { + "name": "你的名字", + "url": "" + }, + "license": "GPL-v3.0-or-later", + "host_application": { + "min_version": "0.8.0", + "max_version": "0.8.0" + }, + "keywords": [], + "categories": [], + "default_locale": "zh-CN", + "locales_path": "_locales" +} \ No newline at end of file diff --git a/plugins/take_picture_plugin/_manifest.json b/plugins/take_picture_plugin/_manifest.json new file mode 100644 index 000000000..add5eaeda --- /dev/null +++ b/plugins/take_picture_plugin/_manifest.json @@ -0,0 +1,19 @@ +{ + "manifest_version": 3, + "name": "take_picture_plugin", + "version": "1.0.0", + "description": "提供生成自拍照和展示最近照片的功能", + "author": { + "name": "SengokuCola", + "url": "" + }, + "license": "GPL-v3.0-or-later", + "host_application": { + "min_version": "0.8.0", + "max_version": "0.8.0" + }, + "keywords": [], + "categories": [], + "default_locale": "zh-CN", + "locales_path": "_locales" +} \ No newline at end of file diff --git a/scripts/manifest_tool.py b/scripts/manifest_tool.py new file mode 100644 index 000000000..4763c1256 --- /dev/null +++ b/scripts/manifest_tool.py @@ -0,0 +1,257 @@ +""" +插件Manifest管理命令行工具 + +提供插件manifest文件的创建、验证和管理功能 +""" + +import os +import sys +import argparse +import json +from pathlib import Path +from src.common.logger import get_logger +from src.plugin_system.utils.manifest_utils import ( + ManifestValidator, +) + +# 添加项目根目录到Python路径 +project_root = Path(__file__).parent.parent.parent.parent +sys.path.insert(0, str(project_root)) + + + +logger = get_logger("manifest_tool") + + +def create_minimal_manifest(plugin_dir: str, plugin_name: str, description: str = "", author: str = "") -> bool: + """创建最小化的manifest文件 + + Args: + plugin_dir: 插件目录 + plugin_name: 插件名称 + description: 插件描述 + author: 插件作者 + + Returns: + bool: 是否创建成功 + """ + manifest_path = os.path.join(plugin_dir, "_manifest.json") + + if os.path.exists(manifest_path): + print(f"❌ Manifest文件已存在: {manifest_path}") + return False + + # 创建最小化manifest + minimal_manifest = { + "manifest_version": 3, + "name": plugin_name, + "version": "1.0.0", + "description": description or f"{plugin_name}插件", + "author": { + "name": author or "Unknown" + } + } + + try: + with open(manifest_path, "w", encoding="utf-8") as f: + json.dump(minimal_manifest, f, ensure_ascii=False, indent=2) + print(f"✅ 已创建最小化manifest文件: {manifest_path}") + return True + except Exception as e: + print(f"❌ 创建manifest文件失败: {e}") + return False + + +def create_complete_manifest(plugin_dir: str, plugin_name: str) -> bool: + """创建完整的manifest模板文件 + + Args: + plugin_dir: 插件目录 + plugin_name: 插件名称 + + Returns: + bool: 是否创建成功 + """ + manifest_path = os.path.join(plugin_dir, "_manifest.json") + + if os.path.exists(manifest_path): + print(f"❌ Manifest文件已存在: {manifest_path}") + return False + + # 创建完整模板 + complete_manifest = { + "manifest_version": 3, + "name": plugin_name, + "version": "1.0.0", + "description": f"{plugin_name}插件描述", + "author": { + "name": "插件作者", + "url": "https://github.com/your-username" + }, + "license": "MIT", + "host_application": { + "min_version": "1.0.0", + "max_version": "4.0.0" + }, + "homepage_url": "https://github.com/your-repo", + "repository_url": "https://github.com/your-repo", + "keywords": ["keyword1", "keyword2"], + "categories": ["Category1"], + "default_locale": "zh-CN", + "locales_path": "_locales", + "plugin_info": { + "is_built_in": False, + "plugin_type": "general", + "components": [ + { + "type": "action", + "name": "sample_action", + "description": "示例动作组件" + } + ] + } + } + + try: + with open(manifest_path, "w", encoding="utf-8") as f: + json.dump(complete_manifest, f, ensure_ascii=False, indent=2) + print(f"✅ 已创建完整manifest模板: {manifest_path}") + print("💡 请根据实际情况修改manifest文件中的内容") + return True + except Exception as e: + print(f"❌ 创建manifest文件失败: {e}") + return False + + +def validate_manifest_file(plugin_dir: str) -> bool: + """验证manifest文件 + + Args: + plugin_dir: 插件目录 + + Returns: + bool: 是否验证通过 + """ + manifest_path = os.path.join(plugin_dir, "_manifest.json") + + if not os.path.exists(manifest_path): + print(f"❌ 未找到manifest文件: {manifest_path}") + return False + + try: + with open(manifest_path, "r", encoding="utf-8") as f: + manifest_data = json.load(f) + + validator = ManifestValidator() + is_valid = validator.validate_manifest(manifest_data) + + # 显示验证结果 + print("📋 Manifest验证结果:") + print(validator.get_validation_report()) + + if is_valid: + print("✅ Manifest文件验证通过") + else: + print("❌ Manifest文件验证失败") + + return is_valid + + except json.JSONDecodeError as e: + print(f"❌ Manifest文件格式错误: {e}") + return False + except Exception as e: + print(f"❌ 验证过程中发生错误: {e}") + return False + + +def scan_plugins_without_manifest(root_dir: str) -> None: + """扫描缺少manifest文件的插件 + + Args: + root_dir: 扫描的根目录 + """ + print(f"🔍 扫描目录: {root_dir}") + + plugins_without_manifest = [] + + for root, dirs, files in os.walk(root_dir): + # 跳过隐藏目录和__pycache__ + dirs[:] = [d for d in dirs if not d.startswith('.') and d != '__pycache__'] + + # 检查是否包含plugin.py文件(标识为插件目录) + if "plugin.py" in files: + manifest_path = os.path.join(root, "_manifest.json") + if not os.path.exists(manifest_path): + plugins_without_manifest.append(root) + + if plugins_without_manifest: + print(f"❌ 发现 {len(plugins_without_manifest)} 个插件缺少manifest文件:") + for plugin_dir in plugins_without_manifest: + plugin_name = os.path.basename(plugin_dir) + print(f" - {plugin_name}: {plugin_dir}") + print("💡 使用 'python manifest_tool.py create-minimal <插件目录>' 创建manifest文件") + else: + print("✅ 所有插件都有manifest文件") + + +def main(): + """主函数""" + parser = argparse.ArgumentParser(description="插件Manifest管理工具") + subparsers = parser.add_subparsers(dest="command", help="可用命令") + + # 创建最小化manifest命令 + create_minimal_parser = subparsers.add_parser("create-minimal", help="创建最小化manifest文件") + create_minimal_parser.add_argument("plugin_dir", help="插件目录路径") + create_minimal_parser.add_argument("--name", help="插件名称") + create_minimal_parser.add_argument("--description", help="插件描述") + create_minimal_parser.add_argument("--author", help="插件作者") + + # 创建完整manifest命令 + create_complete_parser = subparsers.add_parser("create-complete", help="创建完整manifest模板") + create_complete_parser.add_argument("plugin_dir", help="插件目录路径") + create_complete_parser.add_argument("--name", help="插件名称") + + # 验证manifest命令 + validate_parser = subparsers.add_parser("validate", help="验证manifest文件") + validate_parser.add_argument("plugin_dir", help="插件目录路径") + + # 扫描插件命令 + scan_parser = subparsers.add_parser("scan", help="扫描缺少manifest的插件") + scan_parser.add_argument("root_dir", help="扫描的根目录路径") + + args = parser.parse_args() + + if not args.command: + parser.print_help() + return + + try: + if args.command == "create-minimal": + plugin_name = args.name or os.path.basename(os.path.abspath(args.plugin_dir)) + success = create_minimal_manifest( + args.plugin_dir, + plugin_name, + args.description or "", + args.author or "" + ) + sys.exit(0 if success else 1) + + elif args.command == "create-complete": + plugin_name = args.name or os.path.basename(os.path.abspath(args.plugin_dir)) + success = create_complete_manifest(args.plugin_dir, plugin_name) + sys.exit(0 if success else 1) + + elif args.command == "validate": + success = validate_manifest_file(args.plugin_dir) + sys.exit(0 if success else 1) + + elif args.command == "scan": + scan_plugins_without_manifest(args.root_dir) + + except Exception as e: + print(f"❌ 执行命令时发生错误: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/scripts/test_version_compatibility.py b/scripts/test_version_compatibility.py new file mode 100644 index 000000000..986b27b53 --- /dev/null +++ b/scripts/test_version_compatibility.py @@ -0,0 +1,237 @@ +#!/usr/bin/env python3 +""" +版本兼容性检查测试脚本 + +测试版本号标准化、比较和兼容性检查功能 +""" + +import sys +import os + +# 添加项目根目录到Python路径 +project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, project_root) + +from src.plugin_system.utils.manifest_utils import VersionComparator + + +def test_version_normalization(): + """测试版本号标准化功能""" + print("🧪 测试版本号标准化...") + + test_cases = [ + ("0.8.0-snapshot.1", "0.8.0"), + ("0.8.0-snapshot.2", "0.8.0"), + ("0.8.0", "0.8.0"), + ("0.9.0-snapshot.1", "0.9.0"), + ("1.0.0", "1.0.0"), + ("2.1", "2.1.0"), + ("3", "3.0.0"), + ("", "0.0.0"), + ("invalid", "0.0.0"), + ] + + for input_version, expected in test_cases: + result = VersionComparator.normalize_version(input_version) + status = "✅" if result == expected else "❌" + print(f" {status} {input_version} -> {result} (期望: {expected})") + + +def test_version_comparison(): + """测试版本号比较功能""" + print("\n🧪 测试版本号比较...") + + test_cases = [ + ("0.8.0", "0.9.0", -1), # 0.8.0 < 0.9.0 + ("0.9.0", "0.8.0", 1), # 0.9.0 > 0.8.0 + ("1.0.0", "1.0.0", 0), # 1.0.0 == 1.0.0 + ("0.8.0-snapshot.1", "0.8.0", 0), # 标准化后相等 + ("1.2.3", "1.2.4", -1), # 1.2.3 < 1.2.4 + ("2.0.0", "1.9.9", 1), # 2.0.0 > 1.9.9 + ] + + for v1, v2, expected in test_cases: + result = VersionComparator.compare_versions(v1, v2) + status = "✅" if result == expected else "❌" + comparison = "<" if expected == -1 else ">" if expected == 1 else "==" + print(f" {status} {v1} {comparison} {v2} (结果: {result})") + + +def test_version_range_check(): + """测试版本范围检查功能""" + print("\n🧪 测试版本范围检查...") + + test_cases = [ + ("0.8.0", "0.7.0", "0.9.0", True), # 在范围内 + ("0.6.0", "0.7.0", "0.9.0", False), # 低于最小版本 + ("1.0.0", "0.7.0", "0.9.0", False), # 高于最大版本 + ("0.8.0", "0.8.0", "0.8.0", True), # 等于边界 + ("0.8.0", "", "0.9.0", True), # 只有最大版本限制 + ("0.8.0", "0.7.0", "", True), # 只有最小版本限制 + ("0.8.0", "", "", True), # 无版本限制 + ] + + for version, min_ver, max_ver, expected in test_cases: + is_compatible, error_msg = VersionComparator.is_version_in_range(version, min_ver, max_ver) + status = "✅" if is_compatible == expected else "❌" + range_str = f"[{min_ver or '无限制'}, {max_ver or '无限制'}]" + print(f" {status} {version} 在范围 {range_str}: {is_compatible}") + if error_msg: + print(f" 错误信息: {error_msg}") + + +def test_current_version(): + """测试获取当前版本功能""" + print("\n🧪 测试获取当前主机版本...") + + try: + current_version = VersionComparator.get_current_host_version() + print(f" ✅ 当前主机版本: {current_version}") + + # 验证版本号格式 + parts = current_version.split('.') + if len(parts) == 3 and all(part.isdigit() for part in parts): + print(f" ✅ 版本号格式正确") + else: + print(f" ❌ 版本号格式错误") + + except Exception as e: + print(f" ❌ 获取当前版本失败: {e}") + + +def test_manifest_compatibility(): + """测试manifest兼容性检查""" + print("\n🧪 测试manifest兼容性检查...") + + # 模拟manifest数据 + test_manifests = [ + { + "name": "兼容插件", + "host_application": { + "min_version": "0.1.0", + "max_version": "2.0.0" + } + }, + { + "name": "版本过高插件", + "host_application": { + "min_version": "10.0.0", + "max_version": "20.0.0" + } + }, + { + "name": "版本过低插件", + "host_application": { + "min_version": "0.1.0", + "max_version": "0.2.0" + } + }, + { + "name": "无版本要求插件", + # 没有host_application字段 + } + ] + + # 这里需要导入PluginManager来测试,但可能会有依赖问题 + # 所以我们直接使用VersionComparator进行测试 + current_version = VersionComparator.get_current_host_version() + + for manifest in test_manifests: + plugin_name = manifest["name"] + + if "host_application" in manifest: + host_app = manifest["host_application"] + min_version = host_app.get("min_version", "") + max_version = host_app.get("max_version", "") + + is_compatible, error_msg = VersionComparator.is_version_in_range( + current_version, min_version, max_version + ) + + status = "✅" if is_compatible else "❌" + print(f" {status} {plugin_name}: {is_compatible}") + if error_msg: + print(f" {error_msg}") + else: + print(f" ✅ {plugin_name}: True (无版本要求)") + + +def test_additional_snapshot_formats(): + """测试额外的snapshot版本格式""" + print("\n🧪 测试额外的snapshot版本格式...") + + test_cases = [ + # 用户提到的版本格式 + ("0.8.0-snapshot.1", "0.8.0"), + ("0.8.0-snapshot.2", "0.8.0"), + ("0.8.0", "0.8.0"), + ("0.9.0-snapshot.1", "0.9.0"), + + # 边界情况 + ("1.0.0-snapshot.999", "1.0.0"), + ("2.15.3-snapshot.42", "2.15.3"), + ("10.5.0-snapshot.1", "10.5.0"), + # 不正确的snapshot格式(应该被忽略或正确处理) + ("0.8.0-snapshot", "0.0.0"), # 无数字后缀,应该标准化为0.0.0 + ("0.8.0-snapshot.abc", "0.0.0"), # 非数字后缀,应该标准化为0.0.0 + ("0.8.0-beta.1", "0.0.0"), # 其他预发布版本,应该标准化为0.0.0 + ] + + for input_version, expected in test_cases: + result = VersionComparator.normalize_version(input_version) + status = "✅" if result == expected else "❌" + print(f" {status} {input_version} -> {result} (期望: {expected})") + + +def test_snapshot_version_comparison(): + """测试snapshot版本的比较功能""" + print("\n🧪 测试snapshot版本比较...") + + test_cases = [ + # snapshot版本与正式版本比较 + ("0.8.0-snapshot.1", "0.8.0", 0), # 应该相等 + ("0.8.0-snapshot.2", "0.8.0", 0), # 应该相等 + ("0.9.0-snapshot.1", "0.8.0", 1), # 应该大于 + ("0.7.0-snapshot.1", "0.8.0", -1), # 应该小于 + + # snapshot版本之间比较 + ("0.8.0-snapshot.1", "0.8.0-snapshot.2", 0), # 都标准化为0.8.0,相等 + ("0.9.0-snapshot.1", "0.8.0-snapshot.1", 1), # 0.9.0 > 0.8.0 + + # 边界情况 + ("1.0.0-snapshot.1", "0.9.9", 1), # 主版本更高 + ("0.9.0-snapshot.1", "0.8.99", 1), # 次版本更高 + ] + + for version1, version2, expected in test_cases: + result = VersionComparator.compare_versions(version1, version2) + status = "✅" if result == expected else "❌" + comparison = "<" if expected < 0 else "==" if expected == 0 else ">" + print(f" {status} {version1} {comparison} {version2} (结果: {result})") + + +def main(): + """主函数""" + print("🔧 MaiBot插件版本兼容性检查测试") + print("=" * 50) + + try: + test_version_normalization() + test_version_comparison() + test_version_range_check() + test_current_version() + test_manifest_compatibility() + test_additional_snapshot_formats() + test_snapshot_version_comparison() + + print("\n🎉 所有测试完成!") + + except Exception as e: + print(f"\n❌ 测试过程中发生错误: {e}") + import traceback + traceback.print_exc() + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/src/plugin_system/__init__.py b/src/plugin_system/__init__.py index 7a49c6f62..1da361486 100644 --- a/src/plugin_system/__init__.py +++ b/src/plugin_system/__init__.py @@ -23,13 +23,21 @@ from src.plugin_system.core.plugin_manager import plugin_manager from src.plugin_system.core.component_registry import component_registry from src.plugin_system.core.dependency_manager import dependency_manager +# 导入工具模块 +from src.plugin_system.utils import ( + ManifestValidator, + ManifestGenerator, + validate_plugin_manifest, + generate_plugin_manifest +) + __version__ = "1.0.0" __all__ = [ # 基础类 "BasePlugin", - "BaseAction", + "BaseAction", "BaseCommand", # 类型定义 "ComponentType", @@ -47,4 +55,9 @@ __all__ = [ # 装饰器 "register_plugin", "ConfigField", + # 工具函数 + "ManifestValidator", + "ManifestGenerator", + "validate_plugin_manifest", + "generate_plugin_manifest", ] diff --git a/src/plugin_system/base/base_plugin.py b/src/plugin_system/base/base_plugin.py index e445d99cc..66c0b08c0 100644 --- a/src/plugin_system/base/base_plugin.py +++ b/src/plugin_system/base/base_plugin.py @@ -3,6 +3,7 @@ from typing import Dict, List, Type, Optional, Any, Union import os import inspect import toml +import json from src.common.logger import get_logger from src.plugin_system.base.component_types import ( PluginInfo, @@ -37,6 +38,10 @@ class BasePlugin(ABC): python_dependencies: List[PythonDependency] = [] # Python包依赖 config_file_name: Optional[str] = None # 配置文件名 + # 新增:manifest文件相关 + manifest_file_name: str = "_manifest.json" # manifest文件名 + manifest_data: Dict[str, Any] = {} # manifest数据 + # 新增:配置定义 config_schema: Dict[str, Union[Dict[str, ConfigField], str]] = {} config_section_descriptions: Dict[str, str] = {} @@ -51,13 +56,14 @@ class BasePlugin(ABC): self.plugin_dir = plugin_dir # 插件目录路径 self.log_prefix = f"[Plugin:{self.plugin_name}]" + # 加载manifest文件 + self._load_manifest() + # 验证插件信息 self._validate_plugin_info() # 加载插件配置 - self._load_plugin_config() - - # 创建插件信息对象 + self._load_plugin_config() # 创建插件信息对象 self.plugin_info = PluginInfo( name=self.plugin_name, description=self.plugin_description, @@ -68,17 +74,161 @@ class BasePlugin(ABC): config_file=self.config_file_name or "", dependencies=self.dependencies.copy(), python_dependencies=self.python_dependencies.copy(), + # 新增:manifest相关信息 + manifest_data=self.manifest_data.copy(), + license=self.get_manifest_info("license", ""), + homepage_url=self.get_manifest_info("homepage_url", ""), + repository_url=self.get_manifest_info("repository_url", ""), + keywords=self.get_manifest_info("keywords", []).copy() if self.get_manifest_info("keywords") else [], + categories=self.get_manifest_info("categories", []).copy() if self.get_manifest_info("categories") else [], + min_host_version=self.get_manifest_info("host_application.min_version", ""), + max_host_version=self.get_manifest_info("host_application.max_version", ""), ) logger.debug(f"{self.log_prefix} 插件基类初始化完成") def _validate_plugin_info(self): - """验证插件基本信息""" + """验证插件基本信息""" if not self.plugin_name: raise ValueError(f"插件类 {self.__class__.__name__} 必须定义 plugin_name") if not self.plugin_description: raise ValueError(f"插件 {self.plugin_name} 必须定义 plugin_description") + def _load_manifest(self): + """加载manifest文件(强制要求)""" + if not self.plugin_dir: + raise ValueError(f"{self.log_prefix} 没有插件目录路径,无法加载manifest") + + manifest_path = os.path.join(self.plugin_dir, self.manifest_file_name) + + if not os.path.exists(manifest_path): + error_msg = f"{self.log_prefix} 缺少必需的manifest文件: {manifest_path}" + logger.error(error_msg) + raise FileNotFoundError(error_msg) + + try: + with open(manifest_path, "r", encoding="utf-8") as f: + self.manifest_data = json.load(f) + + logger.debug(f"{self.log_prefix} 成功加载manifest文件: {manifest_path}") + + # 验证manifest格式 + self._validate_manifest() + + # 从manifest覆盖插件基本信息(如果插件类中未定义) + self._apply_manifest_overrides() + + except json.JSONDecodeError as e: + error_msg = f"{self.log_prefix} manifest文件格式错误: {e}" + logger.error(error_msg) + raise ValueError(error_msg) #noqa + except IOError as e: + error_msg = f"{self.log_prefix} 读取manifest文件失败: {e}" + logger.error(error_msg) + raise IOError(error_msg) #noqa + + def _apply_manifest_overrides(self): + """从manifest文件覆盖插件信息""" + if not self.manifest_data: + return + + # 如果插件类中的信息为空,则从manifest中获取 + if not self.plugin_name: + self.plugin_name = self.manifest_data.get("name", "") + + if not self.plugin_description: + self.plugin_description = self.manifest_data.get("description", "") + + if self.plugin_version == "1.0.0": # 默认版本 + self.plugin_version = self.manifest_data.get("version", "1.0.0") + + if not self.plugin_author: + author_info = self.manifest_data.get("author", {}) + if isinstance(author_info, dict): + self.plugin_author = author_info.get("name", "") + else: + self.plugin_author = str(author_info) + + def _validate_manifest(self): + """验证manifest文件格式(使用强化的验证器)""" + if not self.manifest_data: + return + + # 导入验证器 + from src.plugin_system.utils.manifest_utils import ManifestValidator + + validator = ManifestValidator() + is_valid = validator.validate_manifest(self.manifest_data) + + # 记录验证结果 + if validator.validation_errors or validator.validation_warnings: + report = validator.get_validation_report() + logger.info(f"{self.log_prefix} Manifest验证结果:\n{report}") + + # 如果有验证错误,抛出异常 + if not is_valid: + error_msg = f"{self.log_prefix} Manifest文件验证失败" + if validator.validation_errors: + error_msg += f": {'; '.join(validator.validation_errors)}" + raise ValueError(error_msg) + + def _generate_default_manifest(self, manifest_path: str): + """生成默认的manifest文件""" + if not self.plugin_name: + logger.debug(f"{self.log_prefix} 插件名称未定义,无法生成默认manifest") + return + + default_manifest = { + "manifest_version": 3, + "name": self.plugin_name, + "version": self.plugin_version, + "description": self.plugin_description or "插件描述", + "author": { + "name": self.plugin_author or "Unknown", + "url": "" + }, + "license": "MIT", + "host_application": { + "min_version": "1.0.0", + "max_version": "4.0.0" + }, + "keywords": [], + "categories": [], + "default_locale": "zh-CN", + "locales_path": "_locales" + } + + try: + with open(manifest_path, "w", encoding="utf-8") as f: + json.dump(default_manifest, f, ensure_ascii=False, indent=2) + logger.info(f"{self.log_prefix} 已生成默认manifest文件: {manifest_path}") + except IOError as e: + logger.error(f"{self.log_prefix} 保存默认manifest文件失败: {e}") + + def get_manifest_info(self, key: str, default: Any = None) -> Any: + """获取manifest信息 + + Args: + key: 信息键,支持点分割的嵌套键(如 "author.name") + default: 默认值 + + Returns: + Any: 对应的值 + """ + if not self.manifest_data: + return default + + keys = key.split(".") + value = self.manifest_data + + for k in keys: + if isinstance(value, dict) and k in value: + value = value[k] + else: + return default + + return value + def _generate_and_save_default_config(self, config_file_path: str): """根据插件的Schema生成并保存默认配置文件""" if not self.config_schema: diff --git a/src/plugin_system/base/component_types.py b/src/plugin_system/base/component_types.py index 01b2c3b4e..bfcafeb8d 100644 --- a/src/plugin_system/base/component_types.py +++ b/src/plugin_system/base/component_types.py @@ -131,6 +131,15 @@ class PluginInfo: python_dependencies: List[PythonDependency] = None # Python包依赖 config_file: str = "" # 配置文件路径 metadata: Dict[str, Any] = None # 额外元数据 + # 新增:manifest相关信息 + manifest_data: Dict[str, Any] = None # manifest文件数据 + license: str = "" # 插件许可证 + homepage_url: str = "" # 插件主页 + repository_url: str = "" # 插件仓库地址 + keywords: List[str] = None # 插件关键词 + categories: List[str] = None # 插件分类 + min_host_version: str = "" # 最低主机版本要求 + max_host_version: str = "" # 最高主机版本要求 def __post_init__(self): if self.components is None: @@ -141,6 +150,12 @@ class PluginInfo: self.python_dependencies = [] if self.metadata is None: self.metadata = {} + if self.manifest_data is None: + self.manifest_data = {} + if self.keywords is None: + self.keywords = [] + if self.categories is None: + self.categories = [] def get_missing_packages(self) -> List[PythonDependency]: """检查缺失的Python包""" diff --git a/src/plugin_system/core/plugin_manager.py b/src/plugin_system/core/plugin_manager.py index 559dec1c6..5f4705848 100644 --- a/src/plugin_system/core/plugin_manager.py +++ b/src/plugin_system/core/plugin_manager.py @@ -1,4 +1,4 @@ -from typing import Dict, List, Optional, Any, TYPE_CHECKING +from typing import Dict, List, Optional, Any, TYPE_CHECKING, Tuple import os import importlib import importlib.util @@ -76,51 +76,92 @@ class PluginManager: logger.debug(f"插件模块加载完成 - 成功: {total_loaded_modules}, 失败: {total_failed_modules}") # 第二阶段:实例化所有已注册的插件类 - from src.plugin_system.base.base_plugin import get_registered_plugin_classes - + from src.plugin_system.base.base_plugin import get_registered_plugin_classes plugin_classes = get_registered_plugin_classes() total_registered = 0 total_failed_registration = 0 for plugin_name, plugin_class in plugin_classes.items(): - # 使用记录的插件目录路径 - plugin_dir = self.plugin_paths.get(plugin_name) + try: + # 使用记录的插件目录路径 + plugin_dir = self.plugin_paths.get(plugin_name) - # 如果没有记录,则尝试查找(fallback) - if not plugin_dir: - plugin_dir = self._find_plugin_directory(plugin_class) - if plugin_dir: - self.plugin_paths[plugin_name] = plugin_dir + # 如果没有记录,则尝试查找(fallback) + if not plugin_dir: + plugin_dir = self._find_plugin_directory(plugin_class) + if plugin_dir: + self.plugin_paths[plugin_name] = plugin_dir # 实例化插件(可能因为缺少manifest而失败) + plugin_instance = plugin_class(plugin_dir=plugin_dir) - plugin_instance = plugin_class(plugin_dir=plugin_dir) + # 检查插件是否启用 + if not plugin_instance.enable_plugin: + logger.info(f"插件 {plugin_name} 已禁用,跳过加载") + continue - # 检查插件是否启用 - if not plugin_instance.enable_plugin: - logger.info(f"插件 {plugin_name} 已禁用,跳过加载") - continue + # 检查版本兼容性 + is_compatible, compatibility_error = self.check_plugin_version_compatibility( + plugin_name, plugin_instance.manifest_data + ) + if not is_compatible: + total_failed_registration += 1 + self.failed_plugins[plugin_name] = compatibility_error + logger.error(f"❌ 插件加载失败: {plugin_name} - {compatibility_error}") + continue - if plugin_instance.register_plugin(): - total_registered += 1 - self.loaded_plugins[plugin_name] = plugin_instance + if plugin_instance.register_plugin(): + total_registered += 1 + self.loaded_plugins[plugin_name] = plugin_instance - # 📊 显示插件详细信息 - plugin_info = component_registry.get_plugin_info(plugin_name) - if plugin_info: - component_types = {} - for comp in plugin_info.components: - comp_type = comp.component_type.name - component_types[comp_type] = component_types.get(comp_type, 0) + 1 + # 📊 显示插件详细信息 + plugin_info = component_registry.get_plugin_info(plugin_name) + if plugin_info: + component_types = {} + for comp in plugin_info.components: + comp_type = comp.component_type.name + component_types[comp_type] = component_types.get(comp_type, 0) + 1 - components_str = ", ".join([f"{count}个{ctype}" for ctype, count in component_types.items()]) - logger.info( - f"✅ 插件加载成功: {plugin_name} v{plugin_info.version} ({components_str}) - {plugin_info.description}" - ) + components_str = ", ".join([f"{count}个{ctype}" for ctype, count in component_types.items()]) + + # 显示manifest信息 + manifest_info = "" + if plugin_info.license: + manifest_info += f" [{plugin_info.license}]" + if plugin_info.keywords: + manifest_info += f" 关键词: {', '.join(plugin_info.keywords[:3])}" # 只显示前3个关键词 + if len(plugin_info.keywords) > 3: + manifest_info += "..." + + logger.info( + f"✅ 插件加载成功: {plugin_name} v{plugin_info.version} ({components_str}){manifest_info} - {plugin_info.description}" + ) + else: + logger.info(f"✅ 插件加载成功: {plugin_name}") else: - logger.info(f"✅ 插件加载成功: {plugin_name}") - else: + total_failed_registration += 1 + self.failed_plugins[plugin_name] = "插件注册失败" + logger.error(f"❌ 插件注册失败: {plugin_name}") + + except FileNotFoundError as e: + # manifest文件缺失 total_failed_registration += 1 - self.failed_plugins[plugin_name] = "插件注册失败" - logger.error(f"❌ 插件加载失败: {plugin_name}") + error_msg = f"缺少manifest文件: {str(e)}" + self.failed_plugins[plugin_name] = error_msg + logger.error(f"❌ 插件加载失败: {plugin_name} - {error_msg}") + + except ValueError as e: + # manifest文件格式错误或验证失败 + total_failed_registration += 1 + error_msg = f"manifest验证失败: {str(e)}" + self.failed_plugins[plugin_name] = error_msg + logger.error(f"❌ 插件加载失败: {plugin_name} - {error_msg}") + + except Exception as e: + # 其他错误 + total_failed_registration += 1 + error_msg = f"未知错误: {str(e)}" + self.failed_plugins[plugin_name] = error_msg + logger.error(f"❌ 插件加载失败: {plugin_name} - {error_msg}") + logger.debug("详细错误信息: ", exc_info=True) # 获取组件统计信息 stats = component_registry.get_registry_stats() @@ -136,19 +177,28 @@ class PluginManager: f"📊 总览: {total_registered}个插件, {total_components}个组件 (Action: {action_count}, Command: {command_count})" ) - # 显示详细的插件列表 - logger.info("📋 已加载插件详情:") + # 显示详细的插件列表 logger.info("📋 已加载插件详情:") for plugin_name, _plugin_class in self.loaded_plugins.items(): plugin_info = component_registry.get_plugin_info(plugin_name) if plugin_info: # 插件基本信息 version_info = f"v{plugin_info.version}" if plugin_info.version else "" author_info = f"by {plugin_info.author}" if plugin_info.author else "unknown" - info_parts = [part for part in [version_info, author_info] if part] + license_info = f"[{plugin_info.license}]" if plugin_info.license else "" + info_parts = [part for part in [version_info, author_info, license_info] if part] extra_info = f" ({', '.join(info_parts)})" if info_parts else "" logger.info(f" 📦 {plugin_name}{extra_info}") + # Manifest信息 + if plugin_info.manifest_data: + if plugin_info.keywords: + logger.info(f" 🏷️ 关键词: {', '.join(plugin_info.keywords)}") + if plugin_info.categories: + logger.info(f" 📁 分类: {', '.join(plugin_info.categories)}") + if plugin_info.homepage_url: + logger.info(f" 🌐 主页: {plugin_info.homepage_url}") + # 组件列表 if plugin_info.components: action_components = [c for c in plugin_info.components if c.component_type.name == "ACTION"] @@ -162,6 +212,18 @@ class PluginManager: command_names = [c.name for c in command_components] logger.info(f" ⚡ Command组件: {', '.join(command_names)}") + # 版本兼容性信息 + if plugin_info.min_host_version or plugin_info.max_host_version: + version_range = "" + if plugin_info.min_host_version: + version_range += f">={plugin_info.min_host_version}" + if plugin_info.max_host_version: + if version_range: + version_range += f", <={plugin_info.max_host_version}" + else: + version_range += f"<={plugin_info.max_host_version}" + logger.info(f" 📋 兼容版本: {version_range}") + # 依赖信息 if plugin_info.dependencies: logger.info(f" 🔗 依赖: {', '.join(plugin_info.dependencies)}") @@ -453,6 +515,47 @@ class PluginManager: return dependency_manager.generate_requirements_file(all_dependencies, output_path) + def check_plugin_version_compatibility(self, plugin_name: str, manifest_data: Dict[str, Any]) -> Tuple[bool, str]: + """检查插件版本兼容性 + + Args: + plugin_name: 插件名称 + manifest_data: manifest数据 + + Returns: + Tuple[bool, str]: (是否兼容, 错误信息) + """ + if "host_application" not in manifest_data: + # 没有版本要求,默认兼容 + return True, "" + + host_app = manifest_data["host_application"] + if not isinstance(host_app, dict): + return True, "" + + min_version = host_app.get("min_version", "") + max_version = host_app.get("max_version", "") + + if not min_version and not max_version: + return True, "" + + try: + from src.plugin_system.utils.manifest_utils import VersionComparator + + current_version = VersionComparator.get_current_host_version() + is_compatible, error_msg = VersionComparator.is_version_in_range( + current_version, min_version, max_version + ) + + if not is_compatible: + return False, f"版本不兼容: {error_msg}" + else: + logger.debug(f"插件 {plugin_name} 版本兼容性检查通过") + return True, "" + + except Exception as e: + logger.warning(f"插件 {plugin_name} 版本兼容性检查失败: {e}") + return True, "" # 检查失败时默认允许加载 # 全局插件管理器实例 plugin_manager = PluginManager() diff --git a/src/plugin_system/utils/__init__.py b/src/plugin_system/utils/__init__.py new file mode 100644 index 000000000..2eed9ccf3 --- /dev/null +++ b/src/plugin_system/utils/__init__.py @@ -0,0 +1,19 @@ +""" +插件系统工具模块 + +提供插件开发和管理的实用工具 +""" + +from src.plugin_system.utils.manifest_utils import ( + ManifestValidator, + ManifestGenerator, + validate_plugin_manifest, + generate_plugin_manifest +) + +__all__ = [ + "ManifestValidator", + "ManifestGenerator", + "validate_plugin_manifest", + "generate_plugin_manifest" +] diff --git a/src/plugin_system/utils/manifest_utils.py b/src/plugin_system/utils/manifest_utils.py new file mode 100644 index 000000000..ca77548b7 --- /dev/null +++ b/src/plugin_system/utils/manifest_utils.py @@ -0,0 +1,459 @@ +""" +插件Manifest工具模块 + +提供manifest文件的验证、生成和管理功能 +""" + +import json +import os +import re +from typing import Dict, Any, Optional, Tuple, List +from src.common.logger import get_logger +from src.config.config import MMC_VERSION + +logger = get_logger("manifest_utils") + + +class VersionComparator: + """版本号比较器 + + 支持语义化版本号比较,自动处理snapshot版本 + """ + + @staticmethod + def normalize_version(version: str) -> str: + """标准化版本号,移除snapshot标识 + + Args: + version: 原始版本号,如 "0.8.0-snapshot.1" + + Returns: + str: 标准化后的版本号,如 "0.8.0" + """ + if not version: + return "0.0.0" + + # 移除snapshot部分 + normalized = re.sub(r'-snapshot\.\d+', '', version.strip()) + + # 确保版本号格式正确 + if not re.match(r'^\d+(\.\d+){0,2}$', normalized): + # 如果不是有效的版本号格式,返回默认版本 + return "0.0.0" + + # 尝试补全版本号 + parts = normalized.split('.') + while len(parts) < 3: + parts.append('0') + normalized = '.'.join(parts[:3]) + + return normalized + + @staticmethod + def parse_version(version: str) -> Tuple[int, int, int]: + """解析版本号为元组 + + Args: + version: 版本号字符串 + + Returns: + Tuple[int, int, int]: (major, minor, patch) + """ + normalized = VersionComparator.normalize_version(version) + try: + parts = normalized.split('.') + return (int(parts[0]), int(parts[1]), int(parts[2])) + except (ValueError, IndexError): + logger.warning(f"无法解析版本号: {version},使用默认版本 0.0.0") + return (0, 0, 0) + + @staticmethod + def compare_versions(version1: str, version2: str) -> int: + """比较两个版本号 + + Args: + version1: 第一个版本号 + version2: 第二个版本号 + + Returns: + int: -1 if version1 < version2, 0 if equal, 1 if version1 > version2 + """ + v1_tuple = VersionComparator.parse_version(version1) + v2_tuple = VersionComparator.parse_version(version2) + + if v1_tuple < v2_tuple: + return -1 + elif v1_tuple > v2_tuple: + return 1 + else: + return 0 + + @staticmethod + def is_version_in_range(version: str, min_version: str = "", max_version: str = "") -> Tuple[bool, str]: + """检查版本是否在指定范围内 + + Args: + version: 要检查的版本号 + min_version: 最小版本号(可选) + max_version: 最大版本号(可选) + + Returns: + Tuple[bool, str]: (是否兼容, 错误信息) + """ + if not min_version and not max_version: + return True, "" + + version_normalized = VersionComparator.normalize_version(version) + + # 检查最小版本 + if min_version: + min_normalized = VersionComparator.normalize_version(min_version) + if VersionComparator.compare_versions(version_normalized, min_normalized) < 0: + return False, f"版本 {version_normalized} 低于最小要求版本 {min_normalized}" + + # 检查最大版本 + if max_version: + max_normalized = VersionComparator.normalize_version(max_version) + if VersionComparator.compare_versions(version_normalized, max_normalized) > 0: + return False, f"版本 {version_normalized} 高于最大支持版本 {max_normalized}" + + return True, "" + + @staticmethod + def get_current_host_version() -> str: + """获取当前主机应用版本 + + Returns: + str: 当前版本号 + """ + return VersionComparator.normalize_version(MMC_VERSION) + + +class ManifestValidator: + """Manifest文件验证器""" + + # 必需字段(必须存在且不能为空) + REQUIRED_FIELDS = [ + "manifest_version", + "name", + "version", + "description", + "author" + ] + + # 可选字段(可以不存在或为空) + OPTIONAL_FIELDS = [ + "license", + "host_application", + "homepage_url", + "repository_url", + "keywords", + "categories", + "default_locale", + "locales_path", + "plugin_info" + ] + + # 建议填写的字段(会给出警告但不会导致验证失败) + RECOMMENDED_FIELDS = [ + "license", + "keywords", + "categories" + ] + + SUPPORTED_MANIFEST_VERSIONS = [3] + + def __init__(self): + self.validation_errors = [] + self.validation_warnings = [] + + def validate_manifest(self, manifest_data: Dict[str, Any]) -> bool: + """验证manifest数据 + + Args: + manifest_data: manifest数据字典 + + Returns: + bool: 是否验证通过(只有错误会导致验证失败,警告不会) + """ + self.validation_errors.clear() + self.validation_warnings.clear() + + # 检查必需字段 + for field in self.REQUIRED_FIELDS: + if field not in manifest_data: + self.validation_errors.append(f"缺少必需字段: {field}") + elif not manifest_data[field]: + self.validation_errors.append(f"必需字段不能为空: {field}") + + # 检查manifest版本 + if "manifest_version" in manifest_data: + version = manifest_data["manifest_version"] + if version not in self.SUPPORTED_MANIFEST_VERSIONS: + self.validation_errors.append(f"不支持的manifest版本: {version},支持的版本: {self.SUPPORTED_MANIFEST_VERSIONS}") + + # 检查作者信息格式 + if "author" in manifest_data: + author = manifest_data["author"] + if isinstance(author, dict): + if "name" not in author or not author["name"]: + self.validation_errors.append("作者信息缺少name字段或为空") + # url字段是可选的 + if "url" in author and author["url"]: + url = author["url"] + if not (url.startswith("http://") or url.startswith("https://")): + self.validation_warnings.append("作者URL建议使用完整的URL格式") + elif isinstance(author, str): + if not author.strip(): + self.validation_errors.append("作者信息不能为空") + else: + self.validation_errors.append("作者信息格式错误,应为字符串或包含name字段的对象") + # 检查主机应用版本要求(可选) + if "host_application" in manifest_data: + host_app = manifest_data["host_application"] + if isinstance(host_app, dict): + min_version = host_app.get("min_version", "") + max_version = host_app.get("max_version", "") + + # 验证版本字段格式 + for version_field in ["min_version", "max_version"]: + if version_field in host_app and not host_app[version_field]: + self.validation_warnings.append(f"host_application.{version_field}为空") + + # 检查当前主机版本兼容性 + if min_version or max_version: + current_version = VersionComparator.get_current_host_version() + is_compatible, error_msg = VersionComparator.is_version_in_range( + current_version, min_version, max_version + ) + + if not is_compatible: + self.validation_errors.append(f"版本兼容性检查失败: {error_msg} (当前版本: {current_version})") + else: + logger.debug(f"版本兼容性检查通过: 当前版本 {current_version} 符合要求 [{min_version}, {max_version}]") + else: + self.validation_errors.append("host_application格式错误,应为对象") + + # 检查URL格式(可选字段) + for url_field in ["homepage_url", "repository_url"]: + if url_field in manifest_data and manifest_data[url_field]: + url = manifest_data[url_field] + if not (url.startswith("http://") or url.startswith("https://")): + self.validation_warnings.append(f"{url_field}建议使用完整的URL格式") + + # 检查数组字段格式(可选字段) + for list_field in ["keywords", "categories"]: + if list_field in manifest_data: + field_value = manifest_data[list_field] + if field_value is not None and not isinstance(field_value, list): + self.validation_errors.append(f"{list_field}应为数组格式") + elif isinstance(field_value, list): + # 检查数组元素是否为字符串 + for i, item in enumerate(field_value): + if not isinstance(item, str): + self.validation_warnings.append(f"{list_field}[{i}]应为字符串") + + # 检查建议字段(给出警告) + for field in self.RECOMMENDED_FIELDS: + if field not in manifest_data or not manifest_data[field]: + self.validation_warnings.append(f"建议填写字段: {field}") + + # 检查plugin_info结构(可选) + if "plugin_info" in manifest_data: + plugin_info = manifest_data["plugin_info"] + if isinstance(plugin_info, dict): + # 检查components数组 + if "components" in plugin_info: + components = plugin_info["components"] + if not isinstance(components, list): + self.validation_errors.append("plugin_info.components应为数组格式") + else: + for i, component in enumerate(components): + if not isinstance(component, dict): + self.validation_errors.append(f"plugin_info.components[{i}]应为对象") + else: + # 检查组件必需字段 + for comp_field in ["type", "name", "description"]: + if comp_field not in component or not component[comp_field]: + self.validation_errors.append(f"plugin_info.components[{i}]缺少必需字段: {comp_field}") + else: + self.validation_errors.append("plugin_info应为对象格式") + + return len(self.validation_errors) == 0 + + def get_validation_report(self) -> str: + """获取验证报告""" + report = [] + + if self.validation_errors: + report.append("❌ 验证错误:") + for error in self.validation_errors: + report.append(f" - {error}") + + if self.validation_warnings: + report.append("⚠️ 验证警告:") + for warning in self.validation_warnings: + report.append(f" - {warning}") + + if not self.validation_errors and not self.validation_warnings: + report.append("✅ Manifest文件验证通过") + + return "\n".join(report) + + +class ManifestGenerator: + """Manifest文件生成器""" + + def __init__(self): + self.template = { + "manifest_version": 3, + "name": "", + "version": "1.0.0", + "description": "", + "author": { + "name": "", + "url": "" + }, + "license": "MIT", + "host_application": { + "min_version": "1.0.0", + "max_version": "4.0.0" + }, + "homepage_url": "", + "repository_url": "", + "keywords": [], + "categories": [], + "default_locale": "zh-CN", + "locales_path": "_locales" + } + + def generate_from_plugin(self, plugin_instance) -> Dict[str, Any]: + """从插件实例生成manifest + + Args: + plugin_instance: BasePlugin实例 + + Returns: + Dict[str, Any]: 生成的manifest数据 + """ + manifest = self.template.copy() + + # 基本信息 + manifest["name"] = plugin_instance.plugin_name + manifest["version"] = plugin_instance.plugin_version + manifest["description"] = plugin_instance.plugin_description + + # 作者信息 + if plugin_instance.plugin_author: + manifest["author"]["name"] = plugin_instance.plugin_author + + # 组件信息 + components = [] + plugin_components = plugin_instance.get_plugin_components() + + for component_info, component_class in plugin_components: + component_data = { + "type": component_info.component_type.value, + "name": component_info.name, + "description": component_info.description + } + + # 添加激活模式信息(对于Action组件) + if hasattr(component_class, 'focus_activation_type'): + activation_modes = [] + if hasattr(component_class, 'focus_activation_type'): + activation_modes.append(component_class.focus_activation_type.value) + if hasattr(component_class, 'normal_activation_type'): + activation_modes.append(component_class.normal_activation_type.value) + component_data["activation_modes"] = list(set(activation_modes)) + + # 添加关键词信息 + if hasattr(component_class, 'activation_keywords'): + keywords = getattr(component_class, 'activation_keywords', []) + if keywords: + component_data["keywords"] = keywords + + components.append(component_data) + + manifest["plugin_info"] = { + "is_built_in": True, + "plugin_type": "general", + "components": components + } + + return manifest + + def save_manifest(self, manifest_data: Dict[str, Any], plugin_dir: str) -> bool: + """保存manifest文件 + + Args: + manifest_data: manifest数据 + plugin_dir: 插件目录 + + Returns: + bool: 是否保存成功 + """ + try: + manifest_path = os.path.join(plugin_dir, "_manifest.json") + with open(manifest_path, "w", encoding="utf-8") as f: + json.dump(manifest_data, f, ensure_ascii=False, indent=2) + logger.info(f"Manifest文件已保存: {manifest_path}") + return True + except Exception as e: + logger.error(f"保存manifest文件失败: {e}") + return False + + +def validate_plugin_manifest(plugin_dir: str) -> bool: + """验证插件目录中的manifest文件 + + Args: + plugin_dir: 插件目录路径 + + Returns: + bool: 是否验证通过 + """ + manifest_path = os.path.join(plugin_dir, "_manifest.json") + + if not os.path.exists(manifest_path): + logger.warning(f"未找到manifest文件: {manifest_path}") + return False + + try: + with open(manifest_path, "r", encoding="utf-8") as f: + manifest_data = json.load(f) + + validator = ManifestValidator() + is_valid = validator.validate_manifest(manifest_data) + + logger.info(f"Manifest验证结果:\n{validator.get_validation_report()}") + + return is_valid + + except Exception as e: + logger.error(f"读取或验证manifest文件失败: {e}") + return False + + +def generate_plugin_manifest(plugin_instance, save_to_file: bool = True) -> Optional[Dict[str, Any]]: + """为插件生成manifest文件 + + Args: + plugin_instance: BasePlugin实例 + save_to_file: 是否保存到文件 + + Returns: + Optional[Dict[str, Any]]: 生成的manifest数据 + """ + try: + generator = ManifestGenerator() + manifest_data = generator.generate_from_plugin(plugin_instance) + + if save_to_file and plugin_instance.plugin_dir: + generator.save_manifest(manifest_data, plugin_instance.plugin_dir) + + return manifest_data + + except Exception as e: + logger.error(f"生成manifest文件失败: {e}") + return None diff --git a/src/plugins/built_in/core_actions/_manifest.json b/src/plugins/built_in/core_actions/_manifest.json new file mode 100644 index 000000000..be20d1b0f --- /dev/null +++ b/src/plugins/built_in/core_actions/_manifest.json @@ -0,0 +1,55 @@ +{ + "manifest_version": 3, + "name": "核心动作插件 (Core Actions)", + "version": "1.0.0", + "description": "系统核心动作插件,提供基础聊天交互功能,包括回复、不回复、表情包发送和聊天模式切换等核心功能。", + "author": { + "name": "MaiBot团队", + "url": "https://github.com/MaiM-with-u" + }, + "license": "GPL-v3.0-or-later", + + "host_application": { + "min_version": "0.8.0", + "max_version": "0.8.0" + }, + "homepage_url": "https://github.com/MaiM-with-u/maibot", + "repository_url": "https://github.com/MaiM-with-u/maibot", + "keywords": ["core", "chat", "reply", "emoji", "action", "built-in"], + "categories": ["Core System", "Chat Management"], + + "default_locale": "zh-CN", + "locales_path": "_locales", + + "plugin_info": { + "is_built_in": true, + "plugin_type": "action_provider", + "components": [ + { + "type": "action", + "name": "reply", + "description": "参与聊天回复,发送文本进行表达" + }, + { + "type": "action", + "name": "no_reply", + "description": "暂时不回复消息,等待新消息或超时" + }, + { + "type": "action", + "name": "emoji", + "description": "发送表情包辅助表达情绪" + }, + { + "type": "action", + "name": "change_to_focus_chat", + "description": "切换到专注聊天,从普通模式切换到专注模式" + }, + { + "type": "action", + "name": "exit_focus_chat", + "description": "退出专注聊天,从专注模式切换到普通模式" + } + ] + } +} diff --git a/src/plugins/built_in/core_actions/plugin.py b/src/plugins/built_in/core_actions/plugin.py index dfd6e82b7..5df0c07c7 100644 --- a/src/plugins/built_in/core_actions/plugin.py +++ b/src/plugins/built_in/core_actions/plugin.py @@ -340,9 +340,11 @@ class CoreActionsPlugin(BasePlugin): - Reply: 回复动作 - NoReply: 不回复动作 - Emoji: 表情动作 + + 注意:插件基本信息优先从_manifest.json文件中读取 """ - # 插件基本信息 + # 插件基本信息(作为fallback,优先从manifest读取) plugin_name = "core_actions" plugin_description = "系统核心动作插件,提供基础聊天交互功能" plugin_version = "1.0.0" diff --git a/src/plugins/built_in/doubao_pic_plugin/_manifest.json b/src/plugins/built_in/doubao_pic_plugin/_manifest.json new file mode 100644 index 000000000..ef654125c --- /dev/null +++ b/src/plugins/built_in/doubao_pic_plugin/_manifest.json @@ -0,0 +1,45 @@ +{ + "manifest_version": 3, + "name": "豆包图片生成插件 (Doubao Image Generator)", + "version": "1.2.0", + "description": "基于火山引擎豆包模型的AI图片生成插件,支持智能LLM判定、高质量图片生成、结果缓存和多尺寸支持。", + "author": { + "name": "MaiBot团队", + "url": "https://github.com/MaiM-with-u" + }, + "license": "GPL-v3.0-or-later", + + "host_application": { + "min_version": "0.8.0", + "max_version": "0.8.0" + }, + "homepage_url": "https://github.com/MaiM-with-u/maibot", + "repository_url": "https://github.com/MaiM-with-u/maibot", + "keywords": ["ai", "image", "generation", "doubao", "volcengine", "art"], + "categories": ["AI Tools", "Image Processing", "Content Generation"], + + "default_locale": "zh-CN", + "locales_path": "_locales", + + "plugin_info": { + "is_built_in": true, + "plugin_type": "content_generator", + "api_dependencies": ["volcengine"], + "components": [ + { + "type": "action", + "name": "doubao_image_generation", + "description": "根据描述使用火山引擎豆包API生成高质量图片", + "activation_modes": ["llm_judge", "keyword"], + "keywords": ["画", "图片", "生成", "画画", "绘制"] + } + ], + "features": [ + "智能LLM判定生成时机", + "高质量AI图片生成", + "结果缓存机制", + "多种图片尺寸支持", + "完整的错误处理" + ] + } +} diff --git a/src/plugins/built_in/mute_plugin/_manifest.json b/src/plugins/built_in/mute_plugin/_manifest.json new file mode 100644 index 000000000..cab15f86e --- /dev/null +++ b/src/plugins/built_in/mute_plugin/_manifest.json @@ -0,0 +1,19 @@ +{ + "manifest_version": 3, + "name": "mute_plugin", + "version": "2.0.0", + "description": "群聊禁言管理插件,提供智能禁言功能", + "author": { + "name": "MaiBot开发团队", + "url": "" + }, + "license": "GPL-v3.0-or-later", + "host_application": { + "min_version": "0.8.0", + "max_version": "0.8.0" + }, + "keywords": [], + "categories": [], + "default_locale": "zh-CN", + "locales_path": "_locales" +} \ No newline at end of file diff --git a/src/plugins/built_in/tts_plugin/_manifest.json b/src/plugins/built_in/tts_plugin/_manifest.json new file mode 100644 index 000000000..21e5e8a8b --- /dev/null +++ b/src/plugins/built_in/tts_plugin/_manifest.json @@ -0,0 +1,43 @@ +{ + "manifest_version": 3, + "name": "文本转语音插件 (Text-to-Speech)", + "version": "1.1.0", + "description": "将文本转换为语音进行播放的插件,支持多种语音模式和智能语音输出场景判断。", + "author": { + "name": "MaiBot团队", + "url": "https://github.com/MaiM-with-u" + }, + "license": "GPL-v3.0-or-later", + + "host_application": { + "min_version": "0.8.0", + "max_version": "0.8.0" + }, + "homepage_url": "https://github.com/MaiM-with-u/maibot", + "repository_url": "https://github.com/MaiM-with-u/maibot", + "keywords": ["tts", "voice", "audio", "speech", "accessibility"], + "categories": ["Audio Tools", "Accessibility", "Voice Assistant"], + + "default_locale": "zh-CN", + "locales_path": "_locales", + + "plugin_info": { + "is_built_in": true, + "plugin_type": "audio_processor", + "components": [ + { + "type": "action", + "name": "tts_action", + "description": "将文本转换为语音进行播放", + "activation_modes": ["llm_judge", "keyword"], + "keywords": ["语音", "tts", "播报", "读出来", "语音播放", "听", "朗读"] + } + ], + "features": [ + "文本转语音播放", + "智能场景判断", + "关键词触发", + "支持多种语音模式" + ] + } +} diff --git a/src/plugins/built_in/vtb_plugin/_manifest.json b/src/plugins/built_in/vtb_plugin/_manifest.json new file mode 100644 index 000000000..a474814fa --- /dev/null +++ b/src/plugins/built_in/vtb_plugin/_manifest.json @@ -0,0 +1,19 @@ +{ + "manifest_version": 3, + "name": "vtb_plugin", + "version": "0.1.0", + "description": "虚拟主播情感表达插件", + "author": { + "name": "MaiBot开发团队", + "url": "" + }, + "license": "GPL-v3.0-or-later", + "host_application": { + "min_version": "0.8.0", + "max_version": "0.8.0" + }, + "keywords": [], + "categories": [], + "default_locale": "zh-CN", + "locales_path": "_locales" +} \ No newline at end of file