Compare commits

..

12 Commits

Author SHA1 Message Date
Soulter 447b4542d1 chore: bump version to 4.19.1 2026-03-05 01:38:54 +08:00
Soulter ead10b5643 refactor: remove runtime_bootstrap module and related initialization 2026-03-05 01:38:27 +08:00
Soulter 6beca2144c revert: #5729
This reverts commit a9c16febf4.
2026-03-05 01:34:07 +08:00
Soulter 2d27bfb6d0 revert: #5744
This reverts commit 3d1c3946f6.
2026-03-05 01:29:36 +08:00
エイカク 3d1c3946f6 feat(ci): add nightly prerelease release flow and updater support (#5744)
* feat: add nightly prerelease release flow and updater support

* feat(ci): auto-generate nightly release notes from latest stable tag

* fix(ci): correct nightly release notes heredoc YAML indentation

* fix(ci): align nightly notes heredoc terminator

* fix(ci): remove heredoc body indentation in nightly notes script

* fix: align nightly release metadata and prerelease rules

* fix: harden nightly release flow and updater release resolution

* fix: improve nightly branch resolution and updater logging

* fix: simplify updater target resolution and nightly release assets

* fix: avoid inputs lookup on non-dispatch release events

* fix: split nightly release fetch and simplify updater flow

* refactor: simplify updater target resolvers and nightly error checks

* fix: type release fetch errors and streamline updater resolution

* refactor: simplify updater target branching and release artifacts

* refactor: simplify release fetching and harden nightly git diagnostics

* fix: validate release payload shape before parsing

* refactor: harden prerelease handling and nightly constants

* refactor: derive archive urls and enrich fetch errors

* refactor: simplify update target resolution flow

* refactor: linearize update target resolution

* refactor: validate update target inputs and sync nightly tag source

* refactor: simplify updater mode resolution and prerelease tests

* refactor: simplify update target resolution flow

* fix: avoid package import when resolving nightly tag

* refactor: simplify updater resolution and centralize release constants

* fix: harden nightly release notes generation in workflow

* refactor: streamline update target resolution and errors

* refactor: simplify updater target resolution and nightly handling

* refactor: simplify updater errors and package release scripts

* refactor: centralize release api constants and loader

* fix(ci): resolve dispatch fallback tag from stable releases
2026-03-05 01:23:49 +09:00
Soulter cd434c5fed chore: bump version to 4.19.0 2026-03-04 23:39:52 +08:00
camera-2018 9683abeb19 feat(telegram): supports sendMessageDraft API (#5726)
* feat(telegram): 使用 sendMessageDraft API 实现私聊流式输出

- 新增 _send_message_draft 方法封装 Telegram Bot API sendMessageDraft
- 私聊流式输出使用 sendMessageDraft 推送草稿动画,群聊保留 edit_message_text 回退
- 使用独立异步发送循环 (_draft_sender_loop) 按固定间隔推送最新缓冲区内容,
  完全解耦 token 到达速度与 API 网络延迟
- 流式结束后发送真实消息保留最终内容(draft 是临时的)
- 使用模块级递增 draft_id 替代随机生成,确保 Telegram 端动画连续性

* fix(telegram): convert draft text to Markdown before sending message draft

* chore(telegram): telegram 适配器重构

- 提取公共方法
- 有新 token 到达时触发流式
- 生成结束后清除draft内容
- 默认draft发送md格式

* style(telegram): ruff format

* style(telegram): ruff check

---------

Co-authored-by: Soulter <905617992@qq.com>
2026-03-04 23:11:57 +08:00
エイカク ab96537308 [codex] fix mcp init timeout keyword mismatch (#5743)
* fix: use timeout_seconds for mcp init startup

* fix: support overridden mcp init timeout in startup

* fix: resolve mcp init timeout from env when unset

* fix: pass mcp init timeout through lifecycle chain
2026-03-04 21:20:07 +09:00
Soulter 78fa58714c fix: require node.js env when uv sync 2026-03-04 18:25:19 +08:00
エイカク 9afe5757be feat: optimize async io performance and benchmark coverage (#5737)
* docs: align deployment sections across multilingual readmes

* docs: normalize deployment punctuation and AUR guidance

* docs: fix french and russian deployment wording

* perf: optimize async io hot paths and extend benchmarks

* fix: address async io review feedback

* fix: address follow-up async io review comments

* fix: align base64 io error handling in message components

* fix: harden attachment export ids and tune io chunking

* fix: preserve best-effort attachment export and batch writes

* test: expand path conversion and helper coverage
2026-03-04 16:26:34 +09:00
エイカク bbc8c62d43 docs: align deployment sections across multilingual readmes (#5734)
* docs: align deployment sections across multilingual readmes

* docs: normalize deployment punctuation and AUR guidance

* docs: fix french and russian deployment wording
2026-03-04 14:41:36 +09:00
エイカク a9c16febf4 fix: 工程化收敛并移除 ASYNC230/ASYNC240 忽略 (#5729)
* test(skills): align sandbox cache tests with readonly behavior

* ci(release): enforce core quality gate before publish

* ci: enforce locked dependency installs in workflows

* security: remove curl-pipe-shell installs

* chore: align project python baseline to 3.12

* ci(dashboard): add explicit typecheck gate

* chore(pre-commit): align ruff hook version with project

* ci(codeql): add javascript-typescript analysis

* chore(ruff): defer py312 migration lint rules

* fix: resolve ruff violations without new ignores

* fix: resolve ASYNC230 and ASYNC240 without ignores

* fix(auth): replace utcnow with timezone-aware UTC now

* fix: avoid blocking file read in file_to_base64
2026-03-04 13:51:00 +09:00
18 changed files with 810 additions and 71 deletions
+1 -1
View File
@@ -1 +1 @@
__version__ = "4.19.2"
__version__ = "4.19.1"
+1 -1
View File
@@ -5,7 +5,7 @@ from typing import Any, TypedDict
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
VERSION = "4.19.2"
VERSION = "4.19.1"
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
WEBHOOK_SUPPORTED_PLATFORMS = [
+6 -2
View File
@@ -97,7 +97,11 @@ class AstrBotCoreLifecycle:
except Exception as e:
logger.error(f"Subagent orchestrator init failed: {e}", exc_info=True)
async def initialize(self) -> None:
async def initialize(
self,
*,
mcp_init_timeout: float | int | str | None = None,
) -> None:
"""初始化 AstrBot 核心生命周期管理类.
负责初始化各个组件, 包括 ProviderManager、PlatformManager、ConversationManager、PluginManager、PipelineScheduler、EventBus、AstrBotUpdator等。
@@ -201,7 +205,7 @@ class AstrBotCoreLifecycle:
await self.plugin_manager.reload()
# 根据配置实例化各个 Provider
await self.provider_manager.initialize()
await self.provider_manager.initialize(init_timeout=mcp_init_timeout)
await self.kb_manager.initialize()
+12 -4
View File
@@ -346,7 +346,10 @@ class FunctionToolManager:
logger.debug(f" 主机: {scheme}://{host}{port}")
async def init_mcp_clients(
self, raise_on_all_failed: bool = False
self,
raise_on_all_failed: bool = False,
*,
init_timeout: float | int | str | None = None,
) -> MCPInitSummary:
"""从项目根目录读取 mcp_server.json 文件,初始化 MCP 服务列表。文件格式如下:
```
@@ -367,6 +370,7 @@ class FunctionToolManager:
```
Timeout behavior:
- 显式 `init_timeout` 参数优先(用于测试或调用方覆盖)。
- 初始化超时使用环境变量 ASTRBOT_MCP_INIT_TIMEOUT 或默认值。
- 动态启用超时使用 ASTRBOT_MCP_ENABLE_TIMEOUT(独立于初始化超时)。
"""
@@ -383,8 +387,12 @@ 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 = self._init_timeout_default
timeout_display = f"{init_timeout:g}"
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}"
active_configs: list[tuple[str, dict, asyncio.Event]] = []
for name, cfg in mcp_server_json_obj.items():
@@ -403,7 +411,7 @@ class FunctionToolManager:
name=name,
cfg=cfg,
shutdown_event=shutdown_event,
timeout=init_timeout,
timeout_seconds=init_timeout_value,
),
name=f"mcp-init:{name}",
)
+7 -2
View File
@@ -269,7 +269,11 @@ class ProviderManager:
return provider
async def initialize(self) -> None:
async def initialize(
self,
*,
init_timeout: float | int | str | None = None,
) -> None:
# 逐个初始化提供商
for provider_config in self.providers_config:
try:
@@ -338,7 +342,8 @@ class ProviderManager:
"on",
}
mcp_init_summary = await self.llm_tools.init_mcp_clients(
raise_on_all_failed=strict_mcp_init
raise_on_all_failed=strict_mcp_init,
init_timeout=init_timeout,
)
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))。
-4
View File
@@ -5,10 +5,6 @@ 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
View File
@@ -1,9 +1,9 @@
[project]
name = "AstrBot"
version = "4.19.2"
version = "4.19.1"
description = "Easy-to-use multi-platform LLM chatbot and development framework"
readme = "README.md"
requires-python = ">=3.12"
requires-python = ">=3.11"
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>=0.2.0",
"shipyard-neo-sdk @ git+https://github.com/AstrBotDevs/shipyard-neo.git#subdirectory=shipyard-neo-sdk",
"python-socks>=2.8.0",
"packaging>=24.2",
]
+1 -1
View File
@@ -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>=0.2.0
shipyard-neo-sdk @ git+https://github.com/AstrBotDevs/shipyard-neo.git#subdirectory=shipyard-neo-sdk
packaging>=24.2
-50
View File
@@ -1,50 +0,0 @@
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)
+20
View File
@@ -7,6 +7,7 @@ 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
@@ -24,6 +25,25 @@ 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")
# ============================================================
# 平台配置工厂
# ============================================================
+268
View File
@@ -0,0 +1,268 @@
"""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
+98
View File
@@ -172,6 +172,15 @@ 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(
@@ -240,6 +249,95 @@ 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 类测试"""
+2 -2
View File
@@ -373,7 +373,7 @@ class TestAstrBotCoreLifecycleInitialize:
new_callable=AsyncMock,
),
):
await lifecycle.initialize()
await lifecycle.initialize(mcp_init_timeout=3.5)
# 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()
mock_provider_manager.initialize.assert_awaited_once_with(init_timeout=3.5)
# Verify platform manager initialized
mock_platform_manager.initialize.assert_awaited_once()
+43
View File
@@ -0,0 +1,43 @@
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
+98
View File
@@ -0,0 +1,98 @@
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)
+71
View File
@@ -0,0 +1,71 @@
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"
+178
View File
@@ -0,0 +1,178 @@
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()