Merge remote-tracking branch 'origin' into dev

# Conflicts:
#	astrbot/core/db/vec_db/faiss_impl/document_storage.py
#	astrbot/core/platform/sources/telegram/tg_adapter.py
#	astrbot/core/platform/sources/weixin_oc/weixin_oc_adapter.py
#	astrbot/core/tools/cron_tools.py
#	astrbot/core/updator.py
#	astrbot/core/zip_updator.py
#	dashboard/src/components/shared/ConfigItemRenderer.vue
#	dashboard/src/utils/providerUtils.js
This commit is contained in:
LIghtJUNction
2026-04-19 05:42:39 +08:00
29 changed files with 1273 additions and 113 deletions
+359
View File
@@ -0,0 +1,359 @@
from dataclasses import dataclass, field
from pathlib import Path
from types import SimpleNamespace
import certifi
import httpx
import pytest
from astrbot.core.zip_updator import RepoZipUpdator
class _FakeJSONResponse:
def __init__(self, payload):
self._payload = payload
def raise_for_status(self) -> None:
return None
def json(self):
return self._payload
class _FakeStreamResponse:
def __init__(self, payload: bytes):
self._payload = payload
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb) -> None:
return None
def raise_for_status(self) -> None:
return None
async def aiter_bytes(self, chunk_size: int = 8192):
for start in range(0, len(self._payload), chunk_size):
yield self._payload[start : start + chunk_size]
class _FakeFailingStreamResponse:
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb) -> None:
return None
def raise_for_status(self) -> None:
return None
async def aiter_bytes(self, chunk_size: int = 8192): # noqa: ARG002
yield b"partial"
raise RuntimeError("stream interrupted")
class _FakeStatusErrorResponse:
def __init__(self, status_code: int, body: str, url: str):
self._status_code = status_code
self._body = body
self._url = url
def raise_for_status(self) -> None:
request = httpx.Request("GET", self._url)
response = httpx.Response(
self._status_code,
text=self._body,
request=request,
)
raise httpx.HTTPStatusError(
"status error",
request=request,
response=response,
)
@dataclass
class _FakeAsyncClientState:
json_payload: list[dict] = field(default_factory=list)
stream_payload: bytes = b""
init_kwargs: dict | None = None
requested_urls: list[str] = field(default_factory=list)
stream_urls: list[str] = field(default_factory=list)
class _FakeStatusErrorAsyncClient:
def __init__(self, response: _FakeStatusErrorResponse):
self._response = response
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb) -> None:
return None
async def get(self, url: str):
return self._response
class _FakeFailingStreamAsyncClient:
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb) -> None:
return None
def stream(self, method: str, url: str): # noqa: ARG002
return _FakeFailingStreamResponse()
def _build_fake_httpx_module(state: _FakeAsyncClientState) -> SimpleNamespace:
class _FakeAsyncClient:
def __init__(self, **kwargs):
state.init_kwargs = kwargs
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb) -> None:
return None
async def get(self, url: str):
state.requested_urls.append(url)
return _FakeJSONResponse(state.json_payload)
def stream(self, method: str, url: str):
assert method == "GET"
state.stream_urls.append(url)
return _FakeStreamResponse(state.stream_payload)
return SimpleNamespace(
AsyncClient=_FakeAsyncClient,
HTTPStatusError=httpx.HTTPStatusError,
)
@pytest.fixture
def fake_async_client_state() -> _FakeAsyncClientState:
return _FakeAsyncClientState()
@pytest.mark.asyncio
async def test_fetch_release_info_uses_httpx_client_with_env_proxy_support(
monkeypatch: pytest.MonkeyPatch,
fake_async_client_state: _FakeAsyncClientState,
) -> None:
import astrbot.core.zip_updator as zip_updator_module
fake_async_client_state.json_payload = [
{
"name": "AstrBot v4.23.2",
"published_at": "2026-04-16T00:00:00Z",
"body": "fix updater socks proxy support",
"tag_name": "v4.23.2",
"zipball_url": "https://example.com/astrbot.zip",
}
]
monkeypatch.setattr(
zip_updator_module,
"aiohttp",
SimpleNamespace(
ClientSession=lambda *args, **kwargs: (_ for _ in ()).throw(
AssertionError(
"fetch_release_info should not use aiohttp.ClientSession"
)
)
),
raising=False,
)
monkeypatch.setattr(
zip_updator_module,
"httpx",
_build_fake_httpx_module(fake_async_client_state),
raising=False,
)
release_info = await RepoZipUpdator().fetch_release_info(
"https://api.soulter.top/releases"
)
assert release_info == [
{
"version": "AstrBot v4.23.2",
"published_at": "2026-04-16T00:00:00Z",
"body": "fix updater socks proxy support",
"tag_name": "v4.23.2",
"zipball_url": "https://example.com/astrbot.zip",
}
]
assert fake_async_client_state.requested_urls == ["https://api.soulter.top/releases"]
assert fake_async_client_state.init_kwargs is not None
assert fake_async_client_state.init_kwargs["follow_redirects"] is True
assert fake_async_client_state.init_kwargs["timeout"] == 30.0
assert fake_async_client_state.init_kwargs["trust_env"] is True
assert fake_async_client_state.init_kwargs["verify"] == certifi.where()
@pytest.mark.asyncio
async def test_download_from_repo_url_uses_httpx_stream_for_zip_download(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
fake_async_client_state: _FakeAsyncClientState,
) -> None:
import astrbot.core.zip_updator as zip_updator_module
fake_async_client_state.stream_payload = b"zip-data"
async def fake_fetch_release_info(self, url: str, latest: bool = True): # noqa: ARG001
return [
{
"version": "AstrBot v4.23.2",
"published_at": "2026-04-16T00:00:00Z",
"body": "fix updater socks proxy support",
"tag_name": "v4.23.2",
"zipball_url": "https://example.com/archive.zip",
}
]
monkeypatch.setattr(RepoZipUpdator, "fetch_release_info", fake_fetch_release_info)
monkeypatch.setattr(
zip_updator_module,
"download_file",
lambda *args, **kwargs: (_ for _ in ()).throw(
AssertionError("download_from_repo_url should not use aiohttp download_file")
),
raising=False,
)
monkeypatch.setattr(
zip_updator_module,
"httpx",
_build_fake_httpx_module(fake_async_client_state),
raising=False,
)
target_path = tmp_path / "AstrBot"
await RepoZipUpdator().download_from_repo_url(
str(target_path),
"https://github.com/AstrBotDevs/AstrBot",
)
assert (tmp_path / "AstrBot.zip").read_bytes() == b"zip-data"
assert fake_async_client_state.stream_urls == ["https://example.com/archive.zip"]
assert fake_async_client_state.init_kwargs is not None
assert fake_async_client_state.init_kwargs["follow_redirects"] is True
assert fake_async_client_state.init_kwargs["timeout"] == 1800.0
assert fake_async_client_state.init_kwargs["trust_env"] is True
assert fake_async_client_state.init_kwargs["verify"] == certifi.where()
def test_create_httpx_client_uses_custom_verify_setting(
monkeypatch: pytest.MonkeyPatch,
fake_async_client_state: _FakeAsyncClientState,
) -> None:
import astrbot.core.zip_updator as zip_updator_module
custom_verify = "/tmp/custom-ca.pem"
monkeypatch.setattr(
zip_updator_module,
"httpx",
_build_fake_httpx_module(fake_async_client_state),
raising=False,
)
RepoZipUpdator(verify=custom_verify)._create_httpx_client(timeout=45.0)
assert fake_async_client_state.init_kwargs is not None
assert fake_async_client_state.init_kwargs["follow_redirects"] is True
assert fake_async_client_state.init_kwargs["timeout"] == 45.0
assert fake_async_client_state.init_kwargs["trust_env"] is True
assert fake_async_client_state.init_kwargs["verify"] == custom_verify
@pytest.mark.asyncio
async def test_fetch_release_info_logs_status_code_and_truncated_body_on_http_error(
monkeypatch: pytest.MonkeyPatch,
) -> None:
import astrbot.core.zip_updator as zip_updator_module
url = "https://api.soulter.top/releases"
body = "x" * 1005
log_messages: list[str] = []
monkeypatch.setattr(
RepoZipUpdator,
"_create_httpx_client",
staticmethod(
lambda timeout=30.0: _FakeStatusErrorAsyncClient( # noqa: ARG005
_FakeStatusErrorResponse(502, body, url)
)
),
)
monkeypatch.setattr(
zip_updator_module.logger,
"error",
lambda message: log_messages.append(message),
)
with pytest.raises(Exception, match="解析版本信息失败"):
await RepoZipUpdator().fetch_release_info(url)
assert any("状态码: 502" in message for message in log_messages)
assert any("内容: " in message for message in log_messages)
assert any("...[truncated]" in message for message in log_messages)
@pytest.mark.asyncio
async def test_download_file_removes_partial_file_when_stream_fails(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
monkeypatch.setattr(
RepoZipUpdator,
"_create_httpx_client",
staticmethod(
lambda timeout=30.0: _FakeFailingStreamAsyncClient() # noqa: ARG005
),
)
target_path = tmp_path / "partial.zip"
with pytest.raises(RuntimeError, match="stream interrupted"):
await RepoZipUpdator()._download_file(
"https://example.com/archive.zip",
str(target_path),
)
assert not target_path.exists()
@pytest.mark.asyncio
async def test_download_file_logs_url_and_target_path_on_failure(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
import astrbot.core.zip_updator as zip_updator_module
url = "https://example.com/archive.zip"
target_path = tmp_path / "logged-partial.zip"
log_messages: list[str] = []
monkeypatch.setattr(
RepoZipUpdator,
"_create_httpx_client",
staticmethod(
lambda timeout=30.0: _FakeFailingStreamAsyncClient() # noqa: ARG005
),
)
monkeypatch.setattr(
zip_updator_module.logger,
"error",
lambda message: log_messages.append(message),
)
with pytest.raises(RuntimeError, match="stream interrupted"):
await RepoZipUpdator()._download_file(url, str(target_path))
assert any(url in message for message in log_messages)
assert any(str(target_path) in message for message in log_messages)
+11 -10
View File
@@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from astrbot.core.cron.manager import CronJobManager
from astrbot.core.cron.manager import CronJobManager, CronJobSchedulingError
from astrbot.core.db.po import CronJob
@@ -190,24 +190,25 @@ class TestAddActiveJob:
@pytest.mark.asyncio
async def test_add_active_job_run_once(self, cron_manager, mock_db, sample_cron_job):
"""Test adding a run-once active job."""
"""Test adding a run-once active job with an invalid returned job."""
sample_cron_job.job_type = "active_agent"
sample_cron_job.run_once = True
mock_db.create_cron_job.return_value = sample_cron_job
run_at = datetime.now(timezone.utc) + timedelta(days=30)
result = await cron_manager.add_active_job(
name="Test Run Once Job",
cron_expression=None,
payload={"session": "test:group:123"},
run_once=True,
run_at=run_at,
)
with pytest.raises(CronJobSchedulingError, match="Invalid isoformat string"):
await cron_manager.add_active_job(
name="Test Run Once Job",
cron_expression=None,
payload={"session": "test:group:123"},
run_once=True,
run_at=run_at,
)
assert result == sample_cron_job
call_kwargs = mock_db.create_cron_job.call_args.kwargs
assert call_kwargs["run_once"] is True
assert call_kwargs["payload"]["run_at"] == run_at.isoformat()
class TestUpdateJob:
+75
View File
@@ -0,0 +1,75 @@
import pytest
from astrbot.core.db.vec_db.faiss_impl.document_storage import DocumentStorage
@pytest.mark.asyncio
async def test_document_storage_fts_insert_search_and_delete(tmp_path):
storage = DocumentStorage(str(tmp_path / "doc.db"))
await storage.initialize()
assert storage.fts5_available is True
await storage.insert_documents_batch(
doc_ids=["chunk-1", "chunk-2"],
texts=["AstrBot 知识库召回性能优化", "FAISS 向量检索"],
metadatas=[
{"kb_doc_id": "doc-1", "kb_id": "kb-1", "chunk_index": 0},
{"kb_doc_id": "doc-1", "kb_id": "kb-1", "chunk_index": 1},
],
)
results = await storage.search_sparse(["知识库"], limit=10)
assert results is not None
assert [result["doc_id"] for result in results] == ["chunk-1"]
await storage.delete_document_by_doc_id("chunk-1")
results = await storage.search_sparse(["知识库"], limit=10)
assert results == []
await storage.close()
@pytest.mark.asyncio
async def test_document_storage_fts_rebuilds_existing_documents(tmp_path):
storage = DocumentStorage(str(tmp_path / "doc.db"))
await storage.initialize()
storage.fts5_available = False
await storage.insert_document(
doc_id="legacy-chunk",
text="legacy 知识库 文本",
metadata={"kb_doc_id": "doc-1", "kb_id": "kb-1", "chunk_index": 0},
)
storage.fts5_available = True
storage._fts_index_ready = False
results = await storage.search_sparse(["知识库"], limit=10)
assert results is not None
assert [result["doc_id"] for result in results] == ["legacy-chunk"]
await storage.close()
@pytest.mark.asyncio
async def test_document_storage_fts_delete_skips_missing_fts_row(tmp_path):
storage = DocumentStorage(str(tmp_path / "doc.db"))
await storage.initialize()
storage.fts5_available = False
await storage.insert_document(
doc_id="legacy-chunk",
text="legacy 知识库 文本",
metadata={"kb_doc_id": "doc-1", "kb_id": "kb-1", "chunk_index": 0},
)
storage.fts5_available = True
await storage.delete_document_by_doc_id("legacy-chunk")
assert await storage.get_document_by_doc_id("legacy-chunk") is None
await storage.close()
+93
View File
@@ -0,0 +1,93 @@
import json
from types import SimpleNamespace
import pytest
from astrbot.core.knowledge_base.retrieval.sparse_retriever import SparseRetriever
def make_doc(chunk_id: str, text: str, chunk_index: int = 0) -> dict:
return {
"doc_id": chunk_id,
"text": text,
"metadata": json.dumps(
{
"chunk_index": chunk_index,
"kb_doc_id": f"doc-{chunk_index}",
"kb_id": "kb-1",
},
),
}
class FTSStorage:
def __init__(self):
self.search_sparse_calls = 0
self.get_documents_calls = 0
async def search_sparse(self, query_tokens: list[str], limit: int):
self.search_sparse_calls += 1
assert query_tokens == ["apple"]
assert limit == 1
return [
{
**make_doc("chunk-1", "apple banana", 0),
"score": -1.0,
},
]
async def get_documents(self, *args, **kwargs):
self.get_documents_calls += 1
return []
class FallbackStorage:
def __init__(self):
self.search_sparse_calls = 0
self.get_documents_calls = 0
async def search_sparse(self, query_tokens: list[str], limit: int):
self.search_sparse_calls += 1
return None
async def get_documents(self, metadata_filters: dict, limit: int | None, offset):
self.get_documents_calls += 1
return [
make_doc("chunk-1", "apple banana", 0),
make_doc("chunk-2", "orange pear", 1),
make_doc("chunk-3", "grape melon", 2),
]
@pytest.mark.asyncio
async def test_sparse_retriever_uses_fts5_when_available():
storage = FTSStorage()
vec_db = SimpleNamespace(document_storage=storage)
retriever = SparseRetriever(kb_db=None)
results = await retriever.retrieve(
query="apple",
kb_ids=["kb-1"],
kb_options={"kb-1": {"vec_db": vec_db, "top_k_sparse": 1}},
)
assert [result.chunk_id for result in results] == ["chunk-1"]
assert storage.search_sparse_calls == 1
assert storage.get_documents_calls == 0
@pytest.mark.asyncio
async def test_sparse_retriever_falls_back_to_bm25_when_fts5_is_unavailable():
storage = FallbackStorage()
vec_db = SimpleNamespace(document_storage=storage)
retriever = SparseRetriever(kb_db=None)
results = await retriever.retrieve(
query="apple",
kb_ids=["kb-1"],
kb_options={"kb-1": {"vec_db": vec_db, "top_k_sparse": 1}},
)
assert [result.chunk_id for result in results] == ["chunk-1"]
assert storage.search_sparse_calls == 1
assert storage.get_documents_calls == 1