diff --git a/bot.py b/bot.py index 68c6e110..b47ccfc2 100644 --- a/bot.py +++ b/bot.py @@ -235,12 +235,21 @@ if __name__ == "__main__": loop.run_until_complete(main_tasks) except KeyboardInterrupt: - # loop.run_until_complete(get_global_api().stop()) logger.warning("收到中断信号,正在优雅关闭...") + + # 取消主任务 + if 'main_tasks' in locals() and main_tasks and not main_tasks.done(): + main_tasks.cancel() + try: + loop.run_until_complete(main_tasks) + except asyncio.CancelledError: + pass + + # 执行优雅关闭 if loop and not loop.is_closed(): try: loop.run_until_complete(graceful_shutdown()) - except Exception as ge: # 捕捉优雅关闭时可能发生的错误 + except Exception as ge: logger.error(f"优雅关闭时发生错误: {ge}") # 新增:检测外部请求关闭 diff --git a/plugins/ChatFrequency/_manifest.json b/plugins/ChatFrequency/_manifest.json index 9a3a9632..669de6b8 100644 --- a/plugins/ChatFrequency/_manifest.json +++ b/plugins/ChatFrequency/_manifest.json @@ -8,18 +8,25 @@ "url": "https://github.com/MaiM-with-u" }, "license": "GPL-v3.0-or-later", - "host_application": { "min_version": "0.10.3" }, "homepage_url": "https://github.com/SengokuCola/BetterFrequency", "repository_url": "https://github.com/SengokuCola/BetterFrequency", - "keywords": ["frequency", "control", "talk_frequency", "plugin", "shortcut"], - "categories": ["Chat", "Frequency", "Control"], - + "keywords": [ + "frequency", + "control", + "talk_frequency", + "plugin", + "shortcut" + ], + "categories": [ + "Chat", + "Frequency", + "Control" + ], "default_locale": "zh-CN", "locales_path": "_locales", - "plugin_info": { "is_built_in": false, "plugin_type": "frequency", @@ -46,5 +53,6 @@ "支持完整命令和简化命令", "快速操作支持" ] - } + }, + "id": "SengokuCola.BetterFrequency" } \ No newline at end of file diff --git a/plugins/emoji_manage_plugin/_manifest.json b/plugins/emoji_manage_plugin/_manifest.json index ee8d8318..68f5c679 100644 --- a/plugins/emoji_manage_plugin/_manifest.json +++ b/plugins/emoji_manage_plugin/_manifest.json @@ -8,18 +8,22 @@ "url": "https://github.com/SengokuCola" }, "license": "GPL-v3.0-or-later", - "host_application": { "min_version": "0.10.4" }, "homepage_url": "https://github.com/SengokuCola/BetterEmoji", "repository_url": "https://github.com/SengokuCola/BetterEmoji", - "keywords": ["emoji", "manage", "plugin"], - "categories": ["Examples", "Tutorial"], - + "keywords": [ + "emoji", + "manage", + "plugin" + ], + "categories": [ + "Examples", + "Tutorial" + ], "default_locale": "zh-CN", "locales_path": "_locales", - "plugin_info": { "is_built_in": false, "plugin_type": "emoji_manage", @@ -31,10 +35,17 @@ }, { "type": "action", - "name": "bye_greeting", + "name": "bye_greeting", "description": "向用户发送告别消息", - "activation_modes": ["keyword"], - "keywords": ["再见", "bye", "88", "拜拜"] + "activation_modes": [ + "keyword" + ], + "keywords": [ + "再见", + "bye", + "88", + "拜拜" + ] }, { "type": "command", @@ -49,5 +60,6 @@ "配置文件示例", "新手教程代码" ] - } + }, + "id": "SengokuCola.BetterEmoji" } \ No newline at end of file diff --git a/plugins/hello_world_plugin/_manifest.json b/plugins/hello_world_plugin/_manifest.json index b1a4c4eb..25234a00 100644 --- a/plugins/hello_world_plugin/_manifest.json +++ b/plugins/hello_world_plugin/_manifest.json @@ -8,18 +8,24 @@ "url": "https://github.com/MaiM-with-u" }, "license": "GPL-v3.0-or-later", - "host_application": { "min_version": "0.8.0" }, "homepage_url": "https://github.com/MaiM-with-u/maibot", "repository_url": "https://github.com/MaiM-with-u/maibot", - "keywords": ["demo", "example", "hello", "greeting", "tutorial"], - "categories": ["Examples", "Tutorial"], - + "keywords": [ + "demo", + "example", + "hello", + "greeting", + "tutorial" + ], + "categories": [ + "Examples", + "Tutorial" + ], "default_locale": "zh-CN", "locales_path": "_locales", - "plugin_info": { "is_built_in": false, "plugin_type": "example", @@ -31,10 +37,17 @@ }, { "type": "action", - "name": "bye_greeting", + "name": "bye_greeting", "description": "向用户发送告别消息", - "activation_modes": ["keyword"], - "keywords": ["再见", "bye", "88", "拜拜"] + "activation_modes": [ + "keyword" + ], + "keywords": [ + "再见", + "bye", + "88", + "拜拜" + ] }, { "type": "command", @@ -49,5 +62,6 @@ "配置文件示例", "新手教程代码" ] - } + }, + "id": "MaiBot开发团队.maibot" } \ No newline at end of file diff --git a/src/main.py b/src/main.py index 09ead248..02702f2c 100644 --- a/src/main.py +++ b/src/main.py @@ -156,7 +156,7 @@ class MainSystem: async def schedule_tasks(self): """调度定时任务""" - while True: + try: tasks = [ get_emoji_manager().start_periodic_check_register(), self.app.run(), @@ -168,6 +168,9 @@ class MainSystem: tasks.append(self.webui_server.start()) await asyncio.gather(*tasks) + except asyncio.CancelledError: + logger.info("调度任务已取消") + raise # async def forget_memory_task(self): # """记忆遗忘任务""" diff --git a/src/webui/config_routes.py b/src/webui/config_routes.py index 84c660ae..c4fca2d0 100644 --- a/src/webui/config_routes.py +++ b/src/webui/config_routes.py @@ -8,7 +8,7 @@ from fastapi import APIRouter, HTTPException, Body from typing import Any from src.common.logger import get_logger -from src.config.config import Config, APIAdapterConfig, CONFIG_DIR +from src.config.config import Config, APIAdapterConfig, CONFIG_DIR, PROJECT_ROOT from src.config.official_configs import ( BotConfig, PersonalityConfig, @@ -421,6 +421,38 @@ async def update_model_config_section(section_name: str, section_data: Any = Bod # ===== 适配器配置管理接口 ===== +def _normalize_adapter_path(path: str) -> str: + """将路径转换为绝对路径(如果是相对路径,则相对于项目根目录)""" + if not path: + return path + + # 如果已经是绝对路径,直接返回 + if os.path.isabs(path): + return path + + # 相对路径,转换为相对于项目根目录的绝对路径 + return os.path.normpath(os.path.join(PROJECT_ROOT, path)) + + +def _to_relative_path(path: str) -> str: + """尝试将绝对路径转换为相对于项目根目录的相对路径,如果无法转换则返回原路径""" + if not path or not os.path.isabs(path): + return path + + try: + # 尝试获取相对路径 + rel_path = os.path.relpath(path, PROJECT_ROOT) + # 如果相对路径不是以 .. 开头(说明文件在项目目录内),则返回相对路径 + if not rel_path.startswith('..'): + return rel_path + except (ValueError, TypeError): + # 在 Windows 上,如果路径在不同驱动器,relpath 会抛出 ValueError + pass + + # 无法转换为相对路径,返回绝对路径 + return path + + @router.get("/adapter-config/path") async def get_adapter_config_path(): """获取保存的适配器配置文件路径""" @@ -438,13 +470,19 @@ async def get_adapter_config_path(): if not adapter_config_path: return {"success": True, "path": None} + # 将路径规范化为绝对路径 + abs_path = _normalize_adapter_path(adapter_config_path) + # 检查文件是否存在并返回最后修改时间 - if os.path.exists(adapter_config_path): + if os.path.exists(abs_path): import datetime - mtime = os.path.getmtime(adapter_config_path) + mtime = os.path.getmtime(abs_path) last_modified = datetime.datetime.fromtimestamp(mtime).isoformat() - return {"success": True, "path": adapter_config_path, "lastModified": last_modified} + # 返回相对路径(如果可能) + display_path = _to_relative_path(abs_path) + return {"success": True, "path": display_path, "lastModified": last_modified} else: + # 文件不存在,返回原路径 return {"success": True, "path": adapter_config_path, "lastModified": None} except Exception as e: @@ -471,15 +509,21 @@ async def save_adapter_config_path(data: dict[str, str] = Body(...)): else: webui_data = {} + # 将路径规范化为绝对路径 + abs_path = _normalize_adapter_path(path) + + # 尝试转换为相对路径保存(如果文件在项目目录内) + save_path = _to_relative_path(abs_path) + # 更新路径 - webui_data["adapter_config_path"] = path + webui_data["adapter_config_path"] = save_path # 保存 os.makedirs("data", exist_ok=True) with open(webui_data_path, "w", encoding="utf-8") as f: json.dump(webui_data, f, ensure_ascii=False, indent=2) - logger.info(f"适配器配置路径已保存: {path}") + logger.info(f"适配器配置路径已保存: {save_path}(绝对路径: {abs_path})") return {"success": True, "message": "路径已保存"} except HTTPException: @@ -496,19 +540,22 @@ async def get_adapter_config(path: str): if not path: raise HTTPException(status_code=400, detail="路径参数不能为空") + # 将路径规范化为绝对路径 + abs_path = _normalize_adapter_path(path) + # 检查文件是否存在 - if not os.path.exists(path): + if not os.path.exists(abs_path): raise HTTPException(status_code=404, detail=f"配置文件不存在: {path}") # 检查文件扩展名 - if not path.endswith(".toml"): + if not abs_path.endswith(".toml"): raise HTTPException(status_code=400, detail="只支持 .toml 格式的配置文件") # 读取文件内容 - with open(path, "r", encoding="utf-8") as f: + with open(abs_path, "r", encoding="utf-8") as f: content = f.read() - logger.info(f"已读取适配器配置: {path}") + logger.info(f"已读取适配器配置: {path} (绝对路径: {abs_path})") return {"success": True, "content": content} except HTTPException: @@ -530,8 +577,11 @@ async def save_adapter_config(data: dict[str, str] = Body(...)): if content is None: raise HTTPException(status_code=400, detail="配置内容不能为空") + # 将路径规范化为绝对路径 + abs_path = _normalize_adapter_path(path) + # 检查文件扩展名 - if not path.endswith(".toml"): + if not abs_path.endswith(".toml"): raise HTTPException(status_code=400, detail="只支持 .toml 格式的配置文件") # 验证 TOML 格式 @@ -542,13 +592,15 @@ async def save_adapter_config(data: dict[str, str] = Body(...)): raise HTTPException(status_code=400, detail=f"TOML 格式错误: {str(e)}") # 确保目录存在 - os.makedirs(os.path.dirname(path) if os.path.dirname(path) else ".", exist_ok=True) + dir_path = os.path.dirname(abs_path) + if dir_path: + os.makedirs(dir_path, exist_ok=True) # 保存文件 - with open(path, "w", encoding="utf-8") as f: + with open(abs_path, "w", encoding="utf-8") as f: f.write(content) - logger.info(f"适配器配置已保存: {path}") + logger.info(f"适配器配置已保存: {path} (绝对路径: {abs_path})") return {"success": True, "message": "配置已保存"} except HTTPException: diff --git a/src/webui/emoji_routes.py b/src/webui/emoji_routes.py index 471eb6d7..29b5d73e 100644 --- a/src/webui/emoji_routes.py +++ b/src/webui/emoji_routes.py @@ -394,11 +394,9 @@ async def register_emoji(emoji_id: int, authorization: Optional[str] = Header(No if emoji.is_registered: raise HTTPException(status_code=400, detail="该表情包已经注册") - if emoji.is_banned: - raise HTTPException(status_code=400, detail="该表情包已被禁用,无法注册") - - # 注册表情包 + # 注册表情包(如果已封禁,自动解除封禁) emoji.is_registered = True + emoji.is_banned = False # 注册时自动解除封禁 emoji.register_time = time.time() emoji.save() diff --git a/src/webui/plugin_routes.py b/src/webui/plugin_routes.py index cb559fb7..0d8ab19b 100644 --- a/src/webui/plugin_routes.py +++ b/src/webui/plugin_routes.py @@ -516,10 +516,14 @@ async def install_plugin(request: InstallPluginRequest, authorization: Optional[ plugins_dir = Path("plugins") plugins_dir.mkdir(exist_ok=True) - target_path = plugins_dir / request.plugin_id + # 将插件 ID 中的点替换为下划线作为文件夹名称(避免文件系统问题) + # 例如: SengokuCola.Mute-Plugin -> SengokuCola_Mute-Plugin + folder_name = request.plugin_id.replace(".", "_") + target_path = plugins_dir / folder_name - # 检查插件是否已安装 - if target_path.exists(): + # 检查插件是否已安装(需要检查两种格式:新格式下划线和旧格式点) + old_format_path = plugins_dir / request.plugin_id + if target_path.exists() or old_format_path.exists(): await update_progress( stage="error", progress=0, @@ -607,6 +611,12 @@ async def install_plugin(request: InstallPluginRequest, authorization: Optional[ for field in required_fields: if field not in manifest: raise ValueError(f"缺少必需字段: {field}") + + # 将插件 ID 写入 manifest(用于后续准确识别) + # 这样即使文件夹名称改变,也能通过 manifest 准确识别插件 + manifest["id"] = request.plugin_id + with open(manifest_path, "w", encoding="utf-8") as f: + json_module.dump(manifest, f, ensure_ascii=False, indent=2) except Exception as e: # 清理失败的安装 @@ -686,20 +696,28 @@ async def uninstall_plugin( plugin_id=request.plugin_id, ) - # 1. 检查插件是否存在 + # 1. 检查插件是否存在(支持新旧两种格式) plugins_dir = Path("plugins") - plugin_path = plugins_dir / request.plugin_id - + # 新格式:下划线 + folder_name = request.plugin_id.replace(".", "_") + plugin_path = plugins_dir / folder_name + # 旧格式:点 + old_format_path = plugins_dir / request.plugin_id + + # 优先使用新格式,如果不存在则尝试旧格式 if not plugin_path.exists(): - await update_progress( - stage="error", - progress=0, - message="插件不存在", - operation="uninstall", - plugin_id=request.plugin_id, - error="插件未安装或已被删除", - ) - raise HTTPException(status_code=404, detail="插件未安装") + if old_format_path.exists(): + plugin_path = old_format_path + else: + await update_progress( + stage="error", + progress=0, + message="插件不存在", + operation="uninstall", + plugin_id=request.plugin_id, + error="插件未安装或已被删除", + ) + raise HTTPException(status_code=404, detail="插件未安装") await update_progress( stage="loading", @@ -812,25 +830,32 @@ async def update_plugin(request: UpdatePluginRequest, authorization: Optional[st plugin_id=request.plugin_id, ) - # 1. 检查插件是否已安装 + # 1. 检查插件是否已安装(支持新旧两种格式) plugins_dir = Path("plugins") - plugin_path = plugins_dir / request.plugin_id - + # 新格式:下划线 + folder_name = request.plugin_id.replace(".", "_") + plugin_path = plugins_dir / folder_name + # 旧格式:点 + old_format_path = plugins_dir / request.plugin_id + + # 优先使用新格式,如果不存在则尝试旧格式 if not plugin_path.exists(): - await update_progress( - stage="error", - progress=0, - message="插件不存在", - operation="update", - plugin_id=request.plugin_id, - error="插件未安装,请先安装", - ) - raise HTTPException(status_code=404, detail="插件未安装") + if old_format_path.exists(): + plugin_path = old_format_path + else: + await update_progress( + stage="error", + progress=0, + message="插件不存在", + operation="update", + plugin_id=request.plugin_id, + error="插件未安装,请先安装", + ) + raise HTTPException(status_code=404, detail="插件未安装") # 2. 读取旧版本信息 manifest_path = plugin_path / "_manifest.json" old_version = "unknown" - plugin_name = request.plugin_id if manifest_path.exists(): try: @@ -839,7 +864,6 @@ async def update_plugin(request: UpdatePluginRequest, authorization: Optional[st with open(manifest_path, "r", encoding="utf-8") as f: manifest = json_module.load(f) old_version = manifest.get("version", "unknown") - _plugin_name = manifest.get("name", request.plugin_id) except Exception: pass @@ -1032,18 +1056,18 @@ async def get_installed_plugins(authorization: Optional[str] = Header(None)) -> if not plugin_path.is_dir(): continue - # 目录名即为插件 ID - plugin_id = plugin_path.name + # 目录名(可能是下划线格式、点格式或其他格式) + folder_name = plugin_path.name # 跳过隐藏目录和特殊目录 - if plugin_id.startswith(".") or plugin_id.startswith("__"): + if folder_name.startswith(".") or folder_name.startswith("__"): continue # 读取 _manifest.json manifest_path = plugin_path / "_manifest.json" if not manifest_path.exists(): - logger.warning(f"插件 {plugin_id} 缺少 _manifest.json,跳过") + logger.warning(f"插件文件夹 {folder_name} 缺少 _manifest.json,跳过") continue try: @@ -1054,9 +1078,58 @@ async def get_installed_plugins(authorization: Optional[str] = Header(None)) -> # 基本验证 if "name" not in manifest or "version" not in manifest: - logger.warning(f"插件 {plugin_id} 的 _manifest.json 格式无效,跳过") + logger.warning(f"插件文件夹 {folder_name} 的 _manifest.json 格式无效,跳过") continue + # 获取插件 ID(优先从 manifest,否则从文件夹名推断) + if "id" in manifest: + # 优先使用 manifest 中的 id(最准确) + plugin_id = manifest["id"] + else: + # 从 manifest 信息构建 ID + # 尝试从 author.name 和 repository_url 构建标准 ID + author_name = None + repo_name = None + + # 获取作者名 + if "author" in manifest: + if isinstance(manifest["author"], dict) and "name" in manifest["author"]: + author_name = manifest["author"]["name"] + elif isinstance(manifest["author"], str): + author_name = manifest["author"] + + # 从 repository_url 获取仓库名 + if "repository_url" in manifest: + repo_url = manifest["repository_url"].rstrip("/") + if repo_url.endswith(".git"): + repo_url = repo_url[:-4] + repo_name = repo_url.split("/")[-1] + + # 构建 ID + if author_name and repo_name: + # 标准格式: Author.RepoName + plugin_id = f"{author_name}.{repo_name}" + elif author_name: + # 如果只有作者,使用 Author.FolderName + plugin_id = f"{author_name}.{folder_name}" + else: + # 从文件夹名推断 + if "_" in folder_name and "." not in folder_name: + # 假设格式为 Author_PluginName,转换为 Author.PluginName + plugin_id = folder_name.replace("_", ".", 1) + else: + # 直接使用文件夹名 + plugin_id = folder_name + + # 将推断的 ID 写入 manifest(方便下次识别) + logger.info(f"为插件 {folder_name} 自动生成 ID: {plugin_id}") + manifest["id"] = plugin_id + try: + with open(manifest_path, "w", encoding="utf-8") as f: + json_module.dump(manifest, f, ensure_ascii=False, indent=2) + except Exception as write_error: + logger.warning(f"无法写入 ID 到 manifest: {write_error}") + # 添加到已安装列表(返回完整的 manifest 信息) installed_plugins.append( {