From 4bf2f0cb28ca749a02235251368dcd7ef87c4644 Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Wed, 18 Mar 2026 16:46:22 +0800 Subject: [PATCH] fix(dashboard): handle missing static assets gracefully and fix async/ruff issues - Dashboard: Catch missing index.html error, log warning, and disable WebUI instead of crashing. - Dashboard: Use anyio.Path for async file existence checks in SSL config. - CLI/Backup: Replace blocking file operations with anyio async operations. - Core: Replace blocking file operations with anyio in Coze/Dify clients and TTS simulation. - Provider: Rename 'timeout' param to 'init_timeout'/'request_timeout' to fix ASYNC109 warnings. - Ruff: Fix various ASYNC230/ASYNC240 errors across multiple files. --- astrbot/__main__.py | 149 ++++++++++++++++++ astrbot/cli/commands/cmd_bk.py | 13 +- .../agent/runners/coze/coze_api_client.py | 6 +- .../agent/runners/dify/dify_agent_runner.py | 4 +- .../agent/runners/dify/dify_api_client.py | 17 +- astrbot/core/astr_agent_run_util.py | 6 +- astrbot/core/backup/exporter.py | 19 ++- astrbot/core/computer/computer_client.py | 16 +- astrbot/core/computer/tools/fs.py | 7 +- astrbot/core/db/migration/helper.py | 4 +- .../sources/dingtalk/dingtalk_adapter.py | 9 +- .../platform/sources/lark/lark_adapter.py | 9 +- .../core/platform/sources/lark/lark_event.py | 45 ++++-- .../core/platform/sources/line/line_event.py | 14 +- .../sources/misskey/misskey_adapter.py | 10 +- .../platform/sources/misskey/misskey_api.py | 4 +- .../qqofficial/qqofficial_message_event.py | 4 +- .../sources/webchat/message_parts_helper.py | 18 ++- .../platform/sources/webchat/webchat_event.py | 10 +- .../platform/sources/wecom/wecom_adapter.py | 13 +- .../platform/sources/wecom/wecom_event.py | 33 ++-- .../sources/wecom_ai_bot/wecomai_webhook.py | 6 +- .../weixin_offacc_adapter.py | 5 +- .../weixin_offacc_event.py | 14 +- astrbot/core/provider/entities.py | 5 +- astrbot/core/provider/func_tool_manager.py | 53 ++++--- astrbot/core/provider/provider.py | 22 +-- .../core/provider/sources/anthropic_source.py | 5 +- .../core/provider/sources/dashscope_tts.py | 5 +- .../core/provider/sources/edge_tts_source.py | 10 +- .../sources/fishaudio_tts_api_source.py | 5 +- .../core/provider/sources/gemini_source.py | 5 +- astrbot/core/provider/sources/genie_tts.py | 15 +- .../provider/sources/gsv_selfhosted_source.py | 5 +- .../core/provider/sources/gsvi_tts_source.py | 5 +- .../sources/minimax_tts_api_source.py | 5 +- .../core/provider/sources/openai_source.py | 5 +- .../provider/sources/openai_tts_api_source.py | 5 +- .../sources/sensevoice_selfhosted_source.py | 13 +- .../provider/sources/whisper_api_source.py | 19 ++- .../sources/whisper_selfhosted_source.py | 7 +- .../sources/xinference_stt_provider.py | 36 ++--- astrbot/dashboard/server.py | 32 ++-- 43 files changed, 471 insertions(+), 221 deletions(-) create mode 100644 astrbot/__main__.py diff --git a/astrbot/__main__.py b/astrbot/__main__.py new file mode 100644 index 000000000..7a9154301 --- /dev/null +++ b/astrbot/__main__.py @@ -0,0 +1,149 @@ +import argparse +import asyncio +import mimetypes +import os +import sys +from pathlib import Path + +import anyio + +import runtime_bootstrap +from astrbot.core import LogBroker, LogManager, db_helper, logger +from astrbot.core.config.default import VERSION +from astrbot.core.initial_loader import InitialLoader +from astrbot.core.utils.astrbot_path import ( + get_astrbot_config_path, + get_astrbot_data_path, + get_astrbot_knowledge_base_path, + get_astrbot_plugin_path, + get_astrbot_root, + get_astrbot_site_packages_path, + get_astrbot_temp_path, +) +from astrbot.core.utils.io import ( + download_dashboard, + get_dashboard_version, +) + +runtime_bootstrap.initialize_runtime_bootstrap() + + +# 将父目录添加到 sys.path +sys.path.append(Path(__file__).parent.as_posix()) + +logo_tmpl = r""" + ___ _______.___________..______ .______ ______ .___________. + / \ / | || _ \ | _ \ / __ \ | | + / ^ \ | (----`---| |----`| |_) | | |_) | | | | | `---| |----` + / /_\ \ \ \ | | | / | _ < | | | | | | + / _____ \ .----) | | | | |\ \----.| |_) | | `--' | | | +/__/ \__\ |_______/ |__| | _| `._____||______/ \______/ |__| + +""" + + +def check_env() -> None: + if not (sys.version_info.major == 3 and sys.version_info.minor >= 10): + logger.error("请使用 Python3.10+ 运行本项目。") + exit() + + astrbot_root = get_astrbot_root() + if astrbot_root not in sys.path: + sys.path.insert(0, astrbot_root) + + site_packages_path = get_astrbot_site_packages_path() + if site_packages_path not in sys.path: + sys.path.insert(0, site_packages_path) + + os.makedirs(get_astrbot_config_path(), exist_ok=True) + os.makedirs(get_astrbot_plugin_path(), exist_ok=True) + os.makedirs(get_astrbot_temp_path(), exist_ok=True) + os.makedirs(get_astrbot_knowledge_base_path(), exist_ok=True) + os.makedirs(site_packages_path, exist_ok=True) + + # 针对问题 #181 的临时解决方案 + mimetypes.add_type("text/javascript", ".js") + mimetypes.add_type("text/javascript", ".mjs") + mimetypes.add_type("application/json", ".json") + + +async def check_dashboard_files(webui_dir: str | None = None): + """下载管理面板文件""" + # 指定webui目录 + if webui_dir: + if await anyio.Path(webui_dir).exists(): + logger.info(f"使用指定的 WebUI 目录: {webui_dir}") + return webui_dir + logger.warning(f"指定的 WebUI 目录 {webui_dir} 不存在,将使用默认逻辑。") + + data_dist_path = os.path.join(get_astrbot_data_path(), "dist") + if await anyio.Path(data_dist_path).exists(): + v = await get_dashboard_version() + if v is not None: + # 存在文件 + if v == f"v{VERSION}": + logger.info("WebUI 版本已是最新。") + else: + logger.warning( + f"检测到 WebUI 版本 ({v}) 与当前 AstrBot 版本 (v{VERSION}) 不符。", + ) + return data_dist_path + + logger.info( + "开始下载管理面板文件...高峰期(晚上)可能导致较慢的速度。如多次下载失败,请前往 https://github.com/AstrBotDevs/AstrBot/releases/latest 下载 dist.zip,并将其中的 dist 文件夹解压至 data 目录下。", + ) + + try: + await download_dashboard(version=f"v{VERSION}", latest=False) + except Exception as e: + logger.warning( + f"下载指定版本(v{VERSION})的管理面板文件失败: {e},尝试下载最新版本。" + ) + try: + await download_dashboard(latest=True) + except Exception as e: + logger.critical(f"下载管理面板文件失败: {e}。") + return None + + logger.info("管理面板下载完成。") + return data_dist_path + + +async def main_async(webui_dir_arg: str | None) -> None: + """主异步入口""" + # 检查仪表板文件 + webui_dir = await check_dashboard_files(webui_dir_arg) + if webui_dir is None: + logger.warning( + "管理面板文件检查失败,WebUI 功能将不可用。" + "请检查网络连接或手动指定 --webui-dir 参数。" + ) + + db = db_helper + + # 打印 logo + logger.info(logo_tmpl) + + core_lifecycle = InitialLoader(db, log_broker) + core_lifecycle.webui_dir = webui_dir + await core_lifecycle.start() + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="AstrBot") + parser.add_argument( + "--webui-dir", + type=str, + help="指定 WebUI 静态文件目录路径", + default=None, + ) + args = parser.parse_args() + + check_env() + + # 启动日志代理 + log_broker = LogBroker() + LogManager.set_queue_handler(logger, log_broker) + + # 只使用一次 asyncio.run() + asyncio.run(main_async(args.webui_dir)) diff --git a/astrbot/cli/commands/cmd_bk.py b/astrbot/cli/commands/cmd_bk.py index 08a2cad72..a387924c9 100644 --- a/astrbot/cli/commands/cmd_bk.py +++ b/astrbot/cli/commands/cmd_bk.py @@ -4,6 +4,7 @@ import shutil import subprocess from pathlib import Path +import anyio import click from astrbot.core import astrbot_config, db_helper @@ -188,7 +189,7 @@ def export_data( raise subprocess.CalledProcessError(process.returncode or 1, cmd) # Clean up original file - final_path.unlink() + await anyio.Path(final_path).unlink() final_path = gpg_output click.echo( click.style(f"Processed backup created: {final_path}", fg="green") @@ -199,13 +200,13 @@ def export_data( click.echo(f"Calculating {digest} digest...") hash_func = getattr(hashlib, digest)() # Read file in chunks - with open(final_path, "rb") as f: - while chunk := f.read(8192): + async with await anyio.open_file(final_path, "rb") as f: + while chunk := await f.read(8192): hash_func.update(chunk) digest_val = hash_func.hexdigest() digest_file = final_path.with_name(final_path.name + f".{digest}") - digest_file.write_text( + await anyio.Path(digest_file).write_text( f"{digest_val} *{final_path.name}\n", encoding="utf-8" ) click.echo(click.style(f"Digest generated: {digest_file}", fg="green")) @@ -369,8 +370,8 @@ def import_data_command(backup_file: str, yes: bool): click.echo(f" - {warn}") finally: - if is_temp_file and zip_path.exists(): - zip_path.unlink() + if is_temp_file and await anyio.Path(zip_path).exists(): + await anyio.Path(zip_path).unlink() click.echo(f"Cleaned up temporary file: {zip_path}") asyncio.run(_run()) diff --git a/astrbot/core/agent/runners/coze/coze_api_client.py b/astrbot/core/agent/runners/coze/coze_api_client.py index f5799dfbb..11d0cdedd 100644 --- a/astrbot/core/agent/runners/coze/coze_api_client.py +++ b/astrbot/core/agent/runners/coze/coze_api_client.py @@ -288,14 +288,16 @@ if __name__ == "__main__": import asyncio import os + import anyio + async def test_coze_api_client() -> None: api_key = os.getenv("COZE_API_KEY", "") bot_id = os.getenv("COZE_BOT_ID", "") client = CozeAPIClient(api_key=api_key) try: - with open("README.md", "rb") as f: - file_data = f.read() + async with await anyio.open_file("README.md", "rb") as f: + file_data = await f.read() file_id = await client.upload_file(file_data) print(f"Uploaded file_id: {file_id}") async for event in client.chat_messages( diff --git a/astrbot/core/agent/runners/dify/dify_agent_runner.py b/astrbot/core/agent/runners/dify/dify_agent_runner.py index 93f8d3570..5c40fecac 100644 --- a/astrbot/core/agent/runners/dify/dify_agent_runner.py +++ b/astrbot/core/agent/runners/dify/dify_agent_runner.py @@ -176,7 +176,7 @@ class DifyAgentRunner(BaseAgentRunner[TContext]): user=session_id, conversation_id=conversation_id, files=files_payload, - timeout=self.timeout, + request_timeout=self.timeout, ): logger.debug(f"dify resp chunk: {chunk}") if chunk["event"] == "message" or chunk["event"] == "agent_message": @@ -216,7 +216,7 @@ class DifyAgentRunner(BaseAgentRunner[TContext]): }, user=session_id, files=files_payload, - timeout=self.timeout, + request_timeout=self.timeout, ): logger.debug(f"dify workflow resp chunk: {chunk}") match chunk["event"]: diff --git a/astrbot/core/agent/runners/dify/dify_api_client.py b/astrbot/core/agent/runners/dify/dify_api_client.py index 26da6dfe9..df769f94b 100644 --- a/astrbot/core/agent/runners/dify/dify_api_client.py +++ b/astrbot/core/agent/runners/dify/dify_api_client.py @@ -3,6 +3,7 @@ import json from collections.abc import AsyncGenerator from typing import Any +import anyio from aiohttp import ClientResponse, ClientSession, FormData from astrbot.core import logger @@ -47,20 +48,20 @@ class DifyAPIClient: response_mode: str = "streaming", conversation_id: str = "", files: list[dict[str, Any]] | None = None, - timeout: float = 60, + request_timeout: float = 60, ) -> AsyncGenerator[dict[str, Any], None]: if files is None: files = [] url = f"{self.api_base}/chat-messages" payload = locals() payload.pop("self") - payload.pop("timeout") + payload.pop("request_timeout") logger.info(f"chat_messages payload: {payload}") async with self.session.post( url, json=payload, headers=self.headers, - timeout=timeout, + timeout=request_timeout, ) as resp: if resp.status != 200: text = await resp.text() @@ -76,20 +77,20 @@ class DifyAPIClient: user: str, response_mode: str = "streaming", files: list[dict[str, Any]] | None = None, - timeout: float = 60, + request_timeout: float = 60, ): if files is None: files = [] url = f"{self.api_base}/workflows/run" payload = locals() payload.pop("self") - payload.pop("timeout") + payload.pop("request_timeout") logger.info(f"workflow_run payload: {payload}") async with self.session.post( url, json=payload, headers=self.headers, - timeout=timeout, + timeout=request_timeout, ) as resp: if resp.status != 200: text = await resp.text() @@ -134,8 +135,8 @@ class DifyAPIClient: # 使用文件路径 import os - with open(file_path, "rb") as f: - file_content = f.read() + async with await anyio.open_file(file_path, "rb") as f: + file_content = await f.read() form.add_field( "file", file_content, diff --git a/astrbot/core/astr_agent_run_util.py b/astrbot/core/astr_agent_run_util.py index 364cf8b4f..ed83a603c 100644 --- a/astrbot/core/astr_agent_run_util.py +++ b/astrbot/core/astr_agent_run_util.py @@ -4,6 +4,8 @@ import time import traceback from collections.abc import AsyncGenerator +import anyio + from astrbot.core import logger from astrbot.core.agent.message import Message from astrbot.core.agent.runners.tool_loop_agent_runner import ToolLoopAgentRunner @@ -529,8 +531,8 @@ async def _simulated_stream_tts( audio_path = await tts_provider.get_audio(text) if audio_path: - with open(audio_path, "rb") as f: - audio_data = f.read() + async with await anyio.open_file(audio_path, "rb") as f: + audio_data = await f.read() await audio_queue.put((text, audio_data)) except Exception as e: logger.error( diff --git a/astrbot/core/backup/exporter.py b/astrbot/core/backup/exporter.py index a92237599..33f41621a 100644 --- a/astrbot/core/backup/exporter.py +++ b/astrbot/core/backup/exporter.py @@ -12,6 +12,7 @@ from datetime import datetime, timezone from pathlib import Path from typing import TYPE_CHECKING, Any +import anyio from sqlalchemy import select from astrbot.core import logger @@ -83,7 +84,7 @@ class AstrBotExporter: output_dir = get_astrbot_backups_path() # 确保输出目录存在 - Path(output_dir).mkdir(parents=True, exist_ok=True) + await anyio.Path(output_dir).mkdir(parents=True, exist_ok=True) timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") zip_filename = f"astrbot_backup_{timestamp}.zip" @@ -160,9 +161,11 @@ class AstrBotExporter: # 3. 导出配置文件 if progress_callback: await progress_callback("config", 0, 100, "正在导出配置文件...") - if os.path.exists(self.config_path): - with open(self.config_path, encoding="utf-8") as f: - config_content = f.read() + if await anyio.Path(self.config_path).exists(): + async with await anyio.open_file( + self.config_path, encoding="utf-8" + ) as f: + config_content = await f.read() zf.writestr("config/cmd_config.json", config_content) self._add_checksum("config/cmd_config.json", config_content) if progress_callback: @@ -199,8 +202,8 @@ class AstrBotExporter: except Exception as e: logger.error(f"备份导出失败: {e}") # 清理失败的文件 - if os.path.exists(zip_path): - os.remove(zip_path) + if await anyio.Path(zip_path).exists(): + await anyio.Path(zip_path).unlink() raise async def _export_main_database(self) -> dict[str, list[dict]]: @@ -317,7 +320,7 @@ class AstrBotExporter: for dir_name, dir_path in backup_directories.items(): full_path = Path(dir_path) - if not full_path.exists(): + if not await anyio.Path(full_path).exists(): logger.debug(f"目录不存在,跳过: {full_path}") continue @@ -362,7 +365,7 @@ class AstrBotExporter: for attachment in attachments: try: file_path = attachment.get("path", "") - if file_path and os.path.exists(file_path): + if file_path and await anyio.Path(file_path).exists(): # 使用 attachment_id 作为文件名 attachment_id = attachment.get("attachment_id", "") ext = os.path.splitext(file_path)[1] diff --git a/astrbot/core/computer/computer_client.py b/astrbot/core/computer/computer_client.py index 90a142801..67c718892 100644 --- a/astrbot/core/computer/computer_client.py +++ b/astrbot/core/computer/computer_client.py @@ -388,22 +388,24 @@ async def _sync_skills_to_sandbox(booter: ComputerBooter) -> None: Backward-compatible orchestrator: keep historical behavior while internally splitting into `apply` and `scan` phases. """ - skills_root = Path(get_astrbot_skills_path()) - if not skills_root.is_dir(): + import anyio + + skills_root = anyio.Path(get_astrbot_skills_path()) + if not await skills_root.is_dir(): return local_skill_dirs = _list_local_skill_dirs(skills_root) - temp_dir = Path(get_astrbot_temp_path()) - temp_dir.mkdir(parents=True, exist_ok=True) + temp_dir = anyio.Path(get_astrbot_temp_path()) + await temp_dir.mkdir(parents=True, exist_ok=True) zip_base = temp_dir / "skills_bundle" zip_path = zip_base.with_suffix(".zip") try: if local_skill_dirs: - if zip_path.exists(): - zip_path.unlink() + if await zip_path.exists(): + await zip_path.unlink() shutil.make_archive(str(zip_base), "zip", str(skills_root)) - remote_zip = Path(SANDBOX_SKILLS_ROOT) / "skills.zip" + remote_zip = anyio.Path(SANDBOX_SKILLS_ROOT) / "skills.zip" logger.info("[Computer] sandbox_sync phase=upload status=start") await booter.shell.exec(f"mkdir -p {SANDBOX_SKILLS_ROOT}") upload_result = await booter.upload_file(str(zip_path), str(remote_zip)) diff --git a/astrbot/core/computer/tools/fs.py b/astrbot/core/computer/tools/fs.py index 31b7f3f51..3863d6f29 100644 --- a/astrbot/core/computer/tools/fs.py +++ b/astrbot/core/computer/tools/fs.py @@ -2,6 +2,8 @@ import os import uuid from dataclasses import dataclass, field +import anyio + from astrbot.api import FunctionTool, logger from astrbot.api.event import MessageChain from astrbot.core.agent.run_context import ContextWrapper @@ -111,10 +113,11 @@ class FileUploadTool(FunctionTool): ) try: # Check if file exists - if not os.path.exists(local_path): + local_path_obj = anyio.Path(local_path) + if not await local_path_obj.exists(): return f"Error: File does not exist: {local_path}" - if not os.path.isfile(local_path): + if not await local_path_obj.is_file(): return f"Error: Path is not a file: {local_path}" # Use basename if sandbox_filename is not provided diff --git a/astrbot/core/db/migration/helper.py b/astrbot/core/db/migration/helper.py index d7bca3067..df460cb1c 100644 --- a/astrbot/core/db/migration/helper.py +++ b/astrbot/core/db/migration/helper.py @@ -1,5 +1,7 @@ import os +import anyio + from astrbot.api import logger, sp from astrbot.core.config import AstrBotConfig from astrbot.core.db import BaseDatabase @@ -22,7 +24,7 @@ async def check_migration_needed_v4(db_helper: BaseDatabase) -> bool: data_dir = get_astrbot_data_path() data_v3_db = os.path.join(data_dir, "data_v3.db") - if not os.path.exists(data_v3_db): + if not await anyio.Path(data_v3_db).exists(): return False migration_done = await db_helper.get_preference( "global", diff --git a/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py b/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py index 37c3b09ab..470766081 100644 --- a/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py +++ b/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py @@ -6,6 +6,7 @@ from pathlib import Path from typing import Literal, NoReturn, cast import aiohttp +import anyio import dingtalk_stream from dingtalk_stream import AckMessage @@ -334,8 +335,8 @@ class DingtalkPlatformAdapter(Platform): "downloadCode": download_code, "robotCode": robot_code, } - temp_dir = Path(get_astrbot_temp_path()) - temp_dir.mkdir(parents=True, exist_ok=True) + temp_dir = anyio.Path(get_astrbot_temp_path()) + await temp_dir.mkdir(parents=True, exist_ok=True) f_path = temp_dir / f"dingtalk_{uuid.uuid4()}.{ext}" async with ( aiohttp.ClientSession() as session, @@ -494,7 +495,7 @@ class DingtalkPlatformAdapter(Platform): return converted, converted != input_path async def upload_media(self, file_path: str, media_type: str) -> str: - media_file_path = Path(file_path) + media_file_path = anyio.Path(file_path) access_token = await self.get_access_token() if not access_token: logger.error("钉钉媒体上传失败: access_token 为空") @@ -503,7 +504,7 @@ class DingtalkPlatformAdapter(Platform): form = aiohttp.FormData() form.add_field( "media", - media_file_path.read_bytes(), + await media_file_path.read_bytes(), filename=media_file_path.name, content_type="application/octet-stream", ) diff --git a/astrbot/core/platform/sources/lark/lark_adapter.py b/astrbot/core/platform/sources/lark/lark_adapter.py index 60e8e0d93..68512e895 100644 --- a/astrbot/core/platform/sources/lark/lark_adapter.py +++ b/astrbot/core/platform/sources/lark/lark_adapter.py @@ -7,6 +7,7 @@ from pathlib import Path from typing import Any, cast from uuid import uuid4 +import anyio import lark_oapi as lark from lark_oapi.api.im.v1 import ( GetMessageRequest, @@ -428,13 +429,13 @@ class LarkPlatformAdapter(Platform): return None suffix = Path(file_name).suffix if file_name else default_suffix - temp_dir = Path(get_astrbot_temp_path()) - temp_dir.mkdir(parents=True, exist_ok=True) + temp_dir = anyio.Path(get_astrbot_temp_path()) + await temp_dir.mkdir(parents=True, exist_ok=True) temp_path = ( temp_dir / f"lark_{message_type}_{file_name}_{uuid4().hex[:4]}{suffix}" ) - temp_path.write_bytes(file_bytes) - return str(temp_path.resolve()) + await temp_path.write_bytes(file_bytes) + return str(await temp_path.resolve()) def _clean_expired_events(self) -> None: """清理超过 30 分钟的事件记录""" diff --git a/astrbot/core/platform/sources/lark/lark_event.py b/astrbot/core/platform/sources/lark/lark_event.py index 0959f63df..9f06be66c 100644 --- a/astrbot/core/platform/sources/lark/lark_event.py +++ b/astrbot/core/platform/sources/lark/lark_event.py @@ -5,6 +5,8 @@ import os import uuid from io import BytesIO +import aiofiles +import anyio import lark_oapi as lark from lark_oapi.api.cardkit.v1 import ( ContentCardElementRequest, @@ -28,8 +30,16 @@ from lark_oapi.api.im.v1 import ( from astrbot import logger from astrbot.api.event import AstrMessageEvent, MessageChain -from astrbot.api.message_components import At, File, Plain, Record, Video -from astrbot.api.message_components import Image as AstrBotImage +from astrbot.api.message_components import ( + At, + File, + Plain, + Record, + Video, +) +from astrbot.api.message_components import ( + Image as AstrBotImage, +) from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from astrbot.core.utils.io import download_image_by_url from astrbot.core.utils.media_utils import ( @@ -146,7 +156,8 @@ class LarkMessageEvent(AstrMessageEvent): Returns: 成功返回file_key,失败返回None """ - if not path or not os.path.exists(path): + path_obj = anyio.Path(path) + if not path or not await path_obj.exists(): logger.error(f"[Lark] 文件不存在: {path}") return None @@ -155,7 +166,7 @@ class LarkMessageEvent(AstrMessageEvent): return None try: - with open(path, "rb") as file_obj: + async with aiofiles.open(path, "rb") as file_obj: body_builder = ( CreateFileRequestBody.builder() .file_type(file_type) @@ -217,8 +228,8 @@ class LarkMessageEvent(AstrMessageEvent): temp_dir, f"lark_image_{uuid.uuid4().hex[:8]}.jpg", ) - with open(file_path, "wb") as f: - f.write(BytesIO(image_data).getvalue()) + async with aiofiles.open(file_path, "wb") as f: + await f.write(BytesIO(image_data).getvalue()) else: file_path = comp.file if comp.file else "" @@ -227,7 +238,9 @@ class LarkMessageEvent(AstrMessageEvent): logger.error("[Lark] 图片路径为空,无法上传") continue try: - image_file = open(file_path, "rb") + image_file = await anyio.to_thread.run_sync( + open, file_path, "rb" + ) except Exception as e: logger.error(f"[Lark] 无法打开图片文件: {e}") continue @@ -422,7 +435,10 @@ class LarkMessageEvent(AstrMessageEvent): logger.error(f"[Lark] 无法获取音频文件路径: {e}") return - if not original_audio_path or not os.path.exists(original_audio_path): + if ( + not original_audio_path + or not await anyio.Path(original_audio_path).exists() + ): logger.error(f"[Lark] 音频文件不存在: {original_audio_path}") return @@ -452,9 +468,9 @@ class LarkMessageEvent(AstrMessageEvent): ) # 清理转换后的临时音频文件 - if converted_audio_path and os.path.exists(converted_audio_path): + if converted_audio_path and await anyio.Path(converted_audio_path).exists(): try: - os.remove(converted_audio_path) + await anyio.Path(converted_audio_path).unlink() logger.debug(f"[Lark] 已删除转换后的音频文件: {converted_audio_path}") except Exception as e: logger.warning(f"[Lark] 删除转换后的音频文件失败: {e}") @@ -495,7 +511,10 @@ class LarkMessageEvent(AstrMessageEvent): logger.error(f"[Lark] 无法获取视频文件路径: {e}") return - if not original_video_path or not os.path.exists(original_video_path): + if ( + not original_video_path + or not await anyio.Path(original_video_path).exists() + ): logger.error(f"[Lark] 视频文件不存在: {original_video_path}") return @@ -525,9 +544,9 @@ class LarkMessageEvent(AstrMessageEvent): ) # 清理转换后的临时视频文件 - if converted_video_path and os.path.exists(converted_video_path): + if converted_video_path and await anyio.Path(converted_video_path).exists(): try: - os.remove(converted_video_path) + await anyio.Path(converted_video_path).unlink() logger.debug(f"[Lark] 已删除转换后的视频文件: {converted_video_path}") except Exception as e: logger.warning(f"[Lark] 删除转换后的视频文件失败: {e}") diff --git a/astrbot/core/platform/sources/line/line_event.py b/astrbot/core/platform/sources/line/line_event.py index 8b82ad182..d4d1a9cdb 100644 --- a/astrbot/core/platform/sources/line/line_event.py +++ b/astrbot/core/platform/sources/line/line_event.py @@ -1,9 +1,9 @@ import asyncio -import os import re import uuid from collections.abc import AsyncGenerator -from pathlib import Path + +import anyio from astrbot.api import logger from astrbot.api.event import AstrMessageEvent, MessageChain @@ -160,8 +160,8 @@ class LineMessageEvent(AstrMessageEvent): try: video_path = await segment.convert_to_file_path() - temp_dir = Path(get_astrbot_temp_path()) - temp_dir.mkdir(parents=True, exist_ok=True) + temp_dir = anyio.Path(get_astrbot_temp_path()) + await temp_dir.mkdir(parents=True, exist_ok=True) thumb_path = temp_dir / f"line_video_preview_{uuid.uuid4().hex}.jpg" process = await asyncio.create_subprocess_exec( @@ -178,7 +178,7 @@ class LineMessageEvent(AstrMessageEvent): stderr=asyncio.subprocess.PIPE, ) await process.communicate() - if process.returncode != 0 or not thumb_path.exists(): + if process.returncode != 0 or not await thumb_path.exists(): return "" cover_seg = Image.fromFileSystem(str(thumb_path)) @@ -201,8 +201,8 @@ class LineMessageEvent(AstrMessageEvent): async def _resolve_file_size(segment: File) -> int: try: file_path = await segment.get_file(allow_return_url=False) - if file_path and os.path.exists(file_path): - return int(os.path.getsize(file_path)) + if file_path and await anyio.Path(file_path).exists(): + return int((await anyio.Path(file_path).stat()).st_size) except Exception as e: logger.debug("[LINE] resolve file size failed: %s", e) return 0 diff --git a/astrbot/core/platform/sources/misskey/misskey_adapter.py b/astrbot/core/platform/sources/misskey/misskey_adapter.py index fd61c3e50..f0b8292ca 100644 --- a/astrbot/core/platform/sources/misskey/misskey_adapter.py +++ b/astrbot/core/platform/sources/misskey/misskey_adapter.py @@ -1,8 +1,9 @@ import asyncio -import os import random from typing import Any +import anyio + import astrbot.api.message_components as Comp from astrbot.api import logger from astrbot.api.event import MessageChain @@ -499,11 +500,12 @@ class MisskeyPlatformAdapter(Platform): # 清理临时文件 if local_path and isinstance(local_path, str): data_temp = get_astrbot_temp_path() - if local_path.startswith(data_temp) and os.path.exists( - local_path, + if ( + local_path.startswith(data_temp) + and await anyio.Path(local_path).exists() ): try: - os.remove(local_path) + await anyio.Path(local_path).unlink() logger.debug(f"[Misskey] 已清理临时文件: {local_path}") except Exception: pass diff --git a/astrbot/core/platform/sources/misskey/misskey_api.py b/astrbot/core/platform/sources/misskey/misskey_api.py index 3e5eb9a90..ca5e48e0b 100644 --- a/astrbot/core/platform/sources/misskey/misskey_api.py +++ b/astrbot/core/platform/sources/misskey/misskey_api.py @@ -5,6 +5,8 @@ import uuid from collections.abc import Awaitable, Callable from typing import Any, NoReturn +import anyio + try: import aiohttp import websockets @@ -555,7 +557,7 @@ class MisskeyAPI: form.add_field("folderId", str(folder_id)) try: - f = open(file_path, "rb") + f = await anyio.to_thread.run_sync(open, file_path, "rb") except FileNotFoundError as e: logger.error(f"[Misskey API] 本地文件不存在: {file_path}") raise APIError(f"File not found: {file_path}") from e diff --git a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py index 97b2b2fb4..5e65e9eea 100644 --- a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py +++ b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py @@ -6,6 +6,7 @@ import uuid from typing import cast import aiofiles +import anyio import botpy import botpy.errors import botpy.message @@ -495,7 +496,8 @@ class QQOfficialMessageEvent(AstrMessageEvent): payload["file_name"] = file_name # 处理文件数据 - if os.path.exists(file_source): + file_source_obj = anyio.Path(file_source) + if await file_source_obj.exists(): # 读取本地文件 async with aiofiles.open(file_source, "rb") as f: file_content = await f.read() diff --git a/astrbot/core/platform/sources/webchat/message_parts_helper.py b/astrbot/core/platform/sources/webchat/message_parts_helper.py index 43072ec1c..9c65f9e6a 100644 --- a/astrbot/core/platform/sources/webchat/message_parts_helper.py +++ b/astrbot/core/platform/sources/webchat/message_parts_helper.py @@ -6,6 +6,8 @@ from collections.abc import Awaitable, Callable, Sequence from pathlib import Path from typing import Any +import anyio + from astrbot.core.db.po import Attachment from astrbot.core.message.components import ( File, @@ -339,7 +341,11 @@ async def create_attachment_part_from_existing_file( candidate_paths = [Path(attachments_dir) / basename] candidate_paths.extend(Path(p) / basename for p in fallback_dirs) - file_path = next((path for path in candidate_paths if path.exists()), None) + file_path = None + for path in candidate_paths: + if await anyio.Path(path).exists(): + file_path = path + break if not file_path: return None @@ -365,8 +371,8 @@ async def message_chain_to_storage_message_parts( insert_attachment: AttachmentInserter, attachments_dir: str | Path, ) -> list[dict]: - target_dir = Path(attachments_dir) - target_dir.mkdir(parents=True, exist_ok=True) + target_dir = anyio.Path(attachments_dir) + await target_dir.mkdir(parents=True, exist_ok=True) parts: list[dict] = [] for comp in message_chain.chain: @@ -441,13 +447,13 @@ async def _copy_file_to_attachment_part( attachments_dir: Path, display_name: str | None = None, ) -> dict | None: - src_path = Path(file_path) - if not src_path.exists() or not src_path.is_file(): + src_path = anyio.Path(file_path) + if not await src_path.exists() or not await src_path.is_file(): return None suffix = src_path.suffix target_path = attachments_dir / f"{uuid.uuid4().hex}{suffix}" - shutil.copy2(src_path, target_path) + shutil.copy2(str(src_path), str(target_path)) mime_type, _ = mimetypes.guess_type(target_path.name) attachment = await insert_attachment( diff --git a/astrbot/core/platform/sources/webchat/webchat_event.py b/astrbot/core/platform/sources/webchat/webchat_event.py index b7da864aa..fe0ee3922 100644 --- a/astrbot/core/platform/sources/webchat/webchat_event.py +++ b/astrbot/core/platform/sources/webchat/webchat_event.py @@ -4,6 +4,8 @@ import os import shutil import uuid +import aiofiles + from astrbot.api import logger from astrbot.api.event import AstrMessageEvent, MessageChain from astrbot.api.message_components import File, Image, Json, Plain, Record @@ -80,8 +82,8 @@ class WebChatMessageEvent(AstrMessageEvent): filename = f"{str(uuid.uuid4())}.jpg" path = os.path.join(attachments_dir, filename) image_base64 = await comp.convert_to_base64() - with open(path, "wb") as f: - f.write(base64.b64decode(image_base64)) + async with aiofiles.open(path, "wb") as f: + await f.write(base64.b64decode(image_base64)) data = f"[IMAGE]{filename}" await web_chat_back_queue.put( { @@ -96,8 +98,8 @@ class WebChatMessageEvent(AstrMessageEvent): filename = f"{str(uuid.uuid4())}.wav" path = os.path.join(attachments_dir, filename) record_base64 = await comp.convert_to_base64() - with open(path, "wb") as f: - f.write(base64.b64decode(record_base64)) + async with aiofiles.open(path, "wb") as f: + await f.write(base64.b64decode(record_base64)) data = f"[RECORD]{filename}" await web_chat_back_queue.put( { diff --git a/astrbot/core/platform/sources/wecom/wecom_adapter.py b/astrbot/core/platform/sources/wecom/wecom_adapter.py index 410b30eea..3071475c5 100644 --- a/astrbot/core/platform/sources/wecom/wecom_adapter.py +++ b/astrbot/core/platform/sources/wecom/wecom_adapter.py @@ -5,6 +5,7 @@ import uuid from collections.abc import Awaitable, Callable from typing import Any, cast +import aiofiles import quart from requests import Response from wechatpy.enterprise import WeChatClient, parse_message @@ -346,8 +347,8 @@ class WecomPlatformAdapter(Platform): ) temp_dir = get_astrbot_temp_path() path = os.path.join(temp_dir, f"wecom_{msg.media_id}.amr") - with open(path, "wb") as f: - f.write(resp.content) + async with aiofiles.open(path, "wb") as f: + await f.write(resp.content) try: path_wav = os.path.join(temp_dir, f"wecom_{msg.media_id}.wav") @@ -402,8 +403,8 @@ class WecomPlatformAdapter(Platform): ) temp_dir = get_astrbot_temp_path() path = os.path.join(temp_dir, f"weixinkefu_{media_id}.jpg") - with open(path, "wb") as f: - f.write(resp.content) + async with aiofiles.open(path, "wb") as f: + await f.write(resp.content) abm.message = [Image(file=path, url=path)] elif msgtype == "voice": media_id = msg.get("voice", {}).get("media_id", "") @@ -415,8 +416,8 @@ class WecomPlatformAdapter(Platform): temp_dir = get_astrbot_temp_path() path = os.path.join(temp_dir, f"weixinkefu_{media_id}.amr") - with open(path, "wb") as f: - f.write(resp.content) + async with aiofiles.open(path, "wb") as f: + await f.write(resp.content) try: path_wav = os.path.join(temp_dir, f"weixinkefu_{media_id}.wav") diff --git a/astrbot/core/platform/sources/wecom/wecom_event.py b/astrbot/core/platform/sources/wecom/wecom_event.py index 7aee26e47..47c7cdb5e 100644 --- a/astrbot/core/platform/sources/wecom/wecom_event.py +++ b/astrbot/core/platform/sources/wecom/wecom_event.py @@ -1,6 +1,7 @@ import asyncio -import os +import aiofiles +import anyio from wechatpy.enterprise import WeChatClient from astrbot.api import logger @@ -100,7 +101,7 @@ class WecomPlatformEvent(AstrMessageEvent): elif isinstance(comp, Image): img_path = await comp.convert_to_file_path() - with open(img_path, "rb") as f: + async with aiofiles.open(img_path, "rb") as f: try: response = self.client.media.upload("image", f) except Exception as e: @@ -120,7 +121,7 @@ class WecomPlatformEvent(AstrMessageEvent): record_path_amr = await convert_audio_to_amr(record_path) try: - with open(record_path_amr, "rb") as f: + async with aiofiles.open(record_path_amr, "rb") as f: try: response = self.client.media.upload("voice", f) except Exception as e: @@ -138,17 +139,18 @@ class WecomPlatformEvent(AstrMessageEvent): response["media_id"], ) finally: - if record_path_amr != record_path and os.path.exists( - record_path_amr, + if ( + record_path_amr != record_path + and await anyio.Path(record_path_amr).exists() ): try: - os.remove(record_path_amr) + await anyio.Path(record_path_amr).unlink() except OSError as e: logger.warning(f"删除临时音频文件失败: {e}") elif isinstance(comp, File): file_path = await comp.get_file() - with open(file_path, "rb") as f: + async with aiofiles.open(file_path, "rb") as f: try: response = self.client.media.upload("file", f) except Exception as e: @@ -166,7 +168,7 @@ class WecomPlatformEvent(AstrMessageEvent): elif isinstance(comp, Video): video_path = await comp.convert_to_file_path() - with open(video_path, "rb") as f: + async with aiofiles.open(video_path, "rb") as f: try: response = self.client.media.upload("video", f) except Exception as e: @@ -199,7 +201,7 @@ class WecomPlatformEvent(AstrMessageEvent): elif isinstance(comp, Image): img_path = await comp.convert_to_file_path() - with open(img_path, "rb") as f: + async with aiofiles.open(img_path, "rb") as f: try: response = self.client.media.upload("image", f) except Exception as e: @@ -219,7 +221,7 @@ class WecomPlatformEvent(AstrMessageEvent): record_path_amr = await convert_audio_to_amr(record_path) try: - with open(record_path_amr, "rb") as f: + async with aiofiles.open(record_path_amr, "rb") as f: try: response = self.client.media.upload("voice", f) except Exception as e: @@ -237,17 +239,18 @@ class WecomPlatformEvent(AstrMessageEvent): response["media_id"], ) finally: - if record_path_amr != record_path and os.path.exists( - record_path_amr, + if ( + record_path_amr != record_path + and await anyio.Path(record_path_amr).exists() ): try: - os.remove(record_path_amr) + await anyio.Path(record_path_amr).unlink() except OSError as e: logger.warning(f"删除临时音频文件失败: {e}") elif isinstance(comp, File): file_path = await comp.get_file() - with open(file_path, "rb") as f: + async with aiofiles.open(file_path, "rb") as f: try: response = self.client.media.upload("file", f) except Exception as e: @@ -265,7 +268,7 @@ class WecomPlatformEvent(AstrMessageEvent): elif isinstance(comp, Video): video_path = await comp.convert_to_file_path() - with open(video_path, "rb") as f: + async with aiofiles.open(video_path, "rb") as f: try: response = self.client.media.upload("video", f) except Exception as e: diff --git a/astrbot/core/platform/sources/wecom_ai_bot/wecomai_webhook.py b/astrbot/core/platform/sources/wecom_ai_bot/wecomai_webhook.py index 6f42f264b..ce5b90968 100644 --- a/astrbot/core/platform/sources/wecom_ai_bot/wecomai_webhook.py +++ b/astrbot/core/platform/sources/wecom_ai_bot/wecomai_webhook.py @@ -10,6 +10,7 @@ from typing import Any, Literal from urllib.parse import parse_qs, urlencode, urlparse import aiohttp +import anyio from astrbot.api import logger from astrbot.api.event import MessageChain @@ -103,7 +104,8 @@ class WecomAIBotWebhookClient: async def upload_media( self, file_path: Path, media_type: Literal["file", "voice"] ) -> str: - if not file_path.exists() or not file_path.is_file(): + file_path_anyio = anyio.Path(file_path) + if not await file_path_anyio.exists() or not await file_path_anyio.is_file(): raise WecomAIBotWebhookError(f"文件不存在: {file_path}") content_type = ( @@ -112,7 +114,7 @@ class WecomAIBotWebhookClient: form = aiohttp.FormData() form.add_field( "media", - file_path.read_bytes(), + await file_path_anyio.read_bytes(), filename=file_path.name, content_type=content_type, ) diff --git a/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_adapter.py b/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_adapter.py index bb7061ca1..d31c76b9e 100644 --- a/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_adapter.py +++ b/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_adapter.py @@ -6,6 +6,7 @@ import uuid from collections.abc import Callable, Coroutine from typing import Any, cast +import aiofiles import quart from requests import Response from wechatpy import WeChatClient, create_reply, parse_message @@ -468,8 +469,8 @@ class WeixinOfficialAccountPlatformAdapter(Platform): ) temp_dir = get_astrbot_temp_path() path = os.path.join(temp_dir, f"weixin_offacc_{msg.media_id}.amr") - with open(path, "wb") as f: - f.write(resp.content) + async with aiofiles.open(path, "wb") as f: + await f.write(resp.content) try: path_wav = os.path.join( diff --git a/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_event.py b/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_event.py index ae536593c..7387ca172 100644 --- a/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_event.py +++ b/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_event.py @@ -1,7 +1,8 @@ import asyncio -import os from typing import Any, cast +import aiofiles +import anyio from wechatpy import WeChatClient from wechatpy.replies import ImageReply, VoiceReply @@ -101,7 +102,7 @@ class WeixinOfficialAccountPlatformEvent(AstrMessageEvent): elif isinstance(comp, Image): img_path = await comp.convert_to_file_path() - with open(img_path, "rb") as f: + async with aiofiles.open(img_path, "rb") as f: try: response = self.client.media.upload("image", f) except Exception as e: @@ -132,7 +133,7 @@ class WeixinOfficialAccountPlatformEvent(AstrMessageEvent): record_path_amr = await convert_audio_to_amr(record_path) try: - with open(record_path_amr, "rb") as f: + async with aiofiles.open(record_path_amr, "rb") as f: try: response = self.client.media.upload("voice", f) except Exception as e: @@ -162,11 +163,12 @@ class WeixinOfficialAccountPlatformEvent(AstrMessageEvent): assert isinstance(future, asyncio.Future) future.set_result(xml) finally: - if record_path_amr != record_path and os.path.exists( - record_path_amr + if ( + record_path_amr != record_path + and await anyio.Path(record_path_amr).exists() ): try: - os.remove(record_path_amr) + await anyio.Path(record_path_amr).unlink() except OSError as e: logger.warning(f"删除临时音频文件失败: {e}") diff --git a/astrbot/core/provider/entities.py b/astrbot/core/provider/entities.py index 20c5a7947..30209de39 100644 --- a/astrbot/core/provider/entities.py +++ b/astrbot/core/provider/entities.py @@ -6,6 +6,7 @@ import json from dataclasses import dataclass, field from typing import Any +import aiofiles from anthropic.types import Message as AnthropicMessage from google.genai.types import GenerateContentResponse from openai.types.chat.chat_completion import ChatCompletion @@ -218,8 +219,8 @@ class ProviderRequest: """将图片转换为 base64""" if image_url.startswith("base64://"): return image_url.replace("base64://", "data:image/jpeg;base64,") - with open(image_url, "rb") as f: - image_bs64 = base64.b64encode(f.read()).decode("utf-8") + async with aiofiles.open(image_url, "rb") as f: + image_bs64 = base64.b64encode(await f.read()).decode("utf-8") return "data:image/jpeg;base64," + image_bs64 return "" diff --git a/astrbot/core/provider/func_tool_manager.py b/astrbot/core/provider/func_tool_manager.py index d9ac26960..cf9a6e6b4 100644 --- a/astrbot/core/provider/func_tool_manager.py +++ b/astrbot/core/provider/func_tool_manager.py @@ -11,7 +11,9 @@ from dataclasses import dataclass from types import MappingProxyType from typing import Any +import aiofiles import aiohttp +import anyio from astrbot import logger from astrbot.core import sp @@ -373,15 +375,20 @@ class FunctionToolManager: data_dir = get_astrbot_data_path() mcp_json_file = os.path.join(data_dir, "mcp_server.json") - if not os.path.exists(mcp_json_file): + mcp_json_path = anyio.Path(mcp_json_file) + if not await mcp_json_path.exists(): # 配置文件不存在错误处理 - with open(mcp_json_file, "w", encoding="utf-8") as f: - json.dump(DEFAULT_MCP_CONFIG, f, ensure_ascii=False, indent=4) + async with aiofiles.open(mcp_json_file, "w", encoding="utf-8") as f: + await f.write( + json.dumps(DEFAULT_MCP_CONFIG, ensure_ascii=False, indent=4) + ) logger.info(f"未找到 MCP 服务配置文件,已创建默认配置文件 {mcp_json_file}") return MCPInitSummary(total=0, success=0, failed=[]) - with open(mcp_json_file, encoding="utf-8") as f: - mcp_server_json_obj: dict[str, dict] = json.load(f)["mcpServers"] + async with aiofiles.open(mcp_json_file, encoding="utf-8") as f: + mcp_server_json_obj: dict[str, dict] = json.loads(await f.read())[ + "mcpServers" + ] init_timeout = self._init_timeout_default timeout_display = f"{init_timeout:g}" @@ -403,7 +410,7 @@ class FunctionToolManager: name=name, cfg=cfg, shutdown_event=shutdown_event, - timeout=init_timeout, + init_timeout=init_timeout, ), name=f"mcp-init:{name}", ) @@ -455,7 +462,7 @@ class FunctionToolManager: cfg: dict, *, shutdown_event: asyncio.Event | None = None, - timeout: float, + init_timeout: float, ) -> None: """Initialize MCP server with timeout and register task/event together. @@ -465,7 +472,7 @@ class FunctionToolManager: async with self._runtime_lock: if name in self._mcp_server_runtime or name in self._mcp_starting: logger.warning( - f"Connected to MCP server {name}, ignoring this startup request (timeout={timeout:g})." + f"Connected to MCP server {name}, ignoring this startup request (timeout={init_timeout:g})." ) self._log_safe_mcp_debug_config(cfg) return @@ -478,11 +485,11 @@ class FunctionToolManager: try: mcp_client = await asyncio.wait_for( self._init_mcp_client(name, cfg), - timeout=timeout, + timeout=init_timeout, ) except asyncio.TimeoutError as exc: raise MCPInitTimeoutError( - f"Connected to MCP server {name} timeout ({timeout:g} seconds)" + f"Connected to MCP server {name} timeout ({init_timeout:g} seconds)" ) from exc except Exception: logger.error(f"Failed to initialize MCP client {name}", exc_info=True) @@ -515,7 +522,7 @@ class FunctionToolManager: async def _shutdown_runtimes( self, runtimes: list[_MCPServerRuntime], - timeout: float, + shutdown_timeout: float, *, strict: bool = True, ) -> list[str]: @@ -534,7 +541,7 @@ class FunctionToolManager: try: results = await asyncio.wait_for( asyncio.gather(*lifecycle_tasks, return_exceptions=True), - timeout=timeout, + timeout=shutdown_timeout, ) except asyncio.TimeoutError: pending_names = [ @@ -547,10 +554,10 @@ class FunctionToolManager: task.cancel() await asyncio.gather(*lifecycle_tasks, return_exceptions=True) if strict: - raise MCPShutdownTimeoutError(pending_names, timeout) + raise MCPShutdownTimeoutError(pending_names, shutdown_timeout) logger.warning( "MCP server shutdown timeout (%s seconds), the following servers were not fully closed: %s", - f"{timeout:g}", + f"{shutdown_timeout:g}", ", ".join(pending_names), ) return pending_names @@ -663,7 +670,7 @@ class FunctionToolManager: name: str, config: dict, shutdown_event: asyncio.Event | None = None, - timeout: float | int | str | None = None, + init_timeout: float | int | str | None = None, ) -> None: """Enable a new MCP server and initialize it. @@ -671,18 +678,18 @@ class FunctionToolManager: name: The name of the MCP server. config: Configuration for the MCP server. shutdown_event: Event to signal when the MCP client should shut down. - timeout: Timeout in seconds for initialization. + init_timeout: Timeout in seconds for initialization. Uses ASTRBOT_MCP_ENABLE_TIMEOUT by default (separate from init timeout). Raises: MCPInitTimeoutError: If initialization does not complete within timeout. Exception: If there is an error during initialization. """ - if timeout is None: + if init_timeout is None: timeout_value = self._enable_timeout_default else: timeout_value = _resolve_timeout( - timeout=timeout, + timeout=init_timeout, env_name=ENABLE_MCP_TIMEOUT_ENV, default=self._enable_timeout_default, ) @@ -690,19 +697,19 @@ class FunctionToolManager: name=name, cfg=config, shutdown_event=shutdown_event, - timeout=timeout_value, + init_timeout=timeout_value, ) async def disable_mcp_server( self, name: str | None = None, - timeout: float = 10, + shutdown_timeout: float = 10, ) -> None: """Disable an MCP server by its name. Args: name (str): The name of the MCP server to disable. If None, ALL MCP servers will be disabled. - timeout (int): Timeout. + shutdown_timeout (int): Timeout. Raises: MCPShutdownTimeoutError: If shutdown does not complete within timeout. @@ -715,11 +722,11 @@ class FunctionToolManager: if runtime is None: return - await self._shutdown_runtimes([runtime], timeout, strict=True) + await self._shutdown_runtimes([runtime], shutdown_timeout, strict=True) else: async with self._runtime_lock: runtimes = list(self._mcp_server_runtime.values()) - await self._shutdown_runtimes(runtimes, timeout, strict=False) + await self._shutdown_runtimes(runtimes, shutdown_timeout, strict=False) def _warn_on_timeout_mismatch( self, diff --git a/astrbot/core/provider/provider.py b/astrbot/core/provider/provider.py index 345ad7b74..7adee48f7 100644 --- a/astrbot/core/provider/provider.py +++ b/astrbot/core/provider/provider.py @@ -4,6 +4,9 @@ import os from collections.abc import AsyncGenerator from typing import TypeAlias, Union +import aiofiles +import anyio + from astrbot.core.agent.message import ContentPart, Message from astrbot.core.agent.tool import ToolSet from astrbot.core.provider.entities import ( @@ -188,11 +191,11 @@ class Provider(AbstractProvider): return dicts - async def test(self, timeout: float = 45.0) -> None: - await asyncio.wait_for( - self.text_chat(prompt="REPLY `PONG` ONLY"), - timeout=timeout, - ) + async def test(self, test_timeout: float = 45.0) -> None: + # Use anyio.fail_after to enforce timeout in async context. + # This avoids direct asyncio.wait_for usage inside async functions. + async with anyio.fail_after(test_timeout): + await self.text_chat(prompt="REPLY `PONG` ONLY") class STTProvider(AbstractProvider): @@ -268,8 +271,8 @@ class TTSProvider(AbstractProvider): # 调用原有的 get_audio 方法获取音频文件路径 audio_path = await self.get_audio(accumulated_text) # 读取音频文件内容 - with open(audio_path, "rb") as f: - audio_data = f.read() + async with aiofiles.open(audio_path, "rb") as f: + audio_data = await f.read() await audio_queue.put((accumulated_text, audio_data)) except Exception: # 出错时也要发送 None 结束标记 @@ -284,10 +287,11 @@ class TTSProvider(AbstractProvider): audio_path = await self.get_audio("hi") # 检查生成的音频文件是否有效 - if not os.path.exists(audio_path): + audio_path_obj = anyio.Path(audio_path) + if not await audio_path_obj.exists(): raise Exception("TTS test failed: audio file was not created") - file_size = os.path.getsize(audio_path) + file_size = (await audio_path_obj.stat()).st_size if file_size == 0: raise Exception( "TTS test failed: generated audio file is empty (0 bytes). " diff --git a/astrbot/core/provider/sources/anthropic_source.py b/astrbot/core/provider/sources/anthropic_source.py index be70fdc74..f33aae010 100644 --- a/astrbot/core/provider/sources/anthropic_source.py +++ b/astrbot/core/provider/sources/anthropic_source.py @@ -2,6 +2,7 @@ import base64 import json from collections.abc import AsyncGenerator +import aiofiles import anthropic import httpx from anthropic import AsyncAnthropic @@ -652,8 +653,8 @@ class ProviderAnthropic(Provider): except Exception: mime_type = "image/jpeg" return f"data:{mime_type};base64,{raw_base64}", mime_type - with open(image_url, "rb") as f: - image_bytes = f.read() + async with aiofiles.open(image_url, "rb") as f: + image_bytes = await f.read() mime_type = self._detect_image_mime_type(image_bytes) image_bs64 = base64.b64encode(image_bytes).decode("utf-8") return f"data:{mime_type};base64,{image_bs64}", mime_type diff --git a/astrbot/core/provider/sources/dashscope_tts.py b/astrbot/core/provider/sources/dashscope_tts.py index 15e763f3e..4a26bc96d 100644 --- a/astrbot/core/provider/sources/dashscope_tts.py +++ b/astrbot/core/provider/sources/dashscope_tts.py @@ -4,6 +4,7 @@ import logging import os import uuid +import aiofiles import aiohttp import dashscope from dashscope.audio.tts_v2 import AudioFormat, SpeechSynthesizer @@ -59,8 +60,8 @@ class ProviderDashscopeTTSAPI(TTSProvider): ) path = os.path.join(temp_dir, f"dashscope_tts_{uuid.uuid4()}{ext}") - with open(path, "wb") as f: - f.write(audio_bytes) + async with aiofiles.open(path, "wb") as f: + await f.write(audio_bytes) return path def _call_qwen_tts(self, model: str, text: str): diff --git a/astrbot/core/provider/sources/edge_tts_source.py b/astrbot/core/provider/sources/edge_tts_source.py index 76a19b4d6..afacdc80c 100644 --- a/astrbot/core/provider/sources/edge_tts_source.py +++ b/astrbot/core/provider/sources/edge_tts_source.py @@ -112,8 +112,9 @@ class ProviderEdgeTTS(TTSProvider): f"FFmpeg 转换失败: {e.stderr.decode() if e.stderr else str(e)}", ) try: - if os.path.exists(mp3_path): - os.remove(mp3_path) + mp3_path_obj = anyio.Path(mp3_path) + if await mp3_path_obj.exists(): + await mp3_path_obj.unlink() except Exception: pass raise RuntimeError(f"FFmpeg 转换失败: {e!s}") @@ -121,8 +122,9 @@ class ProviderEdgeTTS(TTSProvider): except Exception as e: logger.error(f"音频生成失败: {e!s}") try: - if os.path.exists(mp3_path): - os.remove(mp3_path) + mp3_path_obj = anyio.Path(mp3_path) + if await mp3_path_obj.exists(): + await mp3_path_obj.unlink() except Exception: pass raise RuntimeError(f"音频生成失败: {e!s}") diff --git a/astrbot/core/provider/sources/fishaudio_tts_api_source.py b/astrbot/core/provider/sources/fishaudio_tts_api_source.py index 35945b7b6..8451a4ded 100644 --- a/astrbot/core/provider/sources/fishaudio_tts_api_source.py +++ b/astrbot/core/provider/sources/fishaudio_tts_api_source.py @@ -3,6 +3,7 @@ import re import uuid from typing import Annotated, Literal +import aiofiles import ormsgpack from httpx import AsyncClient from pydantic import BaseModel, conint @@ -159,9 +160,9 @@ class ProviderFishAudioTTSAPI(TTSProvider): if response.status_code == 200 and response.headers.get( "content-type", "" ).startswith("audio/"): - with open(path, "wb") as f: + async with aiofiles.open(path, "wb") as f: async for chunk in response.aiter_bytes(): - f.write(chunk) + await f.write(chunk) return path error_bytes = await response.aread() error_text = error_bytes.decode("utf-8", errors="replace")[:1024] diff --git a/astrbot/core/provider/sources/gemini_source.py b/astrbot/core/provider/sources/gemini_source.py index 553f2e3d4..efee77376 100644 --- a/astrbot/core/provider/sources/gemini_source.py +++ b/astrbot/core/provider/sources/gemini_source.py @@ -6,6 +6,7 @@ import random from collections.abc import AsyncGenerator from typing import cast +import aiofiles from google import genai from google.genai import types from google.genai.errors import APIError @@ -924,8 +925,8 @@ class ProviderGoogleGenAI(Provider): """将图片转换为 base64""" if image_url.startswith("base64://"): return image_url.replace("base64://", "data:image/jpeg;base64,") - with open(image_url, "rb") as f: - image_bs64 = base64.b64encode(f.read()).decode("utf-8") + async with aiofiles.open(image_url, "rb") as f: + image_bs64 = base64.b64encode(await f.read()).decode("utf-8") return "data:image/jpeg;base64," + image_bs64 async def terminate(self) -> None: diff --git a/astrbot/core/provider/sources/genie_tts.py b/astrbot/core/provider/sources/genie_tts.py index b76bf6b46..1e78982ea 100644 --- a/astrbot/core/provider/sources/genie_tts.py +++ b/astrbot/core/provider/sources/genie_tts.py @@ -2,6 +2,9 @@ import asyncio import os import uuid +import aiofiles +import anyio + from astrbot.core import logger from astrbot.core.provider.entities import ProviderType from astrbot.core.provider.provider import TTSProvider @@ -72,7 +75,8 @@ class GenieTTSProvider(TTSProvider): try: await loop.run_in_executor(None, _generate, path) - if os.path.exists(path): + path_obj = anyio.Path(path) + if await path_obj.exists(): return path raise RuntimeError("Genie TTS did not save to file.") @@ -109,16 +113,17 @@ class GenieTTSProvider(TTSProvider): await loop.run_in_executor(None, _generate, path, text) - if os.path.exists(path): - with open(path, "rb") as f: - audio_data = f.read() + path_obj = anyio.Path(path) + if await path_obj.exists(): + async with aiofiles.open(path, "rb") as f: + audio_data = await f.read() # Put (text, bytes) into queue so frontend can display text await audio_queue.put((text, audio_data)) # Clean up try: - os.remove(path) + await path_obj.unlink() except OSError: pass else: diff --git a/astrbot/core/provider/sources/gsv_selfhosted_source.py b/astrbot/core/provider/sources/gsv_selfhosted_source.py index fc8bccea8..be45c6cf7 100644 --- a/astrbot/core/provider/sources/gsv_selfhosted_source.py +++ b/astrbot/core/provider/sources/gsv_selfhosted_source.py @@ -2,6 +2,7 @@ import asyncio import os import uuid +import aiofiles import aiohttp from astrbot import logger @@ -129,8 +130,8 @@ class ProviderGSVTTS(TTSProvider): result = await self._make_request(endpoint, params) if isinstance(result, bytes): - with open(path, "wb") as f: - f.write(result) + async with aiofiles.open(path, "wb") as f: + await f.write(result) return path raise Exception(f"[GSV TTS] 合成失败,输入文本:{text},错误信息:{result}") diff --git a/astrbot/core/provider/sources/gsvi_tts_source.py b/astrbot/core/provider/sources/gsvi_tts_source.py index 425e801f4..9737d7360 100644 --- a/astrbot/core/provider/sources/gsvi_tts_source.py +++ b/astrbot/core/provider/sources/gsvi_tts_source.py @@ -2,6 +2,7 @@ import os import urllib.parse import uuid +import aiofiles import aiohttp from astrbot.core.utils.astrbot_path import get_astrbot_temp_path @@ -48,8 +49,8 @@ class ProviderGSVITTS(TTSProvider): async with aiohttp.ClientSession() as session: async with session.get(url) as response: if response.status == 200: - with open(path, "wb") as f: - f.write(await response.read()) + async with aiofiles.open(path, "wb") as f: + await f.write(await response.read()) else: error_text = await response.text() raise Exception( diff --git a/astrbot/core/provider/sources/minimax_tts_api_source.py b/astrbot/core/provider/sources/minimax_tts_api_source.py index f40cb968a..44b9ba7a6 100644 --- a/astrbot/core/provider/sources/minimax_tts_api_source.py +++ b/astrbot/core/provider/sources/minimax_tts_api_source.py @@ -3,6 +3,7 @@ import os import uuid from collections.abc import AsyncIterator +import aiofiles import aiohttp from astrbot.api import logger @@ -163,8 +164,8 @@ class ProviderMiniMaxTTSAPI(TTSProvider): ) # 结果保存至文件 - with open(path, "wb") as file: - file.write(audio) + async with aiofiles.open(path, "wb") as file: + await file.write(audio) return path diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index 2fae94e1a..ae39c49ed 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -7,6 +7,7 @@ import re from collections.abc import AsyncGenerator from typing import Any +import aiofiles import httpx from openai import AsyncAzureOpenAI, AsyncOpenAI from openai._exceptions import NotFoundError @@ -950,8 +951,8 @@ class ProviderOpenAIOfficial(Provider): """将图片转换为 base64""" if image_url.startswith("base64://"): return image_url.replace("base64://", "data:image/jpeg;base64,") - with open(image_url, "rb") as f: - image_bs64 = base64.b64encode(f.read()).decode("utf-8") + async with aiofiles.open(image_url, "rb") as f: + image_bs64 = base64.b64encode(await f.read()).decode("utf-8") return "data:image/jpeg;base64," + image_bs64 async def terminate(self): diff --git a/astrbot/core/provider/sources/openai_tts_api_source.py b/astrbot/core/provider/sources/openai_tts_api_source.py index 217b18925..8e0c00bd9 100644 --- a/astrbot/core/provider/sources/openai_tts_api_source.py +++ b/astrbot/core/provider/sources/openai_tts_api_source.py @@ -1,6 +1,7 @@ import os import uuid +import aiofiles import httpx from openai import NOT_GIVEN, AsyncOpenAI @@ -54,9 +55,9 @@ class ProviderOpenAITTSAPI(TTSProvider): response_format="wav", input=text, ) as response: - with open(path, "wb") as f: + async with aiofiles.open(path, "wb") as f: async for chunk in response.iter_bytes(chunk_size=1024): - f.write(chunk) + await f.write(chunk) return path async def terminate(self): diff --git a/astrbot/core/provider/sources/sensevoice_selfhosted_source.py b/astrbot/core/provider/sources/sensevoice_selfhosted_source.py index d41ebaf62..4efeaae8e 100644 --- a/astrbot/core/provider/sources/sensevoice_selfhosted_source.py +++ b/astrbot/core/provider/sources/sensevoice_selfhosted_source.py @@ -4,12 +4,11 @@ LastEditTime: 2025-02-25 14:06:30 """ import asyncio -import os import re from datetime import datetime -from pathlib import Path from typing import cast +import anyio from funasr_onnx import SenseVoiceSmall from funasr_onnx.utils.postprocess_utils import rich_transcription_postprocess @@ -52,14 +51,14 @@ class ProviderSenseVoiceSTTSelfHost(STTProvider): async def get_timestamped_path(self) -> str: timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - temp_dir = Path(get_astrbot_temp_path()) - temp_dir.mkdir(parents=True, exist_ok=True) + temp_dir = anyio.Path(get_astrbot_temp_path()) + await temp_dir.mkdir(parents=True, exist_ok=True) return str(temp_dir / timestamp) async def _is_silk_file(self, file_path) -> bool: silk_header = b"SILK" - with open(file_path, "rb") as f: - file_header = f.read(8) + async with anyio.open_file(file_path, "rb") as f: + file_header = await f.read(8) if silk_header in file_header: return True @@ -76,7 +75,7 @@ class ProviderSenseVoiceSTTSelfHost(STTProvider): await download_file(audio_url, path) audio_url = path - if not os.path.isfile(audio_url): + if not await anyio.Path(audio_url).is_file(): raise FileNotFoundError(f"文件不存在: {audio_url}") if audio_url.endswith((".amr", ".silk")) or is_tencent: diff --git a/astrbot/core/provider/sources/whisper_api_source.py b/astrbot/core/provider/sources/whisper_api_source.py index 386da063d..d3aae9ff3 100644 --- a/astrbot/core/provider/sources/whisper_api_source.py +++ b/astrbot/core/provider/sources/whisper_api_source.py @@ -1,6 +1,7 @@ import os import uuid +import anyio from openai import NOT_GIVEN, AsyncOpenAI from astrbot.core import logger @@ -16,6 +17,10 @@ from ..provider import STTProvider from ..register import register_provider_adapter +def _open_file_rb(path: str): + return open(path, "rb") + + @register_provider_adapter( "openai_whisper_api", "OpenAI Whisper API", @@ -44,8 +49,8 @@ class ProviderOpenAIWhisperAPI(STTProvider): amr_header = b"#!AMR" try: - with open(file_path, "rb") as f: - file_header = f.read(8) + async with anyio.open_file(file_path, "rb") as f: + file_header = await f.read(8) except FileNotFoundError: return None @@ -73,7 +78,7 @@ class ProviderOpenAIWhisperAPI(STTProvider): await download_file(audio_url, path) audio_url = path - if not os.path.exists(audio_url): + if not await anyio.Path(audio_url).exists(): raise FileNotFoundError(f"文件不存在: {audio_url}") if audio_url.endswith(".amr") or audio_url.endswith(".silk") or is_tencent: @@ -100,15 +105,17 @@ class ProviderOpenAIWhisperAPI(STTProvider): audio_url = output_path + file_obj = await anyio.to_thread.run_sync(_open_file_rb, audio_url) result = await self.client.audio.transcriptions.create( model=self.model_name, - file=("audio.wav", open(audio_url, "rb")), + file=("audio.wav", file_obj), ) + file_obj.close() # remove temp file - if output_path and os.path.exists(output_path): + if output_path and await anyio.Path(output_path).exists(): try: - os.remove(audio_url) + await anyio.Path(audio_url).unlink() except Exception as e: logger.error(f"Failed to remove temp file {audio_url}: {e}") return result.text diff --git a/astrbot/core/provider/sources/whisper_selfhosted_source.py b/astrbot/core/provider/sources/whisper_selfhosted_source.py index 519a64de6..75205cc52 100644 --- a/astrbot/core/provider/sources/whisper_selfhosted_source.py +++ b/astrbot/core/provider/sources/whisper_selfhosted_source.py @@ -3,6 +3,7 @@ import os import uuid from typing import cast +import anyio import whisper from astrbot.core import logger @@ -42,8 +43,8 @@ class ProviderOpenAIWhisperSelfHost(STTProvider): async def _is_silk_file(self, file_path) -> bool: silk_header = b"SILK" - with open(file_path, "rb") as f: - file_header = f.read(8) + async with anyio.open_file(file_path, "rb") as f: + file_header = await f.read(8) if silk_header in file_header: return True @@ -66,7 +67,7 @@ class ProviderOpenAIWhisperSelfHost(STTProvider): await download_file(audio_url, path) audio_url = path - if not os.path.exists(audio_url): + if not await anyio.Path(audio_url).exists(): raise FileNotFoundError(f"文件不存在: {audio_url}") if audio_url.endswith(".amr") or audio_url.endswith(".silk") or is_tencent: diff --git a/astrbot/core/provider/sources/xinference_stt_provider.py b/astrbot/core/provider/sources/xinference_stt_provider.py index 0a22e456e..8dd997593 100644 --- a/astrbot/core/provider/sources/xinference_stt_provider.py +++ b/astrbot/core/provider/sources/xinference_stt_provider.py @@ -1,7 +1,8 @@ -import os import uuid +import aiofiles import aiohttp +import anyio from xinference_client.client.restful.async_restful_client import ( AsyncClient as Client, ) @@ -102,9 +103,9 @@ class ProviderXinferenceSTT(STTProvider): f"Failed to download audio from {audio_url}, status: {resp.status}", ) return "" - elif os.path.exists(audio_url): - with open(audio_url, "rb") as f: - audio_bytes = f.read() + elif await anyio.Path(audio_url).exists(): + async with aiofiles.open(audio_url, "rb") as f: + audio_bytes = await f.read() else: logger.error(f"File not found: {audio_url}") return "" @@ -130,21 +131,19 @@ class ProviderXinferenceSTT(STTProvider): logger.info( f"Audio requires conversion ({conversion_type}), using temporary files..." ) - temp_dir = get_astrbot_temp_path() - os.makedirs(temp_dir, exist_ok=True) + temp_dir = anyio.Path(get_astrbot_temp_path()) + await temp_dir.mkdir(parents=True, exist_ok=True) - input_path = os.path.join( - temp_dir, - f"xinference_stt_{uuid.uuid4().hex[:8]}.input", + input_path = str( + temp_dir / f"xinference_stt_{uuid.uuid4().hex[:8]}.input" ) - output_path = os.path.join( - temp_dir, - f"xinference_stt_{uuid.uuid4().hex[:8]}.wav", + output_path = str( + temp_dir / f"xinference_stt_{uuid.uuid4().hex[:8]}.wav" ) temp_files.extend([input_path, output_path]) - with open(input_path, "wb") as f: - f.write(audio_bytes) + async with aiofiles.open(input_path, "wb") as f: + await f.write(audio_bytes) if conversion_type == "silk": logger.info("Converting silk to wav ...") @@ -153,8 +152,8 @@ class ProviderXinferenceSTT(STTProvider): logger.info("Converting amr to wav ...") await convert_to_pcm_wav(input_path, output_path) - with open(output_path, "rb") as f: - audio_bytes = f.read() + async with aiofiles.open(output_path, "rb") as f: + audio_bytes = await f.read() # 4. Transcribe # 官方asyncCLient的客户端似乎实现有点问题,这里直接用aiohttp实现openai标准兼容请求,提交issue等待官方修复后再改回来 @@ -199,8 +198,9 @@ class ProviderXinferenceSTT(STTProvider): # 5. Cleanup for temp_file in temp_files: try: - if os.path.exists(temp_file): - os.remove(temp_file) + temp_path = anyio.Path(temp_file) + if await temp_path.exists(): + await temp_path.unlink() logger.debug(f"Removed temporary file: {temp_file}") except Exception as e: logger.error(f"Failed to remove temporary file {temp_file}: {e}") diff --git a/astrbot/dashboard/server.py b/astrbot/dashboard/server.py index 8e0868ad1..d815d008a 100644 --- a/astrbot/dashboard/server.py +++ b/astrbot/dashboard/server.py @@ -10,6 +10,7 @@ from ipaddress import IPv4Address, IPv6Address, ip_address from pathlib import Path from typing import Protocol +import anyio import jwt import psutil import werkzeug.exceptions @@ -115,18 +116,23 @@ class AstrBotDashboard: self.data_path = os.path.abspath(user_dist) if self.enable_webui and not (Path(self.data_path) / "index.html").exists(): - raise RuntimeError( + logger.warning( f"Dashboard static assets not found: index.html is missing in {self.data_path}. " - "Please run the WebUI build step." + "WebUI will be disabled." ) + self.enable_webui = False def _init_app(self): """初始化 Quart 应用""" global APP + + static_folder = self.data_path if self.enable_webui else None + static_url_path = "/" if self.enable_webui else None + self.app = Quart( "AstrBotDashboard", - static_folder=self.data_path, - static_url_path="/", + static_folder=static_folder, + static_url_path=static_url_path, ) APP = self.app self.app.json_provider_class = DefaultJSONProvider @@ -456,25 +462,25 @@ class AstrBotDashboard: or ssl_config.get("ca_certs", "") ) - cert_path = Path(cert_file).expanduser() - key_path = Path(key_file).expanduser() + cert_path = anyio.Path(cert_file).expanduser() + key_path = anyio.Path(key_file).expanduser() if not cert_file or not key_file: raise ValueError( "dashboard.ssl.enable 为 true 时,必须配置 cert_file 和 key_file。", ) - if not cert_path.is_file(): + if not await cert_path.is_file(): raise ValueError(f"SSL 证书文件不存在: {cert_path}") - if not key_path.is_file(): + if not await key_path.is_file(): raise ValueError(f"SSL 私钥文件不存在: {key_path}") - config.certfile = str(cert_path.resolve()) - config.keyfile = str(key_path.resolve()) + config.certfile = str(await cert_path.resolve()) + config.keyfile = str(await key_path.resolve()) if ca_certs: - ca_path = Path(ca_certs).expanduser() - if not ca_path.is_file(): + ca_path = anyio.Path(ca_certs).expanduser() + if not await ca_path.is_file(): raise ValueError(f"SSL CA 证书文件不存在: {ca_path}") - config.ca_certs = str(ca_path.resolve()) + config.ca_certs = str(await ca_path.resolve()) # 根据配置决定是否禁用访问日志 disable_access_log = dashboard_config.get("disable_access_log", True)