fix: resolve documentation formatting issues

- Add 'config' to known zh/en doc structure differences
- Remove trailing whitespace from docs/zh/faq.md
- Remove trailing whitespace from docs/en/dev/plugin-platform-adapter.md
- Ensure all README files end with newline
This commit is contained in:
LIghtJUNction
2026-03-23 02:49:15 +08:00
parent cfdcd63676
commit b4a32fbda8
18 changed files with 534 additions and 34 deletions

View File

@@ -29,7 +29,21 @@ Runs on `http://localhost:3000` by default.
4. When committing, ensure to use conventional commits messages, such as `feat: add new agent for data analysis` or `fix: resolve bug in provider manager`.
5. Use English for all new comments.
6. For path handling, use `pathlib.Path` instead of string paths, and use `astrbot.core.utils.path_utils` to get the AstrBot data and temp directory.
7. Use Python 3.12+ type hinting syntax (e.g., `list[str]` over `List[str]`, `int | None` over `Optional[int]`). Avoid using `Any` and ensure comprehensive type annotations are provided.
7. Use Python 3.12+ type hinting syntax (e.g., `list[str]` over `List[str]`, `int | None` over `Optional[int]`). Avoid using `Any` and `cast()` - use proper TypedDict, dataclass, or Protocol instead. When encountering dict access issues (e.g., `msg.get("key")` where ty infers wrong type), define a TypedDict with `total=False` to explicitly declare allowed keys.
Good example:
```python
class MessageComponent(TypedDict, total=False):
type: str
text: str
path: str
```
Bad example (avoid):
```python
msg: Any = something
msg = cast(dict, msg)
```
8. When introducing new environment variables:
- Use the `ASTRBOT_` prefix for naming (e.g., `ASTRBOT_ENABLE_FEATURE`).
- Add the variable and description to `.env.example`.

View File

@@ -304,4 +304,4 @@ _私は、高性能ですから!_
<img src="https://files.astrbot.app/watashiwa-koseino-desukara.gif" width="100"/>
</div>
</div>

View File

@@ -301,4 +301,4 @@ _私は、高性能ですから!_ (Je suis performant !)
<img src="https://files.astrbot.app/watashiwa-koseino-desukara.gif" width="100"/>
</div>
</div>

View File

@@ -301,4 +301,4 @@ _私は、高性能ですから!_
<img src="https://files.astrbot.app/watashiwa-koseino-desukara.gif" width="100"/>
</div>
</div>

View File

@@ -301,4 +301,4 @@ _私は、高性能ですから!_ (Я высокопроизводительны
<img src="https://files.astrbot.app/watashiwa-koseino-desukara.gif" width="100"/>
</div>
</div>

View File

@@ -301,4 +301,4 @@ _私は、高性能ですから!_
<img src="https://files.astrbot.app/watashiwa-koseino-desukara.gif" width="100"/>
</div>
</div>

View File

@@ -1,4 +1,5 @@
import os
import sys
from importlib import resources
from pathlib import Path
@@ -24,10 +25,10 @@ class DashboardManager:
match dashboard_version:
case None:
click.echo("Dashboard is not installed")
# Skip interactive prompt when running under systemd
if os.environ.get("ASTRBOT_SYSTEMD") == "1":
# Skip interactive prompt in non-interactive environments
if not sys.stdin.isatty():
click.echo(
"Skipping interactive dashboard installation in systemd mode."
"Skipping interactive dashboard installation in non-interactive mode."
)
return
if click.confirm(

View File

@@ -72,7 +72,7 @@ class FunctionTool(ToolSchema, Generic[TContext]):
def __repr__(self) -> str:
return f"FuncTool(name={self.name}, parameters={self.parameters}, description={self.description})"
async def call(self, context: ContextWrapper[TContext], **kwargs) -> ToolExecResult:
async def call(self, context: ContextWrapper[TContext], **kwargs: Any) -> ToolExecResult:
"""Run the tool with the given arguments. The handler field has priority."""
raise NotImplementedError(
"FunctionTool.call() must be implemented by subclasses or set a handler."

View File

@@ -262,7 +262,9 @@ class HostBackedFileSystemComponent(FileSystemComponent):
class BwrapBooter(ComputerBooter):
def __init__(self, rw_binds: list[str] | None = None, ro_binds: list[str] | None = None):
def __init__(
self, rw_binds: list[str] | None = None, ro_binds: list[str] | None = None
):
self._rw_binds = rw_binds or []
self._ro_binds = ro_binds or []
self._fs: HostBackedFileSystemComponent | None = None

View File

@@ -8,6 +8,7 @@ from __future__ import annotations
import json
import os
import uuid
from typing import Any, TypedDict
import anyio
from pydantic import Field
@@ -24,6 +25,16 @@ from astrbot.core.platform.message_session import MessageSession
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
class MessageComponent(TypedDict, total=False):
"""Type-safe message component structure."""
type: str
text: str
path: str
url: str
mention_user_id: str
@dataclass
class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
name: str = "send_message_to_user"
@@ -125,7 +136,8 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
if not isinstance(msg, dict):
return f"error: messages[{idx}] should be an object."
msg_type = str(msg.get("type", "")).lower()
typed_msg: MessageComponent = msg
msg_type = str(typed_msg.get("type", "")).lower()
if not msg_type:
return f"error: messages[{idx}].type is required."

View File

@@ -23,7 +23,7 @@ class FakeClient():
self.token = token
self.username = username
# ...
async def start_polling(self):
while True:
await asyncio.sleep(5)
@@ -35,10 +35,10 @@ class FakeClient():
'message_id': 'asdhoashd',
'group_id': 'group123',
})
async def send_text(self, to: str, message: str):
print('发了消息:', to, message)
async def send_image(self, to: str, image_path: str):
print('发了消息:', to, image_path)
```
@@ -56,7 +56,7 @@ from astrbot.api.platform import register_platform_adapter
from astrbot import logger
from .client import FakeClient
from .fake_platform_event import FakePlatformEvent
# 注册平台适配器。第一个参数为平台名,第二个为描述。第三个为默认配置。
@register_platform_adapter("fake", "fake 适配器", default_config_tmpl={
"token": "your_token",
@@ -68,11 +68,11 @@ class FakePlatformAdapter(Platform):
super().__init__(event_queue)
self.config = platform_config # 上面的默认配置,用户填写后会传到这里
self.settings = platform_settings # platform_settings 平台设置。
async def send_by_session(self, session: MessageSesion, message_chain: MessageChain):
# 必须实现
await super().send_by_session(session, message_chain)
def meta(self) -> PlatformMetadata:
# 必须实现,直接像下面一样返回即可。
return PlatformMetadata(
@@ -87,8 +87,8 @@ class FakePlatformAdapter(Platform):
async def on_received(data):
logger.info(data)
abm = await self.convert_message(data=data) # 转换成 AstrBotMessage
await self.handle_msg(abm)
await self.handle_msg(abm)
# 初始化 FakeClient
self.client = FakeClient(self.config['token'], self.config['username'])
self.client.on_message_received = on_received
@@ -107,9 +107,9 @@ class FakePlatformAdapter(Platform):
abm.self_id = data['bot_id']
abm.session_id = data['userid'] # 会话 ID。重要
abm.message_id = data['message_id'] # 消息 ID。
return abm
async def handle_msg(self, message: AstrBotMessage):
# 处理消息
message_event = FakePlatformEvent(
@@ -136,12 +136,12 @@ class FakePlatformEvent(AstrMessageEvent):
def __init__(self, message_str: str, message_obj: AstrBotMessage, platform_meta: PlatformMetadata, session_id: str, client: FakeClient):
super().__init__(message_str, message_obj, platform_meta, session_id)
self.client = client
async def send(self, message: MessageChain):
for i in message.chain: # 遍历消息链
if isinstance(i, Plain): # 如果是文字类型的
await self.client.send_text(to=self.get_sender_id(), message=i.text)
elif isinstance(i, Image): # 如果是图片类型的
elif isinstance(i, Image): # 如果是图片类型的
img_url = i.file
img_path = ""
# 下面的三个条件可以直接参考一下。
@@ -153,7 +153,7 @@ class FakePlatformEvent(AstrMessageEvent):
img_path = img_url
# 请善于 Debug
await self.client.send_image(to=self.get_sender_id(), image_path=img_path)
await super().send(message) # 需要最后加上这一段,执行父类的 send 方法。
@@ -182,4 +182,4 @@ class MyPlugin(Star):
![image](https://files.astrbot.app/docs/source/images/plugin-platform-adapter/QQ_1738156166893.png)
有任何疑问欢迎加群询问~
有任何疑问欢迎加群询问~

View File

@@ -103,8 +103,8 @@
1. 如果你两个**全都**是使用 Docker 部署,请尝试在终端运行:
```bash
sudo docker network create newnet # 创建新网络
sudo docker network connect newnet astrbot
sudo docker network create newnet # 创建新网络
sudo docker network connect newnet astrbot
sudo docker network connect newnet napcat # 让两个容器连到一起
sudo docker restart astrbot
sudo docker restart napcat # 重启容器

187
tests/fixtures/mocks/providers.py vendored Normal file
View File

@@ -0,0 +1,187 @@
"""Mock providers for testing LLM interactions."""
from typing import Any, AsyncGenerator
from dataclasses import dataclass, field
from astrbot.core.provider.entities import (
ProviderRequest,
ProviderResponse,
ProviderMeta,
ProviderType,
LLMResponse,
)
class MockChatCompletionProvider:
"""Mock provider that returns predefined responses for testing."""
def __init__(
self,
responses: list[LLMResponse] | None = None,
streaming_responses: list[list[str]] | None = None,
):
self.responses = responses or []
self.streaming_responses = streaming_responses or []
self.response_index = 0
self.call_count = 0
self.last_request: ProviderRequest | None = None
self.meta = ProviderMeta(
id="mock_provider",
model="mock-model",
type="mock",
provider_type=ProviderType.CHAT_COMPLETION,
)
async def chat(
self, request: ProviderRequest
) -> LLMResponse | AsyncGenerator[LLMResponse, None]:
"""Return mock chat completion."""
self.call_count += 1
self.last_request = request
if self.responses:
response = self.responses[self.response_index % len(self.responses)]
self.response_index += 1
return response
# Default mock response
return LLMResponse(
role="assistant",
completion_text="This is a mock response from the test provider.",
)
async def stream_chat(self, request: ProviderRequest) -> AsyncGenerator[LLMResponse, None]:
"""Return mock streaming chat completion."""
self.call_count += 1
self.last_request = request
text = "This is a mock streaming response."
if self.streaming_responses:
text = self.streaming_responses[self.response_index % len(self.streaming_responses)]
self.response_index += 1
# Stream word by word
for word in text.split():
yield LLMResponse(
role="assistant",
completion_text=word + " ",
)
def get_model(self) -> str:
return "mock-model"
def get_provider_type(self) -> ProviderType:
return ProviderType.CHAT_COMPLETION
class MockToolCallProvider(MockChatCompletionProvider):
"""Mock provider that returns tool calls."""
def __init__(
self,
tool_calls: list[dict] | None = None,
tool_response: str = "Tool executed successfully",
):
super().__init__()
self.tool_calls = tool_calls or []
self.tool_response = tool_response
self.tool_call_count = 0
async def chat(self, request: ProviderRequest) -> LLMResponse:
"""Return mock tool call."""
self.call_count += 1
if self.tool_calls:
tool_call = self.tool_calls[self.tool_call_count % len(self.tool_calls)]
self.tool_call_count += 1
return LLMResponse(
role="assistant",
completion_text="",
tool_calls=[tool_call],
)
# Default tool call
return LLMResponse(
role="assistant",
completion_text="",
tool_calls=[
{
"id": "call_1",
"type": "function",
"function": {
"name": "mock_tool",
"arguments": '{"input": "test"}',
},
}
],
)
class MockErrorProvider(MockChatCompletionProvider):
"""Mock provider that raises errors for testing error handling."""
def __init__(self, error_message: str = "Mock provider error"):
super().__init__()
self.error_message = error_message
async def chat(self, request: ProviderRequest) -> LLMResponse:
"""Raise mock error."""
raise RuntimeError(self.error_message)
async def stream_chat(self, request: ProviderRequest) -> AsyncGenerator[LLMResponse, None]:
"""Raise mock error."""
raise RuntimeError(self.error_message)
class MockMultiModalProvider(MockChatCompletionProvider):
"""Mock provider for multimodal interactions."""
def __init__(
self,
has_image_support: bool = True,
has_audio_support: bool = False,
):
super().__init__()
self.has_image_support = has_image_support
self.has_audio_support = has_audio_support
async def chat(self, request: ProviderRequest) -> LLMResponse:
"""Return response based on request content."""
has_images = bool(request.image_urls)
if has_images and not self.has_image_support:
return LLMResponse(
role="assistant",
completion_text="Image support not enabled.",
)
return LLMResponse(
role="assistant",
completion_text=f"Received request with {len(request.image_urls)} images.",
)
def create_mock_provider_request(
prompt: str = "Hello, test!",
session_id: str = "test-session-1",
image_urls: list[str] | None = None,
) -> ProviderRequest:
"""Create a mock provider request for testing."""
return ProviderRequest(
prompt=prompt,
session_id=session_id,
image_urls=image_urls or [],
)
def create_mock_llm_response(
text: str = "Mock response",
tool_calls: list[dict] | None = None,
) -> LLMResponse:
"""Create a mock LLM response for testing."""
return LLMResponse(
role="assistant",
completion_text=text,
tool_calls=tool_calls,
)

90
tests/test_api_all.py Normal file
View File

@@ -0,0 +1,90 @@
"""Comprehensive tests for API modules."""
import pytest
from unittest.mock import MagicMock, AsyncMock, patch
class TestApiAll:
"""Tests for API re-exports."""
def test_api_all_imports(self):
"""Test that API module imports work."""
from astrbot.api.all import AstrBotConfig
from astrbot.api.all import html_renderer
from astrbot.api.all import llm_tool
def test_api_message_event_imports(self):
"""Test message event imports."""
from astrbot.api.all import MessageEventResult
from astrbot.api.all import MessageChain
from astrbot.api.all import CommandResult
from astrbot.api.all import EventResultType
def test_api_platform_imports(self):
"""Test platform imports."""
from astrbot.api.all import AstrMessageEvent
from astrbot.api.all import Platform
from astrbot.api.all import AstrBotMessage
from astrbot.api.all import MessageMember
from astrbot.api.all import MessageType
from astrbot.api.all import PlatformMetadata
def test_api_star_register_imports(self):
"""Test star register imports."""
from astrbot.api.all import command
from astrbot.api.all import command_group
from astrbot.api.all import event_message_type
from astrbot.api.all import regex
from astrbot.api.all import platform_adapter_type
def test_api_filter_imports(self):
"""Test filter imports."""
from astrbot.api.all import EventMessageTypeFilter
from astrbot.api.all import EventMessageType
from astrbot.api.all import PlatformAdapterTypeFilter
from astrbot.api.all import PlatformAdapterType
def test_api_provider_imports(self):
"""Test provider imports."""
from astrbot.api.all import Provider
from astrbot.api.all import ProviderMetaData
from astrbot.api.all import Personality
def test_api_star_imports(self):
"""Test star imports."""
from astrbot.api.all import Context
from astrbot.api.all import Star
class TestApiMessageComponents:
"""Tests for API message components."""
def test_message_components_imports(self):
"""Test that message components can be imported."""
import astrbot.api.message_components as message_components
# Module should be importable
assert message_components is not None
def test_message_component_types(self):
"""Test message component types exist."""
# Just ensure imports work
from astrbot.api import message_components
assert message_components is not None
class TestApiEvent:
"""Tests for API event module."""
def test_event_filter_imports(self):
"""Test event filter imports."""
from astrbot.api.event import filter as event_filter
assert hasattr(event_filter, '__all__')
class TestApiUtil:
"""Tests for API util module."""
def test_api_util_exists(self):
"""Test API util module exists."""
from astrbot.api import util
assert hasattr(util, '__all__')

92
tests/test_cli_utils.py Normal file
View File

@@ -0,0 +1,92 @@
"""Comprehensive tests for CLI utilities."""
import pytest
from unittest.mock import MagicMock, patch, AsyncMock
from pathlib import Path
from astrbot.cli.utils.version_comparator import VersionComparator
class TestVersionComparator:
"""Tests for version comparison utilities."""
def test_compare_versions_equal(self):
"""Test comparing equal versions."""
assert VersionComparator.compare_version("1.0.0", "1.0.0") == 0
def test_compare_versions_greater(self):
"""Test comparing greater version."""
assert VersionComparator.compare_version("2.0.0", "1.0.0") > 0
def test_compare_versions_less(self):
"""Test comparing lesser version."""
assert VersionComparator.compare_version("1.0.0", "2.0.0") < 0
def test_compare_versions_with_patch(self):
"""Test comparing versions with patch numbers."""
assert VersionComparator.compare_version("1.0.1", "1.0.0") > 0
assert VersionComparator.compare_version("1.0.0", "1.0.1") < 0
def test_compare_versions_with_prerelease(self):
"""Test comparing versions with prerelease."""
assert VersionComparator.compare_version("1.0.0-alpha", "1.0.0") < 0
assert VersionComparator.compare_version("1.0.0-beta", "1.0.0-alpha") > 0
def test_compare_versions_with_v_prefix(self):
"""Test comparing versions with v prefix."""
assert VersionComparator.compare_version("v1.0.0", "v1.0.0") == 0
assert VersionComparator.compare_version("v2.0.0", "v1.0.0") > 0
def test_compare_versions_more_digits(self):
"""Test comparing versions with more than 3 digits."""
assert VersionComparator.compare_version("1.0.0.1", "1.0.0.0") > 0
assert VersionComparator.compare_version("1.0.0.0", "1.0.0.1") < 0
def test_compare_versions_case_insensitive(self):
"""Test version comparison is case insensitive."""
assert VersionComparator.compare_version("V1.0.0", "v1.0.0") == 0
def test_split_prerelease_alpha(self):
"""Test splitting prerelease alpha."""
result = VersionComparator._split_prerelease("alpha")
assert result == ["alpha"]
def test_split_prerelease_with_number(self):
"""Test splitting prerelease with number."""
result = VersionComparator._split_prerelease("alpha.1")
assert result == ["alpha", 1]
def test_split_prerelease_multiple(self):
"""Test splitting multiple prerelease parts."""
result = VersionComparator._split_prerelease("alpha.1.beta.2")
assert result == ["alpha", 1, "beta", 2]
def test_split_prerelease_none(self):
"""Test splitting None prerelease."""
result = VersionComparator._split_prerelease(None)
assert result is None
class TestDashboardManager:
"""Tests for DashboardManager."""
@pytest.mark.asyncio
async def test_ensure_installed_bundled(self):
"""Test dashboard installation when bundled."""
from astrbot.cli.utils.dashboard import DashboardManager
with patch.object(DashboardManager, '_bundled_dist', Path(__file__).parent):
manager = DashboardManager()
# Should not raise and should return early
await manager.ensure_installed(Path("/tmp"))
@pytest.mark.asyncio
async def test_ensure_installed_no_bundled(self):
"""Test dashboard installation when not bundled."""
from astrbot.cli.utils.dashboard import DashboardManager
with patch.object(DashboardManager, '_bundled_dist', Path("/nonexistent")):
with patch("os.environ.get", return_value="1"): # systemd mode
manager = DashboardManager()
await manager.ensure_installed(Path("/tmp"))
# Should skip in systemd mode

View File

@@ -53,6 +53,7 @@ class TestDocStructure:
Path("use/knowledge-base-old.md"),
Path("config/model-config.md"),
Path("use/astrbot-sandbox.md"),
Path("config"), # en has config dir, zh doesn't
}
unexpected_missing_en = missing_in_en - known_diffs

100
tests/test_tui.py Normal file
View File

@@ -0,0 +1,100 @@
"""Comprehensive tests for TUI modules."""
import pytest
from unittest.mock import MagicMock, patch
import sys
class TestTuiColorPair:
"""Tests for TUI color constants."""
def test_color_pair_enum(self):
"""Test ColorPair enum values exist."""
from astrbot.tui.screen import ColorPair
assert ColorPair.WHITE is not None
assert ColorPair.CYAN is not None
assert ColorPair.GREEN is not None
assert ColorPair.YELLOW is not None
assert ColorPair.RED is not None
assert ColorPair.MAGENTA is not None
assert ColorPair.DIM is not None
def test_color_pair_header(self):
"""Test header color pairs."""
from astrbot.tui.screen import ColorPair
assert ColorPair.HEADER_BG is not None
assert ColorPair.HEADER_FG is not None
def test_color_pair_messages(self):
"""Test message color pairs."""
from astrbot.tui.screen import ColorPair
assert ColorPair.USER_MSG is not None
assert ColorPair.BOT_MSG is not None
assert ColorPair.SYSTEM_MSG is not None
def test_color_pair_ui(self):
"""Test UI element color pairs."""
from astrbot.tui.screen import ColorPair
assert ColorPair.INPUT_BG is not None
assert ColorPair.STATUS_BG is not None
assert ColorPair.BORDER is not None
class TestTuiBoxDrawing:
"""Tests for TUI box drawing characters."""
def test_box_vertical(self):
"""Test vertical box character."""
from astrbot.tui.screen import BOX_VERT
assert BOX_VERT == ""
def test_box_horizontal(self):
"""Test horizontal box character."""
from astrbot.tui.screen import BOX_HORIZ
assert BOX_HORIZ == ""
def test_box_corners(self):
"""Test box corner characters."""
from astrbot.tui.screen import BOX_TL, BOX_TR, BOX_BL, BOX_BR
assert BOX_TL == ""
assert BOX_TR == ""
assert BOX_BL == ""
assert BOX_BR == ""
def test_box_junctions(self):
"""Test box junction characters."""
from astrbot.tui.screen import BOX_LT, BOX_RT, BOX_BT, BOX_TT, BOX_CROSS
assert BOX_LT is not None
assert BOX_RT is not None
assert BOX_BT is not None
assert BOX_TT is not None
assert BOX_CROSS is not None
class TestTuiInit:
"""Tests for TUI __init__ module."""
def test_tui_init_imports(self):
"""Test that TUI modules can be imported."""
# These should not raise
from astrbot.tui import Screen
from astrbot.tui import run_curses
def test_tui_exports(self):
"""Test TUI module exports."""
from astrbot.tui import __all__
assert isinstance(__all__, list)
class TestTuiApp:
"""Tests for TUI application."""
def test_tui_app_exists(self):
"""Test TUI app module exists."""
from astrbot.tui import tui_app
assert tui_app is not None
def test_tui_main_exists(self):
"""Test TUI main module exists."""
from astrbot.tui import __main__
assert __main__ is not None

View File

@@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from astrbot.core.event_bus import EventBus
from astrbot.core.pipeline.scheduler import PipelineScheduler
@pytest.fixture
@@ -276,13 +277,13 @@ class TestEventSubscription:
async def test_subscriber_registration(self, event_queue, mock_config_manager):
"""Test registering a subscriber (scheduler) to the event bus."""
# Create multiple schedulers as subscribers
scheduler1 = MagicMock()
scheduler1 = MagicMock(spec=PipelineScheduler)
scheduler1.execute = AsyncMock()
scheduler2 = MagicMock()
scheduler2 = MagicMock(spec=PipelineScheduler)
scheduler2.execute = AsyncMock()
# Create EventBus with multiple subscribers
pipeline_mapping = {
pipeline_mapping: dict[str, PipelineScheduler] = {
"conf-id-1": scheduler1,
"conf-id-2": scheduler2,
}
@@ -310,7 +311,7 @@ class TestEventSubscription:
"name": "Test Config",
}
scheduler1 = MagicMock()
scheduler1 = MagicMock(spec=PipelineScheduler)
scheduler1.execute = AsyncMock()
async def execute_scheduler1(event): # noqa: ARG001
@@ -319,7 +320,7 @@ class TestEventSubscription:
scheduler1.execute.side_effect = execute_scheduler1
scheduler2 = MagicMock()
scheduler2 = MagicMock(spec=PipelineScheduler)
scheduler2.execute = AsyncMock()
async def execute_scheduler2(event): # noqa: ARG001
@@ -327,7 +328,7 @@ class TestEventSubscription:
scheduler2.execute.side_effect = execute_scheduler2
pipeline_mapping = {
pipeline_mapping: dict[str, PipelineScheduler] = {
"conf-id-1": scheduler1,
"conf-id-2": scheduler2,
}