refactor: miscellaneous updates across modules

This commit is contained in:
LIghtJUNction
2026-05-21 19:10:02 +08:00
parent 01aafc21dd
commit 4098303658
82 changed files with 509 additions and 482 deletions
+3 -3
View File
@@ -229,9 +229,9 @@ def init(
click.echo("首次使用前请先设置管理员密码:")
click.echo(" astrbot conf admin")
click.echo()
except Timeout:
except Timeout as err:
raise click.ClickException(
"Cannot acquire lock file. Please check if another instance is running",
)
) from err
except Exception as e:
raise click.ClickException(f"Initialization failed: {e!s}")
raise click.ClickException(f"Initialization failed: {e!s}") from e
+1 -1
View File
@@ -184,7 +184,7 @@ def remove(name: str) -> None:
except Exception as e:
raise click.ClickException(
t("plugin_uninstall_failed_ex", name=name, error=str(e)),
)
) from e
@plug.command()
+1 -1
View File
@@ -361,7 +361,7 @@ def run(
nl=False,
)
click.echo(f" {message}")
except asyncio.TimeoutError:
except TimeoutError:
continue
except asyncio.CancelledError:
break
+1 -1
View File
@@ -254,4 +254,4 @@ def manage_plugin(
shutil.move(backup_path, target_path)
raise click.ClickException(
f"Error {'updating' if is_update else 'installing'} plugin {plugin_name}: {e}",
)
) from e
+1 -1
View File
@@ -156,7 +156,7 @@ async def _quick_test_mcp_connection(config: dict) -> tuple[bool, str]:
return True, ""
return False, f"HTTP {response.status}: {response.reason}"
except asyncio.TimeoutError:
except TimeoutError:
return False, f"Connection timeout: {timeout} seconds"
except Exception as e:
return False, f"{e!s}"
+1 -2
View File
@@ -1,7 +1,7 @@
# Inspired by MoonshotAI/kosong, credits to MoonshotAI/kosong authors for the original implementation.
# License: Apache License 2.0
from typing import Any, ClassVar, Literal, TypeVar, cast
from typing import Any, ClassVar, Literal, Self, TypeVar, cast
from pydantic import (
BaseModel,
@@ -12,7 +12,6 @@ from pydantic import (
model_validator,
)
from pydantic_core import core_schema
from typing_extensions import Self
ContentPartT = TypeVar("ContentPartT", bound="ContentPart")
@@ -1,12 +1,12 @@
import base64
import json
import sys
from typing import Any
from typing import Any, override
import astrbot.core.message.components as Comp
from astrbot import logger
from astrbot.core import sp
from astrbot.core.agent.hooks import BaseAgentRunHooks
from astrbot.core.agent.message import is_checkpoint_message
from astrbot.core.agent.response import AgentResponse, AgentResponseData
from astrbot.core.agent.run_context import ContextWrapper, TContext
from astrbot.core.agent.runners.base import AgentState, BaseAgentRunner
@@ -18,18 +18,8 @@ from astrbot.core.provider.entities import (
)
from astrbot.core.provider.provider import Provider
from ...hooks import BaseAgentRunHooks
from ...message import is_checkpoint_message
from ...response import AgentResponseData
from ...run_context import ContextWrapper, TContext
from ..base import AgentResponse, AgentState, BaseAgentRunner
from .coze_api_client import CozeAPIClient
if sys.version_info >= (3, 12):
from typing import override
else:
from typing_extensions import override
class CozeAgentRunner(BaseAgentRunner[TContext]):
"""Coze Agent Runner"""
@@ -375,7 +365,7 @@ class CozeAgentRunner(BaseAgentRunner[TContext]):
except Exception as e:
logger.error(f"处理图片失败 {image_url}: {e!s}")
raise Exception(f"处理图片失败: {e!s}")
raise Exception(f"处理图片失败: {e!s}") from e
@override
def done(self) -> bool:
@@ -81,7 +81,7 @@ class CozeAPIClient:
try:
result = await response.json()
except json.JSONDecodeError:
raise Exception(f"文件上传响应解析失败: {response_text}")
raise Exception(f"文件上传响应解析失败: {response_text}") from None
if result.get("code") != 0:
raise Exception(f"文件上传失败: {result.get('msg', '未知错误')}")
@@ -90,12 +90,12 @@ class CozeAPIClient:
logger.debug(f"[Coze] 图片上传成功,file_id: {file_id}")
return file_id
except asyncio.TimeoutError:
except TimeoutError:
logger.error("文件上传超时")
raise Exception("文件上传超时")
raise Exception("文件上传超时") from None
except Exception as e:
logger.error(f"文件上传失败: {e!s}")
raise Exception(f"文件上传失败: {e!s}")
raise Exception(f"文件上传失败: {e!s}") from e
async def download_image(self, image_url: str) -> bytes:
"""下载图片并返回字节数据
@@ -118,7 +118,7 @@ class CozeAPIClient:
except Exception as e:
logger.error(f"下载图片失败 {image_url}: {e!s}")
raise Exception(f"下载图片失败: {e!s}")
raise Exception(f"下载图片失败: {e!s}") from e
async def chat_messages(
self,
@@ -203,10 +203,10 @@ class CozeAPIClient:
except json.JSONDecodeError:
event_data = {"content": data_str}
except asyncio.TimeoutError:
raise Exception(f"Coze API 流式请求超时 ({timeout}秒)")
except TimeoutError:
raise Exception(f"Coze API 流式请求超时 ({timeout}秒)") from None
except Exception as e:
raise Exception(f"Coze API 流式请求失败: {e!s}")
raise Exception(f"Coze API 流式请求失败: {e!s}") from e
async def clear_context(self, conversation_id: str):
"""清空会话上下文
@@ -234,12 +234,12 @@ class CozeAPIClient:
try:
return json.loads(response_text)
except json.JSONDecodeError:
raise Exception("Coze API 返回非JSON格式")
raise Exception("Coze API 返回非JSON格式") from None
except asyncio.TimeoutError:
raise Exception("Coze API 请求超时")
except TimeoutError:
raise Exception("Coze API 请求超时") from None
except aiohttp.ClientError as e:
raise Exception(f"Coze API 请求失败: {e!s}")
raise Exception(f"Coze API 请求失败: {e!s}") from e
async def get_message_list(
self,
@@ -275,7 +275,7 @@ class CozeAPIClient:
except Exception as e:
logger.error(f"获取Coze消息列表失败: {e!s}")
raise Exception(f"获取Coze消息列表失败: {e!s}")
raise Exception(f"获取Coze消息列表失败: {e!s}") from e
async def close(self) -> None:
"""关闭会话"""
@@ -299,7 +299,7 @@ if __name__ == "__main__":
async with await anyio.open_file("README.md", "rb") as f:
file_data = await f.read()
file_id = await client.upload_file(file_data)
async for event in client.chat_messages(
async for _event in client.chat_messages(
bot_id=bot_id,
user_id="test_user",
additional_messages=[
@@ -2,10 +2,9 @@ import asyncio
import functools
import queue
import re
import sys
import threading
from collections.abc import AsyncGenerator
from typing import Any
from typing import Any, override
from dashscope import Application
from dashscope.app.application_response import ApplicationResponse
@@ -24,11 +23,6 @@ from astrbot.core.provider.entities import (
)
from astrbot.core.provider.provider import Provider
if sys.version_info >= (3, 12):
from typing import override
else:
from typing_extensions import override
class DashscopeAgentRunner(BaseAgentRunner[TContext]):
"""Dashscope Agent Runner"""
@@ -1,11 +1,10 @@
import asyncio
import hashlib
import json
import sys
import typing as T
from collections import deque
from dataclasses import dataclass, field
from typing import Any
from typing import Any, override
from uuid import uuid4
import astrbot.core.message.components as Comp
@@ -43,11 +42,6 @@ from .deerflow_stream_utils import (
get_message_id,
)
if sys.version_info >= (3, 12):
from typing import override
else:
from typing_extensions import override
class DeerFlowAgentRunner(BaseAgentRunner[TContext]):
"""DeerFlow Agent Runner via LangGraph HTTP API."""
@@ -687,7 +681,7 @@ class DeerFlowAgentRunner(BaseAgentRunner[TContext]):
if event_type == "end":
break
except (asyncio.TimeoutError, TimeoutError):
except TimeoutError:
logger.warning(
"DeerFlow stream timed out after %ss for thread_id=%s; returning partial result.",
self.timeout,
@@ -2,10 +2,9 @@ import codecs
import json
import types
from collections.abc import AsyncGenerator
from typing import Any
from typing import Any, Self
from aiohttp import ClientResponse, ClientSession, ClientTimeout
from typing_extensions import Self
from astrbot.core import logger
@@ -1,7 +1,6 @@
import base64
import os
import sys
from typing import Any
from typing import Any, override
import astrbot.core.message.components as Comp
from astrbot.core import logger, sp
@@ -20,11 +19,6 @@ from astrbot.core.provider.provider import Provider
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
from astrbot.core.utils.io import download_file
if sys.version_info >= (3, 12):
from typing import override
else:
from typing_extensions import override
class DifyAgentRunner(BaseAgentRunner[TContext]):
"""Dify Agent Runner"""
@@ -1,6 +1,5 @@
import asyncio
import copy
import sys
import time
import traceback
import typing as T
@@ -8,8 +7,9 @@ import uuid
from collections.abc import AsyncIterator
from contextlib import suppress
from dataclasses import dataclass, field, replace
from pathlib import Path
from typing import override
import anyio
from mcp.types import (
BlobResourceContents,
CallToolResult,
@@ -26,8 +26,24 @@ from tenacity import (
)
from astrbot import logger
from astrbot.core.agent.message import ImageURLPart, TextPart, ThinkPart
from astrbot.core.agent.context.config import ContextConfig
from astrbot.core.agent.context.manager import ContextManager
from astrbot.core.agent.context.token_counter import EstimateTokenCounter
from astrbot.core.agent.hooks import BaseAgentRunHooks
from astrbot.core.agent.message import (
AssistantMessageSegment,
ImageURLPart,
Message,
TextPart,
ThinkPart,
ToolCallMessageSegment,
bind_checkpoint_messages,
)
from astrbot.core.agent.response import AgentResponseData, AgentStats
from astrbot.core.agent.run_context import ContextWrapper, TContext
from astrbot.core.agent.runners.base import AgentResponse, AgentState, BaseAgentRunner
from astrbot.core.agent.tool import FunctionTool, ToolSet
from astrbot.core.agent.tool_executor import BaseFunctionToolExecutor
from astrbot.core.agent.tool_image_cache import tool_image_cache
from astrbot.core.exceptions import EmptyModelOutputError
from astrbot.core.message.components import Json
@@ -48,26 +64,6 @@ from astrbot.core.provider.modalities import (
)
from astrbot.core.provider.provider import Provider
from ..context.config import ContextConfig
from ..context.manager import ContextManager
from ..context.token_counter import EstimateTokenCounter
from ..hooks import BaseAgentRunHooks
from ..message import (
AssistantMessageSegment,
Message,
ToolCallMessageSegment,
bind_checkpoint_messages,
)
from ..response import AgentResponseData, AgentStats
from ..run_context import ContextWrapper, TContext
from ..tool_executor import BaseFunctionToolExecutor
from .base import AgentResponse, AgentState, BaseAgentRunner
if sys.version_info >= (3, 12):
from typing import override
else:
from typing_extensions import override
@dataclass(slots=True)
class _HandleFunctionToolsResult:
@@ -373,7 +369,9 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
if self.tool_result_overflow_dir is None:
raise ValueError("tool_result_overflow_dir is not configured")
overflow_dir = Path(self.tool_result_overflow_dir).resolve(strict=False)
overflow_dir = await anyio.Path(
self.tool_result_overflow_dir,
).resolve(strict=False)
safe_tool_call_id = (
"".join(
ch if ch.isalnum() or ch in {"-", "_", "."} else "_"
@@ -983,6 +981,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
llm_response.tools_call_name,
llm_response.tools_call_args,
llm_response.tools_call_ids,
strict=False,
):
tool_call_streak = self._track_tool_call_streak(func_tool_name)
yield _HandleFunctionToolsResult.from_message_chain(
+1 -1
View File
@@ -680,7 +680,7 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
),
],
)
except asyncio.TimeoutError:
except TimeoutError:
raise Exception(
f"tool {tool.name} execution timeout after {tool_call_timeout or run_context.tool_call_timeout} seconds.",
) from None
+2 -2
View File
@@ -802,10 +802,10 @@ async def _process_quote_message(
if (
compress_path
and compress_path != path
and os.path.exists(compress_path)
and await asyncio.to_thread(os.path.exists, compress_path)
):
try:
os.remove(compress_path)
await asyncio.to_thread(os.remove, compress_path)
except Exception as exc: # noqa: BLE001
logger.warning("Fail to remove temporary compressed image: %s", exc)
+1 -1
View File
@@ -470,7 +470,7 @@ class AstrBotExporter:
media_files: list[str] = []
media_dir = kb_helper.kb_medias_dir
if media_dir.exists():
for root, _, files in os.walk(media_dir):
for _root, _, files in os.walk(media_dir):
for file in files:
media_files.append(file)
if media_files:
+8 -4
View File
@@ -797,8 +797,10 @@ class AstrBotImporter:
logger.warning(f"媒体文件路径越界,已跳过: {target_path}")
continue
target_path.parent.mkdir(parents=True, exist_ok=True)
with zf.open(name) as src, open(target_path, "wb") as dst:
dst.write(src.read())
with zf.open(name) as src:
content = src.read()
async with await anyio.open_file(target_path, "wb") as dst:
await dst.write(content)
except Exception as e:
result.add_warning(f"导入媒体文件 {name} 失败: {e}")
@@ -864,8 +866,10 @@ class AstrBotImporter:
continue
target_path.parent.mkdir(parents=True, exist_ok=True)
with zf.open(name) as src, open(target_path, "wb") as dst:
dst.write(src.read())
with zf.open(name) as src:
content = src.read()
async with await anyio.open_file(target_path, "wb") as dst:
await dst.write(content)
count += 1
except Exception as e:
logger.warning(f"导入附件 {name} 失败: {e}")
+2
View File
@@ -48,8 +48,10 @@ class ComputerBooter(abc.ABC):
def gui(self) -> GUIComponent | None:
return None
@abc.abstractmethod
async def boot(self, session_id: str) -> None: ...
@abc.abstractmethod
async def shutdown(self, **kwargs) -> None:
"""Shut down the computer sandbox.
+1 -1
View File
@@ -103,7 +103,7 @@ class MockShipyardSandboxClient:
"error": f"Connection error: {e!s}",
"message": "File upload failed",
}
except asyncio.TimeoutError:
except TimeoutError:
logger.warning(
"[Computer] file_upload_failed booter=boxlite error=timeout remote_path=%s",
remote_path,
+29 -12
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import asyncio
import base64
import inspect
import shlex
@@ -8,11 +9,20 @@ from pathlib import Path
from typing import Any
from astrbot.api import logger
from ..olayer import FileSystemComponent, GUIComponent, PythonComponent, ShellComponent
from .base import ComputerBooter
from .cua_defaults import CUA_CONFIG_KEYS, CUA_DEFAULT_CONFIG
from .shipyard_search_file_util import search_files_via_shell
from astrbot.core.computer.booters.base import ComputerBooter
from astrbot.core.computer.booters.cua_defaults import (
CUA_CONFIG_KEYS,
CUA_DEFAULT_CONFIG,
)
from astrbot.core.computer.booters.shipyard_search_file_util import (
search_files_via_shell,
)
from astrbot.core.computer.olayer import (
FileSystemComponent,
GUIComponent,
PythonComponent,
ShellComponent,
)
_POSIX_OS_TYPES = {"linux", "darwin", "macos"}
@@ -646,8 +656,9 @@ class CuaGUIComponent(GUIComponent):
raw = await self._sandbox.screenshot()
data = _screenshot_to_bytes(raw)
if path:
Path(path).parent.mkdir(parents=True, exist_ok=True)
Path(path).write_bytes(data)
_p = Path(path)
await asyncio.to_thread(_p.parent.mkdir, parents=True, exist_ok=True)
await asyncio.to_thread(_p.write_bytes, data)
return {
"success": True,
"path": path,
@@ -846,7 +857,7 @@ class CuaBooter(ComputerBooter):
async def upload_file(self, path: str, file_name: str) -> dict:
local_path = Path(path)
if not local_path.is_file():
if not await asyncio.to_thread(local_path.is_file):
return {"success": False, "error": f"File not found: {path}"}
sandbox = None if self._runtime is None else self._runtime.sandbox
if sandbox is not None and hasattr(sandbox, "upload_file"):
@@ -860,14 +871,16 @@ class CuaBooter(ComputerBooter):
return _normalize_native_upload_result(result, file_name)
write_bytes = _resolve_files_method(files_components, "write_bytes")
if write_bytes is not None:
result = await _maybe_await(write_bytes(file_name, local_path.read_bytes()))
data = await asyncio.to_thread(local_path.read_bytes)
result = await _maybe_await(write_bytes(file_name, data))
return _normalize_native_upload_result(result, file_name)
if not _is_posix_os_type(self.os_type):
return _non_posix_filesystem_result(file_name, self.os_type)
data = await asyncio.to_thread(local_path.read_bytes)
result = await _write_base64_via_shell(
self.shell,
file_name,
local_path.read_bytes(),
data,
)
return {
"success": not bool(result.get("stderr")),
@@ -885,8 +898,12 @@ class CuaBooter(ComputerBooter):
result = await self.shell.exec(f"base64 {shlex.quote(remote_path)}")
if result.get("stderr"):
raise RuntimeError(result["stderr"])
Path(local_path).parent.mkdir(parents=True, exist_ok=True)
Path(local_path).write_bytes(base64.b64decode(result.get("stdout", "")))
_p = Path(local_path)
await asyncio.to_thread(_p.parent.mkdir, parents=True, exist_ok=True)
await asyncio.to_thread(
_p.write_bytes,
base64.b64decode(result.get("stdout", "")),
)
async def available(self) -> bool:
return self._runtime is not None
@@ -23,7 +23,7 @@ from astrbot.core.computer.olayer import (
from .shell_background import build_detached_shell_command
try:
from shipyard_neo import BayClient
from shipyard_neo import BayClient # noqa: F401
from shipyard_neo.sandbox import Sandbox
except ImportError:
logger.warning(
@@ -51,7 +51,7 @@ class NeoPythonComponent(PythonComponent):
self,
code: str,
kernel_id: str | None = None,
timeout: int = 30,
timeout: int = 30, # noqa: ASYNC109
silent: bool = False,
) -> dict[str, Any]:
_ = kernel_id
@@ -88,7 +88,7 @@ class NeoShellComponent(ShellComponent):
command: str,
cwd: str | None = None,
env: dict[str, str] | None = None,
timeout: int | None = 300,
timeout: int | None = 300, # noqa: ASYNC109
shell: bool = True,
background: bool = False,
) -> dict[str, Any]:
@@ -3,7 +3,7 @@ from __future__ import annotations
import shlex
from typing import Any
from ..olayer import ShellComponent
from astrbot.core.computer.olayer import ShellComponent
_MAX_SEARCH_LINE_COLUMNS = 1000
@@ -74,7 +74,7 @@ def _build_grep_command(
def _quote_command(command: list[str]) -> str:
return " ".join(shlex.quote(part) for part in command)
return shlex.join(command)
def build_search_command(
+2 -2
View File
@@ -54,7 +54,7 @@ class PersistentShellSession:
stdin.write(b"exit\n")
await stdin.drain()
await asyncio.wait_for(proc.wait(), timeout=5)
except (TimeoutError, asyncio.TimeoutError):
except TimeoutError:
proc.kill()
await proc.wait()
@@ -218,7 +218,7 @@ class PersistentShellSession:
stdout.read(4096),
timeout=remaining,
)
except asyncio.TimeoutError:
except TimeoutError:
break
if not chunk:
break
+2 -2
View File
@@ -228,8 +228,8 @@ class AstrBotConfig(dict):
try:
del self[key]
self.save_config()
except KeyError:
raise AttributeError(f"没有找到 Key: '{key}'")
except KeyError as err:
raise AttributeError(f"没有找到 Key: '{key}'") from err
def __setattr__(self, key, value) -> None:
self[key] = value
+1
View File
@@ -55,6 +55,7 @@ class BaseDatabase(abc.ABC):
expire_on_commit=False,
)
@abc.abstractmethod
async def initialize(self) -> None:
"""初始化数据库连接"""
+3 -3
View File
@@ -99,8 +99,8 @@ async def migration_platform_table(
db_path=DB_PATH.replace("data_v4.db", "data_v3.db"),
)
secs_from_2023_4_10_to_now = (
datetime.datetime.now(datetime.timezone.utc)
- datetime.datetime(2023, 4, 10, tzinfo=datetime.timezone.utc)
datetime.datetime.now(datetime.UTC)
- datetime.datetime(2023, 4, 10, tzinfo=datetime.UTC)
).total_seconds()
offset_sec = int(secs_from_2023_4_10_to_now)
logger.info(f"迁移旧平台数据,offset_sec: {offset_sec} 秒。")
@@ -147,7 +147,7 @@ async def migration_platform_table(
{
"timestamp": datetime.datetime.fromtimestamp(
bucket_end,
tz=datetime.timezone.utc,
tz=datetime.UTC,
),
"platform_id": platform_id,
"platform_type": platform_type,
+1 -1
View File
@@ -15,7 +15,7 @@ async def migrate_45_to_46(acm: AstrBotConfigManager, ucr: UmopConfigRouter) ->
# 如果任何一项带有 umop,则说明需要迁移
need_migration = False
for conf_id, conf_info in abconf_data.items():
for _conf_id, conf_info in abconf_data.items():
if isinstance(conf_info, dict) and "umop" in conf_info:
need_migration = True
break
@@ -330,7 +330,7 @@ class DocumentStorage:
import json
documents: list[Document] = []
for doc_id, text, metadata in zip(doc_ids, texts, metadatas):
for doc_id, text, metadata in zip(doc_ids, texts, metadatas, strict=False):
document = Document(
doc_id=doc_id,
text=text,
@@ -637,7 +637,7 @@ class DocumentStorage:
"rowid": int(doc.id),
"search_text": to_fts5_search_text(content, self.stopwords),
}
for doc, content in zip(documents, contents)
for doc, content in zip(documents, contents, strict=False)
if doc.id is not None
]
if not fts_params:
+1 -1
View File
@@ -136,7 +136,7 @@ class KnowledgeBaseManager:
return kb_helper
except Exception as e:
if "kb_name" in str(e):
raise ValueError(f"知识库名称 '{kb_name}' 已存在")
raise ValueError(f"知识库名称 '{kb_name}' 已存在") from e
raise
async def get_kb(self, kb_id: str) -> KBHelper | None:
@@ -143,7 +143,7 @@ class SparseRetriever:
"kb_id": kb_id,
"text": doc["text"],
}
for doc, chunk_md in zip(result, chunk_mds)
for doc, chunk_md in zip(result, chunk_mds, strict=False)
]
chunks.extend(mapped_chunks)
top_k_sparse += kb_config.get("top_k_sparse", 50)
+6 -1
View File
@@ -279,7 +279,7 @@ class AstrMessageEvent(abc.ABC):
async def send_streaming(
self,
generator: AsyncGenerator[MessageChain, None],
generator: AsyncGenerator[MessageChain],
use_fallback: bool = False,
) -> None:
"""发送流式消息到消息平台,使用异步生成器。
@@ -291,21 +291,25 @@ class AstrMessageEvent(abc.ABC):
)
self._has_send_oper = True
@abc.abstractmethod
async def send_typing(self) -> None:
"""发送输入中状态。
默认实现为空由具体平台按需重写
"""
@abc.abstractmethod
async def stop_typing(self) -> None:
"""停止输入中状态。
默认实现为空由具体平台按需重写
"""
@abc.abstractmethod
async def _pre_send(self) -> None:
"""调度器会在执行 send() 前调用该方法 deprecated in v3.5.18"""
@abc.abstractmethod
async def _post_send(self) -> None:
"""调度器会在执行 send() 后调用该方法 deprecated in v3.5.18"""
@@ -501,6 +505,7 @@ class AstrMessageEvent(abc.ABC):
"""
await self.send(MessageChain([Plain(emoji)]))
@abc.abstractmethod
async def get_group(self, group_id: str | None = None, **kwargs) -> Group | None:
"""获取一个群聊的数据, 如果不填写 group_id: 如果是私聊消息,返回 None。如果是群聊消息,返回当前群聊的数据。
@@ -22,6 +22,7 @@ from astrbot.api.platform import (
)
from astrbot.core import sp
from astrbot.core.platform.astr_message_event import MessageSesion
from astrbot.core.platform.register import register_platform_adapter
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
from astrbot.core.utils.io import download_file
from astrbot.core.utils.media_utils import (
@@ -31,14 +32,13 @@ from astrbot.core.utils.media_utils import (
get_media_duration,
)
from ...register import register_platform_adapter
from .dingtalk_event import DingtalkMessageEvent
class MyEventHandler(dingtalk_stream.EventHandler):
async def process(self, event: dingtalk_stream.EventMessage):
print(
"2",
logger.debug(
"dingtalk_event: %s %s %s %s",
event.headers.event_type,
event.headers.event_id,
event.headers.event_born_time,
@@ -340,7 +340,7 @@ class DingtalkPlatformAdapter(Platform):
"robotCode": robot_code,
}
temp_dir = Path(get_astrbot_temp_path())
temp_dir.mkdir(parents=True, exist_ok=True)
await asyncio.to_thread(temp_dir.mkdir, parents=True, exist_ok=True)
f_path = temp_dir / f"dingtalk_{uuid.uuid4()}.{ext}"
async with (
aiohttp.ClientSession() as session,
@@ -512,7 +512,7 @@ class DingtalkPlatformAdapter(Platform):
form = aiohttp.FormData()
form.add_field(
"media",
media_file_path.read_bytes(),
await asyncio.to_thread(media_file_path.read_bytes),
filename=media_file_path.name,
content_type="application/octet-stream",
)
@@ -1,15 +1,10 @@
import sys
from collections.abc import Awaitable, Callable
from typing import override
import discord
from astrbot import logger
if sys.version_info >= (3, 12):
from typing import override
else:
from typing_extensions import override
# Discord Bot客户端
class DiscordBotClient(discord.Bot):
@@ -1,7 +1,6 @@
import asyncio
import re
import sys
from typing import Any, cast
from typing import Any, cast, override
import discord
from discord.abc import GuildChannel, Messageable, PrivateChannel
@@ -31,11 +30,6 @@ from astrbot.core.star.star_handler import (
from .client import DiscordBotClient
from .discord_platform_event import DiscordPlatformEvent
if sys.version_info >= (3, 12):
from typing import override
else:
from typing_extensions import override
# 注册平台适配器
@register_platform_adapter(
@@ -164,7 +164,7 @@ class KookPlatformAdapter(Platform):
self.client.wait_until_closed(),
timeout=1.0,
)
except asyncio.TimeoutError:
except TimeoutError:
# 正常超时,继续下一轮 while 检查
continue
@@ -217,7 +217,7 @@ class KookClient:
logger.error(f"[KOOK] 原始响应内容: {msg}")
continue
except asyncio.TimeoutError:
except TimeoutError:
# 超时检查,继续循环
continue
except websockets.exceptions.ConnectionClosed:
@@ -111,7 +111,7 @@ class KookBaseReceiveDataClass(BaseModel):
def to_dict(
self,
mode: Literal["json", "python"] | str = "json",
mode: str = "json",
by_alias=True,
exclude_none=False,
exclude_unset=True,
@@ -151,7 +151,7 @@ class KookBaseSendDataClass(KookBaseReceiveDataClass):
def to_dict(
self,
mode: Literal["json", "python"] | str = "json",
mode: str = "json",
by_alias=True,
exclude_none=True,
exclude_unset=False,
@@ -148,7 +148,7 @@ class LarkMessageEvent(AstrMessageEvent):
成功返回file_key失败返回None
"""
if not path or not os.path.exists(path):
if not path or not await asyncio.to_thread(os.path.exists, path):
logger.error(f"[Lark] 文件不存在: {path}")
return None
@@ -157,36 +157,40 @@ class LarkMessageEvent(AstrMessageEvent):
return None
try:
with open(path, "rb") as file_obj:
body_builder = (
CreateFileRequestBody.builder()
.file_type(file_type)
.file_name(os.path.basename(path))
.file(file_obj)
def _read_file(p: str) -> bytes:
with open(p, "rb") as f:
return f.read()
file_bytes = await asyncio.to_thread(_read_file, path)
file_obj = BytesIO(file_bytes)
body_builder = (
CreateFileRequestBody.builder()
.file_type(file_type)
.file_name(os.path.basename(path))
.file(file_obj)
)
if duration is not None:
body_builder.duration(duration)
request = (
CreateFileRequest.builder().request_body(body_builder.build()).build()
)
response = await lark_client.im.v1.file.acreate(request)
if not response.success():
logger.error(
f"[Lark] 无法上传文件({response.code}): {response.msg}",
)
if duration is not None:
body_builder.duration(duration)
return None
request = (
CreateFileRequest.builder()
.request_body(body_builder.build())
.build()
)
response = await lark_client.im.v1.file.acreate(request)
if response.data is None:
logger.error("[Lark] 上传文件成功但未返回数据(data is None)")
return None
if not response.success():
logger.error(
f"[Lark] 无法上传文件({response.code}): {response.msg}",
)
return None
if response.data is None:
logger.error("[Lark] 上传文件成功但未返回数据(data is None)")
return None
file_key = response.data.file_key
logger.debug(f"[Lark] 文件上传成功: {file_key}")
return file_key
file_key = response.data.file_key
logger.debug(f"[Lark] 文件上传成功: {file_key}")
return file_key
except Exception as e:
logger.error(f"[Lark] 无法打开或上传文件: {e}")
@@ -219,8 +223,12 @@ 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())
def _write_file(p: str, d: bytes) -> None:
with open(p, "wb") as f:
f.write(d)
await asyncio.to_thread(_write_file, file_path, image_data)
else:
file_path = comp.file or ""
@@ -229,7 +237,13 @@ class LarkMessageEvent(AstrMessageEvent):
logger.error("[Lark] 图片路径为空,无法上传")
continue
try:
image_file = open(file_path, "rb")
def _read_image(p: str) -> bytes:
with open(p, "rb") as f:
return f.read()
image_bytes = await asyncio.to_thread(_read_image, file_path)
image_file = BytesIO(image_bytes)
except Exception as e:
logger.error(f"[Lark] 无法打开图片文件: {e}")
continue
@@ -653,7 +667,9 @@ 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 asyncio.to_thread(
os.path.exists, original_audio_path
):
logger.error(f"[Lark] 音频文件不存在: {original_audio_path}")
return
@@ -683,9 +699,11 @@ class LarkMessageEvent(AstrMessageEvent):
)
# 清理转换后的临时音频文件
if converted_audio_path and os.path.exists(converted_audio_path):
if converted_audio_path and await asyncio.to_thread(
os.path.exists, converted_audio_path
):
try:
os.remove(converted_audio_path)
await asyncio.to_thread(os.remove, converted_audio_path)
logger.debug(f"[Lark] 已删除转换后的音频文件: {converted_audio_path}")
except Exception as e:
logger.warning(f"[Lark] 删除转换后的音频文件失败: {e}")
@@ -727,7 +745,9 @@ 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 asyncio.to_thread(
os.path.exists, original_video_path
):
logger.error(f"[Lark] 视频文件不存在: {original_video_path}")
return
@@ -757,9 +777,11 @@ class LarkMessageEvent(AstrMessageEvent):
)
# 清理转换后的临时视频文件
if converted_video_path and os.path.exists(converted_video_path):
if converted_video_path and await asyncio.to_thread(
os.path.exists, converted_video_path
):
try:
os.remove(converted_video_path)
await asyncio.to_thread(os.remove, converted_video_path)
logger.debug(f"[Lark] 已删除转换后的视频文件: {converted_video_path}")
except Exception as e:
logger.warning(f"[Lark] 删除转换后的视频文件失败: {e}")
@@ -18,8 +18,8 @@ from astrbot.api.platform import (
PlatformMetadata,
)
from astrbot.core.platform.astr_message_event import MessageSesion
from astrbot.core.platform.register import register_platform_adapter
from ...register import register_platform_adapter
from .client import MattermostClient
from .mattermost_event import MattermostMessageEvent
@@ -1,8 +1,8 @@
import asyncio
import os
import re
import sys
import uuid
from typing import override
from apscheduler.events import EVENT_JOB_ERROR
from apscheduler.schedulers.asyncio import AsyncIOScheduler
@@ -34,11 +34,6 @@ from astrbot.core.utils.media_utils import convert_audio_to_wav
from .tg_event import TelegramPlatformEvent
if sys.version_info >= (3, 12):
from typing import override
else:
from typing_extensions import override
@register_platform_adapter("telegram", "telegram 适配器")
class TelegramPlatformAdapter(Platform):
@@ -17,9 +17,9 @@ from astrbot.core.platform import (
PlatformMetadata,
)
from astrbot.core.platform.astr_message_event import MessageSesion
from astrbot.core.platform.register import register_platform_adapter
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
from ...register import register_platform_adapter
from .message_parts_helper import (
message_chain_to_storage_message_parts,
parse_webchat_message_parts,
@@ -1,11 +1,10 @@
import asyncio
import os
import sys
import time
import uuid
from collections.abc import Awaitable, Callable
from pathlib import Path
from typing import Any, cast
from typing import Any, cast, override
from urllib.parse import unquote
import quart
@@ -36,11 +35,6 @@ from .wecom_event import WecomPlatformEvent
from .wecom_kf import WeChatKF
from .wecom_kf_message import WeChatKFMessage
if sys.version_info >= (3, 12):
from typing import override
else:
from typing_extensions import override
def _extract_wecom_media_filename(disposition: str | None) -> str | None:
if not disposition:
@@ -395,8 +389,12 @@ 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)
def _write_file(p: str, c: bytes) -> None:
with open(p, "wb") as f:
f.write(c)
await asyncio.to_thread(_write_file, path, resp.content)
try:
path_wav = os.path.join(temp_dir, f"wecom_{msg.media_id}.wav")
@@ -458,8 +456,12 @@ 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)
def _write_file(p: str, c: bytes) -> None:
with open(p, "wb") as f:
f.write(c)
await asyncio.to_thread(_write_file, path, resp.content)
abm.message = [Image(file=path, url=path)]
elif msgtype == "voice":
media_id = msg.get("voice", {}).get("media_id", "")
@@ -471,8 +473,12 @@ 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)
def _write_file(p: str, c: bytes) -> None:
with open(p, "wb") as f:
f.write(c)
await asyncio.to_thread(_write_file, path, resp.content)
try:
path_wav = os.path.join(temp_dir, f"weixinkefu_{media_id}.wav")
@@ -2,7 +2,6 @@
提供常量定义工具函数和辅助方法
"""
import asyncio
import base64
import hashlib
import secrets
@@ -174,7 +173,7 @@ async def process_encrypted_image(
response.raise_for_status()
encrypted_data = await response.read()
logger.info("图片下载成功,大小: %d 字节", len(encrypted_data))
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
except (aiohttp.ClientError, TimeoutError) as e:
error_msg = f"下载图片失败: {e!s}"
logger.error(error_msg)
return False, error_msg
@@ -315,11 +315,10 @@ class WeixinOCAdapter(Platform):
)
finally:
state = self._typing_states.get(user_id)
if state is None:
return
async with state.lock:
if state.cancel_task is current_task:
state.cancel_task = None
if state is not None:
async with state.lock:
if state.cancel_task is current_task:
state.cancel_task = None
async def start_typing(self, user_id: str, owner_id: str) -> None:
state = self._get_typing_state(user_id)
@@ -922,11 +921,6 @@ class WeixinOCAdapter(Platform):
self.meta().id,
qr_console_url,
)
logger.warning(
"weixin_oc(%s): failed to render terminal QR code: %s",
self.meta().id,
e,
)
login_session = OpenClawLoginSession(
session_key=str(uuid.uuid4()),
qrcode=qrcode,
@@ -1231,7 +1225,7 @@ class WeixinOCAdapter(Platform):
continue
try:
await self._poll_qr_status(current_login)
except asyncio.TimeoutError:
except TimeoutError:
logger.debug(
"weixin_oc(%s): qr status long-poll timeout",
self.meta().id,
@@ -1258,7 +1252,7 @@ class WeixinOCAdapter(Platform):
continue
try:
await self._poll_inbound_updates()
except asyncio.TimeoutError:
except TimeoutError:
logger.debug(
"weixin_oc(%s): inbound long-poll timeout",
self.meta().id,
+7 -5
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import asyncio
import base64
import enum
import json
@@ -9,6 +10,7 @@ from pathlib import Path
from typing import Any
from urllib.parse import urlparse
import aiofiles
from anthropic.types import Message as AnthropicMessage
from google.genai.types import GenerateContentResponse
from openai.types.chat.chat_completion import ChatCompletion
@@ -233,7 +235,7 @@ class ProviderRequest:
parsed_url = urlparse(audio_url)
suffix = Path(parsed_url.path).suffix
temp_dir = Path(get_astrbot_temp_path())
temp_dir.mkdir(parents=True, exist_ok=True)
await asyncio.to_thread(temp_dir.mkdir, parents=True, exist_ok=True)
temp_audio_path = (
temp_dir / f"provider_request_audio_{uuid.uuid4().hex}{suffix}"
)
@@ -287,8 +289,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
async def _encode_audio_bs64(
@@ -302,8 +304,8 @@ class ProviderRequest:
if audio_path.startswith("base64://"):
return audio_path.replace("base64://", f"data:{mime_type};base64,", 1)
with open(audio_path, "rb") as f:
audio_bs64 = base64.b64encode(f.read()).decode("utf-8")
async with aiofiles.open(audio_path, "rb") as f:
audio_bs64 = base64.b64encode(await f.read()).decode("utf-8")
return f"data:{mime_type};base64," + audio_bs64
+3 -3
View File
@@ -204,7 +204,7 @@ async def _quick_test_mcp_connection(config: dict) -> tuple[bool, str]:
return True, ""
return False, f"HTTP {response.status}: {response.reason}"
except asyncio.TimeoutError:
except TimeoutError:
return False, f"连接超时: {timeout}"
except Exception as e:
return False, f"{e!s}"
@@ -549,7 +549,7 @@ class FunctionToolManager:
self._init_mcp_client(name, cfg),
timeout=timeout,
)
except asyncio.TimeoutError as exc:
except TimeoutError as exc:
raise MCPInitTimeoutError(
f"Connected to MCP server {name} timeout ({timeout:g} seconds)"
) from exc
@@ -605,7 +605,7 @@ class FunctionToolManager:
asyncio.gather(*lifecycle_tasks, return_exceptions=True),
timeout=timeout,
)
except asyncio.TimeoutError:
except TimeoutError:
pending_names = [
runtime.name
for runtime in runtimes
+1 -1
View File
@@ -738,7 +738,7 @@ class ProviderManager:
)
raise Exception(
f"实例化 {provider_config['type']}({provider_config['id']}) 提供商适配器失败:{e}",
)
) from e
async def reload(self, provider_config: dict) -> None:
async with self.reload_lock:
+2 -1
View File
@@ -57,6 +57,7 @@ class AbstractProvider(abc.ABC):
)
return meta
@abc.abstractmethod
async def test(self) -> None:
"""Test the provider is a
@@ -366,7 +367,7 @@ class EmbeddingProvider(AbstractProvider):
failed_batches.append((batch_idx, batch_texts))
raise Exception(
f"批次 {batch_idx} 处理失败,已重试 {max_retries} 次: {e!s}",
)
) from e
await asyncio.sleep(2**attempt)
tasks = []
@@ -10,6 +10,11 @@ import aiohttp
import dashscope
from dashscope.audio.tts_v2 import AudioFormat, SpeechSynthesizer
from astrbot.core.provider.entities import ProviderType
from astrbot.core.provider.provider import TTSProvider
from astrbot.core.provider.register import register_provider_adapter
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
MultiModalConversation: Any = None
try:
from dashscope.aigc.multimodal_conversation import (
@@ -20,11 +25,6 @@ except (
): # pragma: no cover - older dashscope versions without Qwen TTS support
pass
from astrbot.core.provider.entities import ProviderType
from astrbot.core.provider.provider import TTSProvider
from astrbot.core.provider.register import register_provider_adapter
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
@register_provider_adapter(
"dashscope_tts",
@@ -132,7 +132,7 @@ class ProviderDashscopeTTSAPI(TTSProvider):
) as response,
):
return await response.read()
except (aiohttp.ClientError, asyncio.TimeoutError, OSError) as e:
except (aiohttp.ClientError, TimeoutError, OSError) as e:
logging.exception(f"Failed to download audio from URL {url}: {e}")
return None
+129 -129
View File
@@ -1,129 +1,129 @@
import asyncio
import os
import subprocess
import uuid
import anyio
import edge_tts # type: ignore
from astrbot.core import logger
from astrbot.core.provider.entities import ProviderType
from astrbot.core.provider.provider import TTSProvider
from astrbot.core.provider.register import register_provider_adapter
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
"""
edge_tts 方式,能够免费快速生成语音,使用需要先安装edge-tts库
```
pip install edge_tts
```
Windows 如果提示找不到指定文件,以管理员身份运行命令行窗口,然后再次运行 AstrBot
"""
@register_provider_adapter(
"edge_tts",
"Microsoft Edge TTS",
provider_type=ProviderType.TEXT_TO_SPEECH,
)
class ProviderEdgeTTS(TTSProvider):
def __init__(
self,
provider_config: dict,
provider_settings: dict,
) -> None:
super().__init__(provider_config, provider_settings)
# 设置默认语音,如果没有指定则使用中文小萱
self.voice = provider_config.get("edge-tts-voice", "zh-CN-XiaoxiaoNeural")
self.rate = provider_config.get("rate")
self.volume = provider_config.get("volume")
self.pitch = provider_config.get("pitch")
self.timeout = provider_config.get("timeout", 30)
self.proxy = os.getenv("https_proxy", None)
self.set_model("edge_tts")
async def get_audio(self, text: str) -> str:
temp_dir = get_astrbot_temp_path()
mp3_path = os.path.join(temp_dir, f"edge_tts_temp_{uuid.uuid4()}.mp3")
wav_path = os.path.join(temp_dir, f"edge_tts_{uuid.uuid4()}.wav")
# 构建 Edge TTS 参数
kwargs = {"text": text, "voice": self.voice}
if self.rate:
kwargs["rate"] = self.rate
if self.volume:
kwargs["volume"] = self.volume
if self.pitch:
kwargs["pitch"] = self.pitch
try:
communicate = edge_tts.Communicate(proxy=self.proxy, **kwargs)
await communicate.save(mp3_path)
try:
from pyffmpeg import FFmpeg # type: ignore
ff = FFmpeg()
ff.convert(input_file=mp3_path, output_file=wav_path)
except Exception as e:
logger.debug(f"pyffmpeg 转换失败: {e}, 尝试使用 ffmpeg 命令行进行转换")
# use ffmpeg command line
# 使用ffmpeg将MP3转换为标准WAV格式
p = await asyncio.create_subprocess_exec(
"ffmpeg",
"-y", # 覆盖输出文件
"-i",
mp3_path, # 输入文件
"-acodec",
"pcm_s16le", # 16位PCM编码
"-ar",
"24000", # 采样率24kHz (适合微信语音)
"-ac",
"1", # 单声道
"-af",
"apad=pad_dur=2", # 确保输出时长准确
"-fflags",
"+genpts", # 强制生成时间戳
"-hide_banner", # 隐藏版本信息
wav_path, # 输出文件
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
# 等待进程完成并获取输出
stdout, stderr = await p.communicate()
logger.info(f"[EdgeTTS] FFmpeg 标准输出: {stdout.decode().strip()}")
logger.debug(f"FFmpeg错误输出: {stderr.decode().strip()}")
logger.info(f"[EdgeTTS] 返回值(0代表成功): {p.returncode}")
await anyio.Path(mp3_path).unlink()
wav_path_obj = anyio.Path(wav_path)
if await wav_path_obj.exists() and (await wav_path_obj.stat()).st_size > 0:
return wav_path
logger.error("生成的WAV文件不存在或为空")
raise RuntimeError("生成的WAV文件不存在或为空")
except subprocess.CalledProcessError as e:
logger.error(
f"FFmpeg 转换失败: {e.stderr.decode() if e.stderr else str(e)}",
)
try:
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}")
except Exception as e:
logger.error(f"音频生成失败: {e!s}")
try:
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}")
import asyncio
import os
import subprocess
import uuid
import anyio
import edge_tts # type: ignore
from astrbot.core import logger
from astrbot.core.provider.entities import ProviderType
from astrbot.core.provider.provider import TTSProvider
from astrbot.core.provider.register import register_provider_adapter
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
"""
edge_tts 方式,能够免费快速生成语音,使用需要先安装edge-tts库
```
pip install edge_tts
```
Windows 如果提示找不到指定文件,以管理员身份运行命令行窗口,然后再次运行 AstrBot
"""
@register_provider_adapter(
"edge_tts",
"Microsoft Edge TTS",
provider_type=ProviderType.TEXT_TO_SPEECH,
)
class ProviderEdgeTTS(TTSProvider):
def __init__(
self,
provider_config: dict,
provider_settings: dict,
) -> None:
super().__init__(provider_config, provider_settings)
# 设置默认语音,如果没有指定则使用中文小萱
self.voice = provider_config.get("edge-tts-voice", "zh-CN-XiaoxiaoNeural")
self.rate = provider_config.get("rate")
self.volume = provider_config.get("volume")
self.pitch = provider_config.get("pitch")
self.timeout = provider_config.get("timeout", 30)
self.proxy = os.getenv("https_proxy", None)
self.set_model("edge_tts")
async def get_audio(self, text: str) -> str:
temp_dir = get_astrbot_temp_path()
mp3_path = os.path.join(temp_dir, f"edge_tts_temp_{uuid.uuid4()}.mp3")
wav_path = os.path.join(temp_dir, f"edge_tts_{uuid.uuid4()}.wav")
# 构建 Edge TTS 参数
kwargs = {"text": text, "voice": self.voice}
if self.rate:
kwargs["rate"] = self.rate
if self.volume:
kwargs["volume"] = self.volume
if self.pitch:
kwargs["pitch"] = self.pitch
try:
communicate = edge_tts.Communicate(proxy=self.proxy, **kwargs)
await communicate.save(mp3_path)
try:
from pyffmpeg import FFmpeg # type: ignore
ff = FFmpeg()
ff.convert(input_file=mp3_path, output_file=wav_path)
except Exception as e:
logger.debug(f"pyffmpeg 转换失败: {e}, 尝试使用 ffmpeg 命令行进行转换")
# use ffmpeg command line
# 使用ffmpeg将MP3转换为标准WAV格式
p = await asyncio.create_subprocess_exec(
"ffmpeg",
"-y", # 覆盖输出文件
"-i",
mp3_path, # 输入文件
"-acodec",
"pcm_s16le", # 16位PCM编码
"-ar",
"24000", # 采样率24kHz (适合微信语音)
"-ac",
"1", # 单声道
"-af",
"apad=pad_dur=2", # 确保输出时长准确
"-fflags",
"+genpts", # 强制生成时间戳
"-hide_banner", # 隐藏版本信息
wav_path, # 输出文件
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
# 等待进程完成并获取输出
stdout, stderr = await p.communicate()
logger.info(f"[EdgeTTS] FFmpeg 标准输出: {stdout.decode().strip()}")
logger.debug(f"FFmpeg错误输出: {stderr.decode().strip()}")
logger.info(f"[EdgeTTS] 返回值(0代表成功): {p.returncode}")
await anyio.Path(mp3_path).unlink()
wav_path_obj = anyio.Path(wav_path)
if await wav_path_obj.exists() and (await wav_path_obj.stat()).st_size > 0:
return wav_path
logger.error("生成的WAV文件不存在或为空")
raise RuntimeError("生成的WAV文件不存在或为空")
except subprocess.CalledProcessError as e:
logger.error(
f"FFmpeg 转换失败: {e.stderr.decode() if e.stderr else str(e)}",
)
try:
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}") from e
except Exception as e:
logger.error(f"音频生成失败: {e!s}")
try:
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}") from e
@@ -48,7 +48,7 @@ class GeminiEmbeddingProvider(EmbeddingProvider):
assert values is not None
return values
except APIError as e:
raise Exception(f"Gemini Embedding API请求失败: {e.message}")
raise Exception(f"Gemini Embedding API请求失败: {e.message}") from e
async def get_embeddings(self, text: list[str]) -> list[list[float]]:
"""批量获取文本的嵌入"""
@@ -66,7 +66,7 @@ class GeminiEmbeddingProvider(EmbeddingProvider):
embeddings.append(vals)
return embeddings
except APIError as e:
raise Exception(f"Gemini Embedding API批量请求失败: {e.message}")
raise Exception(f"Gemini Embedding API批量请求失败: {e.message}") from e
def get_dim(self) -> int:
"""获取向量的维度"""
+4 -2
View File
@@ -53,7 +53,9 @@ class GenieTTSProvider(TTSProvider):
language=language,
)
except Exception as e:
raise RuntimeError(f"Failed to load character {self.character_name}: {e}")
raise RuntimeError(
f"Failed to load character {self.character_name}: {e}"
) from e
def support_stream(self) -> bool:
return True
@@ -84,7 +86,7 @@ class GenieTTSProvider(TTSProvider):
raise RuntimeError("Genie TTS did not save to file.")
except Exception as e:
raise RuntimeError(f"Genie TTS generation failed: {e}")
raise RuntimeError(f"Genie TTS generation failed: {e}") from e
async def get_audio_stream(
self,
@@ -1,4 +1,5 @@
from ..register import register_provider_adapter
from astrbot.core.provider.register import register_provider_adapter
from .openai_source import ProviderOpenAIOfficial
@@ -1,8 +1,7 @@
from astrbot import logger
from astrbot.core.provider.register import register_provider_adapter
from astrbot.core.provider.sources.anthropic_source import ProviderAnthropic
from ..register import register_provider_adapter
MINIMAX_TOKEN_PLAN_MODELS = [
"MiniMax-M2.7",
"MiniMax-M2.7-highspeed",
@@ -143,7 +143,7 @@ class ProviderMiniMaxTTSAPI(TTSProvider):
buffer = buffer[-1024:]
except aiohttp.ClientError as e:
raise Exception(f"MiniMax TTS API请求失败: {e!s}")
raise Exception(f"MiniMax TTS API请求失败: {e!s}") from e
async def _audio_play(self, audio_stream: AsyncIterator[str]) -> bytes:
"""解码数据流到 audio 比特流"""
@@ -178,4 +178,4 @@ class ProviderMiniMaxTTSAPI(TTSProvider):
return path
except aiohttp.ClientError as e:
raise Exception(f"MiniMax TTS API request failed: {e!s}")
raise Exception(f"MiniMax TTS API request failed: {e!s}") from e
@@ -1,10 +1,9 @@
import aiohttp
from astrbot import logger
from ..entities import ProviderType
from ..provider import EmbeddingProvider
from ..register import register_provider_adapter
from astrbot.core.provider.entities import ProviderType
from astrbot.core.provider.provider import EmbeddingProvider
from astrbot.core.provider.register import register_provider_adapter
@register_provider_adapter(
@@ -1,10 +1,9 @@
import aiohttp
from astrbot import logger
from ..entities import ProviderType, RerankResult
from ..provider import RerankProvider
from ..register import register_provider_adapter
from astrbot.core.provider.entities import ProviderType, RerankResult
from astrbot.core.provider.provider import RerankProvider
from astrbot.core.provider.register import register_provider_adapter
@register_provider_adapter(
@@ -1,10 +1,9 @@
import aiohttp
from astrbot import logger
from ..entities import ProviderType
from ..provider import EmbeddingProvider
from ..register import register_provider_adapter
from astrbot.core.provider.entities import ProviderType
from astrbot.core.provider.provider import EmbeddingProvider
from astrbot.core.provider.register import register_provider_adapter
@register_provider_adapter(
@@ -164,4 +164,4 @@ class ProviderVolcengineTTS(TTSProvider):
except Exception as e:
error_details = traceback.format_exc()
logger.debug(f"火山引擎 TTS 异常详情: {error_details}")
raise Exception(f"火山引擎 TTS 异常: {e!s}")
raise Exception(f"火山引擎 TTS 异常: {e!s}") from e
+10 -7
View File
@@ -15,6 +15,7 @@ from astrbot.core.astrbot_config_mgr import AstrBotConfigManager
from astrbot.core.config.astrbot_config import AstrBotConfig
from astrbot.core.conversation_mgr import ConversationManager
from astrbot.core.db import BaseDatabase
from astrbot.core.exceptions import ProviderNotFoundError
from astrbot.core.knowledge_base.kb_mgr import KnowledgeBaseManager
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.persona_mgr import PersonaManager
@@ -31,19 +32,21 @@ from astrbot.core.provider.provider import (
STTProvider,
TTSProvider,
)
from astrbot.core.star.filter.command import CommandFilter
from astrbot.core.star.filter.platform_adapter_type import (
ADAPTER_NAME_2_TYPE,
PlatformAdapterType,
)
from astrbot.core.star.filter.regex import RegexFilter
from astrbot.core.star.star import StarMetadata, star_map, star_registry
from astrbot.core.star.star_handler import (
EventType,
StarHandlerMetadata,
star_handlers_registry,
)
from astrbot.core.subagent_orchestrator import SubAgentOrchestrator
from astrbot.core.utils.astrbot_path import get_astrbot_system_tmp_path
from ..exceptions import ProviderNotFoundError
from .filter.command import CommandFilter
from .filter.regex import RegexFilter
from .star import StarMetadata, star_map, star_registry
from .star_handler import EventType, StarHandlerMetadata, star_handlers_registry
logger = logging.getLogger("astrbot")
if TYPE_CHECKING:
@@ -479,7 +482,7 @@ class Context:
try:
session = MessageSesion.from_str(session)
except BaseException as e:
raise ValueError("不合法的 session 字符串: " + str(e))
raise ValueError("不合法的 session 字符串: " + str(e)) from e
for platform in self.platform_manager.platform_insts:
if platform.meta().id == session.platform_name:
+1 -1
View File
@@ -26,7 +26,7 @@ class StarHandlerRegistry(Generic[T]):
self._handlers.sort(key=lambda h: -h.extras_configs["priority"])
def _print_handlers(self) -> None:
for handler in self._handlers:
for _handler in self._handlers:
pass
@overload
+32 -24
View File
@@ -17,6 +17,7 @@ from pathlib import Path
from types import ModuleType
from typing import Any
import aiofiles
import yaml
from packaging.specifiers import InvalidSpecifier, SpecifierSet
from packaging.version import InvalidVersion, Version
@@ -317,7 +318,7 @@ class PluginManager:
如果 target_plugin None则检查所有插件的依赖
"""
plugin_dir = self.plugin_store_path
if not os.path.exists(plugin_dir):
if not await asyncio.to_thread(os.path.exists, plugin_dir):
return False
to_update = []
if target_plugin:
@@ -336,7 +337,7 @@ class PluginManager:
plugin_label: str,
) -> None:
requirements_path = os.path.join(plugin_dir_path, "requirements.txt")
if not os.path.exists(requirements_path):
if not await asyncio.to_thread(os.path.exists, requirements_path):
return
try:
@@ -961,15 +962,15 @@ class PluginManager:
plugin_dir_path,
self.conf_schema_fname,
)
if os.path.exists(plugin_schema_path):
if await asyncio.to_thread(os.path.exists, plugin_schema_path):
# 加载插件配置
with open(plugin_schema_path, encoding="utf-8") as f:
async with aiofiles.open(plugin_schema_path, encoding="utf-8") as f:
plugin_config = AstrBotConfig(
config_path=os.path.join(
self.plugin_config_path,
f"{root_dir_name}_config.json",
),
schema=json.loads(f.read()),
schema=json.loads(await f.read()),
)
logo_path = os.path.join(plugin_dir_path, self.logo_fname)
@@ -1149,7 +1150,7 @@ class PluginManager:
metadata.activated = False
# Plugin logo path
if os.path.exists(logo_path):
if await asyncio.to_thread(os.path.exists, logo_path):
metadata.logo_path = logo_path
assert metadata.module_path, f"插件 {metadata.name} 模块路径为空"
@@ -1268,7 +1269,7 @@ class PluginManager:
except Exception:
logger.warning(traceback.format_exc())
if os.path.exists(plugin_path):
if await asyncio.to_thread(os.path.exists, plugin_path):
try:
remove_dir(plugin_path)
logger.warning(f"已清理安装失败的插件目录: {plugin_path}")
@@ -1281,9 +1282,9 @@ class PluginManager:
self.plugin_config_path,
f"{dir_name}_config.json",
)
if os.path.exists(plugin_config_path):
if await asyncio.to_thread(os.path.exists, plugin_config_path):
try:
os.remove(plugin_config_path)
await asyncio.to_thread(os.remove, plugin_config_path)
logger.warning(f"已清理安装失败插件配置: {plugin_config_path}")
except Exception as e:
logger.warning(
@@ -1395,7 +1396,7 @@ class PluginManager:
_, repo_name, _ = self.updator.parse_github_url(repo_url)
repo_name = self.updator.format_name(repo_name)
plugin_path = os.path.join(self.plugin_store_path, repo_name)
if os.path.exists(plugin_path):
if await asyncio.to_thread(os.path.exists, plugin_path):
raise Exception(
f"安装失败:目录 {os.path.basename(plugin_path)} 已存在。",
)
@@ -1415,7 +1416,8 @@ class PluginManager:
self.plugin_store_path,
metadata_dir_name,
)
if target_plugin_path != plugin_path and os.path.exists(
if target_plugin_path != plugin_path and await asyncio.to_thread(
os.path.exists,
target_plugin_path,
):
raise Exception(f"安装失败:目录 {metadata_dir_name} 已存在。")
@@ -1449,13 +1451,13 @@ class PluginManager:
# Extract README.md content if exists
readme_content = None
readme_path = os.path.join(plugin_path, "README.md")
if not os.path.exists(readme_path):
if not await asyncio.to_thread(os.path.exists, readme_path):
readme_path = os.path.join(plugin_path, "readme.md")
if os.path.exists(readme_path):
if await asyncio.to_thread(os.path.exists, readme_path):
try:
with open(readme_path, encoding="utf-8") as f:
readme_content = f.read()
async with aiofiles.open(readme_path, encoding="utf-8") as f:
readme_content = await f.read()
except Exception as e:
logger.warning(
f"读取插件 {dir_name} 的 README.md 文件失败: {e!s}",
@@ -1529,7 +1531,7 @@ class PluginManager:
except Exception as e:
raise Exception(
f"移除插件成功,但是删除插件文件夹失败: {e!s}。您可以手动删除该文件夹,位于 addons/plugins/ 下。",
)
) from e
self._cleanup_plugin_optional_artifacts(
root_dir_name=root_dir_name,
@@ -1560,7 +1562,7 @@ class PluginManager:
self._cleanup_plugin_state(dir_name)
plugin_path = os.path.join(self.plugin_store_path, dir_name)
if os.path.exists(plugin_path):
if await asyncio.to_thread(os.path.exists, plugin_path):
try:
remove_dir(plugin_path)
except Exception as e:
@@ -1569,7 +1571,7 @@ class PluginManager:
"failed_plugin_dir_remove_error",
error=f"{e!s}",
),
)
) from e
else:
logger.debug(
"插件目录不存在,视为已部分卸载状态,继续清理失败插件记录和可选产物: %s",
@@ -1813,7 +1815,10 @@ class PluginManager:
self.plugin_store_path,
metadata_dir_name,
)
if target_plugin_path != desti_dir and os.path.exists(target_plugin_path):
if target_plugin_path != desti_dir and await asyncio.to_thread(
os.path.exists,
target_plugin_path,
):
skip_failed_tracking = True
raise Exception(f"安装失败:目录 {metadata_dir_name} 已存在。")
if target_plugin_path != desti_dir:
@@ -1850,13 +1855,13 @@ class PluginManager:
# Extract README.md content if exists
readme_content = None
readme_path = os.path.join(desti_dir, "README.md")
if not os.path.exists(readme_path):
if not await asyncio.to_thread(os.path.exists, readme_path):
readme_path = os.path.join(desti_dir, "readme.md")
if os.path.exists(readme_path):
if await asyncio.to_thread(os.path.exists, readme_path):
try:
with open(readme_path, encoding="utf-8") as f:
readme_content = f.read()
async with aiofiles.open(readme_path, encoding="utf-8") as f:
readme_content = await f.read()
except Exception as e:
logger.warning(f"读取插件 {dir_name} 的 README.md 文件失败: {e!s}")
@@ -1889,7 +1894,10 @@ class PluginManager:
)
raise
finally:
if (skip_failed_tracking or temp_desti_dir != desti_dir) and os.path.isdir(
if (
skip_failed_tracking or temp_desti_dir != desti_dir
) and await asyncio.to_thread(
os.path.isdir,
temp_desti_dir,
):
try:
+10 -10
View File
@@ -33,6 +33,7 @@ Local path resolution rule:
- In sandbox runtime, relative paths are passed through unchanged.
"""
import asyncio
import os
import uuid
from dataclasses import dataclass, field
@@ -46,6 +47,13 @@ from astrbot.core.astr_agent_context import AstrAgentContext
from astrbot.core.computer.computer_client import get_booter
from astrbot.core.computer.file_read_utils import read_file_tool_result
from astrbot.core.message.components import File, Image
from astrbot.core.tools.computer_tools import util as computer_util
from astrbot.core.tools.computer_tools.util import (
check_admin_permission,
is_local_runtime,
normalize_umo_for_workspace,
)
from astrbot.core.tools.registry import builtin_tool
from astrbot.core.utils.astrbot_path import (
get_astrbot_plugin_path,
get_astrbot_skills_path,
@@ -53,14 +61,6 @@ from astrbot.core.utils.astrbot_path import (
get_astrbot_temp_path,
)
from ..registry import builtin_tool
from . import util as computer_util
from .util import (
check_admin_permission,
is_local_runtime,
normalize_umo_for_workspace,
)
_COMPUTER_RUNTIME_TOOL_CONFIG = {
"provider_settings.computer_use_runtime": ("local", "sandbox"),
}
@@ -707,10 +707,10 @@ class FileUploadTool(FunctionTool):
)
try:
# Check if file exists
if not os.path.exists(local_path):
if not await asyncio.to_thread(os.path.exists, local_path):
return f"Error: File does not exist: {local_path}"
if not os.path.isfile(local_path):
if not await asyncio.to_thread(os.path.isfile, local_path):
return f"Error: Path is not a file: {local_path}"
# Use basename if sandbox_filename is not provided
+2 -3
View File
@@ -9,9 +9,8 @@ from astrbot.core.agent.tool import ToolExecResult
from astrbot.core.astr_agent_context import AstrAgentContext, AstrMessageEvent
from astrbot.core.computer.computer_client import get_booter, get_local_booter
from astrbot.core.message.message_event_result import MessageChain
from ..registry import builtin_tool
from .util import check_admin_permission
from astrbot.core.tools.computer_tools.util import check_admin_permission
from astrbot.core.tools.registry import builtin_tool
_OS_NAME = platform.system()
_SANDBOX_PYTHON_TOOL_CONFIG = {
+2 -1
View File
@@ -219,7 +219,8 @@ def builtin_tool(tool_cls: TFunctionTool) -> TFunctionTool: ...
@overload
def builtin_tool(
*, config: dict[str, Any] | None = None,
*,
config: dict[str, Any] | None = None,
) -> Callable[[TFunctionTool], TFunctionTool]: ...
+11 -11
View File
@@ -197,7 +197,9 @@ async def download_file(
downloaded_size = 0
start_time = time.time()
if show_progress:
print(f"Downloading: {url} | Size: {total_size / 1024:.2f} KB")
logger.info(
f"Downloading: {url} | Size: {total_size / 1024:.2f} KB"
)
await _emit_download_progress(
progress_callback,
{
@@ -208,7 +210,7 @@ async def download_file(
"speed": 0,
},
)
with open(path, "wb") as f:
async with await anyio.open_file(path, "wb") as f:
while True:
chunk = await resp.content.read(8192)
if not chunk:
@@ -233,9 +235,8 @@ async def download_file(
},
)
if show_progress:
print(
f"\rProgress: {percent:.2%} Speed: {speed:.2f} KB/s",
end="",
logger.info(
f"Progress: {percent:.2%} Speed: {speed:.2f} KB/s",
)
await _emit_download_progress(
progress_callback,
@@ -274,7 +275,7 @@ async def download_file(
downloaded_size = 0
start_time = time.time()
if show_progress:
print(f"Size: {total_size / 1024:.2f} KB | URL: {url}")
logger.info(f"Size: {total_size / 1024:.2f} KB | URL: {url}")
await _emit_download_progress(
progress_callback,
{
@@ -285,7 +286,7 @@ async def download_file(
"speed": 0,
},
)
with open(path, "wb") as f:
async with await anyio.open_file(path, "wb") as f:
while True:
chunk = await resp.content.read(8192)
if not chunk:
@@ -308,9 +309,8 @@ async def download_file(
},
)
if show_progress:
print(
f"\rProgress: {percent:.2%} Speed: {speed:.2f} KB/s",
end="",
logger.info(
f"Progress: {percent:.2%} Speed: {speed:.2f} KB/s",
)
await _emit_download_progress(
progress_callback,
@@ -428,7 +428,7 @@ def should_use_bundled_dashboard_dist(
async def get_dashboard_version():
# First check user data directory (manually updated / downloaded dashboard).
dist_dir = os.path.join(get_astrbot_data_path(), "dist")
if os.path.exists(dist_dir):
if await asyncio.to_thread(os.path.exists, dist_dir):
from astrbot.core.config.default import VERSION
if should_use_bundled_dashboard_dist(dist_dir, VERSION):
+10 -10
View File
@@ -149,11 +149,11 @@ async def convert_video_format(
logger.debug(f"[Media Utils] 视频转换成功: {video_path} -> {output_path}")
return output_path
except FileNotFoundError:
except FileNotFoundError as err:
logger.error(
"[Media Utils] ffmpeg未安装或不在PATH中,无法转换视频格式。请安装ffmpeg: https://ffmpeg.org/",
)
raise Exception("ffmpeg not found")
raise Exception("ffmpeg not found") from err
except Exception as e:
logger.error(f"[Media Utils] 转换视频格式时出错: {e}")
raise
@@ -224,8 +224,8 @@ async def convert_audio_format(
raise Exception(f"ffmpeg conversion failed: {error_msg}")
logger.debug(f"[Media Utils] 音频转换成功: {audio_path} -> {output_path}")
return output_path
except FileNotFoundError:
raise Exception("ffmpeg not found")
except FileNotFoundError as err:
raise Exception("ffmpeg not found") from err
async def convert_audio_to_amr(audio_path: str, output_path: str | None = None) -> str:
@@ -347,8 +347,8 @@ async def extract_video_cover(
error_msg = stderr.decode() if stderr else "未知错误"
raise Exception(f"ffmpeg extract cover failed: {error_msg}")
return output_path
except FileNotFoundError:
raise Exception("ffmpeg not found")
except FileNotFoundError as err:
raise Exception("ffmpeg not found") from err
def _compress_image_sync(
@@ -422,14 +422,14 @@ async def compress_image(
return url_or_path
else:
local_path = Path(url_or_path)
if not local_path.exists():
if not await asyncio.to_thread(local_path.exists):
return url_or_path
if local_path.stat().st_size < min_file_size_bytes and not _exceeds_max_size(
if (
await asyncio.to_thread(local_path.stat)
).st_size < min_file_size_bytes and not _exceeds_max_size(
local_path,
):
return url_or_path
with local_path.open("rb") as f:
data = f.read()
def _read_local_path():
lp = Path(url_or_path)
+1 -1
View File
@@ -264,7 +264,7 @@ class StorageCleaner:
def _cleanup_empty_dirs(root_dir: Path) -> None:
if not root_dir.exists():
return
for dirpath, dirnames, filenames in os.walk(root_dir, topdown=False):
for dirpath, _dirnames, _filenames in os.walk(root_dir, topdown=False):
path = Path(dirpath)
if path == root_dir:
continue
+1 -1
View File
@@ -143,7 +143,7 @@ class TempDirCleaner:
self._stop_event.wait(),
timeout=self.CHECK_INTERVAL_SECONDS,
)
except asyncio.TimeoutError:
except TimeoutError:
continue
logger.info("TempDirCleaner stopped.")
+3 -4
View File
@@ -84,7 +84,7 @@ class AuthRoute(Route):
nonce = secrets.token_hex(32)
self._login_challenges[challenge_id] = {
"nonce": nonce,
"expires_at": datetime.datetime.now(datetime.timezone.utc)
"expires_at": datetime.datetime.now(datetime.UTC)
+ datetime.timedelta(minutes=1),
}
@@ -321,8 +321,7 @@ class AuthRoute(Route):
def generate_jwt(self, username):
payload = {
"username": username,
"exp": datetime.datetime.now(datetime.timezone.utc)
+ datetime.timedelta(days=7),
"exp": datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=7),
}
jwt_token = self.config["dashboard"].get("jwt_secret", None)
if not jwt_token:
@@ -401,7 +400,7 @@ class AuthRoute(Route):
)
def _prune_login_challenges(self) -> None:
now = datetime.datetime.now(datetime.timezone.utc)
now = datetime.datetime.now(datetime.UTC)
expired_ids = [
challenge_id
for challenge_id, challenge in self._login_challenges.items()
+1 -1
View File
@@ -44,7 +44,7 @@ async def track_conversation(convs: dict, conv_id: str):
async def _poll_webchat_stream_result(back_queue, username: str):
try:
result = await asyncio.wait_for(back_queue.get(), timeout=1)
except asyncio.TimeoutError:
except TimeoutError:
# Return a sentinel so the caller can send an SSE heartbeat to
# keep the connection alive during long-running operations (e.g.
# context compression with reasoning models). See #6938.
+4 -3
View File
@@ -85,8 +85,9 @@ class LiveChatSession:
wav_file.writeframes(frame)
self.temp_audio_path = audio_path
size = await asyncio.to_thread(os.path.getsize, audio_path)
logger.info(
f"[Live Chat] 音频文件已保存: {audio_path}, 大小: {os.path.getsize(audio_path)} bytes",
f"[Live Chat] 音频文件已保存: {audio_path}, 大小: {size} bytes",
)
return audio_path, time.time() - start_time
@@ -520,7 +521,7 @@ class LiveChatRoute(Route):
try:
result = await asyncio.wait_for(back_queue.get(), timeout=1)
except asyncio.TimeoutError:
except TimeoutError:
continue
if not result:
@@ -826,7 +827,7 @@ class LiveChatRoute(Route):
try:
result = await asyncio.wait_for(back_queue.get(), timeout=0.5)
except asyncio.TimeoutError:
except TimeoutError:
continue
if not result:
+8 -2
View File
@@ -23,7 +23,13 @@ from .chat import (
ChatRoute,
collect_plain_text_from_message_parts,
)
from .route import Response, Route, RouteContext
from .route import (
Response,
Route,
RouteContext,
get_runtime_guard_message,
is_runtime_request_ready,
)
class OpenApiRoute(Route):
@@ -391,7 +397,7 @@ class OpenApiRoute(Route):
return
try:
result = await asyncio.wait_for(back_queue.get(), timeout=1)
except asyncio.TimeoutError:
except TimeoutError:
continue
if not await self._ensure_runtime_ready():
+1 -1
View File
@@ -1882,7 +1882,7 @@ class PluginRoute(Route):
*(_update_one(name) for name in plugin_names),
return_exceptions=True,
)
for name, result in zip(plugin_names, raw_results):
for name, result in zip(plugin_names, raw_results, strict=False):
if isinstance(result, asyncio.CancelledError):
raise result
if isinstance(result, BaseException):
+14 -9
View File
@@ -10,6 +10,7 @@ from functools import cmp_to_key
from pathlib import Path
import aiohttp
import anyio
import psutil
from quart import request
from sqlmodel import col, select
@@ -491,13 +492,17 @@ class StatRoute(Route):
changelog_path = os.path.join(changelogs_dir, filename)
# 规范化路径,防止符号链接攻击
changelog_path = os.path.realpath(changelog_path)
changelogs_dir = os.path.realpath(changelogs_dir)
changelog_path = await asyncio.to_thread(os.path.realpath, changelog_path)
changelogs_dir = await asyncio.to_thread(os.path.realpath, changelogs_dir)
# 验证最终路径在预期的 changelogs 目录内(防止路径遍历)
# 确保规范化后的路径以 changelogs_dir 开头,且是目录内的文件
changelog_path_normalized = os.path.normpath(changelog_path)
changelogs_dir_normalized = os.path.normpath(changelogs_dir)
changelog_path_normalized = await asyncio.to_thread(
os.path.normpath, changelog_path
)
changelogs_dir_normalized = await asyncio.to_thread(
os.path.normpath, changelogs_dir
)
# 检查路径是否在预期目录内(必须是目录的子文件,不能是目录本身)
expected_prefix = changelogs_dir_normalized + os.sep
@@ -507,21 +512,21 @@ class StatRoute(Route):
)
return Response().error("Invalid version format").__dict__
if not os.path.exists(changelog_path):
if not await asyncio.to_thread(os.path.exists, changelog_path):
return (
Response()
.error(f"Changelog for version {version} not found")
.__dict__
)
if not os.path.isfile(changelog_path):
if not await asyncio.to_thread(os.path.isfile, changelog_path):
return (
Response()
.error(f"Changelog for version {version} not found")
.__dict__
)
with open(changelog_path, encoding="utf-8") as f:
content = f.read()
async with await anyio.open_file(changelog_path, encoding="utf-8") as f:
content = await f.read()
return Response().ok({"content": content, "version": version}).__dict__
except Exception as e:
@@ -534,7 +539,7 @@ class StatRoute(Route):
project_path = get_astrbot_path()
changelogs_dir = os.path.join(project_path, "changelogs")
if not os.path.exists(changelogs_dir):
if not await asyncio.to_thread(os.path.exists, changelogs_dir):
return Response().ok({"versions": []}).__dict__
versions = []
-1
View File
@@ -1 +0,0 @@
+2 -2
View File
@@ -71,13 +71,13 @@ async def check_dashboard_files(webui_dir: str | None = None):
"""下载管理面板文件"""
# 指定webui目录
if webui_dir:
if os.path.exists(webui_dir):
if await asyncio.to_thread(os.path.exists, webui_dir):
logger.info("Using WebUI directory: %s", webui_dir)
return webui_dir
logger.warning("WebUI directory not found: %s. Using default.", webui_dir)
data_dist_path = os.path.join(get_astrbot_data_path(), "dist")
if os.path.exists(data_dist_path):
if await asyncio.to_thread(os.path.exists, data_dist_path):
v = await get_dashboard_version()
if should_use_bundled_dashboard_dist(data_dist_path, VERSION):
bundled_dist = get_bundled_dashboard_dist_path()
-2
View File
@@ -1,5 +1,3 @@
from astrbot.runtime_bootstrap import initialize_runtime_bootstrap
__all__ = ["initialize_runtime_bootstrap"]
import logging
+2 -1
View File
@@ -7,7 +7,8 @@ performance regressions and improvements over time.
import asyncio
import gc
import tracemalloc
from typing import Callable, Any
from typing import Any
from collections.abc import Callable
from dataclasses import dataclass
import pytest
+2 -1
View File
@@ -1,6 +1,7 @@
"""Mock providers for testing LLM interactions."""
from typing import Any, AsyncGenerator
from typing import Any
from collections.abc import AsyncGenerator
from dataclasses import dataclass, field
from astrbot.core.provider.entities import (
+1 -1
View File
@@ -75,7 +75,7 @@ def test_login_challenge_pbkdf2():
# The proof is HMAC-SHA256(derived_key, nonce) — the derived key IS the digest
proof = hmac.new(
dk,
"any-nonce".encode(),
b"any-nonce",
hashlib.sha256,
).hexdigest()
+2 -1
View File
@@ -3,7 +3,8 @@ import os
import sys
from pathlib import Path
from types import SimpleNamespace
from typing import Any, AsyncGenerator, cast
from typing import Any, cast
from collections.abc import AsyncGenerator
from unittest.mock import AsyncMock
import pytest