Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| af581e7f21 | |||
| 9e371ee10b | |||
| 7cf77adbc8 | |||
| 31673ee521 | |||
| ff22030dde |
@@ -1 +1 @@
|
||||
__version__ = "4.19.1"
|
||||
__version__ = "4.19.2"
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import Any, TypedDict
|
||||
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
|
||||
VERSION = "4.19.1"
|
||||
VERSION = "4.19.2"
|
||||
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
|
||||
|
||||
WEBHOOK_SUPPORTED_PLATFORMS = [
|
||||
|
||||
@@ -97,11 +97,7 @@ class AstrBotCoreLifecycle:
|
||||
except Exception as e:
|
||||
logger.error(f"Subagent orchestrator init failed: {e}", exc_info=True)
|
||||
|
||||
async def initialize(
|
||||
self,
|
||||
*,
|
||||
mcp_init_timeout: float | int | str | None = None,
|
||||
) -> None:
|
||||
async def initialize(self) -> None:
|
||||
"""初始化 AstrBot 核心生命周期管理类.
|
||||
|
||||
负责初始化各个组件, 包括 ProviderManager、PlatformManager、ConversationManager、PluginManager、PipelineScheduler、EventBus、AstrBotUpdator等。
|
||||
@@ -205,7 +201,7 @@ class AstrBotCoreLifecycle:
|
||||
await self.plugin_manager.reload()
|
||||
|
||||
# 根据配置实例化各个 Provider
|
||||
await self.provider_manager.initialize(init_timeout=mcp_init_timeout)
|
||||
await self.provider_manager.initialize()
|
||||
|
||||
await self.kb_manager.initialize()
|
||||
|
||||
|
||||
@@ -346,10 +346,7 @@ class FunctionToolManager:
|
||||
logger.debug(f" 主机: {scheme}://{host}{port}")
|
||||
|
||||
async def init_mcp_clients(
|
||||
self,
|
||||
raise_on_all_failed: bool = False,
|
||||
*,
|
||||
init_timeout: float | int | str | None = None,
|
||||
self, raise_on_all_failed: bool = False
|
||||
) -> MCPInitSummary:
|
||||
"""从项目根目录读取 mcp_server.json 文件,初始化 MCP 服务列表。文件格式如下:
|
||||
```
|
||||
@@ -370,7 +367,6 @@ class FunctionToolManager:
|
||||
```
|
||||
|
||||
Timeout behavior:
|
||||
- 显式 `init_timeout` 参数优先(用于测试或调用方覆盖)。
|
||||
- 初始化超时使用环境变量 ASTRBOT_MCP_INIT_TIMEOUT 或默认值。
|
||||
- 动态启用超时使用 ASTRBOT_MCP_ENABLE_TIMEOUT(独立于初始化超时)。
|
||||
"""
|
||||
@@ -387,12 +383,8 @@ class FunctionToolManager:
|
||||
with open(mcp_json_file, encoding="utf-8") as f:
|
||||
mcp_server_json_obj: dict[str, dict] = json.load(f)["mcpServers"]
|
||||
|
||||
init_timeout_value = _resolve_timeout(
|
||||
timeout=init_timeout,
|
||||
env_name=MCP_INIT_TIMEOUT_ENV,
|
||||
default=self._init_timeout_default,
|
||||
)
|
||||
timeout_display = f"{init_timeout_value:g}"
|
||||
init_timeout = self._init_timeout_default
|
||||
timeout_display = f"{init_timeout:g}"
|
||||
|
||||
active_configs: list[tuple[str, dict, asyncio.Event]] = []
|
||||
for name, cfg in mcp_server_json_obj.items():
|
||||
@@ -411,7 +403,7 @@ class FunctionToolManager:
|
||||
name=name,
|
||||
cfg=cfg,
|
||||
shutdown_event=shutdown_event,
|
||||
timeout_seconds=init_timeout_value,
|
||||
timeout=init_timeout,
|
||||
),
|
||||
name=f"mcp-init:{name}",
|
||||
)
|
||||
|
||||
@@ -269,11 +269,7 @@ class ProviderManager:
|
||||
|
||||
return provider
|
||||
|
||||
async def initialize(
|
||||
self,
|
||||
*,
|
||||
init_timeout: float | int | str | None = None,
|
||||
) -> None:
|
||||
async def initialize(self) -> None:
|
||||
# 逐个初始化提供商
|
||||
for provider_config in self.providers_config:
|
||||
try:
|
||||
@@ -342,8 +338,7 @@ class ProviderManager:
|
||||
"on",
|
||||
}
|
||||
mcp_init_summary = await self.llm_tools.init_mcp_clients(
|
||||
raise_on_all_failed=strict_mcp_init,
|
||||
init_timeout=init_timeout,
|
||||
raise_on_all_failed=strict_mcp_init
|
||||
)
|
||||
if (
|
||||
mcp_init_summary.total > 0
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
### 新增
|
||||
|
||||
- 集成 KOOK 平台适配器 ([#5658](https://github.com/AstrBotDevs/AstrBot/pull/5658))。
|
||||
- 集成 DeerFlow Agent Runner 并优化流式处理 ([#5581](https://github.com/AstrBotDevs/AstrBot/pull/5581))。
|
||||
- 新增 Discord pre-react Emoji 支持 ([#5609](https://github.com/AstrBotDevs/AstrBot/pull/5609))。
|
||||
- 新增 Telegram 支持 `sendMessageDraft` 流式实时输出 API ([#5726](https://github.com/AstrBotDevs/AstrBot/issues/5726))
|
||||
- 支持在 Agent 运行时进行消息跟进能力,跟进的消息实时注入给 Agent ([#5484](https://github.com/AstrBotDevs/AstrBot/pull/5484))。
|
||||
- 集成 DeerFlow Agent Runner 并优化流式处理 ([#5581](https://github.com/AstrBotDevs/AstrBot/pull/5581))。
|
||||
- 新增 shell, ipython tool 中包含操作系统信息,提高 windows 下 tool call 成功率 ([#5677](https://github.com/AstrBotDevs/AstrBot/pull/5677))。
|
||||
- Sandbox 支持 Shipyard-neo - 支持 Skills 自迭代 ([#5028](https://github.com/AstrBotDevs/AstrBot/pull/5028))。
|
||||
- 新增 ChatUI WebSocket 传输模式选择,OpenAPI Chat API 支持 WebSocket 连接 ([#5410](https://github.com/AstrBotDevs/AstrBot/pull/5410))。
|
||||
@@ -5,6 +5,10 @@ import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import runtime_bootstrap
|
||||
|
||||
runtime_bootstrap.initialize_runtime_bootstrap()
|
||||
|
||||
from astrbot.core import LogBroker, LogManager, db_helper, logger # noqa: E402
|
||||
from astrbot.core.config.default import VERSION # noqa: E402
|
||||
from astrbot.core.initial_loader import InitialLoader # noqa: E402
|
||||
|
||||
+3
-3
@@ -1,9 +1,9 @@
|
||||
[project]
|
||||
name = "AstrBot"
|
||||
version = "4.19.1"
|
||||
version = "4.19.2"
|
||||
description = "Easy-to-use multi-platform LLM chatbot and development framework"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
keywords = ["Astrbot", "Astrbot Module", "Astrbot Plugin"]
|
||||
|
||||
@@ -61,7 +61,7 @@ dependencies = [
|
||||
"xinference-client",
|
||||
"tenacity>=9.1.2",
|
||||
"shipyard-python-sdk>=0.2.4",
|
||||
"shipyard-neo-sdk @ git+https://github.com/AstrBotDevs/shipyard-neo.git#subdirectory=shipyard-neo-sdk",
|
||||
"shipyard-neo-sdk>=0.2.0",
|
||||
"python-socks>=2.8.0",
|
||||
"packaging>=24.2",
|
||||
]
|
||||
|
||||
+1
-1
@@ -54,5 +54,5 @@ markitdown-no-magika[docx,xls,xlsx]>=0.1.2
|
||||
xinference-client
|
||||
tenacity>=9.1.2
|
||||
shipyard-python-sdk>=0.2.4
|
||||
shipyard-neo-sdk @ git+https://github.com/AstrBotDevs/shipyard-neo.git#subdirectory=shipyard-neo-sdk
|
||||
shipyard-neo-sdk>=0.2.0
|
||||
packaging>=24.2
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import logging
|
||||
import ssl
|
||||
from typing import Any
|
||||
|
||||
import aiohttp.connector as aiohttp_connector
|
||||
|
||||
from astrbot.utils.http_ssl_common import build_ssl_context_with_certifi
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _try_patch_aiohttp_ssl_context(
|
||||
ssl_context: ssl.SSLContext,
|
||||
log_obj: Any | None = None,
|
||||
) -> bool:
|
||||
log = log_obj or logger
|
||||
attr_name = "_SSL_CONTEXT_VERIFIED"
|
||||
|
||||
if not hasattr(aiohttp_connector, attr_name):
|
||||
log.warning(
|
||||
"aiohttp connector does not expose _SSL_CONTEXT_VERIFIED; skipped patch.",
|
||||
)
|
||||
return False
|
||||
|
||||
current_value = getattr(aiohttp_connector, attr_name, None)
|
||||
if current_value is not None and not isinstance(current_value, ssl.SSLContext):
|
||||
log.warning(
|
||||
"aiohttp connector exposes _SSL_CONTEXT_VERIFIED with unexpected type; skipped patch.",
|
||||
)
|
||||
return False
|
||||
|
||||
setattr(aiohttp_connector, attr_name, ssl_context)
|
||||
log.info("Configured aiohttp verified SSL context with system+certifi trust chain.")
|
||||
return True
|
||||
|
||||
|
||||
def configure_runtime_ca_bundle(log_obj: Any | None = None) -> bool:
|
||||
log = log_obj or logger
|
||||
|
||||
try:
|
||||
log.info("Bootstrapping runtime CA bundle.")
|
||||
ssl_context = build_ssl_context_with_certifi(log_obj=log)
|
||||
return _try_patch_aiohttp_ssl_context(ssl_context, log_obj=log)
|
||||
except Exception as exc:
|
||||
log.error("Failed to configure runtime CA bundle for aiohttp: %r", exc)
|
||||
return False
|
||||
|
||||
|
||||
def initialize_runtime_bootstrap(log_obj: Any | None = None) -> bool:
|
||||
return configure_runtime_ca_bundle(log_obj=log_obj)
|
||||
Vendored
-20
@@ -7,7 +7,6 @@ import shutil
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
from urllib.parse import urlparse
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from astrbot.core.message.components import BaseMessageComponent
|
||||
@@ -25,25 +24,6 @@ class NoopAwaitable:
|
||||
return None
|
||||
|
||||
|
||||
def get_bound_tcp_port(site: Any) -> int:
|
||||
"""Resolve the bound aiohttp TCP site port for tests.
|
||||
|
||||
We prefer the public ``site.name`` first. Some aiohttp test setups with
|
||||
ephemeral ports may not expose a usable port there, so we fall back to
|
||||
``site._server.sockets`` as a test-only compatibility path.
|
||||
"""
|
||||
parsed = urlparse(getattr(site, "name", ""))
|
||||
if parsed.port is not None and parsed.port > 0:
|
||||
return parsed.port
|
||||
|
||||
server = getattr(site, "_server", None)
|
||||
sockets = getattr(server, "sockets", None) if server else None
|
||||
if sockets:
|
||||
return sockets[0].getsockname()[1]
|
||||
|
||||
raise RuntimeError("Unable to resolve bound TCP port from aiohttp site")
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 平台配置工厂
|
||||
# ============================================================
|
||||
|
||||
@@ -1,268 +0,0 @@
|
||||
"""Performance benchmark tests for core AstrBot execution paths.
|
||||
|
||||
Run with:
|
||||
uv run pytest tests/performance/test_benchmarks.py -q -s
|
||||
|
||||
Optional output:
|
||||
ASTRBOT_BENCHMARK_OUTPUT=/tmp/astrbot_benchmark.json
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import time
|
||||
import zipfile
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
from typing import Awaitable, Callable
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from aiohttp import web
|
||||
|
||||
from astrbot.core.backup.exporter import AstrBotExporter
|
||||
from astrbot.core.message.components import File, Image, Record
|
||||
from astrbot.core.utils.io import download_file, file_to_base64
|
||||
from tests.fixtures.helpers import get_bound_tcp_port
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class BenchmarkResult:
|
||||
name: str
|
||||
iterations: int
|
||||
warmup: int
|
||||
min_ms: float
|
||||
max_ms: float
|
||||
mean_ms: float
|
||||
p50_ms: float
|
||||
p95_ms: float
|
||||
ops_per_sec: float
|
||||
|
||||
|
||||
def _percentile(values: list[float], q: float) -> float:
|
||||
if not values:
|
||||
return 0.0
|
||||
sorted_values = sorted(values)
|
||||
if len(sorted_values) == 1:
|
||||
return sorted_values[0]
|
||||
rank = (len(sorted_values) - 1) * q
|
||||
lower = math.floor(rank)
|
||||
upper = math.ceil(rank)
|
||||
if lower == upper:
|
||||
return sorted_values[lower]
|
||||
weight = rank - lower
|
||||
return sorted_values[lower] * (1 - weight) + sorted_values[upper] * weight
|
||||
|
||||
|
||||
async def run_async_benchmark(
|
||||
name: str,
|
||||
func: Callable[[], Awaitable[None]],
|
||||
*,
|
||||
iterations: int,
|
||||
warmup: int = 5,
|
||||
) -> BenchmarkResult:
|
||||
for _ in range(warmup):
|
||||
await func()
|
||||
|
||||
samples_ms: list[float] = []
|
||||
for _ in range(iterations):
|
||||
start_ns = time.perf_counter_ns()
|
||||
await func()
|
||||
elapsed_ms = (time.perf_counter_ns() - start_ns) / 1_000_000
|
||||
samples_ms.append(elapsed_ms)
|
||||
|
||||
mean_ms = sum(samples_ms) / len(samples_ms)
|
||||
return BenchmarkResult(
|
||||
name=name,
|
||||
iterations=iterations,
|
||||
warmup=warmup,
|
||||
min_ms=min(samples_ms),
|
||||
max_ms=max(samples_ms),
|
||||
mean_ms=mean_ms,
|
||||
p50_ms=_percentile(samples_ms, 0.50),
|
||||
p95_ms=_percentile(samples_ms, 0.95),
|
||||
ops_per_sec=1000 / mean_ms if mean_ms > 0 else 0.0,
|
||||
)
|
||||
|
||||
|
||||
def _print_report(results: list[BenchmarkResult]) -> None:
|
||||
print("\nAstrBot Benchmark Report")
|
||||
print("-" * 84)
|
||||
print(
|
||||
f"{'case':35} {'iters':>7} {'mean(ms)':>10} {'p50(ms)':>10} "
|
||||
f"{'p95(ms)':>10} {'ops/s':>10}"
|
||||
)
|
||||
print("-" * 84)
|
||||
for result in results:
|
||||
print(
|
||||
f"{result.name:35} {result.iterations:7d} "
|
||||
f"{result.mean_ms:10.4f} {result.p50_ms:10.4f} "
|
||||
f"{result.p95_ms:10.4f} {result.ops_per_sec:10.1f}"
|
||||
)
|
||||
|
||||
|
||||
def _scaled_iterations(value: int) -> int:
|
||||
scale = int(os.environ.get("ASTRBOT_BENCHMARK_SCALE", "1"))
|
||||
return max(1, value * scale)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.slow
|
||||
async def test_core_performance_benchmarks(tmp_path: Path) -> None:
|
||||
"""Measure representative performance paths across core modules."""
|
||||
data = os.urandom(256 * 1024)
|
||||
|
||||
payload_path = tmp_path / "payload.bin"
|
||||
payload_path.write_bytes(data)
|
||||
|
||||
image = Image.fromFileSystem(str(payload_path))
|
||||
record = Record.fromFileSystem(str(payload_path))
|
||||
file_component = File(name="payload.bin", file=str(payload_path))
|
||||
exists_path = tmp_path / "exists_target.txt"
|
||||
exists_path.write_text("ok", encoding="utf-8")
|
||||
|
||||
attachments_dir = tmp_path / "attachments"
|
||||
attachments_dir.mkdir()
|
||||
attachments: list[dict[str, str]] = []
|
||||
attachments_with_missing: list[dict[str, str]] = []
|
||||
for i in range(64):
|
||||
file_path = attachments_dir / f"attachment_{i}.bin"
|
||||
file_path.write_bytes(data[:2048])
|
||||
attachments.append({"attachment_id": f"att_{i}", "path": str(file_path)})
|
||||
if i % 4 == 0:
|
||||
missing_path = attachments_dir / f"missing_{i}.bin"
|
||||
attachments_with_missing.append(
|
||||
{"attachment_id": f"att_missing_{i}", "path": str(missing_path)}
|
||||
)
|
||||
attachments_with_missing.append(
|
||||
{"attachment_id": f"att_existing_{i}", "path": str(file_path)}
|
||||
)
|
||||
|
||||
exporter = AstrBotExporter(main_db=MagicMock())
|
||||
zip_path = tmp_path / "attachments_bench.zip"
|
||||
micro_batch = 32
|
||||
download_target = tmp_path / "download_target.bin"
|
||||
download_payload = os.urandom(512 * 1024)
|
||||
|
||||
async def handle_download(_request):
|
||||
return web.Response(body=download_payload)
|
||||
|
||||
app = web.Application()
|
||||
app.router.add_get("/download.bin", handle_download)
|
||||
runner = web.AppRunner(app, access_log=None)
|
||||
await runner.setup()
|
||||
site = web.TCPSite(runner, "127.0.0.1", 0)
|
||||
await site.start()
|
||||
port = get_bound_tcp_port(site)
|
||||
download_url = f"http://127.0.0.1:{port}/download.bin"
|
||||
|
||||
async def bench_file_to_base64() -> None:
|
||||
await file_to_base64(str(payload_path))
|
||||
|
||||
async def bench_image_convert_to_base64() -> None:
|
||||
await image.convert_to_base64()
|
||||
|
||||
async def bench_record_convert_to_base64() -> None:
|
||||
await record.convert_to_base64()
|
||||
|
||||
async def bench_image_convert_to_file_path() -> None:
|
||||
for _ in range(micro_batch):
|
||||
await image.convert_to_file_path()
|
||||
|
||||
async def bench_file_component_get_file() -> None:
|
||||
await file_component.get_file()
|
||||
|
||||
async def bench_to_thread_exists() -> None:
|
||||
await asyncio.to_thread(exists_path.exists)
|
||||
|
||||
async def bench_export_attachments_existing() -> None:
|
||||
if zip_path.exists():
|
||||
zip_path.unlink()
|
||||
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
await exporter._export_attachments(zf, attachments)
|
||||
zip_path.unlink(missing_ok=True)
|
||||
|
||||
async def bench_export_attachments_with_missing() -> None:
|
||||
if zip_path.exists():
|
||||
zip_path.unlink()
|
||||
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
await exporter._export_attachments(zf, attachments_with_missing)
|
||||
zip_path.unlink(missing_ok=True)
|
||||
|
||||
async def bench_download_file_local_http() -> None:
|
||||
await download_file(download_url, str(download_target))
|
||||
download_target.unlink(missing_ok=True)
|
||||
|
||||
try:
|
||||
results = [
|
||||
await run_async_benchmark(
|
||||
"utils.io.file_to_base64(256KB)",
|
||||
bench_file_to_base64,
|
||||
iterations=_scaled_iterations(120),
|
||||
),
|
||||
await run_async_benchmark(
|
||||
"components.Image.convert_to_base64",
|
||||
bench_image_convert_to_base64,
|
||||
iterations=_scaled_iterations(120),
|
||||
),
|
||||
await run_async_benchmark(
|
||||
"components.Record.convert_to_base64",
|
||||
bench_record_convert_to_base64,
|
||||
iterations=_scaled_iterations(120),
|
||||
),
|
||||
await run_async_benchmark(
|
||||
f"components.Image.convert_to_file_path(x{micro_batch})",
|
||||
bench_image_convert_to_file_path,
|
||||
iterations=_scaled_iterations(140),
|
||||
),
|
||||
await run_async_benchmark(
|
||||
"components.File.get_file(local)",
|
||||
bench_file_component_get_file,
|
||||
iterations=_scaled_iterations(140),
|
||||
),
|
||||
await run_async_benchmark(
|
||||
"asyncio.to_thread(Path.exists)",
|
||||
bench_to_thread_exists,
|
||||
iterations=_scaled_iterations(240),
|
||||
),
|
||||
await run_async_benchmark(
|
||||
"backup.exporter._export_attachments(existing)",
|
||||
bench_export_attachments_existing,
|
||||
iterations=_scaled_iterations(20),
|
||||
warmup=2,
|
||||
),
|
||||
await run_async_benchmark(
|
||||
"backup.exporter._export_attachments(mixed)",
|
||||
bench_export_attachments_with_missing,
|
||||
iterations=_scaled_iterations(20),
|
||||
warmup=2,
|
||||
),
|
||||
await run_async_benchmark(
|
||||
"utils.io.download_file(local_http_512KB)",
|
||||
bench_download_file_local_http,
|
||||
iterations=_scaled_iterations(12),
|
||||
warmup=2,
|
||||
),
|
||||
]
|
||||
finally:
|
||||
await runner.cleanup()
|
||||
|
||||
_print_report(results)
|
||||
|
||||
output_path = os.environ.get("ASTRBOT_BENCHMARK_OUTPUT")
|
||||
if output_path:
|
||||
Path(output_path).write_text(
|
||||
json.dumps([asdict(result) for result in results], indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# Keep assertions broad: benchmarks are for measurement, not strict gating.
|
||||
assert len(results) == 9
|
||||
for result in results:
|
||||
assert result.iterations > 0
|
||||
assert result.mean_ms > 0
|
||||
assert result.max_ms >= result.min_ms
|
||||
assert result.p95_ms >= result.p50_ms
|
||||
@@ -172,15 +172,6 @@ class TestAstrBotExporter:
|
||||
assert "test.json" in exporter._checksums
|
||||
assert exporter._checksums["test.json"].startswith("sha256:")
|
||||
|
||||
def test_read_text_if_exists(self, tmp_path):
|
||||
"""测试 _read_text_if_exists 行为。"""
|
||||
exporter = AstrBotExporter(main_db=MagicMock())
|
||||
file_path = tmp_path / "config.json"
|
||||
file_path.write_text('{"k":"v"}', encoding="utf-8")
|
||||
|
||||
assert exporter._read_text_if_exists(str(file_path)) == '{"k":"v"}'
|
||||
assert exporter._read_text_if_exists(str(tmp_path / "missing.json")) is None
|
||||
|
||||
def test_generate_manifest(self, mock_main_db, mock_kb_manager):
|
||||
"""测试生成清单"""
|
||||
exporter = AstrBotExporter(
|
||||
@@ -249,95 +240,6 @@ class TestAstrBotExporter:
|
||||
assert "databases/main_db.json" in namelist
|
||||
assert "config/cmd_config.json" in namelist
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_export_attachments_exports_existing_and_skips_missing(
|
||||
self, mock_main_db, tmp_path
|
||||
):
|
||||
"""测试附件导出:存在文件写入 ZIP,不存在文件跳过。"""
|
||||
exporter = AstrBotExporter(main_db=mock_main_db, kb_manager=None)
|
||||
|
||||
existing_file = tmp_path / "exists.txt"
|
||||
existing_file.write_text("hello", encoding="utf-8")
|
||||
missing_file = tmp_path / "missing.txt"
|
||||
zip_path = tmp_path / "attachments.zip"
|
||||
|
||||
attachments = [
|
||||
{"attachment_id": "att_ok", "path": str(existing_file)},
|
||||
{"attachment_id": "att_missing", "path": str(missing_file)},
|
||||
]
|
||||
|
||||
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
await exporter._export_attachments(zf, attachments)
|
||||
|
||||
with zipfile.ZipFile(zip_path, "r") as zf:
|
||||
namelist = zf.namelist()
|
||||
|
||||
assert "files/attachments/att_ok.txt" in namelist
|
||||
assert "files/attachments/att_missing.txt" not in namelist
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_export_attachments_skips_empty_attachment_id(
|
||||
self, mock_main_db, tmp_path
|
||||
):
|
||||
"""测试附件导出:attachment_id 为空时跳过,避免覆盖冲突。"""
|
||||
exporter = AstrBotExporter(main_db=mock_main_db, kb_manager=None)
|
||||
|
||||
file_a = tmp_path / "a.txt"
|
||||
file_b = tmp_path / "b.txt"
|
||||
file_a.write_text("a", encoding="utf-8")
|
||||
file_b.write_text("b", encoding="utf-8")
|
||||
zip_path = tmp_path / "attachments_empty_id.zip"
|
||||
|
||||
attachments = [
|
||||
{"attachment_id": "", "path": str(file_a)},
|
||||
{"path": str(file_b)},
|
||||
{"attachment_id": "att_ok", "path": str(file_a)},
|
||||
]
|
||||
|
||||
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
await exporter._export_attachments(zf, attachments)
|
||||
|
||||
with zipfile.ZipFile(zip_path, "r") as zf:
|
||||
namelist = zf.namelist()
|
||||
|
||||
assert "files/attachments/att_ok.txt" in namelist
|
||||
assert "files/attachments/.txt" not in namelist
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_export_attachments_keeps_best_effort_on_unexpected_write_error(
|
||||
self, mock_main_db, tmp_path
|
||||
):
|
||||
"""测试附件导出:单个非 OSError 写入异常不会中断后续附件导出。"""
|
||||
exporter = AstrBotExporter(main_db=mock_main_db, kb_manager=None)
|
||||
|
||||
file_a = tmp_path / "a.txt"
|
||||
file_b = tmp_path / "b.txt"
|
||||
file_a.write_text("a", encoding="utf-8")
|
||||
file_b.write_text("b", encoding="utf-8")
|
||||
zip_path = tmp_path / "attachments_best_effort.zip"
|
||||
|
||||
attachments = [
|
||||
{"attachment_id": "att_boom", "path": str(file_a)},
|
||||
{"attachment_id": "att_ok", "path": str(file_b)},
|
||||
]
|
||||
|
||||
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
original_write = zf.write
|
||||
|
||||
def flaky_write(filename, arcname=None, *args, **kwargs):
|
||||
if arcname == "files/attachments/att_boom.txt":
|
||||
raise RuntimeError("boom")
|
||||
return original_write(filename, arcname, *args, **kwargs)
|
||||
|
||||
with patch.object(zf, "write", side_effect=flaky_write):
|
||||
await exporter._export_attachments(zf, attachments)
|
||||
|
||||
with zipfile.ZipFile(zip_path, "r") as zf:
|
||||
namelist = zf.namelist()
|
||||
|
||||
assert "files/attachments/att_boom.txt" not in namelist
|
||||
assert "files/attachments/att_ok.txt" in namelist
|
||||
|
||||
|
||||
class TestAstrBotImporter:
|
||||
"""AstrBotImporter 类测试"""
|
||||
|
||||
@@ -373,7 +373,7 @@ class TestAstrBotCoreLifecycleInitialize:
|
||||
new_callable=AsyncMock,
|
||||
),
|
||||
):
|
||||
await lifecycle.initialize(mcp_init_timeout=3.5)
|
||||
await lifecycle.initialize()
|
||||
|
||||
# Verify database initialized
|
||||
mock_db.initialize.assert_awaited_once()
|
||||
@@ -388,7 +388,7 @@ class TestAstrBotCoreLifecycleInitialize:
|
||||
mock_persona_mgr.initialize.assert_awaited_once()
|
||||
|
||||
# Verify provider manager initialized
|
||||
mock_provider_manager.initialize.assert_awaited_once_with(init_timeout=3.5)
|
||||
mock_provider_manager.initialize.assert_awaited_once()
|
||||
|
||||
# Verify platform manager initialized
|
||||
mock_platform_manager.initialize.assert_awaited_once()
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import pytest
|
||||
|
||||
from tests.fixtures.helpers import get_bound_tcp_port
|
||||
|
||||
|
||||
class _DummySiteNoAttrs:
|
||||
pass
|
||||
|
||||
|
||||
class _DummySocket:
|
||||
def __init__(self, port: int) -> None:
|
||||
self._port = port
|
||||
|
||||
def getsockname(self):
|
||||
return ("127.0.0.1", self._port)
|
||||
|
||||
|
||||
class _DummyServer:
|
||||
def __init__(self, port: int) -> None:
|
||||
self.sockets = [_DummySocket(port)]
|
||||
|
||||
|
||||
class _DummySiteWithName:
|
||||
def __init__(self, port: int) -> None:
|
||||
self.name = f"http://localhost:{port}"
|
||||
|
||||
|
||||
class _DummySiteWithServer:
|
||||
def __init__(self, port: int) -> None:
|
||||
self._server = _DummyServer(port)
|
||||
|
||||
|
||||
def test_get_bound_tcp_port_raises_on_unresolvable_site():
|
||||
with pytest.raises(RuntimeError, match="Unable to resolve bound TCP port"):
|
||||
get_bound_tcp_port(_DummySiteNoAttrs())
|
||||
|
||||
|
||||
def test_get_bound_tcp_port_uses_name_port_when_available():
|
||||
assert get_bound_tcp_port(_DummySiteWithName(8081)) == 8081
|
||||
|
||||
|
||||
def test_get_bound_tcp_port_falls_back_to_server_sockets():
|
||||
assert get_bound_tcp_port(_DummySiteWithServer(9092)) == 9092
|
||||
@@ -1,98 +0,0 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from astrbot.core.provider import func_tool_manager
|
||||
from astrbot.core.provider.func_tool_manager import FunctionToolManager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mcp_init_harness(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path,
|
||||
):
|
||||
manager = FunctionToolManager()
|
||||
data_dir = tmp_path / "data"
|
||||
data_dir.mkdir()
|
||||
|
||||
(data_dir / "mcp_server.json").write_text(
|
||||
json.dumps({"mcpServers": {"demo": {"active": True}}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
func_tool_manager,
|
||||
"get_astrbot_data_path",
|
||||
lambda: data_dir,
|
||||
)
|
||||
|
||||
called = {}
|
||||
|
||||
async def fake_start_mcp_server(*, name, cfg, shutdown_event, timeout_seconds):
|
||||
called[name] = {
|
||||
"cfg": cfg,
|
||||
"shutdown_event_type": type(shutdown_event).__name__,
|
||||
"timeout_seconds": timeout_seconds,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(manager, "_start_mcp_server", fake_start_mcp_server)
|
||||
return manager, called
|
||||
|
||||
|
||||
def assert_demo_init_result(summary, called, *, timeout_seconds: float) -> None:
|
||||
assert summary.total == 1
|
||||
assert summary.success == 1
|
||||
assert summary.failed == []
|
||||
assert called["demo"]["cfg"] == {"active": True}
|
||||
assert called["demo"]["shutdown_event_type"] == "Event"
|
||||
assert called["demo"]["timeout_seconds"] == timeout_seconds
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_init_mcp_clients_passes_timeout_seconds_keyword(mcp_init_harness):
|
||||
manager, called = mcp_init_harness
|
||||
|
||||
summary = await manager.init_mcp_clients()
|
||||
|
||||
assert_demo_init_result(
|
||||
summary,
|
||||
called,
|
||||
timeout_seconds=manager._init_timeout_default,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_init_mcp_clients_passes_overridden_init_timeout(
|
||||
mcp_init_harness,
|
||||
):
|
||||
manager, called = mcp_init_harness
|
||||
|
||||
summary = await manager.init_mcp_clients(init_timeout=3.5)
|
||||
|
||||
assert_demo_init_result(summary, called, timeout_seconds=3.5)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_init_mcp_clients_reads_env_timeout_when_not_overridden(
|
||||
mcp_init_harness,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
manager, called = mcp_init_harness
|
||||
manager._init_timeout_default = 20.0 # ensure env override is observable
|
||||
monkeypatch.setenv("ASTRBOT_MCP_INIT_TIMEOUT", "3.5")
|
||||
|
||||
summary = await manager.init_mcp_clients()
|
||||
|
||||
assert_demo_init_result(summary, called, timeout_seconds=3.5)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_init_mcp_clients_prefers_explicit_timeout_over_env(
|
||||
mcp_init_harness,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
manager, called = mcp_init_harness
|
||||
monkeypatch.setenv("ASTRBOT_MCP_INIT_TIMEOUT", "7.0")
|
||||
|
||||
summary = await manager.init_mcp_clients(init_timeout=3.5)
|
||||
|
||||
assert_demo_init_result(summary, called, timeout_seconds=3.5)
|
||||
@@ -1,71 +0,0 @@
|
||||
import pytest
|
||||
from aiohttp import web
|
||||
|
||||
from astrbot.core.utils import io as io_module
|
||||
from astrbot.core.utils.io import _stream_to_file, download_file
|
||||
from tests.fixtures.helpers import get_bound_tcp_port
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_file_downloads_content(tmp_path):
|
||||
payload = b"astrbot-download-payload" * 256
|
||||
|
||||
async def handle(_request):
|
||||
return web.Response(body=payload)
|
||||
|
||||
app = web.Application()
|
||||
app.router.add_get("/file.bin", handle)
|
||||
runner = web.AppRunner(app, access_log=None)
|
||||
await runner.setup()
|
||||
site = web.TCPSite(runner, "127.0.0.1", 0)
|
||||
await site.start()
|
||||
|
||||
try:
|
||||
port = get_bound_tcp_port(site)
|
||||
url = f"http://127.0.0.1:{port}/file.bin"
|
||||
|
||||
out = tmp_path / "downloaded.bin"
|
||||
await download_file(url, str(out))
|
||||
|
||||
assert out.read_bytes() == payload
|
||||
finally:
|
||||
await runner.cleanup()
|
||||
|
||||
|
||||
class _DummyStream:
|
||||
def __init__(self, chunks: list[bytes]) -> None:
|
||||
self._chunks = chunks
|
||||
|
||||
async def read(self, _size: int) -> bytes:
|
||||
if not self._chunks:
|
||||
return b""
|
||||
return self._chunks.pop(0)
|
||||
|
||||
|
||||
class _RecordingFile:
|
||||
def __init__(self) -> None:
|
||||
self.writes: list[bytes] = []
|
||||
|
||||
def write(self, data: bytes) -> int:
|
||||
self.writes.append(data)
|
||||
return len(data)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_to_file_batches_multiple_chunks_per_write(monkeypatch):
|
||||
monkeypatch.setattr(io_module, "_DOWNLOAD_READ_CHUNK_SIZE", 4)
|
||||
monkeypatch.setattr(io_module, "_DOWNLOAD_FLUSH_THRESHOLD", 10)
|
||||
|
||||
stream = _DummyStream([b"aaaa", b"bbbb", b"cccc"])
|
||||
file_obj = _RecordingFile()
|
||||
|
||||
await _stream_to_file(
|
||||
stream,
|
||||
file_obj,
|
||||
total_size=12,
|
||||
start_time=0.0,
|
||||
show_progress=False,
|
||||
)
|
||||
|
||||
assert len(file_obj.writes) == 1
|
||||
assert file_obj.writes[0] == b"aaaabbbbcccc"
|
||||
@@ -1,178 +0,0 @@
|
||||
import base64
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from aiohttp import web
|
||||
|
||||
from astrbot.core.message import components as components_module
|
||||
from astrbot.core.message.components import File, Image, Record
|
||||
from tests.fixtures.helpers import get_bound_tcp_port
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_image_convert_to_file_path_returns_absolute_path(tmp_path):
|
||||
file_path = tmp_path / "img.bin"
|
||||
file_path.write_bytes(b"img")
|
||||
|
||||
image = Image(file=str(file_path))
|
||||
resolved = await image.convert_to_file_path()
|
||||
|
||||
assert resolved == os.path.abspath(str(file_path))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_record_convert_to_file_path_returns_absolute_path(tmp_path):
|
||||
file_path = tmp_path / "record.bin"
|
||||
file_path.write_bytes(b"record")
|
||||
|
||||
record = Record(file=str(file_path))
|
||||
resolved = await record.convert_to_file_path()
|
||||
|
||||
assert resolved == os.path.abspath(str(file_path))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_file_component_get_file_returns_absolute_path(tmp_path):
|
||||
file_path = tmp_path / "file.bin"
|
||||
file_path.write_bytes(b"file")
|
||||
|
||||
file_component = File(name="file.bin", file=str(file_path))
|
||||
resolved = await file_component.get_file()
|
||||
|
||||
assert resolved == os.path.abspath(str(file_path))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_image_convert_to_base64_raises_on_missing_file(tmp_path):
|
||||
image = Image(file=str(tmp_path / "missing.bin"))
|
||||
|
||||
with pytest.raises(Exception, match="not a valid file"):
|
||||
await image.convert_to_base64()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_record_convert_to_base64_raises_on_missing_file(tmp_path):
|
||||
record = Record(file=str(tmp_path / "missing.bin"))
|
||||
|
||||
with pytest.raises(Exception, match="not a valid file"):
|
||||
await record.convert_to_base64()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_image_convert_to_base64_reads_existing_local_file(tmp_path):
|
||||
raw = b"image-bytes"
|
||||
file_path = tmp_path / "exists_image.bin"
|
||||
file_path.write_bytes(raw)
|
||||
|
||||
image = Image(file=str(file_path))
|
||||
encoded = await image.convert_to_base64()
|
||||
|
||||
assert base64.b64decode(encoded) == raw
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_record_convert_to_base64_reads_existing_local_file(tmp_path):
|
||||
raw = b"record-bytes"
|
||||
file_path = tmp_path / "exists_record.bin"
|
||||
file_path.write_bytes(raw)
|
||||
|
||||
record = Record(file=str(file_path))
|
||||
encoded = await record.convert_to_base64()
|
||||
|
||||
assert base64.b64decode(encoded) == raw
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_image_convert_to_base64_maps_permission_error(monkeypatch):
|
||||
async def _raise_permission_error(_path: str) -> str:
|
||||
raise PermissionError("permission denied")
|
||||
|
||||
monkeypatch.setattr(components_module, "file_to_base64", _raise_permission_error)
|
||||
|
||||
image = Image(file="/tmp/forbidden-image")
|
||||
with pytest.raises(Exception, match="not a valid file"):
|
||||
await image.convert_to_base64()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_record_convert_to_base64_maps_permission_error(monkeypatch):
|
||||
async def _raise_permission_error(_path: str) -> str:
|
||||
raise PermissionError("permission denied")
|
||||
|
||||
monkeypatch.setattr(components_module, "file_to_base64", _raise_permission_error)
|
||||
|
||||
record = Record(file="/tmp/forbidden-record")
|
||||
with pytest.raises(Exception, match="not a valid file"):
|
||||
await record.convert_to_base64()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_image_convert_to_file_path_from_base64_creates_absolute_file():
|
||||
payload = b"image-base64-payload"
|
||||
image = Image(file=f"base64://{base64.b64encode(payload).decode()}")
|
||||
|
||||
resolved = await image.convert_to_file_path()
|
||||
resolved_path = Path(resolved)
|
||||
|
||||
assert resolved_path.is_absolute()
|
||||
assert resolved_path.exists()
|
||||
assert resolved_path.read_bytes() == payload
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_record_convert_to_file_path_from_base64_creates_absolute_file():
|
||||
payload = b"record-base64-payload"
|
||||
record = Record(file=f"base64://{base64.b64encode(payload).decode()}")
|
||||
|
||||
resolved = await record.convert_to_file_path()
|
||||
resolved_path = Path(resolved)
|
||||
|
||||
assert resolved_path.is_absolute()
|
||||
assert resolved_path.exists()
|
||||
assert resolved_path.read_bytes() == payload
|
||||
|
||||
|
||||
async def _serve_payload(payload: bytes, route: str):
|
||||
async def handle(_request):
|
||||
return web.Response(body=payload)
|
||||
|
||||
app = web.Application()
|
||||
app.router.add_get(route, handle)
|
||||
runner = web.AppRunner(app, access_log=None)
|
||||
await runner.setup()
|
||||
site = web.TCPSite(runner, "127.0.0.1", 0)
|
||||
await site.start()
|
||||
return runner, get_bound_tcp_port(site)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_image_convert_to_file_path_from_http_creates_absolute_file():
|
||||
payload = b"image-http-payload"
|
||||
runner, port = await _serve_payload(payload, "/img.bin")
|
||||
try:
|
||||
image = Image(file=f"http://127.0.0.1:{port}/img.bin")
|
||||
resolved = await image.convert_to_file_path()
|
||||
resolved_path = Path(resolved)
|
||||
|
||||
assert resolved_path.is_absolute()
|
||||
assert resolved_path.exists()
|
||||
assert resolved_path.read_bytes() == payload
|
||||
finally:
|
||||
await runner.cleanup()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_record_convert_to_file_path_from_http_creates_absolute_file():
|
||||
payload = b"record-http-payload"
|
||||
runner, port = await _serve_payload(payload, "/record.bin")
|
||||
try:
|
||||
record = Record(file=f"http://127.0.0.1:{port}/record.bin")
|
||||
resolved = await record.convert_to_file_path()
|
||||
resolved_path = Path(resolved)
|
||||
|
||||
assert resolved_path.is_absolute()
|
||||
assert resolved_path.exists()
|
||||
assert resolved_path.read_bytes() == payload
|
||||
finally:
|
||||
await runner.cleanup()
|
||||
Reference in New Issue
Block a user