feat: 优化任务调度和插件管理,支持路径规范化及插件 ID 自动生成

pull/1385/head
墨梓柒 2025-11-24 21:29:04 +08:00
parent a1dd26d578
commit 513182067d
No known key found for this signature in database
GPG Key ID: 4A65B9DBA35F7635
8 changed files with 248 additions and 79 deletions

13
bot.py
View File

@ -235,12 +235,21 @@ if __name__ == "__main__":
loop.run_until_complete(main_tasks) loop.run_until_complete(main_tasks)
except KeyboardInterrupt: except KeyboardInterrupt:
# loop.run_until_complete(get_global_api().stop())
logger.warning("收到中断信号,正在优雅关闭...") 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(): if loop and not loop.is_closed():
try: try:
loop.run_until_complete(graceful_shutdown()) loop.run_until_complete(graceful_shutdown())
except Exception as ge: # 捕捉优雅关闭时可能发生的错误 except Exception as ge:
logger.error(f"优雅关闭时发生错误: {ge}") logger.error(f"优雅关闭时发生错误: {ge}")
# 新增:检测外部请求关闭 # 新增:检测外部请求关闭

View File

@ -8,18 +8,25 @@
"url": "https://github.com/MaiM-with-u" "url": "https://github.com/MaiM-with-u"
}, },
"license": "GPL-v3.0-or-later", "license": "GPL-v3.0-or-later",
"host_application": { "host_application": {
"min_version": "0.10.3" "min_version": "0.10.3"
}, },
"homepage_url": "https://github.com/SengokuCola/BetterFrequency", "homepage_url": "https://github.com/SengokuCola/BetterFrequency",
"repository_url": "https://github.com/SengokuCola/BetterFrequency", "repository_url": "https://github.com/SengokuCola/BetterFrequency",
"keywords": ["frequency", "control", "talk_frequency", "plugin", "shortcut"], "keywords": [
"categories": ["Chat", "Frequency", "Control"], "frequency",
"control",
"talk_frequency",
"plugin",
"shortcut"
],
"categories": [
"Chat",
"Frequency",
"Control"
],
"default_locale": "zh-CN", "default_locale": "zh-CN",
"locales_path": "_locales", "locales_path": "_locales",
"plugin_info": { "plugin_info": {
"is_built_in": false, "is_built_in": false,
"plugin_type": "frequency", "plugin_type": "frequency",
@ -46,5 +53,6 @@
"支持完整命令和简化命令", "支持完整命令和简化命令",
"快速操作支持" "快速操作支持"
] ]
} },
"id": "SengokuCola.BetterFrequency"
} }

View File

@ -8,18 +8,22 @@
"url": "https://github.com/SengokuCola" "url": "https://github.com/SengokuCola"
}, },
"license": "GPL-v3.0-or-later", "license": "GPL-v3.0-or-later",
"host_application": { "host_application": {
"min_version": "0.10.4" "min_version": "0.10.4"
}, },
"homepage_url": "https://github.com/SengokuCola/BetterEmoji", "homepage_url": "https://github.com/SengokuCola/BetterEmoji",
"repository_url": "https://github.com/SengokuCola/BetterEmoji", "repository_url": "https://github.com/SengokuCola/BetterEmoji",
"keywords": ["emoji", "manage", "plugin"], "keywords": [
"categories": ["Examples", "Tutorial"], "emoji",
"manage",
"plugin"
],
"categories": [
"Examples",
"Tutorial"
],
"default_locale": "zh-CN", "default_locale": "zh-CN",
"locales_path": "_locales", "locales_path": "_locales",
"plugin_info": { "plugin_info": {
"is_built_in": false, "is_built_in": false,
"plugin_type": "emoji_manage", "plugin_type": "emoji_manage",
@ -33,8 +37,15 @@
"type": "action", "type": "action",
"name": "bye_greeting", "name": "bye_greeting",
"description": "向用户发送告别消息", "description": "向用户发送告别消息",
"activation_modes": ["keyword"], "activation_modes": [
"keywords": ["再见", "bye", "88", "拜拜"] "keyword"
],
"keywords": [
"再见",
"bye",
"88",
"拜拜"
]
}, },
{ {
"type": "command", "type": "command",
@ -49,5 +60,6 @@
"配置文件示例", "配置文件示例",
"新手教程代码" "新手教程代码"
] ]
} },
"id": "SengokuCola.BetterEmoji"
} }

View File

@ -8,18 +8,24 @@
"url": "https://github.com/MaiM-with-u" "url": "https://github.com/MaiM-with-u"
}, },
"license": "GPL-v3.0-or-later", "license": "GPL-v3.0-or-later",
"host_application": { "host_application": {
"min_version": "0.8.0" "min_version": "0.8.0"
}, },
"homepage_url": "https://github.com/MaiM-with-u/maibot", "homepage_url": "https://github.com/MaiM-with-u/maibot",
"repository_url": "https://github.com/MaiM-with-u/maibot", "repository_url": "https://github.com/MaiM-with-u/maibot",
"keywords": ["demo", "example", "hello", "greeting", "tutorial"], "keywords": [
"categories": ["Examples", "Tutorial"], "demo",
"example",
"hello",
"greeting",
"tutorial"
],
"categories": [
"Examples",
"Tutorial"
],
"default_locale": "zh-CN", "default_locale": "zh-CN",
"locales_path": "_locales", "locales_path": "_locales",
"plugin_info": { "plugin_info": {
"is_built_in": false, "is_built_in": false,
"plugin_type": "example", "plugin_type": "example",
@ -33,8 +39,15 @@
"type": "action", "type": "action",
"name": "bye_greeting", "name": "bye_greeting",
"description": "向用户发送告别消息", "description": "向用户发送告别消息",
"activation_modes": ["keyword"], "activation_modes": [
"keywords": ["再见", "bye", "88", "拜拜"] "keyword"
],
"keywords": [
"再见",
"bye",
"88",
"拜拜"
]
}, },
{ {
"type": "command", "type": "command",
@ -49,5 +62,6 @@
"配置文件示例", "配置文件示例",
"新手教程代码" "新手教程代码"
] ]
} },
"id": "MaiBot开发团队.maibot"
} }

View File

@ -156,7 +156,7 @@ class MainSystem:
async def schedule_tasks(self): async def schedule_tasks(self):
"""调度定时任务""" """调度定时任务"""
while True: try:
tasks = [ tasks = [
get_emoji_manager().start_periodic_check_register(), get_emoji_manager().start_periodic_check_register(),
self.app.run(), self.app.run(),
@ -168,6 +168,9 @@ class MainSystem:
tasks.append(self.webui_server.start()) tasks.append(self.webui_server.start())
await asyncio.gather(*tasks) await asyncio.gather(*tasks)
except asyncio.CancelledError:
logger.info("调度任务已取消")
raise
# async def forget_memory_task(self): # async def forget_memory_task(self):
# """记忆遗忘任务""" # """记忆遗忘任务"""

View File

@ -8,7 +8,7 @@ from fastapi import APIRouter, HTTPException, Body
from typing import Any from typing import Any
from src.common.logger import get_logger 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 ( from src.config.official_configs import (
BotConfig, BotConfig,
PersonalityConfig, 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") @router.get("/adapter-config/path")
async def 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: if not adapter_config_path:
return {"success": True, "path": None} 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 import datetime
mtime = os.path.getmtime(adapter_config_path) mtime = os.path.getmtime(abs_path)
last_modified = datetime.datetime.fromtimestamp(mtime).isoformat() 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: else:
# 文件不存在,返回原路径
return {"success": True, "path": adapter_config_path, "lastModified": None} return {"success": True, "path": adapter_config_path, "lastModified": None}
except Exception as e: except Exception as e:
@ -471,15 +509,21 @@ async def save_adapter_config_path(data: dict[str, str] = Body(...)):
else: else:
webui_data = {} 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) os.makedirs("data", exist_ok=True)
with open(webui_data_path, "w", encoding="utf-8") as f: with open(webui_data_path, "w", encoding="utf-8") as f:
json.dump(webui_data, f, ensure_ascii=False, indent=2) 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": "路径已保存"} return {"success": True, "message": "路径已保存"}
except HTTPException: except HTTPException:
@ -496,19 +540,22 @@ async def get_adapter_config(path: str):
if not path: if not path:
raise HTTPException(status_code=400, detail="路径参数不能为空") 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}") 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 格式的配置文件") 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() content = f.read()
logger.info(f"已读取适配器配置: {path}") logger.info(f"已读取适配器配置: {path} (绝对路径: {abs_path})")
return {"success": True, "content": content} return {"success": True, "content": content}
except HTTPException: except HTTPException:
@ -530,8 +577,11 @@ async def save_adapter_config(data: dict[str, str] = Body(...)):
if content is None: if content is None:
raise HTTPException(status_code=400, detail="配置内容不能为空") 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 格式的配置文件") raise HTTPException(status_code=400, detail="只支持 .toml 格式的配置文件")
# 验证 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)}") 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) f.write(content)
logger.info(f"适配器配置已保存: {path}") logger.info(f"适配器配置已保存: {path} (绝对路径: {abs_path})")
return {"success": True, "message": "配置已保存"} return {"success": True, "message": "配置已保存"}
except HTTPException: except HTTPException:

View File

@ -394,11 +394,9 @@ async def register_emoji(emoji_id: int, authorization: Optional[str] = Header(No
if emoji.is_registered: if emoji.is_registered:
raise HTTPException(status_code=400, detail="该表情包已经注册") raise HTTPException(status_code=400, detail="该表情包已经注册")
if emoji.is_banned: # 注册表情包(如果已封禁,自动解除封禁)
raise HTTPException(status_code=400, detail="该表情包已被禁用,无法注册")
# 注册表情包
emoji.is_registered = True emoji.is_registered = True
emoji.is_banned = False # 注册时自动解除封禁
emoji.register_time = time.time() emoji.register_time = time.time()
emoji.save() emoji.save()

View File

@ -516,10 +516,14 @@ async def install_plugin(request: InstallPluginRequest, authorization: Optional[
plugins_dir = Path("plugins") plugins_dir = Path("plugins")
plugins_dir.mkdir(exist_ok=True) 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( await update_progress(
stage="error", stage="error",
progress=0, progress=0,
@ -608,6 +612,12 @@ async def install_plugin(request: InstallPluginRequest, authorization: Optional[
if field not in manifest: if field not in manifest:
raise ValueError(f"缺少必需字段: {field}") 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: except Exception as e:
# 清理失败的安装 # 清理失败的安装
import shutil import shutil
@ -686,11 +696,19 @@ async def uninstall_plugin(
plugin_id=request.plugin_id, plugin_id=request.plugin_id,
) )
# 1. 检查插件是否存在 # 1. 检查插件是否存在(支持新旧两种格式)
plugins_dir = Path("plugins") 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(): if not plugin_path.exists():
if old_format_path.exists():
plugin_path = old_format_path
else:
await update_progress( await update_progress(
stage="error", stage="error",
progress=0, progress=0,
@ -812,11 +830,19 @@ async def update_plugin(request: UpdatePluginRequest, authorization: Optional[st
plugin_id=request.plugin_id, plugin_id=request.plugin_id,
) )
# 1. 检查插件是否已安装 # 1. 检查插件是否已安装(支持新旧两种格式)
plugins_dir = Path("plugins") 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(): if not plugin_path.exists():
if old_format_path.exists():
plugin_path = old_format_path
else:
await update_progress( await update_progress(
stage="error", stage="error",
progress=0, progress=0,
@ -830,7 +856,6 @@ async def update_plugin(request: UpdatePluginRequest, authorization: Optional[st
# 2. 读取旧版本信息 # 2. 读取旧版本信息
manifest_path = plugin_path / "_manifest.json" manifest_path = plugin_path / "_manifest.json"
old_version = "unknown" old_version = "unknown"
plugin_name = request.plugin_id
if manifest_path.exists(): if manifest_path.exists():
try: try:
@ -839,7 +864,6 @@ async def update_plugin(request: UpdatePluginRequest, authorization: Optional[st
with open(manifest_path, "r", encoding="utf-8") as f: with open(manifest_path, "r", encoding="utf-8") as f:
manifest = json_module.load(f) manifest = json_module.load(f)
old_version = manifest.get("version", "unknown") old_version = manifest.get("version", "unknown")
_plugin_name = manifest.get("name", request.plugin_id)
except Exception: except Exception:
pass pass
@ -1032,18 +1056,18 @@ async def get_installed_plugins(authorization: Optional[str] = Header(None)) ->
if not plugin_path.is_dir(): if not plugin_path.is_dir():
continue 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 continue
# 读取 _manifest.json # 读取 _manifest.json
manifest_path = plugin_path / "_manifest.json" manifest_path = plugin_path / "_manifest.json"
if not manifest_path.exists(): if not manifest_path.exists():
logger.warning(f"插件 {plugin_id} 缺少 _manifest.json跳过") logger.warning(f"插件文件夹 {folder_name} 缺少 _manifest.json跳过")
continue continue
try: 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: 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 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 信息) # 添加到已安装列表(返回完整的 manifest 信息)
installed_plugins.append( installed_plugins.append(
{ {