zhenxun_bot/zhenxun/utils/manager/zhenxun_repo_manager.py
HibiKier a3142ad065
Some checks are pending
Sequential Lint and Type Check / ruff-call (push) Waiting to run
Sequential Lint and Type Check / pyright-call (push) Blocked by required conditions
feat(submodule): 添加子模块管理功能,支持子模块的初始化、更新和信息获取
2025-08-03 23:57:13 +08:00

688 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
真寻仓库管理器
负责真寻主仓库的更新、版本检查、文件处理等功能
"""
import os
from pathlib import Path
import shutil
import tarfile
from typing import ClassVar
import zipfile
from zhenxun.configs.path_config import DATA_PATH, FONT_PATH, TEMP_PATH
from zhenxun.services.log import logger
from zhenxun.utils.github_utils import GithubUtils
from zhenxun.utils.github_utils.models import RepoInfo
from zhenxun.utils.http_utils import AsyncHttpx
from zhenxun.utils.manager.virtual_env_package_manager import VirtualEnvPackageManager
from zhenxun.utils.platform import PlatformUtils
from zhenxun.utils.repo_utils import AliyunRepoManager, GithubRepoManager
from zhenxun.utils.repo_utils.models import (
SubmoduleConfig,
)
from zhenxun.utils.repo_utils.submodule_manager import SubmoduleManager
from zhenxun.utils.repo_utils.utils import clean_git
LOG_COMMAND = "ZhenxunRepoManager"
class DownloadException(Exception):
"""资源下载异常"""
pass
class ZhenxunRepoConfig:
"""真寻仓库配置"""
# GitHub 仓库 URL
ZHENXUN_BOT_GIT = "https://github.com/zhenxun-org/zhenxun_bot.git"
ZHENXUN_BOT_GITHUB_URL = "https://github.com/HibiKier/zhenxun_bot/tree/main"
DEFAULT_GITHUB_URL = "https://github.com/HibiKier/zhenxun_bot/tree/main"
RELEASE_URL = "https://api.github.com/repos/HibiKier/zhenxun_bot/releases/latest"
# 资源仓库 URL
RESOURCE_GITHUB_URL = (
"https://github.com/zhenxun-org/zhenxun-bot-resources/tree/main"
)
# Web UI 仓库 URL
WEBUI_GIT = "https://github.com/HibiKier/zhenxun_bot_webui.git"
# 文件路径配置
VERSION_FILE_STRING = "__version__"
VERSION_FILE = Path() / VERSION_FILE_STRING
PYPROJECT_FILE_STRING = "pyproject.toml"
PYPROJECT_FILE = Path() / PYPROJECT_FILE_STRING
PYPROJECT_LOCK_FILE_STRING = "poetry.lock"
PYPROJECT_LOCK_FILE = Path() / PYPROJECT_LOCK_FILE_STRING
REQ_TXT_FILE_STRING = "requirements.txt"
REQ_TXT_FILE = Path() / REQ_TXT_FILE_STRING
BASE_PATH_STRING = "zhenxun"
BASE_PATH = Path() / BASE_PATH_STRING
# 资源路径配置
RESOURCE_PATH = Path() / "resources"
# Web UI 路径配置
WEBUI_PATH = DATA_PATH / "web_ui" / "public"
WEBUI_BACKUP_PATH = DATA_PATH / "web_ui" / "backup_public"
WEBUI_GIT_PATH = DATA_PATH / "web_ui" / "git_web_ui"
# 临时文件路径
TMP_PATH = TEMP_PATH / "zhenxun_update"
BACKUP_PATH = Path() / "backup"
RESOURCE_TMP_PATH = TEMP_PATH / "_resource_tmp"
# 下载文件配置
DOWNLOAD_GZ_FILE_STRING = "download_latest_file.tar.gz"
DOWNLOAD_ZIP_FILE_STRING = "download_latest_file.zip"
DOWNLOAD_GZ_FILE = TMP_PATH / DOWNLOAD_GZ_FILE_STRING
DOWNLOAD_ZIP_FILE = TMP_PATH / DOWNLOAD_ZIP_FILE_STRING
# 资源文件配置
RESOURCE_ZIP_FILE = RESOURCE_TMP_PATH / "resources.zip"
UNZIP_PATH: Path | None = None
# 需要替换的文件夹
REPLACE_FOLDERS: ClassVar[list[str]] = [
"builtin_plugins",
"services",
"utils",
"models",
"configs",
]
# 日志标识
COMMAND = "真寻仓库管理"
class ZhenxunRepoManager:
"""真寻仓库管理器"""
def __init__(self):
self.config = ZhenxunRepoConfig()
# 初始化子模块管理器
self.submodule_manager = SubmoduleManager(GithubRepoManager)
def __clear_folder(self, folder_path: Path):
for filename in os.listdir(folder_path):
file_path = folder_path / filename
try:
if file_path.is_file():
os.unlink(file_path)
elif file_path.is_dir() and not filename.startswith("."):
shutil.rmtree(file_path)
except Exception as e:
logger.warning(f"无法删除 {file_path}", LOG_COMMAND, e=e)
async def check_version(self) -> str:
"""检查真寻更新版本
返回:
str: 更新信息
"""
cur_version = self._get_current_version()
data = await self._get_latest_release_data()
if not data:
return "检查更新获取版本失败..."
return (
"检测到当前版本更新\n"
f"当前版本:{cur_version}\n"
f"最新版本:{data.get('name')}\n"
f"创建日期:{data.get('created_at')}\n"
f"更新内容:\n{data.get('body')}"
)
async def update_repository(
self,
bot,
user_id: str,
version_type: str,
force: bool,
source: str,
zip_update: bool,
update_type: str,
) -> str:
"""更新真寻仓库
参数:
bot: Bot实例
user_id: 用户ID
version_type: 更新版本类型 (main/release)
force: 是否强制更新
source: 更新源 (git/ali)
zip_update: 是否下载zip文件
update_type: 更新方式 (git/download)
返回:
str: 更新结果消息
"""
cur_version = self._get_current_version()
await PlatformUtils.send_superuser(
bot,
f"检测真寻已更新,当前版本:{cur_version}\n开始更新...",
user_id,
)
if zip_update:
return await self._zip_update(version_type)
elif source == "git":
result = await GithubRepoManager.update(
self.config.ZHENXUN_BOT_GIT,
Path(),
use_git=update_type == "git",
force=force,
)
else:
result = await AliyunRepoManager.update(
self.config.ZHENXUN_BOT_GIT,
Path(),
force=force,
)
if not result.success:
return f"版本更新失败...错误: {result.error_message}"
await PlatformUtils.send_superuser(
bot, "真寻更新完成,开始安装依赖...", user_id
)
await VirtualEnvPackageManager.install_requirement(self.config.REQ_TXT_FILE)
return (
f"版本更新完成!\n"
f"版本: {cur_version} -> {result.new_version}\n"
f"变更文件个数: {len(result.changed_files)}"
f"{'' if source == 'git' else '(阿里云更新不支持查看变更文件)'}\n"
"请重新启动真寻以完成更新!"
)
async def _zip_update(self, version_type: str) -> str:
"""ZIP文件更新
参数:
version_type: 版本类型 (main/release)
返回:
str: 更新结果
"""
logger.info("开始下载真寻最新版文件....", self.config.COMMAND)
cur_version = self._get_current_version()
url = None
new_version = None
repo_info = GithubUtils.parse_github_url(self.config.DEFAULT_GITHUB_URL)
if version_type in {"main"}:
repo_info.branch = version_type
new_version = await self._get_version_from_repo(repo_info)
if new_version:
new_version = new_version.split(":")[-1].strip()
url = await repo_info.get_archive_download_urls()
elif version_type == "release":
data = await self._get_latest_release_data()
if not data:
return "获取更新版本失败..."
new_version = data.get("name", "")
url = await repo_info.get_release_source_download_urls_tgz(new_version)
if not url:
return "获取版本下载链接失败..."
if self.config.TMP_PATH.exists():
logger.debug(f"删除临时文件夹 {self.config.TMP_PATH}", self.config.COMMAND)
shutil.rmtree(self.config.TMP_PATH)
logger.debug(
f"开始更新版本:{cur_version} -> {new_version} | 下载链接:{url}",
self.config.COMMAND,
)
download_file = (
self.config.DOWNLOAD_GZ_FILE
if version_type == "release"
else self.config.DOWNLOAD_ZIP_FILE
)
if await AsyncHttpx.download_file(url, download_file, stream=True):
logger.debug("下载真寻最新版文件完成...", self.config.COMMAND)
self._handle_downloaded_files(new_version)
result = "版本更新完成"
return (
f"{result}\n"
f"版本: {cur_version} -> {new_version}\n"
"请重新启动真寻以完成更新!"
)
else:
logger.debug("下载真寻最新版文件失败...", self.config.COMMAND)
return ""
def _handle_downloaded_files(self, latest_version: str | None):
"""处理下载的文件
参数:
latest_version: 最新版本号
"""
self.config.BACKUP_PATH.mkdir(exist_ok=True, parents=True)
logger.debug("开始解压文件压缩包...", self.config.COMMAND)
download_file = self.config.DOWNLOAD_GZ_FILE
if self.config.DOWNLOAD_GZ_FILE.exists():
tf = tarfile.open(self.config.DOWNLOAD_GZ_FILE)
else:
download_file = self.config.DOWNLOAD_ZIP_FILE
tf = zipfile.ZipFile(self.config.DOWNLOAD_ZIP_FILE)
tf.extractall(self.config.TMP_PATH)
logger.debug("解压文件压缩包完成...", self.config.COMMAND)
download_file_path = self.config.TMP_PATH / next(
x
for x in os.listdir(self.config.TMP_PATH)
if (self.config.TMP_PATH / x).is_dir()
)
_pyproject = download_file_path / self.config.PYPROJECT_FILE_STRING
_lock_file = download_file_path / self.config.PYPROJECT_LOCK_FILE_STRING
_req_file = download_file_path / self.config.REQ_TXT_FILE_STRING
extract_path = download_file_path / self.config.BASE_PATH_STRING
target_path = self.config.BASE_PATH
# 备份现有文件
if self.config.PYPROJECT_FILE.exists():
logger.debug(
f"移除备份文件: {self.config.PYPROJECT_FILE}", self.config.COMMAND
)
shutil.move(
self.config.PYPROJECT_FILE,
self.config.BACKUP_PATH / self.config.PYPROJECT_FILE_STRING,
)
if self.config.PYPROJECT_LOCK_FILE.exists():
logger.debug(
f"移除备份文件: {self.config.PYPROJECT_LOCK_FILE}", self.config.COMMAND
)
shutil.move(
self.config.PYPROJECT_LOCK_FILE,
self.config.BACKUP_PATH / self.config.PYPROJECT_LOCK_FILE_STRING,
)
if self.config.REQ_TXT_FILE.exists():
logger.debug(
f"移除备份文件: {self.config.REQ_TXT_FILE}", self.config.COMMAND
)
shutil.move(
self.config.REQ_TXT_FILE,
self.config.BACKUP_PATH / self.config.REQ_TXT_FILE_STRING,
)
# 移动新文件
if _pyproject.exists():
logger.debug("移动文件: pyproject.toml", self.config.COMMAND)
shutil.move(_pyproject, self.config.PYPROJECT_FILE)
if _lock_file.exists():
logger.debug("移动文件: poetry.lock", self.config.COMMAND)
shutil.move(_lock_file, self.config.PYPROJECT_LOCK_FILE)
if _req_file.exists():
logger.debug("移动文件: requirements.txt", self.config.COMMAND)
shutil.move(_req_file, self.config.REQ_TXT_FILE)
# 处理文件夹
for folder in self.config.REPLACE_FOLDERS:
_dir = self.config.BASE_PATH / folder
_backup_dir = self.config.BACKUP_PATH / folder
if _backup_dir.exists():
logger.debug(f"删除备份文件夹 {_backup_dir}", self.config.COMMAND)
shutil.rmtree(_backup_dir)
if _dir.exists():
logger.debug(f"移动旧文件夹 {_dir}", self.config.COMMAND)
shutil.move(_dir, _backup_dir)
else:
logger.warning(f"文件夹 {_dir} 不存在,跳过删除", self.config.COMMAND)
for folder in self.config.REPLACE_FOLDERS:
src_folder_path = extract_path / folder
dest_folder_path = target_path / folder
if src_folder_path.exists():
logger.debug(
f"移动文件夹: {src_folder_path} -> {dest_folder_path}",
self.config.COMMAND,
)
shutil.move(src_folder_path, dest_folder_path)
else:
logger.debug(f"源文件夹不存在: {src_folder_path}", self.config.COMMAND)
# 清理临时文件
if tf:
tf.close()
if download_file.exists():
logger.debug(f"删除下载文件: {download_file}", self.config.COMMAND)
download_file.unlink()
if extract_path.exists():
logger.debug(f"删除解压文件夹: {extract_path}", self.config.COMMAND)
shutil.rmtree(extract_path)
if self.config.TMP_PATH.exists():
shutil.rmtree(self.config.TMP_PATH)
# 更新版本文件
if latest_version:
with open(self.config.VERSION_FILE, "w", encoding="utf8") as f:
f.write(f"__version__: {latest_version}")
def _get_current_version(self) -> str:
"""获取当前版本
返回:
str: 当前版本号
"""
_version = "v0.0.0"
if self.config.VERSION_FILE.exists():
if text := self.config.VERSION_FILE.open(encoding="utf8").readline():
_version = text.split(":")[-1].strip()
return _version
async def _get_latest_release_data(self) -> dict:
"""获取最新版本信息
返回:
dict: 最新版本数据
"""
for _ in range(3):
try:
res = await AsyncHttpx.get(self.config.RELEASE_URL)
if res.status_code == 200:
return res.json()
except TimeoutError:
pass
except Exception as e:
logger.error("检查更新真寻获取版本失败", e=e)
return {}
async def _get_version_from_repo(self, repo_info: RepoInfo) -> str:
"""从指定分支获取版本号
参数:
repo_info: 仓库信息
返回:
str: 版本号
"""
version_url = await repo_info.get_raw_download_urls(path="__version__")
try:
res = await AsyncHttpx.get(version_url)
if res.status_code == 200:
return res.text.strip()
except Exception as e:
logger.error(f"获取 {repo_info.branch} 分支版本失败", e=e)
return "未知版本"
# ==================== 资源管理相关方法 ====================
async def init_resources(
self, force: bool = False, is_zip: bool = False, git_source: str = "ali"
) -> str:
"""初始化资源文件
参数:
force: 是否强制更新
is_zip: 是否下载zip文件
git_source: 更新源 (ali/git)
返回:
str: 操作结果
"""
if (FONT_PATH.exists() and os.listdir(FONT_PATH)) and not force:
return "资源文件已存在,跳过初始化"
try:
if is_zip:
if self.config.RESOURCE_TMP_PATH.exists():
logger.debug(
"resources临时文件夹已存在移除resources临时文件夹",
self.config.COMMAND,
)
await clean_git(self.config.RESOURCE_TMP_PATH)
shutil.rmtree(self.config.RESOURCE_TMP_PATH, ignore_errors=True)
self.config.RESOURCE_TMP_PATH.mkdir(parents=True, exist_ok=True)
await self._download_resources()
self._handle_resource_files()
else:
if git_source == "ali":
result = await AliyunRepoManager.update(
self.config.RESOURCE_GITHUB_URL, self.config.RESOURCE_PATH
)
else:
result = await GithubRepoManager.update(
self.config.RESOURCE_GITHUB_URL, self.config.RESOURCE_PATH
)
if not result.success:
return f"资源更新失败...错误: {result.error_message}"
self.config.UNZIP_PATH = self.config.RESOURCE_TMP_PATH / "resources"
self._handle_resource_files()
if self.config.RESOURCE_TMP_PATH.exists():
logger.debug("移除resources临时文件夹", self.config.COMMAND)
await clean_git(self.config.RESOURCE_TMP_PATH)
shutil.rmtree(self.config.RESOURCE_TMP_PATH)
return "资源文件初始化成功!"
except Exception as e:
logger.error("资源文件初始化失败", self.config.COMMAND, e=e)
return f"资源文件初始化失败: {e}"
def _handle_resource_files(self):
"""处理资源文件"""
if not hasattr(self.config, "UNZIP_PATH") or not self.config.UNZIP_PATH:
return
self._recursive_folder(self.config.UNZIP_PATH, ".")
def _recursive_folder(self, dir: Path, parent_path: str):
"""递归处理文件夹
参数:
dir: 目录路径
parent_path: 父路径
"""
for file in dir.iterdir():
if file.is_dir():
self._recursive_folder(file, f"{parent_path}/{file.name}")
else:
res_file = Path(parent_path) / file.name
if res_file.exists():
res_file.unlink()
res_file.parent.mkdir(parents=True, exist_ok=True)
file.rename(res_file)
async def _download_resources(self):
"""下载资源文件"""
repo_info = GithubUtils.parse_github_url(self.config.RESOURCE_GITHUB_URL)
url = await repo_info.get_archive_download_urls()
logger.debug("开始下载resources资源包...", self.config.COMMAND)
if not await AsyncHttpx.download_file(
url, self.config.RESOURCE_ZIP_FILE, stream=True
):
logger.error(
"下载resources资源包失败请尝试重启重新下载或前往 "
"https://github.com/zhenxun-org/zhenxun-bot-resources 手动下载..."
)
raise DownloadException("下载resources资源包失败...")
logger.debug("下载resources资源文件压缩包完成...", self.config.COMMAND)
tf = zipfile.ZipFile(self.config.RESOURCE_ZIP_FILE)
tf.extractall(self.config.RESOURCE_TMP_PATH)
logger.debug("解压文件压缩包完成...", self.config.COMMAND)
download_file_path = self.config.RESOURCE_TMP_PATH / next(
x
for x in os.listdir(self.config.RESOURCE_TMP_PATH)
if (self.config.RESOURCE_TMP_PATH / x).is_dir()
)
self.config.UNZIP_PATH = download_file_path / "resources"
if tf:
tf.close()
# ==================== 子模块管理相关方法 ====================
async def init_submodules(self) -> str:
"""初始化子模块
返回:
str: 操作结果
"""
try:
# 定义子模块配置
submodule_configs = [
SubmoduleConfig(
name="resources",
path="resources",
repo_url=self.config.RESOURCE_GITHUB_URL,
branch="main",
enabled=True,
),
SubmoduleConfig(
name="web_ui",
path="data/web_ui/public",
repo_url=self.config.WEBUI_GIT,
branch="main",
enabled=True,
),
]
# 初始化子模块
success = await self.submodule_manager.init_submodules(
Path(), submodule_configs
)
if success:
return "子模块初始化成功!"
else:
return "子模块初始化失败!"
except Exception as e:
logger.error("子模块初始化失败", self.config.COMMAND, e=e)
return f"子模块初始化失败: {e}"
async def update_submodules(self) -> str:
"""更新子模块
返回:
str: 操作结果
"""
try:
# 定义子模块配置
submodule_configs = [
SubmoduleConfig(
name="resources",
path="resources",
repo_url=self.config.RESOURCE_GITHUB_URL,
branch="main",
enabled=True,
),
SubmoduleConfig(
name="web_ui",
path="data/web_ui/public",
repo_url=self.config.WEBUI_GIT,
branch="main",
enabled=True,
),
]
# 更新子模块
results = await self.submodule_manager.update_submodules(
Path(), submodule_configs
)
success_count = sum(1 for result in results if result.success)
total_count = len(results)
return f"子模块更新完成!成功: {success_count}/{total_count}"
except Exception as e:
logger.error("子模块更新失败", self.config.COMMAND, e=e)
return f"子模块更新失败: {e}"
async def get_submodule_info(self) -> str:
"""获取子模块信息
返回:
str: 子模块信息
"""
try:
# 定义子模块配置
submodule_configs = [
SubmoduleConfig(
name="resources",
path="resources",
repo_url=self.config.RESOURCE_GITHUB_URL,
branch="main",
enabled=True,
),
SubmoduleConfig(
name="web_ui",
path="data/web_ui/public",
repo_url=self.config.WEBUI_GIT,
branch="main",
enabled=True,
),
]
# 获取子模块信息
submodule_infos = await self.submodule_manager.get_submodule_info(
Path(), submodule_configs
)
info_text = "子模块信息:\n"
for info in submodule_infos:
info_text += f"- {info.config.name}:\n"
info_text += f" 路径: {info.config.path}\n"
info_text += f" 当前版本: {info.current_version}\n"
info_text += f" 最新版本: {info.latest_version}\n"
info_text += f" 状态: {info.update_status}\n"
return info_text
except Exception as e:
logger.error("获取子模块信息失败", self.config.COMMAND, e=e)
return f"获取子模块信息失败: {e}"
# ==================== Web UI 管理相关方法 ====================
async def webui_download_zip(self) -> str:
"""下载 WEBUI_ASSETS 资源"""
webui_assets_path = TEMP_PATH / "webui_assets.zip"
download_url = await GithubUtils.parse_github_url(
self.config.WEBUI_GIT
).get_archive_download_urls()
logger.info("开始下载 WEBUI_ASSETS 资源...", LOG_COMMAND)
if await AsyncHttpx.download_file(
download_url, webui_assets_path, follow_redirects=True
):
logger.info("下载 WEBUI_ASSETS 成功!", LOG_COMMAND)
raise DownloadException("下载 WEBUI_ASSETS 失败", LOG_COMMAND)
def __backup_webui(self):
"""备份 WEBUI_ASSERT 资源"""
if self.config.WEBUI_PATH.exists():
if self.config.WEBUI_BACKUP_PATH.exists():
logger.debug(
f"删除旧的备份webui文件夹 {self.config.WEBUI_BACKUP_PATH}",
LOG_COMMAND,
)
shutil.rmtree(self.config.WEBUI_BACKUP_PATH)
shutil.copytree(self.config.WEBUI_PATH, self.config.WEBUI_BACKUP_PATH)
# async def webui_unzip(self) -> str:
# """使用zip更新 Web UI
# 参数:
# is_zip: 是否下载 ZIP 文件
# source: 更新源 (git/ali)
# 返回:
# str: 更新结果
# """
# self.__backup_webui()
# self.__clear_folder(self.config.WEBUI_PATH)